diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index dcd1f2dd3..d346c1a64 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -374,10 +374,12 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`, | Sidecar event | Key | Goes to | |---|---|---| -| `pty:data` | `data.id` | its owner; dropped while the id is mid-transfer, its bytes being in the replay | -| `terminal:semanticEvents` | `data.id` | its owner; dropped while the id is mid-transfer — the target re-derives them from the raw replay, feeding both pane state and its `AlertManager` (rationale) | -| `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, overflow dropping the oldest (`held_events_come_back_in_order_and_bounded`) | -| `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed | +| `pty:data` | `data.id` | its owner; the source until the id's mark passes, then dropped until its replay, its bytes being in it | +| `terminal:semanticEvents` | `data.id` | its owner; the source until the id's mark passes, then dropped until its replay — the window receiving the replay re-derives them from it, feeding both pane state and its `AlertManager` (rationale) | +| `terminal:protocolEvents` | `data.id` | its owner; the source until the id's mark passes, then **held** and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, the oldest dropped past it (`held_events_come_back_in_order_and_bounded`) | +| `pty:exit` | `data.id` | its owner, never suppressed | +| `pty:replay` | `data.forWindow`, then `data.id` | the requesting window, including exited buffers; without an address, its owner; never suppressed | +| `pty:marked` | `data.id` | the source still consuming the id, which then falls silent until its replay; otherwise its owner | | `pty:list` | `data.forWindow` | the window that asked | | `alert:*` carrying `data.id` | `data.id` | its owner | | `dor:controlRequest` | `params.workspace`, `params.window`, `data.surfaceId` | in that precedence: the window holding the named Workspace (§Workspace registry), the named window, the caller's Surface's owner; none → the focused window | @@ -572,12 +574,16 @@ below reads that record rather than inferring itself from the suppression map. `transfer_workspace` / `open_workspace_window`. On `Ok` it marks the Workspace **transferring**: the Wall stays mounted and the notes stay put, nothing is released, and `getWindowSnapshot` omits it. -2. **Rust** reassigns `terminalIds` to the target and suppresses their output - until each one's replay has been emitted there; the sidecar buffers a chunk - before it emits and Rust's reader is one ordered thread, so a chunk in the gap - is dropped once and replayed once (rationale). It queues the record and nudges - the target with `workspace-arriving` carrying nothing — a new window has no - listener, so its payload is pulled at boot instead. +2. **Rust** reassigns `terminalIds` to the target, keeps routing their output to + the source, and asks the sidecar to stamp a `pty:marked` line per id; at that + line the id's suppression begins, until its replay has been emitted to the + target. The source serializes each buffer at its mark and invokes + `transfer_workspace_content`, which attaches the content to the record and + only then nudges the target with `workspace-arriving` carrying nothing — or, + for a tear-out, builds the new window, whose boot pulls a payload that is + complete (`docs/specs/transport.md` → "Transferring a Workspace"; + rationale). **An arrival without content is not drainable** + (`an_arrival_is_drainable_only_once_its_content_landed`). 3. **Target** drains with `take_arrivals` and, per arrival, arms its collector *before* calling `adopt_ready(workspaceId)` — the hop that removes the whole "arrived before armed" class of bug (rationale). Rust answers @@ -615,8 +621,30 @@ below reads that record rather than inferring itself from the suppression map. source unsuppressed, drop the record, and emit `workspace-arrival-failed`; the source clears **transferring** and the Workspace is simply still there. With both ends gone the shells are reaped rather than left owned by a dead label. - **The gap is lost on a hand-back**: suppressed from the invoke with no replay - to follow, it is the one path nothing recovers. +- **Must change transfer ownership and source routing under one routing lock**, + so output before the mark always reaches the source + (`transfer_ownership_and_source_routing_change_together`). +- **Must reject a repeated move while that Workspace is in flight**, preserving + the first attempt’s content and recovery state. Async continuations act only + on their own attempt (`keeps the first move recoverable when the same tab is + dropped twice` in `standalone/src/workspace-move.test.ts`). +- **A hand-back replays what the marked ids missed.** From an id's mark to the + hand-back every byte went to the target, or nowhere, so `hand_back_arrival` + returns each id the content marked to the source *suppressed* and asks the + sidecar for `outputSince(mark)` scoped to the source (`requestId` + `handback-`); that replay lifts the suppression and lands in the + existing xterms (`acceptHandBackReplay`), the held protocol events behind it. + **Must record source cuts at `pty:marked`, retaining them through target + replay and natural PTY exit until settlement, and carry replay ids in the failure event**; content + submission and the source invoke reply may both still be pending. **Must discard + cuts on explicit kill and never recreate an exited PTY’s owner on hand-back.** + **Must apply a handed-back PTY’s exit status after its replay**, leaving its + existing pane dead with no running command or active watch + (`settles the replayed watch when a marked buffer belongs to an exited PTY` in + `standalone/src/tauri-adapter.test.ts`). An id the + sidecar never stamped goes straight back: a whole-buffer + replay would paint it twice (`a_hand_back_replays_only_the_marked_ids`; + rationale). - **`planArrival` never throws into `bootstrap()`.** A refused sole arrival on the boot path renders a fresh one-pane Workspace, never a blank window. - **`take_arrivals` does not consume.** The record settles at `adopt_done`, so a diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index 5d222a954..cc74348c1 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -108,6 +108,31 @@ ends at `adopt_failed` or at the target's `Destroyed`, not at a timer. ## Arrival queue +**Why the mark is stamped in the stream rather than asked for.** A mark fetched +by request answers at some instant the sidecar chose, while the source's xterm +stands at whatever `pty:data` had reached it — two clocks nothing aligns, so a +serialization taken against a fetched mark either repeats or loses the bytes +between them. A `marked` line written into the same stdout as the data is +ordered with it by construction: the sidecar's reader is one thread, Rust's +reader is one thread, and the webview's event queue is one queue. The one gap +left is the parser's incomplete-sequence buffer, which can hold bytes older +than the mark past it; that tail is the same class of cut the bounded replay +always made, and the target's parser resynchronizes on the next ground byte +(2026-09). + + + +**Why a hand-back replays since the mark, and only the marked ids.** The first +hand-back returned the ids unsuppressed and silent: the source's xterm stood at +the mark, and every byte from there to the hand-back had gone to a target that +never mounted it — dropped while suppressed, or painted in a webview that then +closed. The since-mark replay is the arrival's own second half aimed back at the +source, which is why it rides the same `pty:requestInit` and the same +suppression-lifting `pty:replay` path rather than a new message. An id the +content did not mark has no such gap: the source either saw every byte live or +serialized the whole buffer it still holds, and the sidecar's only answer for +an unmarked id is that whole buffer again (2026-09). + The first build emitted `workspace-arriving` straight at the target. A window torn out seconds earlier, or one restoring at launch, has no listener yet and is a perfectly ordinary drop target — the payload went nowhere, and because the diff --git a/docs/specs/transport.md b/docs/specs/transport.md index dc9090a85..d6415131b 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -136,15 +136,43 @@ and it turns on three rules: from a webview unmount**: a Wall unmounts on a reload and on a StrictMode double-mount, and releasing there would strand every PTY the window still owns. A move the target never took leaves the Workspace exactly as it was. -- **Suppress until the replay.** The host moves ownership synchronously and - drops the moving PTYs' output until each one's replay has reached the new - owner, so no byte is painted twice and none is lost. It fails open after a - bound rather than silencing a pane forever (rationale). -- **Ask for exactly the moving ids.** `pty:requestInit` names them, and - `list(ids)` follows the same **omitted is not empty** rule `interrupt` carries - — a caller forwarding a computed set that came out empty gets a no-op, not - every PTY in the process. The moving ids include each pane's helper Session, - which no other field names. +- **Split at a mark stamped in the stream, then suppress until the replay.** + The host moves ownership synchronously but keeps every byte flowing to the + source until the sidecar's `pty:marked` line for the id — written behind every + `pty:data` it had sent and ahead of every one after — so the source, on + seeing it, drains xterm's write queue and holds exactly the bytes before the + mark. It serializes the buffer there (`serializeTerminal`, + `@xterm/addon-serialize`) and hands it over as the arrival's *content*; from + the mark the host drops the id's output until the target's replay has reached + it. The target writes the serialized buffer, then the replay of everything + after the mark, so the whole transcript crosses, not the sidecar's bounded + tail, and no byte is painted twice or lost. An id the host never marked is + serialized anyway and replayed whole. A hand-back is the same split kept: the + source still holds the bytes before the mark and receives the host's replay of + everything after it into the same xterm (`docs/specs/standalone.md` → + "Arrival queue"). **Must include retained, naturally exited buffers in explicit + marked requests with `alive: false` and their exit code**, replaying their + since-mark tail; ordinary discovery remains live-only, and explicit kill + discards the buffer (`list` in `standalone/sidecar/pty-core.js`). Suppression fails open after a bound rather than silencing + a pane forever (rationale). +- **Ask for exactly the moving ids, at their marks.** `pty:requestInit` names + them with their marks, and `list(ids, …, marks)` replays `outputSince(mark)` + for a marked id and the whole buffer otherwise; ids follow the same **omitted + is not empty** rule `interrupt` carries — a caller forwarding a computed set + that came out empty gets a no-op, not every PTY in the process. The moving + ids include each pane's helper Session, which no other field names. +- **Pins travel with the buffers.** The source takes each note's marker lines at + the instant it serializes (`snapshotTerminalPins`); the target re-registers + them at those lines once the rebuilt buffer has been parsed + (`restoreTerminalPins`), and the pin's byte-for-byte proof still decides + whether it is trusted (`docs/specs/notepad.md` → Source pins). + +Source of truth: `captureTransferContent` in +`lib/src/components/wall/workspace-transfer.ts`; `mark` / `list` in +`standalone/sidecar/pty-core.js`; `standalone/src/workspace-move.ts`. Pinned by +`a mark is ordered in the stream and a since-mark replay is exactly the +remainder` in `standalone/sidecar/pty-core.test.js` and +`standalone/src/workspace-move.test.ts`. **Cold restore** (neither live PTYs nor a browser-only resume) falls back to saved session state: new PTYs in the saved CWDs under the currently selected Dormouse shell, plus the saved Lath layout. No transcript is replayed ("What is persisted"), and any pane carrying a recovery command auto-runs it. `reconnect.ts` waits 500 ms for the PTY list, and 3 s more where a retry is asked for. diff --git a/lib/package.json b/lib/package.json index 91e1ef771..9d15a7a64 100644 --- a/lib/package.json +++ b/lib/package.json @@ -20,13 +20,14 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", - "@zxing/browser": "0.2.1", - "@zxing/library": "0.23.0", "@xterm/addon-fit": "0.12.0-beta.301", "@xterm/addon-image": "0.10.0-beta.301", + "@xterm/addon-serialize": "0.15.0-beta.301", "@xterm/addon-unicode-graphemes": "0.5.0-beta.301", "@xterm/addon-webgl": "0.20.0-beta.300", "@xterm/xterm": "6.1.0-beta.304", + "@zxing/browser": "0.2.1", + "@zxing/library": "0.23.0", "clsx": "^2.1.1", "dor-lib-common": "workspace:*", "fflate": "0.8.3", diff --git a/lib/src/components/wall/workspace-transfer.ts b/lib/src/components/wall/workspace-transfer.ts index 499ec9de9..c136f9502 100644 --- a/lib/src/components/wall/workspace-transfer.ts +++ b/lib/src/components/wall/workspace-transfer.ts @@ -1,6 +1,7 @@ -import { snapshotNotepadForTransfer, removeSurface } from '../../lib/notepad/notepad-store'; +import { snapshotNotepadForTransfer, snapshotTerminalPins, removeSurface } from '../../lib/notepad/notepad-store'; +import type { TransferredPin } from '../../lib/notepad/source-link'; import { forgetHelper, getHelper } from '../../lib/helper-terminal'; -import { releaseSession } from '../../lib/terminal-registry'; +import { releaseSession, serializeTerminal } from '../../lib/terminal-registry'; import type { VolatileNotepadSnapshot } from '../../lib/notepad/types'; import type { PersistedSession, PersistedWorkspace, WorkspaceId } from '../../lib/session-types'; import type { SaveOptions } from '../../lib/session-save'; @@ -18,8 +19,9 @@ export interface WorkspaceTransferPayload { workspaceId: WorkspaceId; /** What the target restores the Workspace from. */ workspace: PersistedWorkspace; - /** The notes riding along; the target hydrates them. Pins do not travel — - * they are markers in xterm instances this release disposes. */ + /** The notes riding along; the target hydrates them. Their pins follow in + * the content (`captureTransferContent`), once the buffers they point + * into have been serialized. */ notepad: VolatileNotepadSnapshot; /** Member Surfaces holding a PTY, **plus each one's helper Session**: exactly * what changes ownership. A helper is not a member Surface — it has no pane @@ -116,3 +118,46 @@ export async function prepareWorkspaceTransfer( }, }; } + +/** One terminal's half of a transfer's content: what the target writes before + * it attaches, and where the host's replay picks up. */ +export interface TransferredTerminal { + /** The buffer as the escape stream that rebuilds it; `''` for a Session this + * Window no longer held. */ + serialized: string; + /** The sidecar's output position the serialization stands at; absent when + * the host never stamped one, and the target then replays the whole buffer + * behind the serialized one. */ + mark?: number; +} + +/** The second half of a transfer's payload (`docs/specs/transport.md` → + * "Transferring a Workspace"): captured once every terminal's mark has + * passed, and attached to the arrival the host queued at the invoke. */ +export interface WorkspaceTransferContent { + terminals: Record; + pins: TransferredPin[]; +} + +/** + * Serialize every terminal at its mark, and take its pins at the same instant. + * + * **Only after the host's `marked` line for each id**: everything this Window + * was sent before that line is in the buffer once the write queue drains, and + * everything after it is what the target's since-mark replay carries — the two + * tile the stream with nothing lost and nothing twice. An id with no mark (the + * host never answered for it) is serialized anyway and replayed whole, which + * at worst repeats its tail. + */ +export async function captureTransferContent( + terminalIds: readonly string[], + marks: ReadonlyMap, +): Promise { + const terminals: Record = {}; + for (const id of terminalIds) { + const serialized = (await serializeTerminal(id)) ?? ''; + const mark = marks.get(id); + terminals[id] = mark === undefined ? { serialized } : { serialized, mark }; + } + return { terminals, pins: snapshotTerminalPins(terminalIds) }; +} diff --git a/lib/src/lib/notepad/notepad-store.test.ts b/lib/src/lib/notepad/notepad-store.test.ts index d610b4b35..3b8d8473c 100644 --- a/lib/src/lib/notepad/notepad-store.test.ts +++ b/lib/src/lib/notepad/notepad-store.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const registryMock = vi.hoisted(() => ({ getTerminalInstance: vi.fn<(id: string) => unknown>() })); +vi.mock('../terminal-registry', () => ({ getTerminalInstance: registryMock.getTerminalInstance })); import type { IMarker } from '@xterm/xterm'; import { FakePtyAdapter, setPlatform } from '../platform'; import type { CwdState } from '../terminal-state'; @@ -20,7 +23,9 @@ import { pendingBatchId, pruneEmptyNote, removeSurface, + restoreTerminalPins, registerNotepadSurfaceMetaResolver, + snapshotTerminalPins, setNoteText, setOpenNotepadId, setStagedArchiveDeletions, @@ -604,3 +609,64 @@ it('mirrors and resumes a pending batch even after its last note is deleted', as expect(pendingBatchId('s1')).toBe(batchId); expect(buildVolatileSnapshot().surfaces[0].notes).toEqual([]); }); + +describe('pins travelling with a Workspace', () => { + beforeEach(() => { + registryMock.getTerminalInstance.mockReset(); + }); + + it('snapshots the live pins of the named terminals and re-pins them at the same lines', () => { + const pinned = source('term-1'); + const noteId = addTerminalNote('term-1', [{ text: 'twelve chars' }], pinned)!; + addTerminalNote('term-1', [{ text: 'unpinned' }]); + addTerminalNote('term-2', [{ text: 'elsewhere' }], source('term-2')); + + const pins = snapshotTerminalPins(['term-1']); + expect(pins).toEqual([{ + surfaceId: 'term-1', + noteId, + startLine: 3, + endLine: 3, + startColumn: 0, + endColumn: 12, + shape: pinned.shape, + expectedRawText: pinned.expectedRawText, + }]); + + // The target: notes hydrated without sources, a rebuilt buffer, and the + // same absolute lines to point at. + clearAllNotepads(); + hydrateNotepadFromVolatile( + { surfaces: [{ surfaceId: 'term-1', surfaceTitle: '', surfaceKind: 'terminal', cwd: null, terminalId: 'term-1', + notes: [{ id: noteId, createdAt: 1, content: { kind: 'terminal', runs: [{ text: 'twelve chars' }] } }] }], stagedDeletions: {} }, + ['term-1'], + ); + const registered: number[] = []; + registryMock.getTerminalInstance.mockImplementation((id) => id === 'term-1' ? { + cols: 80, + buffer: { active: { type: 'normal', baseY: 0, cursorY: 5, length: 10, getLine: () => undefined } }, + registerMarker: (offset: number) => { registered.push(5 + offset); return marker(); }, + } : null); + restoreTerminalPins(pins); + expect(registered).toEqual([3, 3]); + expect(getNotes('term-1')[0].source?.terminalId).toBe('term-1'); + // Again is a no-op: a note already pinned is not pinned twice. + restoreTerminalPins(pins); + expect(registered).toEqual([3, 3]); + }); + + it('leaves a note unpinned when its terminal is not here or the lines fall outside the buffer', () => { + const noteId = addTerminalNote('term-1', [{ text: 'x' }])!; + const pin = { surfaceId: 'term-1', noteId, startLine: 30, endLine: 31, startColumn: 0, endColumn: 1, shape: 'linewise' as const, expectedRawText: 'x' }; + registryMock.getTerminalInstance.mockReturnValue(null); + restoreTerminalPins([pin]); + expect(getNotes('term-1')[0].source).toBeUndefined(); + registryMock.getTerminalInstance.mockReturnValue({ + cols: 80, + buffer: { active: { type: 'normal', baseY: 0, cursorY: 0, length: 10, getLine: () => undefined } }, + registerMarker: () => marker(), + }); + restoreTerminalPins([pin]); + expect(getNotes('term-1')[0].source).toBeUndefined(); + }); +}); diff --git a/lib/src/lib/notepad/notepad-store.ts b/lib/src/lib/notepad/notepad-store.ts index a54a29b67..32e96fdcb 100644 --- a/lib/src/lib/notepad/notepad-store.ts +++ b/lib/src/lib/notepad/notepad-store.ts @@ -8,6 +8,8 @@ import { hasTerminal, type SurfaceKind } from 'dor/commands/types'; import { getPlatformOrNull } from '../platform'; import type { CwdState } from '../terminal-state'; import { toArchivedNote } from './archive-model'; +import { getTerminalInstance } from '../terminal-registry'; +import { registerTerminalSourceAtLines, transferredPinOf, type TransferredPin } from './source-link'; import type { LiveNote, NotepadArchiveMutation, @@ -472,6 +474,47 @@ export function snapshotNotepadForTransfer(surfaceIds: Iterable): Volati return collectVolatile(notepadSurfaceIds().filter((id) => wanted.has(id))); } +/** + * The pins riding along with a Workspace, taken at the same instant as the + * terminal buffers they point into (`docs/specs/transport.md` → "Transferring + * a Workspace"). Helpers and browser Surfaces hold no sources, so they add none. + */ +export function snapshotTerminalPins(surfaceIds: Iterable): TransferredPin[] { + const pins: TransferredPin[] = []; + for (const surfaceId of surfaceIds) { + for (const note of getNotes(surfaceId)) { + const pin = note.source ? transferredPinOf(surfaceId, note.id, note.source) : null; + if (pin) pins.push(pin); + } + } + return pins; +} + +/** + * Re-pin transferred notes into the terminals this Window has rebuilt. Runs + * after `hydrateNotepadFromVolatile` and after each terminal's serialized + * buffer is written, so the lines line up; a pin whose note or terminal is not + * here, or whose lines fall outside the rebuilt buffer, is simply left unpinned + * — the note itself travelled either way. + */ +export function restoreTerminalPins(pins: readonly TransferredPin[]): void { + let changed = false; + for (const pin of pins) { + const notes = notesBySurface.get(pin.surfaceId); + const terminal = getTerminalInstance(pin.surfaceId); + if (!notes || !terminal) continue; + const index = notes.findIndex((note) => note.id === pin.noteId && !note.source); + if (index === -1) continue; + const source = registerTerminalSourceAtLines(terminal, pin); + if (!source) continue; + const next = [...notes]; + next[index] = { ...next[index], source }; + notesBySurface.set(pin.surfaceId, next); + changed = true; + } + if (changed) notify(); +} + function collectVolatile(ids: readonly string[]): VolatileNotepadSnapshot { const surfaces: VolatileSurfaceNotes[] = []; for (const surfaceId of ids) { diff --git a/lib/src/lib/notepad/source-link.test.ts b/lib/src/lib/notepad/source-link.test.ts index 9def01a8a..8aef6ae64 100644 --- a/lib/src/lib/notepad/source-link.test.ts +++ b/lib/src/lib/notepad/source-link.test.ts @@ -22,8 +22,10 @@ import { extractSelectionText } from '../selection-text'; import { disposeTerminalSource, registerTerminalSource, + registerTerminalSourceAtLines, resolveTerminalSource, revealResolvedSource, + transferredPinOf, type SourceTerminalLike, } from './source-link'; @@ -374,3 +376,48 @@ describe('revealResolvedSource', () => { expect(getMouseSelectionState('reveal').selection).toBeNull(); }); }); + +describe('a pin travelling with a Workspace', () => { + const LINES = ['zero', 'one', 'two', 'three', 'four']; + + it('round-trips through absolute lines onto a buffer rebuilt to the same shape', () => { + const source = makeTerminal(LINES, { baseY: 2, cursorY: 2 }); + const pinned = capture(source, sel({ startRow: 1, startCol: 1, endRow: 3, endCol: 2 })); + const pin = transferredPinOf('t1', 'note-1', pinned); + expect(pin).toEqual({ + surfaceId: 't1', + noteId: 'note-1', + startLine: 1, + endLine: 3, + startColumn: pinned.startColumn, + endColumn: pinned.endColumn, + shape: 'linewise', + expectedRawText: pinned.expectedRawText, + }); + + // The target's rebuilt buffer stands at a different cursor: absolute lines + // are what carry over, and the proof still reads back byte for byte. + const target = makeTerminal(LINES, { baseY: 1, cursorY: 3 }); + const restored = registerTerminalSourceAtLines(target.terminal, pin!); + expect(restored?.startMarker.line).toBe(1); + expect(restored?.endMarker.line).toBe(3); + expect(resolveTerminalSource(target.terminal, restored!)).toMatchObject({ ok: true }); + }); + + it('carries nothing for a pin whose markers are already gone, and refuses lines off the buffer', () => { + const source = makeTerminal(LINES); + const pinned = capture(source, sel({ startRow: 0, endRow: 1 })); + pinned.startMarker.dispose(); + expect(transferredPinOf('t1', 'note-1', pinned)).toBeNull(); + + const short = makeTerminal(['only']); + expect(registerTerminalSourceAtLines(short.terminal, { + surfaceId: 't1', noteId: 'n', startLine: 0, endLine: 3, startColumn: 0, endColumn: 0, shape: 'linewise', expectedRawText: '', + })).toBeNull(); + expect(short.markers.every((marker) => marker.isDisposed)).toBe(true); + const alternate = makeTerminal(LINES, { type: 'alternate' }); + expect(registerTerminalSourceAtLines(alternate.terminal, { + surfaceId: 't1', noteId: 'n', startLine: 0, endLine: 1, startColumn: 0, endColumn: 0, shape: 'linewise', expectedRawText: '', + })).toBeNull(); + }); +}); diff --git a/lib/src/lib/notepad/source-link.ts b/lib/src/lib/notepad/source-link.ts index e50a1b917..d1d2d6e97 100644 --- a/lib/src/lib/notepad/source-link.ts +++ b/lib/src/lib/notepad/source-link.ts @@ -74,6 +74,71 @@ export function registerTerminalSource( }; } +/** + * A pin as it travels with a Workspace to another Window + * (`docs/specs/transport.md` → "Transferring a Workspace"): the markers' + * absolute buffer lines at the moment the source serialized, plus everything + * `registerTerminalSource` kept beside them. The target rebuilds the buffer + * from that serialization first, so the same lines hold the same text — which + * `resolveTerminalSource` then proves before the pin is trusted. + */ +export interface TransferredPin { + surfaceId: string; + noteId: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + shape: 'linewise' | 'block'; + expectedRawText: string; +} + +/** What a live source looks like on the wire; `null` for one whose markers + * are already gone. */ +export function transferredPinOf(surfaceId: string, noteId: string, source: RuntimeTerminalSource): TransferredPin | null { + if (source.startMarker.isDisposed || source.endMarker.isDisposed) return null; + return { + surfaceId, + noteId, + startLine: source.startMarker.line, + endLine: source.endMarker.line, + startColumn: source.startColumn, + endColumn: source.endColumn, + shape: source.shape, + expectedRawText: source.expectedRawText, + }; +} + +/** Re-pin at absolute buffer lines: the inverse of `transferredPinOf`, on a + * buffer rebuilt to the same shape. Null when either line is out of the + * buffer, or the terminal is on its alternate buffer. */ +export function registerTerminalSourceAtLines( + terminal: SourceTerminalLike, + pin: TransferredPin, +): RuntimeTerminalSource | null { + const buf = terminal.buffer.active; + if (buf.type === 'alternate') return null; + if (pin.startLine < 0 || pin.endLine >= buf.length || pin.endLine < pin.startLine) return null; + const cursorRow = buf.baseY + buf.cursorY; + const startMarker = terminal.registerMarker(pin.startLine - cursorRow); + if (!startMarker || startMarker.isDisposed) return null; + const endMarker = terminal.registerMarker(pin.endLine - cursorRow); + if (!endMarker || endMarker.isDisposed) { + startMarker.dispose(); + endMarker?.dispose(); + return null; + } + return { + terminalId: pin.surfaceId, + startMarker, + endMarker, + startColumn: pin.startColumn, + endColumn: pin.endColumn, + shape: pin.shape, + expectedRawText: pin.expectedRawText, + }; +} + /** * Rebuild the captured range from the markers' current lines and the stored * columns, then prove it: the candidate range must read back exactly the text diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index a3d79cc4f..7a4fd59cb 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -33,6 +33,15 @@ export interface PtyReplayDetail { requestId?: string; } +/** A PTY's output position, stamped in the stream by the host for a transfer + * (`docs/specs/transport.md` → "Transferring a Workspace"): every `pty:data` + * delivered before it is at or before `mark`. */ +export interface PtyMarkedDetail { + id: string; + mark: number; + requestId?: string; +} + /** * A TCP socket in the LISTEN state opened by a terminal's shell process or any * of its descendant subprocesses. `address` is the bind interface — `0.0.0.0` @@ -411,6 +420,9 @@ export interface PlatformAdapter { offPtyList(handler: (detail: PtyListDetail) => void): void; onPtyReplay(handler: (detail: PtyReplayDetail) => void): void; offPtyReplay(handler: (detail: PtyReplayDetail) => void): void; + /** Hosts that hand Workspaces between windows stamp marks; returns the + * unsubscribe. Absent on hosts with one window. */ + onPtyMarked?(handler: (detail: PtyMarkedDetail) => void): () => void; // Host-initiated session persistence onRequestSessionFlush(handler: (detail: SessionFlushRequest) => void): void; diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 5cf969c51..735e4e6e2 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -90,6 +90,18 @@ describe('resumeOrRestore', () => { vi.clearAllMocks(); }); + it('resumes an explicitly listed exited buffer without restarting its shell', async () => { + const platform = createPlatform([{ id: 'exited', alive: false, exitCode: 7 }], null); + const live = await collectLivePtys(platform); + expect(live.timedOut).toBe(false); + expect(live.replay.get('exited')).toBe('exited-replay'); + const result = resumeOrRestoreFrom(platform, live); + expect(result.paneIds).toEqual(['exited']); + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('exited', 'exited-replay', { alive: false, exitCode: 7 }); + expect(terminalRegistryMocks.restoreTerminal).not.toHaveBeenCalled(); + expect(platform.spawnPty).not.toHaveBeenCalled(); + }); + it('restores helpers outside the primary layout and disarms autorun', async () => { const layout = lathLayoutFor('parent'); const helper = { parentId: 'parent', command: 'git status' }; diff --git a/lib/src/lib/terminal-lifecycle.release.test.ts b/lib/src/lib/terminal-lifecycle.release.test.ts index d98870999..261871f31 100644 --- a/lib/src/lib/terminal-lifecycle.release.test.ts +++ b/lib/src/lib/terminal-lifecycle.release.test.ts @@ -14,6 +14,7 @@ vi.mock('@xterm/addon-fit', () => ({ }, })); vi.mock('@xterm/addon-image', () => ({ ImageAddon: class {} })); +vi.mock('@xterm/addon-serialize', () => ({ SerializeAddon: class { serialize(): string { return ''; } } })); vi.mock('@xterm/addon-unicode-graphemes', () => ({ UnicodeGraphemesAddon: class {} })); vi.mock('@xterm/xterm', () => ({ Terminal: class { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 72c54bbd7..f19788271 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,5 +1,6 @@ import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; +import { SerializeAddon } from '@xterm/addon-serialize'; import { ImageAddon, type IImageAddonOptions } from '@xterm/addon-image'; import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; import { TerminalWebglRenderer } from './terminal-webgl'; @@ -132,7 +133,7 @@ function readDisplayTextFromBuffer(terminal: Terminal, range: IBufferRange): str } } -function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } { +function createXtermHost(): { terminal: Terminal; fit: FitAddon; serialize: SerializeAddon; element: HTMLDivElement } { const styles = getComputedStyle(document.body); const editorFontSize = parseInt(styles.getPropertyValue('--vscode-editor-font-size'), 10) || 12; const editorFontFamily = styles.getPropertyValue('--vscode-editor-font-family').trim() || "'SF Mono', Menlo, Monaco, monospace"; @@ -179,6 +180,8 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi terminal.loadAddon(new UnicodeGraphemesAddon()); const fit = new FitAddon(); terminal.loadAddon(fit); + const serialize = new SerializeAddon(); + terminal.loadAddon(serialize); if (cfg.terminal.inlineImages) terminal.loadAddon(new ImageAddon(IMAGE_ADDON_OPTIONS)); const element = document.createElement('div'); @@ -187,7 +190,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi terminal.open(element); paintTerminalHost(element, terminal, theme.background); - return { terminal, fit, element }; + return { terminal, fit, serialize, element }; } /** PTY data/exit listeners. Returns the unsubscribe pair. */ @@ -288,7 +291,7 @@ function wireXtermHandlers( } function setupTerminalEntry(id: string, options: { shell?: string; untouched?: boolean; helper?: HelperIdentity } = {}): TerminalEntry { - const { terminal, fit, element } = createXtermHost(); + const { terminal, fit, serialize, element } = createXtermHost(); const selectionBaselineRef = { current: null as string | null }; // Every module that finalizes a selection arms the render handler through // this one setter: the mouse router at drag end, a note's pin on reveal. @@ -323,6 +326,7 @@ function setupTerminalEntry(id: string, options: { shell?: string; untouched?: b shellKind: shellCommandKind(options.shell, PLATFORM_STRING), terminal, fit, + serialize, element, cleanup, setSelectionBaseline, @@ -528,6 +532,30 @@ export function mountElement(id: string, container: HTMLElement): void { (entry.webglRenderer ??= new TerminalWebglRenderer(entry.terminal, entry.element)).mount(); } +/** + * The buffer as the escape stream that rebuilds it — scrollback, cursor, modes + * — for a Session about to be handed to another Window + * (`docs/specs/transport.md` → "Transferring a Workspace"). **Flushed first**: + * xterm parses writes asynchronously, and the split point the host stamped is + * everything this Session was *sent*, so anything still queued is drained into + * the buffer before it is read. Null for a Session this webview does not hold. + */ +export async function serializeTerminal(id: string): Promise { + const entry = registry.get(id); + if (!entry) return null; + await flushTerminal(id); + return entry.serialize.serialize(); +} + +/** Resolves once everything written to the Session so far is in its buffer. + * xterm parses asynchronously, so a reader of buffer lines — a pin being + * re-registered over a rebuilt transcript — waits here first. */ +export function flushTerminal(id: string): Promise { + const entry = registry.get(id); + if (!entry) return Promise.resolve(); + return new Promise((resolve) => entry.terminal.write('', resolve)); +} + /** Where a hidden helper's xterm element waits between reveals: still in the * document, preserving its DOM state and scrollback * (docs/specs/terminal-context.md → Helper lifecycle). */ diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 8ad05904f..bb1a5c908 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -52,6 +52,8 @@ export { releaseSession, restoreTerminal, resumeTerminal, + serializeTerminal, + flushTerminal, setPendingShellOpts, unmountElement, } from './terminal-lifecycle'; diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 2bf3f8f6c..0ae4f5026 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -1,4 +1,5 @@ import type { TerminalWebglRenderer } from './terminal-webgl'; +import type { SerializeAddon } from '@xterm/addon-serialize'; import type { HelperIdentity } from './terminal-context-types'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; @@ -23,6 +24,9 @@ export interface TerminalEntry { untouched: boolean; /** Renderer ownership follows mount/unmount rather than terminal lifetime. */ webglRenderer?: TerminalWebglRenderer; + /** Reads the buffer back as the escape stream that rebuilds it, for a + * transfer (`serializeTerminal`). Loaded at create: it costs nothing idle. */ + serialize: SerializeAddon; /** * The PTY process has exited (onPtyExit fired or resume restored it as * exited) but the pane lingers in the registry showing "[Process exited…]". diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9de83f766..5bf24d262 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@xterm/addon-image': specifier: 0.10.0-beta.301 version: 0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304) + '@xterm/addon-serialize': + specifier: 0.15.0-beta.301 + version: 0.15.0-beta.301(@xterm/xterm@6.1.0-beta.304) '@xterm/addon-unicode-graphemes': specifier: 0.5.0-beta.301 version: 0.5.0-beta.301(@xterm/xterm@6.1.0-beta.304) @@ -245,6 +248,9 @@ importers: '@xterm/addon-image': specifier: 0.10.0-beta.301 version: 0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304) + '@xterm/addon-serialize': + specifier: 0.15.0-beta.301 + version: 0.15.0-beta.301(@xterm/xterm@6.1.0-beta.304) '@xterm/addon-unicode-graphemes': specifier: 0.5.0-beta.301 version: 0.5.0-beta.301(@xterm/xterm@6.1.0-beta.304) @@ -2397,6 +2403,11 @@ packages: peerDependencies: '@xterm/xterm': ^6.1.0-beta.304 + '@xterm/addon-serialize@0.15.0-beta.301': + resolution: {integrity: sha512-zWT0Sy5GCSTnarAXRiicu3XYHgam8DPtpkWfSLPME+A3s7U1A6Jy+ZATt8vMkatyeVOPI0GICYRuQJLZVgnVow==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.304 + '@xterm/addon-unicode-graphemes@0.5.0-beta.301': resolution: {integrity: sha512-EBZIZYS6RItWTGO/KpLqV6y3IKGYyugbP07lK0x6G7185FVA61L2t0/XVtVEQNpczsbPo8zAxBhpoV72TP8CYw==} peerDependencies: @@ -6478,6 +6489,10 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.304 + '@xterm/addon-serialize@0.15.0-beta.301(@xterm/xterm@6.1.0-beta.304)': + dependencies: + '@xterm/xterm': 6.1.0-beta.304 + '@xterm/addon-unicode-graphemes@0.5.0-beta.301(@xterm/xterm@6.1.0-beta.304)': dependencies: '@xterm/xterm': 6.1.0-beta.304 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index c11a2bdc5..f048c7d14 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,13 +24,13 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 9600, + "docs/specs/standalone.md": 9900, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5500, + "docs/specs/transport.md": 5750, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/package.json b/standalone/package.json index d6f516b64..036d1aa39 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -23,6 +23,7 @@ "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "0.12.0-beta.301", "@xterm/addon-image": "0.10.0-beta.301", + "@xterm/addon-serialize": "0.15.0-beta.301", "@xterm/addon-unicode-graphemes": "0.5.0-beta.301", "@xterm/addon-webgl": "0.20.0-beta.300", "@xterm/xterm": "6.1.0-beta.304", diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index dd851377a..fd29c4a6c 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -149,7 +149,8 @@ function handleLine(line) { case 'pty:kill': mgr.kill(data.id); break; // One window's own PTYs, and the answer names it so the host can route // the list and every replay behind it back (docs/specs/standalone.md). - case 'pty:requestInit': mgr.list(data?.ids, data?.forWindow, data?.requestId); break; + case 'pty:requestInit': mgr.list(data?.ids, data?.forWindow, data?.requestId, data?.marks); break; + case 'pty:mark': mgr.mark(data?.ids, data?.requestId); break; case 'pty:context': mgr.context(data, data.requestId); break; case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break; case 'pty:getCwds': mgr.getCwds(data.ids, data.requestId); break; diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 9e6aae250..204db1fb6 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1245,7 +1245,7 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice cancelRepaint(id); ptys.set(id, p); - const session = { chunks: [], chars: 0, received: 0 }; + const session = { chunks: [], chars: 0, received: 0, shell: config.shell }; sessions.set(id, session); ptyShells.set(id, config.shell); @@ -1268,6 +1268,7 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice }); p.onExit(({ exitCode, signal }) => { + session.exitCode = exitCode; send('exit', { id, exitCode, signal }); if (ptys.get(id) === p) { cancelRepaint(id); @@ -1377,17 +1378,44 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice * collector can tell its own answer from a concurrent one's * (docs/specs/transport.md -> "Reconnection"). */ - function list(ids, forWindow, requestId) { - const targets = Array.isArray(ids) ? ids.filter((id) => ptys.has(id)) : [...ptys.keys()]; + function list(ids, forWindow, requestId, marks) { + // Explicit marked requests may resume a naturally exited buffer. Ordinary + // discovery still lists only live PTYs, and kill removes the retained buffer. + const targets = Array.isArray(ids) + ? ids.filter((id) => ptys.has(id) || (replay && typeof marks?.[id] === 'number' && sessions.has(id))) + : [...ptys.keys()]; const result = targets.map((id) => ({ - id, alive: true, shell: ptyShells.get(id), ...(helpers.has(id) ? { helper: helpers.get(id) } : {}), + id, alive: ptys.has(id), shell: sessions.get(id)?.shell, + ...(!ptys.has(id) ? { exitCode: sessions.get(id)?.exitCode } : {}), ...(helpers.has(id) ? { helper: helpers.get(id) } : {}), })); const addressed = { ...(forWindow ? { forWindow } : {}), ...(requestId === undefined || requestId === null ? {} : { requestId }), }; send('list', { ptys: result, ...addressed }); - if (replay) for (const { id } of result) send('replay', { id, data: sessions.get(id).chunks.join(''), ...addressed }); + if (!replay) return; + for (const { id } of result) { + // A marked id replays only what came after its mark: the window that + // asked already holds everything before it, serialized by the window + // that handed the PTY over (docs/specs/transport.md -> "Transferring a + // Workspace"). Without a shared `sliceSince` there is no mark arithmetic, + // and the whole buffer is the safe answer. + const mark = marks && typeof marks[id] === 'number' ? marks[id] : null; + const data = mark !== null && sliceSince ? outputSince(id, mark) : sessions.get(id).chunks.join(''); + send('replay', { id, data, ...addressed }); + } + } + + /** Stamp each PTY's output position, **in the stream**: the `marked` line is + * written behind every `data` line already sent for the id and ahead of every + * one after it, so a reader that consumes in order holds exactly the bytes + * before the mark when it sees it. Unknown ids answer 0, so a caller waiting + * on a set never hangs on a PTY that exited meanwhile. */ + function mark(ids, requestId) { + const addressed = requestId === undefined || requestId === null ? {} : { requestId }; + for (const id of Array.isArray(ids) ? ids : []) { + send('marked', { id, mark: receivedChars(id), ...addressed }); + } } // Only explicit settings edits write this installation-global preference. No @@ -1570,5 +1598,5 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice return { spawn, write, resize, hasPty, kill, killAll, list, context, getCwd, getCwds, getOpenPorts, getOpenPortsMany, interrupt, gracefulKill, getShells, - liveIds, receivedChars, outputSince }; + liveIds, receivedChars, outputSince, mark }; }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 8e27cebc5..77a947b4d 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1886,6 +1886,65 @@ test('a chunk emitted just before list([id]) appears in the replay exactly once' assert.equal(replay.data.data.split('mid-transfer').length - 1, 1); }); +// A transfer's split point: everything a reader consumed before it saw the +// `marked` line is at or before the mark, and a replay since that mark is +// exactly the rest — so the two halves tile the stream with nothing lost and +// nothing twice, however the chunks fell around the request. +test('a mark is ordered in the stream and a since-mark replay is exactly the remainder', () => { + const events = []; + const pty = fakePtyModule(); + const sliceSince = (chunks, held, received, mark) => { + const skip = Math.max(0, held - (received - mark)); + return chunks.join('').slice(skip); + }; + const mgr = create((event, data) => { events.push({ event, data }); }, pty.module, { replay: true, sliceSince }); + mgr.spawn('a'); + pty.listeners.get('a').data('one'); + pty.listeners.get('a').data('two'); + mgr.mark(['a', 'gone'], 'req-1'); + pty.listeners.get('a').data('three'); + + const order = events.map((entry) => entry.event === 'data' ? entry.data.data : entry.event); + assert.deepEqual(order.slice(0, 5), ['one', 'two', 'marked', 'marked', 'three']); + const marked = events.filter((entry) => entry.event === 'marked').map((entry) => entry.data); + assert.deepEqual(marked, [{ id: 'a', mark: 6, requestId: 'req-1' }, { id: 'gone', mark: 0, requestId: 'req-1' }]); + + mgr.list(['a'], 'ws-2', 'init-1', { a: 6 }); + const replay = events.filter((entry) => entry.event === 'replay').at(-1); + assert.deepEqual(replay.data, { id: 'a', data: 'three', forWindow: 'ws-2', requestId: 'init-1' }); + // An unmarked id in the same request still gets its whole buffer. + mgr.list(['a'], 'ws-2', 'init-2', {}); + assert.equal(events.filter((entry) => entry.event === 'replay').at(-1).data.data, 'onetwothree'); +}); + +test('marked requests recover exited buffers without reviving or discovering the PTY', () => { + const events = []; + const pty = fakePtyModule(); + const mgr = create((event, data) => events.push({ event, data }), pty.module, { + replay: true, + sliceSince: (chunks, held, received, mark) => chunks.join('').slice(Math.max(0, held - (received - mark))), + }); + mgr.spawn('a'); + pty.listeners.get('a').data('before'); + mgr.mark(['a'], 'mark-1'); + pty.listeners.get('a').data('after'); + pty.listeners.get('a').exit({ exitCode: 7 }); + assert.equal(mgr.hasPty('a'), false); + events.length = 0; + mgr.list(undefined, 'main', 'discovery'); + assert.deepEqual(events[0].data.ptys, []); + assert.equal(events.length, 1); + events.length = 0; + mgr.list(['a'], 'main', 'handback-1', { a: 6 }); + assert.deepEqual(events[0].data.ptys.map(({ id, alive, exitCode }) => ({ id, alive, exitCode })), [{ id: 'a', alive: false, exitCode: 7 }]); + assert.deepEqual(events[1], { event: 'replay', data: { id: 'a', data: 'after', forWindow: 'main', requestId: 'handback-1' } }); + mgr.kill('a'); + events.length = 0; + mgr.list(['a'], 'main', 'after-kill', { a: 6 }); + assert.deepEqual(events[0].data.ptys, []); + assert.equal(events.length, 1); +}); + test('gracefulKill targets only the named PTYs', async () => { const events = []; const pty = fakePtyModule(); diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 1ab85d670..b534a858f 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -91,11 +91,23 @@ struct RoutingState { /// to the new owner behind its replay (`routing::Route::Hold`). Only ever /// emptied together with `awaiting_replay` (`lift_suppression`). held: HashMap>, + /// Ids between a transfer's invoke and the sidecar's `marked` line, each + /// with the source still consuming (`routing::RouteView::marking`). + marking: HashMap, + // Source cut points survive target replay until the arrival settles. + transfer_marks: HashMap, } impl RoutingState { /// `routing::lift_suppression` over this state's two halves. The caller /// republishes `WindowState::suppressed` after it, still under the lock. + fn mark_transfer(&mut self, id: &str, mark: u64) { + if self.marking.remove(id).is_some() { + self.transfer_marks.insert(id.to_string(), mark); + self.awaiting_replay.insert(id.to_string(), Instant::now()); + } + } + fn lift_suppression(&mut self, id: &str) -> Vec { routing::lift_suppression(&mut self.awaiting_replay, &mut self.held, id) } @@ -157,6 +169,7 @@ impl WindowState { fn mint(&self, id: &str, label: &str) { let mut routing = guard(&self.routing); routing.owners.insert(id.to_string(), label.to_string()); + routing.transfer_marks.remove(id); // Whatever was held belonged to the PTY that never arrived, not this one. routing.lift_suppression(id); self.suppressed @@ -185,25 +198,63 @@ impl WindowState { if suppress { routing.awaiting_replay.insert(id.clone(), now); } else { - // A hand-back. The gap is lost here: the source was suppressed - // like any other non-owner from the invoke on, and no replay - // follows a hand-back, so the bytes and everything derived from - // them are gone from its pane. A later stage recovers the gap - // (docs/specs/standalone.md -> "Arrival queue"). + // A hand-back. What was held for the target has nothing to + // follow here: an unmarked id's source saw every byte live, + // and a marked one is re-suppressed by `hand_back_arrival` + // until its since-mark replay. routing.lift_suppression(id); + routing.marking.remove(id); } } self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } - /// Forget one PTY entirely (a kill, or its exit). - fn forget_pty(&self, id: &str) { + /// Move ownership and open source routing under one lock, before any chunk + /// can observe the target owner without the source's marking phase. + fn begin_transfer(&self, ids: &[String], source: &str, target: &str) { + let mut routing = guard(&self.routing); + for id in ids { + if let Some(owner) = routing.owners.get_mut(id) { + *owner = target.to_string(); + } + routing.lift_suppression(id); + routing.transfer_marks.remove(id); + routing.marking.insert(id.clone(), source.to_string()); + } + self.suppressed.store(routing.awaiting_replay.len(), Ordering::Relaxed); + } + + /// Drain the source cuts and return only still-live ownership. An exited + /// id gets its retained replay by explicit address, never a phantom owner. + fn hand_back(&self, ids: &[String], source: &str) -> JsonValue { + let mut routing = guard(&self.routing); + let mut marks = serde_json::Map::new(); + for id in ids { + let mark = routing.transfer_marks.remove(id); + if let Some(mark) = mark { marks.insert(id.clone(), JsonValue::from(mark)); } + routing.marking.remove(id); + if let Some(owner) = routing.owners.get_mut(id) { + *owner = source.to_string(); + if mark.is_some() { routing.awaiting_replay.insert(id.clone(), Instant::now()); } + else { routing.lift_suppression(id); } + } + } + self.suppressed.store(routing.awaiting_replay.len(), Ordering::Relaxed); + JsonValue::Object(marks) + } + + fn forget_pty(&self, id: &str) { self.remove_pty(id, false); } + fn exited_pty(&self, id: &str) { self.remove_pty(id, true); } + + fn remove_pty(&self, id: &str, keep_cut: bool) { let mut routing = guard(&self.routing); routing.owners.remove(id); routing.lift_suppression(id); - self.suppressed - .store(routing.awaiting_replay.len(), Ordering::Relaxed); + routing.marking.remove(id); + // Natural exit retains the sidecar buffer; explicit kill discards it. + if !keep_cut { routing.transfer_marks.remove(id); } + self.suppressed.store(routing.awaiting_replay.len(), Ordering::Relaxed); } /// Drop any suppression on `ids`, leaving ownership alone. What settles an @@ -214,6 +265,7 @@ impl WindowState { let mut routing = guard(&self.routing); for id in ids { routing.lift_suppression(id); + routing.transfer_marks.remove(id); } self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); @@ -340,6 +392,7 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { awaiting_replay: &routing.awaiting_replay, dor_targets: &routing.dor_targets, registry: ®istry, + marking: &routing.marking, }, ) { Route::Drop => Delivery::Nowhere, @@ -411,7 +464,20 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { match event { "pty:exit" => { if let Some(id) = id() { - state.forget_pty(id); + state.exited_pty(id); + } + } + // The source has been sent everything before the mark; from here the + // id is silent until the target's replay of everything after it. + "pty:marked" => { + if let Some(id) = id() { + let mut routing = guard(&state.routing); + if let Some(mark) = data.get("mark").and_then(JsonValue::as_u64) { + routing.mark_transfer(id, mark); + state + .suppressed + .store(routing.awaiting_replay.len(), Ordering::Relaxed); + } } } "pty:replay" => { @@ -2799,6 +2865,8 @@ fn arrival_from(from: &str, to: &str, payload: JsonValue) -> Result() { + let msg = serde_json::json!({ + "event": "pty:mark", + "data": { "ids": arrival.terminal_ids, "requestId": format!("mark-{}", arrival.workspace_id) }, + }); + send_to_sidecar(&sidecar, msg.to_string()); + } // Recorded on disk here, in neither window's snapshot: the source omits a // transferring Workspace from its saves and the target writes only after // adoption, so a crash in the gap would otherwise restore it nowhere @@ -2866,10 +2946,19 @@ fn spawn_arrival_watchdog(app: AppHandle, arrival: &routing::Arrival) { }); } -/// One arrival will never be adopted: give its shells back to the source, -/// unsuppressed, and tell the source so it clears the Workspace's transferring -/// mark. **The Workspace simply stays where it is** — nothing was released, so -/// there is nothing to put back. +/// One arrival will never be adopted: give its shells back to the source and +/// tell the source so it clears the Workspace's transferring mark. **The +/// Workspace simply stays where it is** — nothing was released, so there is +/// nothing to put back. +/// +/// **A marked id goes back suppressed, behind a replay of what it missed.** +/// From its mark to now every byte went to the target, or nowhere, and the +/// source's xterm stands at the mark; so the sidecar is asked for +/// `outputSince(mark)` scoped to the source, and that replay lifts the +/// suppression on its way out (`dispatch_sidecar_event`), the held protocol +/// events behind it. Marks come from the routing state at `pty:marked`, never +/// from the later serialized content. Only an id the sidecar never stamped +/// goes straight back. /// /// The record must already be out of the queue; the caller took it. fn hand_back_arrival( @@ -2888,12 +2977,31 @@ fn hand_back_arrival( append_log(format!("[window] could not record hand-back: {e}")); } } - windows.reassign(&arrival.terminal_ids, &arrival.from, false); + let marks = windows.hand_back(&arrival.terminal_ids, &arrival.from); + let (marked, _) = routing::hand_back_ids(arrival, &marks); + // Told before the replay is asked for, so the source is listening for + // it (`acceptHandBackReplay` in `standalone/src/workspace-move.ts`). + let _ = app.emit_to( arrival.from.as_str(), "dormouse://workspace-arrival-failed", - serde_json::json!({ "workspaceId": arrival.workspace_id, "reason": reason }), + serde_json::json!({ "workspaceId": arrival.workspace_id, "reason": reason, "replayIds": marked }), ); + if marked.is_empty() { + return; + } + if let Some(sidecar) = app.try_state::() { + let msg = serde_json::json!({ + "event": "pty:requestInit", + "data": { + "forWindow": arrival.from, + "ids": marked, + "requestId": format!("handback-{}", arrival.workspace_id), + "marks": marks, + }, + }); + send_to_sidecar(&sidecar, msg.to_string()); + } return; } if let Ok(dir) = sessions_dir(app) { @@ -2945,29 +3053,68 @@ fn open_workspace_window( _ => None, } }; - let arrival = arrival_from(window.label(), &label, payload)?; - // The one thing needed after the record is queued, so the payload itself is - // moved rather than cloned. - let workspace_id = arrival.workspace_id.clone(); - append_log(format!("[window] tearing {workspace_id} out into {label}")); + let mut arrival = arrival_from(window.label(), &label, payload)?; + // Built once the content lands (`transfer_workspace_content`), so the new + // window's boot drains a payload that is complete; held as JSON so the + // record stays free of window types. + arrival.pending_window = Some(match geometry { + Some(g) => serde_json::json!({ "x": g.x, "y": g.y, "width": g.width, "height": g.height }), + None => JsonValue::Null, + }); + append_log(format!("[window] tearing {} out into {label}", arrival.workspace_id)); begin_arrival(&app, &windows, arrival)?; - if let Err(err) = build_window(&app, &label, geometry) { - // Nothing will ever drain the queue, and the PTYs would stay suppressed - // and ownerless. The source is waiting on this `Err` and has released - // nothing, so the ids go back in silence — no `arrival-failed`, which - // would clear a transferring mark that was never set. - if let Some(arrival) = - routing::take_arrival(&mut guard(&windows.arrivals), &workspace_id, &label) - { - windows.reassign(&arrival.terminal_ids, &arrival.from, false); - if let Ok(dir) = sessions_dir(&app) { - let _ = return_arrival_on_disk(&dir, &arrival); + + Ok(label) +} + +/// The source has serialized every terminal at its mark: attach the content, +/// then either build the torn-out window or nudge the existing target. +#[tauri::command(async)] +fn transfer_workspace_content( + app: AppHandle, + window: tauri::Window, + windows: tauri::State<'_, WindowState>, + workspace_id: String, + content: JsonValue, +) -> Result<(), String> { + let (to, pending_window) = { + let mut arrivals = guard(&windows.arrivals); + let arrival = arrivals + .iter_mut() + .find(|arrival| arrival.workspace_id == workspace_id && arrival.from == window.label()) + .ok_or_else(|| format!("no arrival of '{workspace_id}' from {}", window.label()))?; + arrival.content = Some(content); + (arrival.to.clone(), arrival.pending_window.take()) + }; + match pending_window { + Some(geometry) => { + let geometry = geometry.as_object().map(|g| WindowGeometry { + x: g.get("x").and_then(JsonValue::as_f64).unwrap_or(0.0), + y: g.get("y").and_then(JsonValue::as_f64).unwrap_or(0.0), + width: g.get("width").and_then(JsonValue::as_f64).unwrap_or(0.0), + height: g.get("height").and_then(JsonValue::as_f64).unwrap_or(0.0), + }); + if let Err(err) = build_window(&app, &to, geometry) { + // Nothing will ever drain the queue, and the source has + // already marked the Workspace transferring — `handOff` set + // it when the invoke returned. So the ids go back *and* the + // source is told: `arrival-failed` is what clears that mark. + if let Some(arrival) = + routing::take_arrival(&mut guard(&windows.arrivals), &workspace_id, &to) + { + hand_back_arrival(&app, &windows, &arrival, "the new window could not be built"); + } + return Err(err); } + send_window_labels(&app); + } + None => { + // A nudge, carrying nothing: the payload is in the queue, and a + // window with no listener yet finds it there. + let _ = app.emit_to(to.as_str(), "dormouse://workspace-arriving", ()); } - return Err(err); } - send_window_labels(&app); - Ok(label) + Ok(()) } /// Move a Workspace into a window that already exists. @@ -3001,9 +3148,7 @@ fn transfer_workspace( if let Some(target) = app.get_webview_window(&to) { let _ = target.set_focus(); } - // A nudge, carrying nothing: the payload is in the queue, and a window with - // no listener yet finds it there. - let _ = app.emit_to(to.as_str(), "dormouse://workspace-arriving", ()); + // Nudged from `transfer_workspace_content`, once there is content to drain. Ok(()) } @@ -3030,16 +3175,16 @@ fn adopt_ready( request_id: Option, ) -> Result<(), String> { let label = window.label(); - let ids = { + let (ids, marks) = { let arrivals = guard(&windows.arrivals); let arrival = routing::find_arrival(&arrivals, &workspace_id) .filter(|arrival| arrival.to == label) .ok_or_else(|| format!("no arrival of '{workspace_id}' into {label}"))?; - arrival.terminal_ids.clone() + (arrival.terminal_ids.clone(), routing::arrival_marks(arrival)) }; let msg = serde_json::json!({ "event": "pty:requestInit", - "data": { "forWindow": label, "ids": ids, "requestId": request_id }, + "data": { "forWindow": label, "ids": ids, "requestId": request_id, "marks": marks }, }); send_to_sidecar(&state, msg.to_string()); Ok(()) @@ -4139,6 +4284,7 @@ pub fn run() { adopt_done, adopt_failed, take_arrivals, + transfer_workspace_content, workspace_reserve_ids, workspace_report, workspace_registry, @@ -4261,6 +4407,8 @@ mod tests { to: to.to_string(), terminal_ids: Vec::new(), payload: serde_json::json!({ "workspaceId": id, "workspace": workspace_json(id, "Moved") }), + content: None, + pending_window: None, queued_at: std::time::Instant::now(), } } @@ -4387,6 +4535,59 @@ mod tests { ); } + #[test] + fn a_pty_exit_keeps_its_cut_until_the_arrival_settles() { + let windows = super::WindowState::default(); + windows.mint("t1", "main"); + windows.begin_transfer(&["t1".to_string()], "main", "ws-2"); + guard(&windows.routing).mark_transfer("t1", 42); + windows.exited_pty("t1"); + assert_eq!(guard(&windows.routing).transfer_marks.get("t1"), Some(&42)); + assert_eq!(windows.hand_back(&["t1".to_string()], "main")["t1"], 42); + let routing = guard(&windows.routing); + assert!(!routing.transfer_marks.contains_key("t1")); + assert!(!routing.owners.contains_key("t1")); + assert!(!routing.awaiting_replay.contains_key("t1")); + } + + #[test] + fn transfer_ownership_and_source_routing_change_together() { + let windows = super::WindowState::default(); + windows.mint("t1", "main"); + windows.begin_transfer(&["t1".to_string()], "main", "ws-2"); + let state = guard(&windows.routing); + let view = super::RouteView { + owners: &state.owners, awaiting_replay: &state.awaiting_replay, + dor_targets: &state.dor_targets, registry: &super::workspaces::Registry::default(), + marking: &state.marking, + }; + assert_eq!(state.owners.get("t1").map(String::as_str), Some("ws-2")); + assert!(matches!(super::routing::route("pty:data", &serde_json::json!({"id": "t1", "data": "before-mark"}), &view), super::routing::Route::EmitTo("main"))); + } + + #[test] + fn explicit_kill_discards_a_cut_with_its_buffer() { + let windows = super::WindowState::default(); + windows.mint("t1", "main"); + windows.begin_transfer(&["t1".to_string()], "main", "ws-2"); + guard(&windows.routing).mark_transfer("t1", 42); + windows.forget_pty("t1"); + assert!(!guard(&windows.routing).transfer_marks.contains_key("t1")); + } + + #[test] + fn a_source_cut_survives_target_replay_and_needs_no_serialized_content() { + let mut state = super::RoutingState::default(); + state.marking.insert("t1".to_string(), "main".to_string()); + state.mark_transfer("t1", 42); + assert!(state.awaiting_replay.contains_key("t1")); + assert_eq!(state.transfer_marks.get("t1"), Some(&42)); + state.lift_suppression("t1"); + assert_eq!(state.transfer_marks.get("t1"), Some(&42)); + state.mark_transfer("t2", 99); // a late mark after hand-back is inert + assert!(!state.transfer_marks.contains_key("t2")); + } + #[test] fn adoption_keeps_the_journal_until_both_snapshots_are_durable() { let dir = TempDir::new("arrival-commit"); @@ -4469,7 +4670,8 @@ mod tests { #[test] fn journal_commands_run_off_the_main_thread() { let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap().replace("\r\n", "\n"); - for command in ["transfer_workspace", "open_workspace_window", "adopt_done", "adopt_failed", "close_window"] { + for command in ["transfer_workspace", "transfer_workspace_content", "open_workspace_window", "adopt_done", "adopt_failed", "close_window"] { + assert!(source.contains(&format!("#[tauri::command(async)]\nfn {command}(")), "{command} must run off the UI thread"); } } diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index 9129b6456..662a49954 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -70,6 +70,9 @@ pub struct RouteView<'a> { pub dor_targets: &'a HashMap, /// Every window's Workspaces, for a request naming one explicitly. pub registry: &'a crate::workspaces::Registry, + /// Ids whose transfer is between the invoke and the sidecar's `marked` + /// line, each with the source still consuming its output. + pub marking: &'a HashMap, } fn str_field<'a>(data: &'a JsonValue, key: &str) -> Option<&'a str> { @@ -99,41 +102,66 @@ fn owner<'a>(map: &'a HashMap, id: &str) -> Route<'a> { /// The one decision every sidecar stdout line passes through. pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Route<'a> { match event { - // Terminal traffic, keyed by the PTY it came from. + // Terminal traffic, keyed by the PTY it came from. Until the sidecar's + // `marked` line passes, the source keeps consuming: it serializes what + // it holds at that line, and the target replays only what follows. "pty:data" => { let Some(id) = str_field(data, "id") else { return Route::Broadcast; }; + if let Some(source) = view.marking.get(id) { + return Route::EmitTo(source.as_str()); + } if view.awaiting_replay.contains_key(id) { return Route::Drop; } owner(view.owners, id) } - // The replay is the raw bytes, OSCs included, and the target's replay - // path re-derives these from it — so a held copy would apply on top of - // what the replay rebuilt, and `commandStart` is not idempotent. + // Derived once at the sidecar's parse site. The replay is the raw bytes, + // OSCs included, and the window receiving it re-derives these from it — + // a held copy would apply on top of what the replay rebuilt, and + // `commandStart` is not idempotent — so a semantic event goes with its + // chunk: to the source until the mark, dropped while suppressed. "terminal:semanticEvents" => { let Some(id) = str_field(data, "id") else { return Route::Broadcast; }; + if let Some(source) = view.marking.get(id) { + return Route::EmitTo(source.as_str()); + } if view.awaiting_replay.contains_key(id) { return Route::Drop; } owner(view.owners, id) } - // Derived once at the sidecar's parse site and rebuilt by no replay - // path, so a chunk's protocol events outlive the chunk's drop. + // A protocol event (a notification, a progress bar) is carried by no + // replay, so it outlives its chunk's drop: held for the id's next owner. "terminal:protocolEvents" => { let Some(id) = str_field(data, "id") else { return Route::Broadcast; }; + if let Some(source) = view.marking.get(id) { + return Route::EmitTo(source.as_str()); + } if view.awaiting_replay.contains_key(id) { return Route::Hold; } owner(view.owners, id) } + // The split point itself goes to the window still consuming; the + // caller then turns the id's suppression on behind it. + "pty:marked" => match str_field(data, "id") { + Some(id) => match view.marking.get(id) { + Some(source) => Route::EmitTo(source.as_str()), + None => owner(view.owners, id), + }, + None => Route::Broadcast, + }, // Never suppressed: a replay is exactly what the suppression is waiting // for, and the caller lifts the suppression after this emit. + "pty:replay" if str_field(data, "forWindow").is_some() => { + Route::EmitTo(str_field(data, "forWindow").unwrap()) + } "pty:exit" | "pty:replay" => match str_field(data, "id") { Some(id) => owner(view.owners, id), None => Route::Broadcast, @@ -235,6 +263,13 @@ pub struct Arrival { /// watchdog's record *this* one rather than a later re-drop of the same /// Workspace into the same window. pub queued_at: Instant, + /// What the source serialized once every mark had passed — each terminal's + /// buffer and mark, and the notepad pins — merged into the payload the + /// target drains. **An arrival without it is not yet drainable.** + pub content: Option, + /// A tear-out's window geometry, held until the content lands: the window + /// is built then, so its boot drains a payload that is complete. + pub pending_window: Option, } /// Every arrival in flight, oldest first. A Vec, not a map: there are a handful @@ -307,10 +342,51 @@ pub fn arrival_payloads(arrivals: &Arrivals, label: &str) -> Vec { arrivals .iter() .filter(|arrival| arrival.to == label) - .map(|arrival| arrival.payload.clone()) + .filter_map(|arrival| { + let content = arrival.content.as_ref()?; + let mut payload = arrival.payload.clone(); + if let (Some(object), Some(extra)) = (payload.as_object_mut(), content.as_object()) { + for (key, value) in extra { + object.insert(key.clone(), value.clone()); + } + } + Some(payload) + }) .collect() } +/// The per-id replay marks an arrival's content carries, for the target's +/// `pty:requestInit`. Absent content, or an id without a mark, replays whole. +pub fn arrival_marks(arrival: &Arrival) -> JsonValue { + let mut marks = serde_json::Map::new(); + if let Some(terminals) = arrival + .content + .as_ref() + .and_then(|content| content.get("terminals")) + .and_then(JsonValue::as_object) + { + for (id, entry) in terminals { + if let Some(mark) = entry.get("mark").and_then(JsonValue::as_u64) { + marks.insert(id.clone(), JsonValue::from(mark)); + } + } + } + JsonValue::Object(marks) +} + +/// What a hand-back does with an arrival's ids, split by whether `marks` +/// (recorded at `pty:marked`, independently of content) carries one: the marked ids go back to the source +/// suppressed, behind a replay since their marks, and the rest go straight +/// back — the source still holds their whole buffer, so a replay would paint +/// it twice. +pub fn hand_back_ids(arrival: &Arrival, marks: &JsonValue) -> (Vec, Vec) { + arrival + .terminal_ids + .iter() + .cloned() + .partition(|id| marks.get(id).is_some()) +} + /// Every id an in-flight arrival claims: the ids the sweep may not release and /// a boot list may not place as panes. pub fn arrival_ids(arrivals: &Arrivals) -> HashSet { @@ -539,6 +615,7 @@ mod tests { let owned = labels(&[("a", "main"), ("b", "ws-2")]); let none = awaiting(&[]); let no_dor = HashMap::new(); + let no_marking: HashMap = HashMap::new(); let mut registry = crate::workspaces::Registry::default(); crate::workspaces::report( &mut registry, @@ -555,6 +632,7 @@ mod tests { awaiting_replay: &none, dor_targets: &no_dor, registry: ®istry, + marking: &no_marking, }; let from_main = |params: JsonValue| json!({ "surfaceId": "a", "params": params }); assert_eq!( @@ -592,11 +670,13 @@ mod tests { let none = awaiting(&[]); let dor = labels(&[("dor-7", "ws-2")]); let no_registry = crate::workspaces::Registry::default(); + let no_marking: HashMap = HashMap::new(); let view = RouteView { owners: &owned, awaiting_replay: &none, dor_targets: &dor, registry: &no_registry, + marking: &no_marking, }; let cases: &[(&str, JsonValue, Route)] = &[ ("pty:data", json!({"id":"a"}), Route::EmitTo("main")), @@ -618,6 +698,9 @@ mod tests { ), ("pty:exit", json!({"id":"b"}), Route::EmitTo("ws-2")), ("pty:replay", json!({"id":"a"}), Route::EmitTo("main")), + ("pty:replay", json!({"id":"exited", "forWindow":"ws-2"}), Route::EmitTo("ws-2")), + ("pty:replay", json!({"id":"b", "forWindow":"main"}), Route::EmitTo("main")), + ("pty:replay", json!({"id":"exited"}), Route::Drop), ( "pty:list", json!({"forWindow":"ws-2","ptys":[]}), @@ -682,11 +765,13 @@ mod tests { let none = awaiting(&[]); let no_dor = HashMap::new(); let no_registry = crate::workspaces::Registry::default(); + let no_marking: HashMap = HashMap::new(); let view = RouteView { owners: &owned, awaiting_replay: &none, dor_targets: &no_dor, registry: &no_registry, + marking: &no_marking, }; assert_eq!( route( @@ -736,15 +821,38 @@ mod tests { let none = awaiting(&[]); let no_dor = HashMap::new(); let no_registry = crate::workspaces::Registry::default(); + let no_marking: HashMap = HashMap::new(); let suppressed = RouteView { owners: &owned, awaiting_replay: &held, dor_targets: &no_dor, registry: &no_registry, + marking: &no_marking, }; assert_eq!(route("pty:data", &json!({"id":"a"}), &suppressed), Route::Drop); - // The target re-derives semantic events from the raw replay, so a held - // copy would apply twice: dropped with the bytes they describe. + // Before the mark passes, the source still consumes — and the mark + // itself goes to it, so it knows where it stands. + let marking = labels(&[("a", "main")]); + let marking_view = RouteView { + owners: &owned, + awaiting_replay: &none, + dor_targets: &no_dor, + registry: &no_registry, + marking: &marking, + }; + assert_eq!(route("pty:data", &json!({"id":"a"}), &marking_view), Route::EmitTo("main")); + assert_eq!( + route("terminal:semanticEvents", &json!({"id":"a"}), &marking_view), + Route::EmitTo("main") + ); + assert_eq!( + route("terminal:protocolEvents", &json!({"id":"a"}), &marking_view), + Route::EmitTo("main") + ); + assert_eq!(route("pty:marked", &json!({"id":"a"}), &marking_view), Route::EmitTo("main")); + // A chunk's semantic events are re-derived from the replay by whoever + // receives it: dropped with the chunk. Its protocol events are in no + // replay: held, not dropped. assert_eq!( route("terminal:semanticEvents", &json!({"id":"a"}), &suppressed), Route::Drop @@ -766,6 +874,7 @@ mod tests { awaiting_replay: &none, dor_targets: &no_dor, registry: &no_registry, + marking: &no_marking, }; assert_eq!( route("pty:data", &json!({"id":"a"}), &released), @@ -851,9 +960,54 @@ mod tests { terminal_ids: ids.iter().map(|id| (*id).to_string()).collect(), payload: json!({ "workspaceId": workspace_id }), queued_at: Instant::now(), + content: Some(json!({})), + pending_window: None, } } + #[test] + fn an_arrival_is_drainable_only_once_its_content_landed() { + let mut arrivals = Arrivals::new(); + let mut pending = arrival("ws-a", "main", "ws-2", &["t1", "t2"]); + pending.content = None; + queue_arrival(&mut arrivals, pending); + assert!(arrival_payloads(&arrivals, "ws-2").is_empty()); + assert_eq!(arrival_marks(&arrivals[0]), json!({})); + + arrivals[0].content = Some(json!({ + "terminals": { "t1": { "serialized": "\x1b[1mhi", "mark": 42 }, "t2": { "serialized": "" } }, + "pins": [], + })); + let payloads = arrival_payloads(&arrivals, "ws-2"); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0]["workspaceId"], "ws-a"); + assert_eq!(payloads[0]["terminals"]["t1"]["mark"], 42); + assert_eq!(payloads[0]["pins"], json!([])); + assert_eq!(arrival_marks(&arrivals[0]), json!({ "t1": 42 })); + } + + /// A hand-back replays exactly the marked ids since their marks; an id the + /// sidecar did not mark goes straight back. Content need not exist yet. + #[test] + fn a_hand_back_replays_only_the_marked_ids() { + let mut pending = arrival("ws-a", "main", "ws-2", &["t1", "t2"]); + pending.content = None; + let marks = json!({ "t1": 42 }); + assert_eq!( + hand_back_ids(&pending, &marks), + (vec!["t1".to_string()], vec!["t2".to_string()]) + ); + + pending.content = Some(json!({ + "terminals": { "t1": { "serialized": "", "mark": 42 }, "t2": { "serialized": "" } }, + })); + let marks = arrival_marks(&pending); + assert_eq!( + hand_back_ids(&pending, &marks), + (vec!["t1".to_string()], vec!["t2".to_string()]) + ); + } + #[test] fn an_expiry_retires_only_the_record_it_was_armed_for() { let mut arrivals = Arrivals::new(); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index cded9f9b2..c87c7ee52 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -14,6 +14,7 @@ import type { PlatformAdapter, PtyDataDetail, PtyListDetail, + PtyMarkedDetail, PtyReplayDetail, BurrowLink, } from "dormouse-lib/lib/platform/types"; @@ -69,6 +70,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>(); private listHandlers = new Set<(detail: PtyListDetail) => void>(); private replayHandlers = new Set<(detail: PtyReplayDetail) => void>(); + private markedHandlers = new Set<(detail: PtyMarkedDetail) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); private alertManager = new AlertManager(); private unlistenHost: (() => void) | null = null; @@ -308,6 +310,10 @@ export class BrowserSidecarAdapter implements PlatformAdapter { offPtyList(handler: (detail: PtyListDetail) => void): void { this.listHandlers.delete(handler); } onPtyReplay(handler: (detail: PtyReplayDetail) => void): void { this.replayHandlers.add(handler); } offPtyReplay(handler: (detail: PtyReplayDetail) => void): void { this.replayHandlers.delete(handler); } + onPtyMarked(handler: (detail: PtyMarkedDetail) => void): () => void { + this.markedHandlers.add(handler); + return () => { this.markedHandlers.delete(handler); }; + } onRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {} offRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {} notifySessionFlushComplete(_requestId: string): void {} @@ -383,6 +389,8 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } else if (event === "pty:list") { for (const pty of (data as PtyListDetail).ptys) if (pty.helper) this.alertManager.setHelper(pty.id, true); for (const handler of this.listHandlers) handler(data as PtyListDetail); + } else if (event === "pty:marked") { + for (const handler of this.markedHandlers) handler(data as PtyMarkedDetail); } else if (event === "pty:replay") { // The one stream the sidecar does not parse; see TauriAdapter, including // why the one-shot parser still needs the theme. diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index ef39108c7..4e66c83a2 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -472,6 +472,20 @@ describe("TauriAdapter terminal stream", () => { expect(alerts.some((detail) => detail.id === "replay-pty" && detail.watchingEnabled)).toBe(true); }); + it("settles the replayed watch when a marked buffer belongs to an exited PTY", async () => { + const { adapter, deliver } = await listening(); + const alerts: AlertStateDetail[] = []; + adapter.onAlertState((detail) => void alerts.push(detail)); + deliver("alert:watchedCommands", { names: ["sleep"] }); + deliver("pty:list", { ptys: [{ id: "exited-replay", alive: false, exitCode: 7 }], requestId: "handback-1" }); + deliver("pty:replay", { + id: "exited-replay", requestId: "handback-1", + data: "\x1b]633;E;sleep 5\x07\x1b]633;C\x07", + }); + expect(getTerminalPaneState("exited-replay").currentCommand).toBeNull(); + expect(alerts[alerts.length - 1]?.watchingEnabled).toBe(false); + }); + it("pushes the resolved theme so the sidecar can answer a colour query", async () => { const { adapter, invoke } = await listening(); adapter.requestInit(); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 5af7080fc..aef8c9fa9 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -17,6 +17,7 @@ import type { PtyDataDetail, PtyInfo, PtyListDetail, + PtyMarkedDetail, PtyReplayDetail, BurrowLink, SessionFlushRequest, @@ -93,6 +94,7 @@ export class TauriAdapter implements PlatformAdapter { private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>(); private listHandlers = new Set<(detail: PtyListDetail) => void>(); private replayHandlers = new Set<(detail: PtyReplayDetail) => void>(); + private markedHandlers = new Set<(detail: PtyMarkedDetail) => void>(); private filesDroppedHandlers = new Set<(paths: string[]) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); // The two app-global stores are the sidecar's, so this window applies what @@ -141,6 +143,8 @@ export class TauriAdapter implements PlatformAdapter { } async init(): Promise { + const replayExits = new Map(); + const replayKey = (id: string, requestId?: string) => JSON.stringify([requestId, id]); // Registered together rather than one await after another: every `listen` // is an independent round trip to Rust, and serializing them puts the whole // set in front of the first paint. @@ -175,6 +179,11 @@ export class TauriAdapter implements PlatformAdapter { }), listenToWindow<{ ptys: PtyInfo[]; requestId?: string }>("pty:list", (event) => { + for (const pty of event.payload.ptys) { + const key = replayKey(pty.id, event.payload.requestId); + if (!pty.alive) replayExits.set(key, pty.exitCode ?? -1); + else replayExits.delete(key); + } for (const pty of event.payload.ptys) if (pty.helper) this.alertManager.setHelper(pty.id, true); for (const handler of this.listHandlers) { handler(event.payload); @@ -197,11 +206,25 @@ export class TauriAdapter implements PlatformAdapter { const events = collectTerminalSemanticEvents(parsed.events); this.alertManager.applyTerminalSemanticEvents(id, events); applyTerminalSemanticEvents(id, events); + // A listed exited buffer can contain a command-start with no finish. + // Apply its exit after rebuilding the replay's watch, for either target + // adoption or source hand-back. + const key = replayKey(id, requestId); + const exitCode = replayExits.get(key); + if (exitCode !== undefined) { + replayExits.delete(key); + this.alertManager.onExit(id, exitCode); + applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode }]); + } for (const handler of this.replayHandlers) { handler({ id, data: parsed.visibleData, requestId }); } }), + listenToWindow("pty:marked", (event) => { + for (const handler of this.markedHandlers) handler(event.payload); + }), + // Inert while dragDropEnabled=false in tauri.conf.json. See diffplug/dormouse#38 and tauri-apps/tauri#14373. listenToWindow<{ paths: string[] }>("dormouse://files-dropped", (event) => { const paths = event.payload.paths ?? []; @@ -583,6 +606,11 @@ export class TauriAdapter implements PlatformAdapter { this.replayHandlers.add(handler); } + onPtyMarked(handler: (detail: PtyMarkedDetail) => void): () => void { + this.markedHandlers.add(handler); + return () => { this.markedHandlers.delete(handler); }; + } + offPtyReplay(handler: (detail: PtyReplayDetail) => void): void { this.replayHandlers.delete(handler); } diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index 5e4386781..81152fcae 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -15,6 +15,7 @@ import type { */ const mocks = vi.hoisted(() => ({ + writes: [] as string[], invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown), listen: vi.fn(async () => () => {}), })); @@ -30,6 +31,7 @@ vi.mock("@xterm/addon-fit", () => ({ }, })); vi.mock("@xterm/addon-image", () => ({ ImageAddon: class {} })); +vi.mock("@xterm/addon-serialize", () => ({ SerializeAddon: class { serialize(): string { return ""; } } })); vi.mock("@xterm/addon-unicode-graphemes", () => ({ UnicodeGraphemesAddon: class {} })); vi.mock("@xterm/xterm", () => ({ Terminal: class { @@ -37,7 +39,7 @@ vi.mock("@xterm/xterm", () => ({ modes = { mouseTrackingMode: "none" as const, bracketedPasteMode: false }; loadAddon(): void {} open(): void {} - write(): void {} + write(data: string, callback?: () => void): void { mocks.writes.push(data); callback?.(); } focus(): void {} blur(): void {} onData(): { dispose: () => void } { return { dispose: () => {} }; } @@ -69,6 +71,7 @@ import { } from "dormouse-lib/lib/window-session-aggregator"; import { getTerminalInstance } from "dormouse-lib/lib/terminal-registry"; import { setPlatform } from "dormouse-lib/lib/platform"; +import { disposeAllSessions, getOrCreateTerminal } from "dormouse-lib/lib/terminal-registry"; import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; const WORKSPACE_ID = "ws-moving"; @@ -107,6 +110,10 @@ function payload(overrides: Partial = {}): WorkspaceTr /** Rust's arrival table: what is in flight into this window, keyed by Workspace. */ let arrivals: WorkspaceTransferPayload[] = []; +/** Push one `pty:replay` at whatever this window's adapter has subscribed. */ +let deliverList: (detail: { ptys: PtyInfo[]; requestId?: string }) => void = () => {}; +let deliverReplay: (detail: { id: string; data: string; requestId?: string }) => void = () => {}; + /** A prepared transfer whose commit is observable. */ function prepared( onCommit: () => void = () => {}, @@ -123,7 +130,10 @@ function prepared( * The adapter's `pty:list` / `pty:replay` answer only once something asks — * which is the property the `adopt_ready` hop exists to guarantee. */ -function fakePlatform(order: string[] = [], opts: { answer?: boolean } = {}): PlatformAdapter { +function fakePlatform( + order: string[] = [], + opts: { answer?: boolean; marks?: Record; stamp?: boolean } = {}, +): PlatformAdapter { const platform = new FakePtyAdapter(); let listHandler: ((detail: { ptys: PtyInfo[]; requestId?: string }) => void) | null = null; let replayHandler: ((detail: { id: string; data: string; requestId?: string }) => void) | null = null; @@ -131,6 +141,14 @@ function fakePlatform(order: string[] = [], opts: { answer?: boolean } = {}): Pl vi.spyOn(platform, "offPtyList").mockImplementation(() => { listHandler = null; }); vi.spyOn(platform, "onPtyReplay").mockImplementation((handler) => { replayHandler = handler; }); vi.spyOn(platform, "offPtyReplay").mockImplementation(() => { replayHandler = null; }); + // What Rust routes to this window unasked: a hand-back's since-mark replay. + deliverReplay = (detail) => replayHandler?.(detail); + deliverList = (detail) => listHandler?.(detail); + let markedHandler: ((detail: { id: string; mark: number; requestId?: string }) => void) | null = null; + (platform as unknown as { onPtyMarked: unknown }).onPtyMarked = (handler: typeof markedHandler) => { + markedHandler = handler; + return () => { markedHandler = null; }; + }; vi.spyOn(platform, "requestInit").mockImplementation(() => { throw new Error("an arrival must never ask for the whole Window"); }); @@ -139,13 +157,22 @@ function fakePlatform(order: string[] = [], opts: { answer?: boolean } = {}): Pl (platform as unknown as { alertSeed: unknown }).alertSeed = vi.fn(); mocks.invoke.mockImplementation(async (cmd: string, args?: unknown) => { order.push(cmd); - const workspaceId = (args as { workspaceId?: string } | undefined)?.workspaceId; + const workspaceId = (args as { workspaceId?: string; payload?: { workspaceId?: string } } | undefined)?.workspaceId + ?? (args as { payload?: { workspaceId?: string } } | undefined)?.payload?.workspaceId; const settle = () => { const at = arrivals.findIndex((arrival) => arrival.workspaceId === workspaceId); if (at < 0) throw new Error(`no arrival of '${workspaceId}'`); arrivals.splice(at, 1); }; if (cmd === "take_arrivals") return arrivals.map((arrival) => ({ ...arrival })); + if (cmd === "transfer_workspace" || cmd === "open_workspace_window") { + // The host stamps each id's mark in the stream, behind every byte the + // source was sent; the source serializes at that line. Stamped *before* + // the invoke resolves, as Rust does inside `begin_arrival`: a source that + // only listens once the invoke is back misses every one of them. + const ids = opts.stamp === false ? [] : (args as { payload: { terminalIds: string[] } }).payload.terminalIds; + for (const id of ids) markedHandler?.({ id, mark: opts.marks?.[id] ?? 0, requestId: `mark-${workspaceId}` }); + } if (cmd === "adopt_done" || cmd === "adopt_failed") { settle(); return undefined; } if (cmd === "adopt_ready") { const arrival = arrivals.find((entry) => entry.workspaceId === workspaceId); @@ -171,7 +198,9 @@ function fakePlatform(order: string[] = [], opts: { answer?: boolean } = {}): Pl beforeEach(() => { vi.clearAllMocks(); + mocks.writes.length = 0; arrivals = []; + disposeAllSessions(); mocks.invoke.mockResolvedValue(undefined); mocks.listen.mockResolvedValue(() => {}); resetWallHandles(); @@ -207,7 +236,9 @@ describe("the source half", () => { // The record is built while the Sessions are live. Nothing is released at // the invoke: the target can still refuse, and a Workspace released here // would have no Sessions and no window that owned them. - expect(order).toEqual(["prepare", "transfer_workspace"]); + // The content follows once the marks pass: the fake host stamps none, + // so it is serialized at once and replayed whole. + expect(order).toEqual(["prepare", "transfer_workspace", "transfer_workspace_content"]); const [, args] = mocks.invoke.mock.calls.find(([cmd]) => cmd === "transfer_workspace")!; expect(args).toMatchObject({ to: "ws-2", payload: { at: { x: 10, y: 4 }, terminalIds: ["pane-a"] } }); @@ -254,6 +285,131 @@ describe("the source half", () => { expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).toContain(WORKSPACE_ID); }); + it("writes a hand-back's since-mark replay into the xterms that never left", async () => { + // Between the mark and the hand-back every byte went to the target, or + // nowhere. Rust replays that slice to this window; it lands in the existing + // instances, and no Session is restarted or killed for it. + const platform = fakePlatform([], { marks: { "pane-a": 42 } }); + const killed = vi.spyOn(platform, "killPty"); + initWorkspaceMoves(platform); + getOrCreateTerminal("pane-a"); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { + prepareWorkspaceTransfer: async () => prepared(), + })); + createWorkspace({ id: WORKSPACE_ID, name: "Deploys" }); + await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 }); + mocks.writes.length = 0; + + await emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, reason: "wedged", replayIds: ["pane-a"] }); + // Another collection's replay is not this one's. + deliverReplay({ id: "pane-a", data: "not-mine", requestId: "boot-1" }); + deliverReplay({ id: "pane-a", data: "since-the-mark", requestId: `handback-${WORKSPACE_ID}` }); + + expect(mocks.writes).toEqual(["since-the-mark"]); + expect(killed).not.toHaveBeenCalled(); + // The last id lets go of the adapter. + expect(platform.offPtyReplay).toHaveBeenCalled(); + deliverReplay({ id: "pane-a", data: "late", requestId: `handback-${WORKSPACE_ID}` }); + expect(mocks.writes).toEqual(["since-the-mark"]); + }); + + it("marks a handed-back exited PTY dead after replaying its final bytes", async () => { + const platform = fakePlatform(); + initWorkspaceMoves(platform); + const entry = getOrCreateTerminal("pane-a"); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: async () => prepared() })); + const transfer = transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 0, y: 0 }); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("transfer_workspace_content", expect.anything())); + await emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, replayIds: ["pane-a"] }); + const requestId = `handback-${WORKSPACE_ID}`; + deliverList({ ptys: [{ id: "pane-a", alive: false, exitCode: 7 }], requestId }); + mocks.writes.length = 0; + deliverReplay({ id: "pane-a", data: "final-output", requestId }); + await transfer; + expect(entry.exited).toBe(true); + expect(mocks.writes[0]).toContain("final-output"); + expect(mocks.writes[mocks.writes.length - 1]).toContain("Process exited with code 7"); + expect(platform.offPtyList).toHaveBeenCalled(); + }); + + it("receives recovery replay when hand-back precedes the source invoke reply", async () => { + const platform = fakePlatform(); + initWorkspaceMoves(platform); + getOrCreateTerminal("pane-a"); + createWorkspace({ id: WORKSPACE_ID }); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: async () => prepared() })); + const host = mocks.invoke.getMockImplementation()!; + mocks.invoke.mockImplementation(async (cmd, args) => { + const result = await host(cmd, args); + if (cmd === "transfer_workspace") { + await emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, replayIds: ["pane-a"] }); + mocks.writes.length = 0; + deliverReplay({ id: "pane-a", data: "early-gap", requestId: `handback-${WORKSPACE_ID}` }); + } + return result; + }); + await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 0, y: 0 }); + expect(mocks.writes).toEqual(["early-gap"]); + expect(mocks.invoke).not.toHaveBeenCalledWith("transfer_workspace_content", expect.anything()); + }); + + it("keeps the first move recoverable when the same tab is dropped twice", async () => { + initWorkspaceMoves(fakePlatform()); + getOrCreateTerminal("pane-a"); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: async () => prepared() })); + const host = mocks.invoke.getMockImplementation()!; + let release!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + mocks.invoke.mockImplementation(async (cmd, args) => { + const result = await host(cmd, args); + if (cmd === "transfer_workspace") await blocked; + return result; + }); + const first = transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 0, y: 0 }); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("transfer_workspace", expect.anything())); + await transferWorkspaceTo(WORKSPACE_ID, "ws-3", { x: 0, y: 0 }); + expect(mocks.invoke.mock.calls.filter(([cmd]) => cmd === "transfer_workspace")).toHaveLength(1); + release(); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("transfer_workspace_content", expect.anything())); + await emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, replayIds: ["pane-a"] }); + mocks.writes.length = 0; + deliverReplay({ id: "pane-a", data: "first-move-gap", requestId: `handback-${WORKSPACE_ID}` }); + await first; + expect(mocks.writes).toEqual(["first-move-gap"]); + }); + + it("accepts no hand-back replay for an id the host never marked", async () => { + // An unmarked id was serialized whole and its xterm still holds every + // byte: Rust asks the sidecar for nothing, and a whole-buffer replay + // arriving anyway would paint the transcript twice. + vi.useFakeTimers(); + try { + const platform = fakePlatform([], { stamp: false }); + initWorkspaceMoves(platform); + getOrCreateTerminal("pane-a"); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { + prepareWorkspaceTransfer: async () => prepared(), + })); + createWorkspace({ id: WORKSPACE_ID, name: "Deploys" }); + const moved = transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 }); + await vi.advanceTimersByTimeAsync(3000); // past the mark wait + await moved; + const [, args] = mocks.invoke.mock.calls.find(([cmd]) => cmd === "transfer_workspace_content")!; + expect(args).toMatchObject({ content: { terminals: { "pane-a": { serialized: "" } } } }); + mocks.writes.length = 0; + + const failed = emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, reason: "wedged", replayIds: [] }); + await vi.advanceTimersByTimeAsync(1); + await failed; + deliverReplay({ id: "pane-a", data: "whole-buffer", requestId: `handback-${WORKSPACE_ID}` }); + + expect(mocks.writes).toEqual([]); + expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).toContain(WORKSPACE_ID); + } finally { + vi.useRealTimers(); + } + }); + it("releases only the Workspace that departed", async () => { // Two in flight into the same window: one landing must not take the other // with it (Rust announces one departure per arrival, from its `adopt_done`). @@ -495,6 +651,36 @@ describe("the target half", () => { }); }); +describe("a transfer's content", () => { + it("serializes each terminal at the host's mark and hands the content over behind the invoke", async () => { + const order: string[] = []; + initWorkspaceMoves(fakePlatform(order, { marks: { "pane-a": 42 } })); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { + prepareWorkspaceTransfer: async () => prepared(), + })); + + await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 1, y: 1 }); + + expect(order.filter((cmd) => cmd !== "take_arrivals")).toEqual(["transfer_workspace", "transfer_workspace_content"]); + const [, args] = mocks.invoke.mock.calls.find(([cmd]) => cmd === "transfer_workspace_content")!; + expect(args).toEqual({ + workspaceId: WORKSPACE_ID, + content: { terminals: { "pane-a": { serialized: "", mark: 42 } }, pins: [] }, + }); + }); + + it("writes the source's buffer ahead of the since-mark replay when it mounts the arrival", async () => { + arrivals = [payload({ + terminals: { "pane-a": { serialized: "\u001b[1mfrom-source\u001b[0m", mark: 42 } }, + pins: [], + } as Partial)]; + await bootFromTearOut(fakePlatform()); + // One write: the rebuilt buffer, then everything after the mark, in order. + expect(mocks.writes).toContain("\u001b[1mfrom-source\u001b[0mscrollback:pane-a"); + expect(mocks.writes.filter((w) => w.includes("scrollback:pane-a"))).toHaveLength(1); + }); +}); + describe("a torn-out window's boot", () => { it("boots from the queued payload rather than from disk", async () => { const order: string[] = []; diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 5def36c54..0aeea11b0 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -2,11 +2,20 @@ import { invoke } from "@tauri-apps/api/core"; import { releaseSession } from "dormouse-lib/lib/terminal-registry"; import { forgetHelper } from "dormouse-lib/lib/helper-terminal"; import { collectLivePtys, resumeOrRestoreFrom } from "dormouse-lib/lib/reconnect"; -import { hydrateNotepadFromVolatile, removeSurface } from "dormouse-lib/lib/notepad/notepad-store"; +import { flushTerminal } from "dormouse-lib/lib/terminal-registry"; +import { REPLAY_MODE_RESET, writeReplay } from "dormouse-lib/lib/terminal-report-filter"; +import { applyTerminalSemanticEvents } from "dormouse-lib/lib/terminal-state-store"; +import { registry as terminalRegistry } from "dormouse-lib/lib/terminal-store"; +import { hydrateNotepadFromVolatile, removeSurface, restoreTerminalPins } from "dormouse-lib/lib/notepad/notepad-store"; import { getWallHandle } from "dormouse-lib/components/wall/wall-handles"; import { forgetWorkspaceBootPlan, setWorkspaceBootPlan } from "dormouse-lib/components/wall/workspace-boot-plans"; import { wallBootFromResult, type WallBootPlans } from "dormouse-lib/components/wall/wall-types"; -import type { PreparedWorkspaceTransfer, WorkspaceTransferPayload } from "dormouse-lib/components/wall/workspace-transfer"; +import { + captureTransferContent, + type PreparedWorkspaceTransfer, + type WorkspaceTransferContent, + type WorkspaceTransferPayload, +} from "dormouse-lib/components/wall/workspace-transfer"; import { clearWorkspaceTransferring, forgetWorkspaceSession, @@ -20,7 +29,7 @@ import { moveWorkspace, setActiveWorkspace, } from "dormouse-lib/lib/workspace-store"; -import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; +import type { PlatformAdapter, PtyInfo, PtyReplayDetail } from "dormouse-lib/lib/platform/types"; import type { WorkspaceId } from "dormouse-lib/lib/session-types"; import { installWindowPersistence } from "./window-restore"; import { listenToWindow } from "./window-label"; @@ -42,13 +51,17 @@ import { workspaceDropTarget } from "./workspace-tabs"; * arrival's* PTYs, mount the Workspace, and call `adopt_done` — which is what * releases the source. * - * Rust reassigns ownership *synchronously* when the source invokes, and - * suppresses those PTYs' output until each one's replay has been emitted to the - * target — so between the two halves no byte is painted twice and none is lost. + * Rust reassigns ownership *synchronously* when the source invokes, but the + * source keeps consuming each PTY until the sidecar's `marked` line for it + * passes; it then serializes what it holds and hands that over as the + * arrival's *content*, and Rust suppresses the PTY until the target's replay of + * everything after the mark has been emitted — so between the two halves no + * byte is painted twice and none is lost, and the target rebuilds the whole + * buffer rather than the sidecar's bounded tail. */ /** Wire the payload up as one drop point, so both invokes carry the same shape. */ -interface MovePayload extends WorkspaceTransferPayload { +interface MovePayload extends WorkspaceTransferPayload, Partial { /** Where the pointer released, in the target window's logical client space. * The target turns it into a strip index; it alone knows its own tabs. */ at?: { x: number; y: number }; @@ -95,15 +108,66 @@ async function handOff( command: string, args: Record, ): Promise { + const { workspaceId, terminalIds } = prepared.payload; + if (inFlight.has(workspaceId)) return; + // Armed before the invoke: Rust asks the sidecar to stamp the marks inside + // `begin_arrival`, so a `marked` line can arrive ahead of the invoke's reply. + const pendingMarks = marksFor(terminalIds, `mark-${workspaceId}`); + inFlight.set(workspaceId, prepared); try { await invoke(command, args); } catch (err) { + if (inFlight.get(workspaceId) === prepared) inFlight.delete(workspaceId); console.warn(`[workspace-move] ${command} refused; the Workspace stays here`, err); - return; + return; // `pendingMarks` unsubscribes itself at the timeout } - const { workspaceId } = prepared.payload; - inFlight.set(workspaceId, prepared); + if (inFlight.get(workspaceId) !== prepared) return; // handed back before invoke replied markWorkspaceTransferring(workspaceId); + // The second half: once every terminal's mark has passed this window, what it + // holds is exactly the bytes before the mark. Serialized here, attached to the + // arrival by Rust, and only then drained by the target. + const marks = await pendingMarks; + if (inFlight.get(workspaceId) !== prepared) return; // handed back while we waited + const content = await captureTransferContent(terminalIds, marks); + if (inFlight.get(workspaceId) !== prepared) return; // handed back while serializing + try { + await invoke("transfer_workspace_content", { workspaceId, content }); + } catch (err) { + // The arrival is gone (the target closed, or the watchdog handed it back); + // `workspace-arrival-failed` has put, or will put, this Window back. + console.warn("[workspace-move] transfer_workspace_content refused", err); + } +} + +/** The host stamps marks well inside this; past it, an unmarked id is + * serialized anyway and replayed whole, which at worst repeats its tail. */ +const MARK_TIMEOUT_MS = 2000; + +/** The platform this window moves through; set by `initWorkspaceMoves`. */ +let movePlatform: PlatformAdapter | null = null; + +/** Wait for the sidecar's `marked` line for each id, in stream order behind + * every byte this window was sent before it. */ +function marksFor(ids: readonly string[], requestId: string): Promise> { + const marks = new Map(); + const wanted = new Set(ids); + if (wanted.size === 0 || !movePlatform?.onPtyMarked) return Promise.resolve(marks); + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + unsubscribe(); + resolve(marks); + }; + const unsubscribe = movePlatform!.onPtyMarked!((detail) => { + if (detail.requestId !== requestId || !wanted.has(detail.id)) return; + marks.set(detail.id, detail.mark); + if (marks.size === wanted.size) finish(); + }); + const timer = setTimeout(finish, MARK_TIMEOUT_MS); + }); } /** Hand this Workspace to a window that already exists. */ @@ -161,12 +225,62 @@ function handleDeparted(workspaceId: WorkspaceId): void { /** * The target never took it. Nothing was released, so there is nothing to put * back: drop the transferring mark and the Workspace is simply still here, its - * xterms receiving output again the moment Rust unsuppresses them. + * xterms receiving output again the moment Rust unsuppresses them — behind the + * replay of what they missed, where a mark had passed. */ -function handleArrivalFailed(workspaceId: WorkspaceId, reason: string): void { +function handleArrivalFailed(workspaceId: WorkspaceId, reason: string, replayIds: readonly string[]): void { if (!inFlight.delete(workspaceId)) return; clearWorkspaceTransferring(workspaceId); console.warn(`[workspace-move] ${workspaceId} was not adopted (${reason}); it stays here`); + if (replayIds.length) acceptHandBackReplay(workspaceId, replayIds); +} + +/** A hand-back's replay is one since-mark slice per id over the sidecar's + * stdio; the same room an arrival gets. */ +const HAND_BACK_REPLAY_TIMEOUT_MS = ARRIVAL_TIMEOUT_MS; + +/** + * Catch the replay Rust requests for a handed-back Workspace: every byte from + * each id's mark to the hand-back went to the target, or nowhere, and its xterm + * here stands at the mark. The replay of `outputSince(mark)` for exactly the + * marked ids goes into the existing instances (`docs/specs/standalone.md` → + * "Arrival queue"). Collector-free: subscribe, write, and let go on the last + * id or the timeout. + */ +function acceptHandBackReplay(workspaceId: WorkspaceId, ids: readonly string[]): void { + const platform = movePlatform; + if (!platform) return; + const requestId = `handback-${workspaceId}`; + const wanted = new Set(ids); + const exited = new Map(); + const onList = (detail: { ptys: PtyInfo[]; requestId?: string }) => { + if (detail.requestId !== requestId) return; + for (const pty of detail.ptys) { + if (wanted.has(pty.id) && !pty.alive) exited.set(pty.id, pty.exitCode ?? -1); + } + }; + const finish = () => { + clearTimeout(timer); + platform.offPtyReplay(onReplay); + platform.offPtyList(onList); + }; + const onReplay = (detail: PtyReplayDetail) => { + if (detail.requestId !== requestId || !wanted.delete(detail.id)) return; + const entry = terminalRegistry.get(detail.id); + if (entry) { + const exitCode = exited.get(detail.id); + writeReplay(entry, detail.data, ...(exitCode === undefined ? [] : [REPLAY_MODE_RESET])); + if (exitCode !== undefined) { + if (!entry.exited) entry.terminal.write(`\r\n[Process exited with code ${exitCode}]\r\n`); + entry.exited = true; + applyTerminalSemanticEvents(detail.id, [{ type: 'commandFinish', exitCode }]); + } + } + if (wanted.size === 0) finish(); + }; + platform.onPtyList(onList); + platform.onPtyReplay(onReplay); + const timer = setTimeout(finish, HAND_BACK_REPLAY_TIMEOUT_MS); } // --- Target ------------------------------------------------------------------ @@ -215,6 +329,13 @@ async function planArrival( + "refusing rather than restarting shells that are still running", ); } + // The source's buffers come first, then the host's replay of everything + // after each mark: together they are the whole transcript, not the sidecar's + // bounded tail (`docs/specs/transport.md` → "Transferring a Workspace"). + for (const [id, terminal] of Object.entries(payload.terminals ?? {})) { + if (!ptyIds.has(id) || !terminal.serialized) continue; + live.replay.set(id, terminal.serialized + (live.replay.get(id) ?? "")); + } const result = resumeOrRestoreFrom(platform, live, { savedSession: payload.workspace.session, ptyIds, @@ -222,6 +343,12 @@ async function planArrival( // The notes travelled in the payload rather than through the archive: a move // is not a closure (`docs/specs/notepad.md` → "Closure"). hydrateNotepadFromVolatile(payload.notepad, payload.allIds); + // Their pins point into the buffers just rebuilt at the same lines — once + // xterm has parsed the rebuild, which it does asynchronously. + if (payload.pins?.length) { + await Promise.all([...ptyIds].map((id) => flushTerminal(id))); + restoreTerminalPins(payload.pins); + } return wallBootFromResult(result); } @@ -303,6 +430,7 @@ async function drainArrivals(): Promise { /** Listen for Workspaces arriving in, and leaving, this window. */ export function initWorkspaceMoves(platform: PlatformAdapter): void { + movePlatform = platform; const adoptQueued = async () => { for (const payload of await drainArrivals()) await adoptWorkspace(platform, payload); }; @@ -312,9 +440,9 @@ export function initWorkspaceMoves(platform: PlatformAdapter): void { void listenToWindow<{ workspaceId: WorkspaceId }>("dormouse://workspace-departed", (event) => { handleDeparted(event.payload.workspaceId); }); - void listenToWindow<{ workspaceId: WorkspaceId; reason?: string }>( + void listenToWindow<{ workspaceId: WorkspaceId; reason?: string; replayIds?: string[] }>( "dormouse://workspace-arrival-failed", - (event) => handleArrivalFailed(event.payload.workspaceId, event.payload.reason ?? "no reason given"), + (event) => handleArrivalFailed(event.payload.workspaceId, event.payload.reason ?? "no reason given", event.payload.replayIds ?? []), ); // Immediately, and not only on the nudge: a Workspace dropped on this window // while it was still booting is already in the queue, and its `emit_to` diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 60efdbdf2..f29c60c29 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -69,6 +69,13 @@ "author": "The xterm.js authors", "homepage": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image" }, + { + "name": "@xterm/addon-serialize", + "version": "0.15.0-beta.301", + "license": "MIT", + "author": "The xterm.js authors", + "homepage": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize" + }, { "name": "@xterm/addon-unicode-graphemes", "version": "0.5.0-beta.301",