From 419574675cfa866bbfc82139601743bb672a5eaf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 14:28:53 -0700 Subject: [PATCH 1/9] Carry the whole transcript and its pins across a Workspace move A moved terminal was rebuilt from the sidecar's bounded replay, losing older scrollback and every notepad pin. The transfer is now two halves. Rust moves ownership at the invoke but keeps routing each PTY to the source until the sidecar stamps a `pty:marked` line in the stream behind every byte it had sent; the source drains xterm's write queue there, serializes each buffer with the serialize addon, takes each note's marker lines, and hands that content to Rust, which attaches it to the arrival, only then nudging the target (or, for a tear-out, building the window). From the mark the id is suppressed until the target's replay, which the sidecar now cuts at the mark. The target writes the serialized buffer ahead of that replay and re-pins the notes once xterm has parsed it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u --- docs/specs/standalone.md | 19 ++- docs/specs/standalone.rationale.md | 14 ++ docs/specs/transport.md | 40 ++++-- lib/package.json | 5 +- lib/src/components/wall/workspace-transfer.ts | 53 ++++++- lib/src/lib/notepad/notepad-store.test.ts | 66 +++++++++ lib/src/lib/notepad/notepad-store.ts | 43 ++++++ lib/src/lib/notepad/source-link.test.ts | 47 ++++++ lib/src/lib/notepad/source-link.ts | 65 +++++++++ lib/src/lib/platform/types.ts | 12 ++ .../lib/terminal-lifecycle.release.test.ts | 1 + lib/src/lib/terminal-lifecycle.ts | 34 ++++- lib/src/lib/terminal-registry.ts | 2 + lib/src/lib/terminal-store.ts | 4 + pnpm-lock.yaml | 15 ++ scripts/spec-word-budgets.json | 4 +- standalone/package.json | 1 + standalone/sidecar/main.js | 3 +- standalone/sidecar/pty-core.js | 28 +++- standalone/sidecar/pty-core.test.js | 31 ++++ standalone/src-tauri/src/lib.rs | 136 ++++++++++++++---- standalone/src-tauri/src/routing.rs | 107 +++++++++++++- standalone/src/browser-sidecar-adapter.ts | 8 ++ standalone/src/tauri-adapter.ts | 11 ++ standalone/src/workspace-move.test.ts | 59 +++++++- standalone/src/workspace-move.ts | 82 ++++++++++- website/src/data/dependencies-npm.json | 7 + 27 files changed, 827 insertions(+), 70 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 71795b29c..5817fdfac 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -366,9 +366,10 @@ 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 | +| `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`, `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay (`held_events_come_back_in_order_and_bounded`) — no replay carries them | | `pty:exit`, `pty:replay` | `data.id` | 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 | @@ -547,12 +548,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 diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index 66685ab07..3cdad05b8 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -98,6 +98,20 @@ 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). + + + 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 474f5c458..f5b822558 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -132,15 +132,37 @@ 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. 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 4bef0c21d..232fe313d 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` @@ -399,6 +408,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/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 d08887830..ca385ef4c 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": 8950, + "docs/specs/standalone.md": 9000, "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": 5350, + "docs/specs/transport.md": 5550, "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 4e064dfbd..6c6649793 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1346,7 +1346,7 @@ 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) { + function list(ids, forWindow, requestId, marks) { const targets = Array.isArray(ids) ? ids.filter((id) => ptys.has(id)) : [...ptys.keys()]; const result = targets.map((id) => ({ id, alive: true, shell: ptyShells.get(id), ...(helpers.has(id) ? { helper: helpers.get(id) } : {}), @@ -1356,7 +1356,29 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice ...(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 @@ -1539,5 +1561,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 3ec558cb3..4d029666b 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1802,6 +1802,37 @@ 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('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 d47a2d1d3..c26973bbb 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -90,6 +90,9 @@ struct RoutingState { /// Derived terminal events that arrived while their id was suppressed, /// delivered to the new owner behind its replay (`routing::Route::Hold`). 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, } #[derive(Default)] @@ -180,18 +183,30 @@ impl WindowState { // A hand-back: what was held for the target belongs to the // source again, which saw the bytes live and needs no events. routing.held.remove(id); + routing.marking.remove(id); } } self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } + /// Open a transfer's marking phase: ownership is the target's, but every + /// byte keeps reaching `source` until the sidecar's `marked` line passes + /// (§Transfer). Suppression begins at that line, not here. + fn begin_marking(&self, ids: &[String], source: &str) { + let mut routing = guard(&self.routing); + for id in ids { + routing.marking.insert(id.clone(), source.to_string()); + } + } + /// Forget one PTY entirely (a kill, or its exit). fn forget_pty(&self, id: &str) { let mut routing = guard(&self.routing); routing.owners.remove(id); routing.awaiting_replay.remove(id); routing.held.remove(id); + routing.marking.remove(id); self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } @@ -327,6 +342,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, @@ -401,6 +417,19 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { state.forget_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 routing.marking.remove(id).is_some() { + routing.awaiting_replay.insert(id.to_string(), Instant::now()); + state + .suppressed + .store(routing.awaiting_replay.len(), Ordering::Relaxed); + } + } + } "pty:replay" => { if let Some(id) = id() { let queue = { @@ -2467,6 +2496,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()); + } // Moved on disk here, not left to the two webviews' debounced saves: out of // the source's snapshot first, then into the target's, so a crash in the // gap restores the Workspace once — in the target, with fresh shells — and @@ -2614,29 +2658,70 @@ 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 _ = unstage_arrival_on_disk(&dir, &label, &arrival.workspace_id); + 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] +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 PTYs would stay + // ownerless. The source has released nothing, so the ids go + // back in silence — no `arrival-failed`, which would clear a + // transferring mark the source still holds and expects. + if let Some(arrival) = + routing::take_arrival(&mut guard(&windows.arrivals), &workspace_id, &to) + { + windows.reassign(&arrival.terminal_ids, &arrival.from, false); + if let Ok(dir) = sessions_dir(&app) { + let _ = unstage_arrival_on_disk(&dir, &to, &arrival.workspace_id); + } + } + 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. @@ -2670,9 +2755,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(()) } @@ -2699,16 +2782,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(()) @@ -3777,6 +3860,7 @@ pub fn run() { adopt_done, adopt_failed, take_arrivals, + transfer_workspace_content, workspace_reserve_ids, workspace_report, workspace_registry, diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index 99d570284..68467c1ad 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -69,6 +69,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> { @@ -98,11 +101,16 @@ 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; } @@ -114,11 +122,23 @@ pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Rout 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:exit" | "pty:replay" => match str_field(data, "id") { @@ -222,6 +242,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 @@ -294,10 +321,38 @@ 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) +} + /// 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 { @@ -515,6 +570,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, @@ -531,6 +587,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!( @@ -568,11 +625,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")), @@ -658,11 +717,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( @@ -708,13 +769,31 @@ 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); + // 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("pty:marked", &json!({"id":"a"}), &marking_view), Route::EmitTo("main")); // A chunk's derived events are in no replay: held, not dropped. assert_eq!( route("terminal:semanticEvents", &json!({"id":"a"}), &suppressed), @@ -736,6 +815,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), @@ -821,9 +901,32 @@ 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 })); + } + #[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 5833f855d..bb08ae2db 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.ts b/standalone/src/tauri-adapter.ts index f7cb2b217..c9cd935b2 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 @@ -196,6 +198,10 @@ export class TauriAdapter implements PlatformAdapter { } }), + 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 ?? []; @@ -577,6 +583,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 00c713c6d..d95aa13b1 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: () => {} }; } @@ -68,6 +70,7 @@ import { resetWindowSessionAggregator, } from "dormouse-lib/lib/window-session-aggregator"; import { setPlatform } from "dormouse-lib/lib/platform"; +import { disposeAllSessions } from "dormouse-lib/lib/terminal-registry"; import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; const WORKSPACE_ID = "ws-moving"; @@ -122,7 +125,7 @@ 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 } = {}): 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; @@ -130,6 +133,11 @@ 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; }); + 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"); }); @@ -138,13 +146,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. + const ids = (args as { payload: { terminalIds: string[] } }).payload.terminalIds; + setTimeout(() => { + for (const id of ids) markedHandler?.({ id, mark: opts.marks?.[id] ?? 0, requestId: `mark-${workspaceId}` }); + }, 0); + } if (cmd === "adopt_done" || cmd === "adopt_failed") { settle(); return undefined; } if (cmd === "adopt_ready") { const arrival = arrivals.find((entry) => entry.workspaceId === workspaceId); @@ -170,7 +187,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(); @@ -206,7 +225,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"] } }); @@ -448,6 +469,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 798706a13..c5cc5bb1b 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -1,10 +1,16 @@ import { invoke } from "@tauri-apps/api/core"; import { collectLivePtys, resumeOrRestoreFrom } from "dormouse-lib/lib/reconnect"; -import { hydrateNotepadFromVolatile } from "dormouse-lib/lib/notepad/notepad-store"; +import { flushTerminal } from "dormouse-lib/lib/terminal-registry"; +import { hydrateNotepadFromVolatile, restoreTerminalPins } from "dormouse-lib/lib/notepad/notepad-store"; import { getWallHandle } from "dormouse-lib/components/wall/wall-handles"; import { 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, @@ -40,13 +46,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 }; @@ -99,9 +109,53 @@ async function handOff( console.warn(`[workspace-move] ${command} refused; the Workspace stays here`, err); return; } - const { workspaceId } = prepared.payload; + const { workspaceId, terminalIds } = prepared.payload; inFlight.set(workspaceId, prepared); 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 marksFor(terminalIds, `mark-${workspaceId}`); + if (!inFlight.has(workspaceId)) return; // handed back while we waited + const content = await captureTransferContent(terminalIds, marks); + 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. */ @@ -209,6 +263,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, @@ -216,6 +277,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); + } // The AlertManager is per webview, so a persisted TODO has to be seeded into // this one — the source's went with its window. for (const pane of payload.workspace.session.panes) { @@ -278,6 +345,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); }; 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", From e4e3040d1b917877d5b996c094d915efac8d827c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 17:36:54 -0700 Subject: [PATCH 2/9] Replay what a handed-back Workspace missed, and close two transfer gaps Three review findings on the marked transfer, plus the gap they exposed. The source armed its `marked` listener only after the invoke resolved, but Rust sends `pty:mark` inside `begin_arrival` and writes two session files before it replies, so the sidecar's `marked` lines could land on an empty handler set: every transfer then ate the full mark timeout and shipped an unmarked id that painted its tail twice. `handOff` now arms `marksFor` before the invoke; the test fake stamps marks synchronously inside the invoke mock so the race is pinned rather than hidden by a `setTimeout`. A tear-out whose `build_window` failed handed the ids back in silence, carried over from when that failure arrived on the first invoke. The mark is set by then, so nothing cleared it and the Workspace was omitted from every snapshot until the next restart. The branch now goes through `hand_back_arrival`, which emits `workspace-arrival-failed`. The routing table said derived events were held for the whole of mid-transfer while `route` sent them to the source until the mark. `terminal:semanticEvents` are re-derived from the replay by whichever window receives it, so they now go with their chunk (source until the mark, dropped while suppressed); `terminal:protocolEvents` are in no replay, so those alone are held. The `route` arms are split so the concurrent `workspaces-harden` change merges cleanly. The gap: on any hand-back, every byte from an id's mark to the hand-back had gone to the target or nowhere, and the source's xterm stood at the mark. `hand_back_arrival` now returns the ids the content marked to the source suppressed and asks the sidecar for `outputSince(mark)` scoped to the source (`requestId` `handback-`); the replay lifts the suppression on the existing path and the source writes it into its existing xterms (`acceptHandBackReplay`). Ids with no mark, and an arrival with no content yet, go straight back: the source still holds their whole buffer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus --- docs/specs/standalone.md | 13 ++++- docs/specs/standalone.rationale.md | 11 ++++ docs/specs/transport.md | 7 ++- scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/lib.rs | 60 ++++++++++++++------ standalone/src-tauri/src/routing.rs | 76 ++++++++++++++++++++++--- standalone/src/workspace-move.test.ts | 82 ++++++++++++++++++++++++--- standalone/src/workspace-move.ts | 62 ++++++++++++++++++-- 8 files changed, 271 insertions(+), 42 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 5817fdfac..7b6a5080d 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -367,7 +367,8 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`, | Sidecar event | Key | Goes to | |---|---|---| | `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`, `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay (`held_events_come_back_in_order_and_bounded`) — no replay carries them | +| `terminal:protocolEvents` | `data.id` | its owner; the source until the id's mark passes, then **held** and delivered, in order, behind the replay (`held_events_come_back_in_order_and_bounded`) — no replay carries them | +| `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 | | `pty:exit`, `pty:replay` | `data.id` | 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 | @@ -585,6 +586,16 @@ 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. +- **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. + An id without a mark — no content yet, or one the sidecar never stamped — + missed nothing its source does not hold and 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 3cdad05b8..de0637729 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -112,6 +112,17 @@ always made, and the target's parser resynchronizes on the next ground byte +**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 f5b822558..e1079559d 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -143,8 +143,11 @@ and it turns on three rules: 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. Suppression fails open after a bound - rather than silencing a pane forever (rationale). + 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"). 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 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index ca385ef4c..d8a2cb680 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 9000, + "docs/specs/standalone.md": 9150, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index c26973bbb..6bdb334a4 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -87,8 +87,8 @@ struct RoutingState { /// dor requestId -> the window handling it, so a cancel reaches the window /// holding the subscription, watch or completion claim it releases. dor_targets: HashMap, - /// Derived terminal events that arrived while their id was suppressed, - /// delivered to the new owner behind its replay (`routing::Route::Hold`). + /// Protocol events that arrived while their id was suppressed, delivered + /// to the new owner behind its replay (`routing::Route::Hold`). held: HashMap>, /// Ids between a transfer's invoke and the sidecar's `marked` line, each /// with the source still consuming (`routing::RouteView::marking`). @@ -180,8 +180,8 @@ impl WindowState { routing.awaiting_replay.insert(id.clone(), now); } else { routing.awaiting_replay.remove(id); - // A hand-back: what was held for the target belongs to the - // source again, which saw the bytes live and needs no events. + // An unmarked hand-back: the source saw every byte live, so + // what was held for the target has nothing to follow. routing.held.remove(id); routing.marking.remove(id); } @@ -2582,10 +2582,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. An id with no mark — no content yet, or one the sidecar +/// never stamped — missed nothing the source does not hold, and a whole-buffer +/// replay would paint its transcript twice: it goes straight back. /// /// The record must already be out of the queue; the caller took it. fn hand_back_arrival( @@ -2606,12 +2615,32 @@ fn hand_back_arrival( } } if app.get_webview_window(&arrival.from).is_some() { - windows.reassign(&arrival.terminal_ids, &arrival.from, false); + let marks = routing::arrival_marks(arrival); + let (marked, unmarked) = routing::hand_back_ids(arrival, &marks); + windows.reassign(&unmarked, &arrival.from, false); + windows.reassign(&marked, &arrival.from, true); + // 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 }), ); + 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; } // Both ends are gone, so these shells belong to no window and nothing would @@ -2699,17 +2728,14 @@ fn transfer_workspace_content( 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 PTYs would stay - // ownerless. The source has released nothing, so the ids go - // back in silence — no `arrival-failed`, which would clear a - // transferring mark the source still holds and expects. + // 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) { - windows.reassign(&arrival.terminal_ids, &arrival.from, false); - if let Ok(dir) = sessions_dir(&app) { - let _ = unstage_arrival_on_disk(&dir, &to, &arrival.workspace_id); - } + hand_back_arrival(&app, &windows, &arrival, "the new window could not be built"); } return Err(err); } diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index 68467c1ad..a22b3d7d5 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -116,9 +116,25 @@ pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Rout } owner(view.owners, id) } - // Derived once at the sidecar's parse site and carried by no replay, so - // a chunk's events outlive the chunk's drop. - "terminal:semanticEvents" | "terminal:protocolEvents" => { + // Derived once at the sidecar's parse site. A semantic event (CWD, + // prompt, title) is re-derived from the replay by whichever window + // receives it, so it 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) + } + // 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; }; @@ -353,6 +369,19 @@ pub fn arrival_marks(arrival: &Arrival) -> JsonValue { JsonValue::Object(marks) } +/// What a hand-back does with an arrival's ids, split by whether `marks` +/// (`arrival_marks`) 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 { @@ -741,13 +770,13 @@ mod tests { #[test] fn held_events_come_back_in_order_and_bounded() { let mut held = HashMap::new(); - hold_event(&mut held, "a", "terminal:semanticEvents", json!({"n":1})); + hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":1})); hold_event(&mut held, "a", "terminal:protocolEvents", json!({"n":2})); - hold_event(&mut held, "b", "terminal:semanticEvents", json!({"n":3})); + hold_event(&mut held, "b", "terminal:protocolEvents", json!({"n":3})); assert_eq!( take_held(&mut held, "a"), vec![ - ("terminal:semanticEvents".to_string(), json!({"n":1})), + ("terminal:protocolEvents".to_string(), json!({"n":1})), ("terminal:protocolEvents".to_string(), json!({"n":2})), ] ); @@ -755,7 +784,7 @@ mod tests { assert_eq!(held.len(), 1); for n in 0..(HELD_EVENTS_MAX + 5) { - hold_event(&mut held, "c", "terminal:semanticEvents", json!({"n":n})); + hold_event(&mut held, "c", "terminal:protocolEvents", json!({"n":n})); } let queue = take_held(&mut held, "c"); assert_eq!(queue.len(), HELD_EVENTS_MAX); @@ -793,11 +822,17 @@ mod tests { 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 derived events are in no replay: held, not dropped. + // 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::Hold + Route::Drop ); assert_eq!( route("terminal:protocolEvents", &json!({"id":"a"}), &suppressed), @@ -927,6 +962,29 @@ mod tests { assert_eq!(arrival_marks(&arrivals[0]), json!({ "t1": 42 })); } + /// A hand-back replays exactly the marked ids since their marks; an id the + /// content did not mark, or an arrival with no content yet, goes straight + /// back — its source still holds the whole buffer. + #[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 = arrival_marks(&pending); + assert_eq!( + hand_back_ids(&pending, &marks), + (vec![], vec!["t1".to_string(), "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/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index d95aa13b1..7b9670db0 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -70,7 +70,7 @@ import { resetWindowSessionAggregator, } from "dormouse-lib/lib/window-session-aggregator"; import { setPlatform } from "dormouse-lib/lib/platform"; -import { disposeAllSessions } from "dormouse-lib/lib/terminal-registry"; +import { disposeAllSessions, getOrCreateTerminal } from "dormouse-lib/lib/terminal-registry"; import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; const WORKSPACE_ID = "ws-moving"; @@ -109,6 +109,9 @@ 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 deliverReplay: (detail: { id: string; data: string; requestId?: string }) => void = () => {}; + /** A prepared transfer whose commit is observable. */ function prepared( onCommit: () => void = () => {}, @@ -125,7 +128,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; marks?: Record } = {}): 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; @@ -133,6 +139,8 @@ function fakePlatform(order: string[] = [], opts: { answer?: boolean; marks?: Re 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); let markedHandler: ((detail: { id: string; mark: number; requestId?: string }) => void) | null = null; (platform as unknown as { onPtyMarked: unknown }).onPtyMarked = (handler: typeof markedHandler) => { markedHandler = handler; @@ -156,11 +164,11 @@ function fakePlatform(order: string[] = [], opts: { answer?: boolean; marks?: Re 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. - const ids = (args as { payload: { terminalIds: string[] } }).payload.terminalIds; - setTimeout(() => { - for (const id of ids) markedHandler?.({ id, mark: opts.marks?.[id] ?? 0, requestId: `mark-${workspaceId}` }); - }, 0); + // 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") { @@ -274,6 +282,66 @@ 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" }); + // 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("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" }); + 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`). diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index c5cc5bb1b..900bd7d04 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -1,6 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; import { collectLivePtys, resumeOrRestoreFrom } from "dormouse-lib/lib/reconnect"; import { flushTerminal } from "dormouse-lib/lib/terminal-registry"; +import { writeReplay } from "dormouse-lib/lib/terminal-report-filter"; +import { registry as terminalRegistry } from "dormouse-lib/lib/terminal-store"; import { hydrateNotepadFromVolatile, restoreTerminalPins } from "dormouse-lib/lib/notepad/notepad-store"; import { getWallHandle } from "dormouse-lib/components/wall/wall-handles"; import { setWorkspaceBootPlan } from "dormouse-lib/components/wall/workspace-boot-plans"; @@ -24,7 +26,7 @@ import { moveWorkspace, setActiveWorkspace, } from "dormouse-lib/lib/workspace-store"; -import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; +import type { PlatformAdapter, 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"; @@ -84,6 +86,14 @@ const ARRIVAL_TIMEOUT_MS = 3000; */ const inFlight = new Map(); +/** + * The marks the content this Window handed over carries, by Workspace: exactly + * the ids whose bytes since the mark went to the target — or nowhere — and so + * exactly what a hand-back replays into the xterms still here. Recorded before + * `transfer_workspace_content`, because that invoke can itself hand back. + */ +const handedMarks = new Map>(); + async function prepare(workspaceId: WorkspaceId): Promise { const handle = getWallHandle(workspaceId); if (!handle) return null; @@ -103,21 +113,26 @@ async function handOff( command: string, args: Record, ): Promise { + const { workspaceId, terminalIds } = prepared.payload; + // 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}`); try { await invoke(command, args); } catch (err) { console.warn(`[workspace-move] ${command} refused; the Workspace stays here`, err); - return; + return; // `pendingMarks` unsubscribes itself at the timeout } - const { workspaceId, terminalIds } = prepared.payload; inFlight.set(workspaceId, prepared); 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 marksFor(terminalIds, `mark-${workspaceId}`); + const marks = await pendingMarks; if (!inFlight.has(workspaceId)) return; // handed back while we waited const content = await captureTransferContent(terminalIds, marks); + if (!inFlight.has(workspaceId)) return; // handed back while serializing + handedMarks.set(workspaceId, marks); try { await invoke("transfer_workspace_content", { workspaceId, content }); } catch (err) { @@ -196,6 +211,7 @@ function handleDeparted(workspaceId: WorkspaceId): void { return; } inFlight.delete(workspaceId); + handedMarks.delete(workspaceId); prepared.commit(); // Moving a Window's last Workspace away closes it — without confirming, // archiving or killing, because nothing ended: the Surfaces are alive @@ -213,12 +229,47 @@ 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 { if (!inFlight.delete(workspaceId)) return; + const marks = handedMarks.get(workspaceId); + handedMarks.delete(workspaceId); clearWorkspaceTransferring(workspaceId); console.warn(`[workspace-move] ${workspaceId} was not adopted (${reason}); it stays here`); + if (marks?.size) acceptHandBackReplay(workspaceId, [...marks.keys()]); +} + +/** 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 finish = () => { + clearTimeout(timer); + platform.offPtyReplay(onReplay); + }; + const onReplay = (detail: PtyReplayDetail) => { + if (detail.requestId !== requestId || !wanted.delete(detail.id)) return; + const entry = terminalRegistry.get(detail.id); + if (entry) writeReplay(entry, detail.data); + if (wanted.size === 0) finish(); + }; + platform.onPtyReplay(onReplay); + const timer = setTimeout(finish, HAND_BACK_REPLAY_TIMEOUT_MS); } // --- Target ------------------------------------------------------------------ @@ -409,5 +460,6 @@ export async function bootFromTearOut(platform: PlatformAdapter): Promise Date: Thu, 10 Sep 2026 18:34:26 -0700 Subject: [PATCH 3/9] Recover source output when transfers fail before content submission --- docs/specs/standalone.md | 6 ++-- scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/lib.rs | 48 +++++++++++++++++++++++---- standalone/src-tauri/src/routing.rs | 9 +++-- standalone/src/workspace-move.test.ts | 25 ++++++++++++-- standalone/src/workspace-move.ts | 25 ++++---------- 6 files changed, 80 insertions(+), 35 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index e0d0976ea..425d175a2 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -620,8 +620,10 @@ below reads that record rather than inferring itself from the suppression map. 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. - An id without a mark — no content yet, or one the sidecar never stamped — - missed nothing its source does not hold and goes straight back: a whole-buffer + **Must record source cuts at `pty:marked`, retaining them through target + replay until settlement, and carry replay ids in the failure event**; content + submission and the source invoke reply may both still be pending. 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 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 97714bab0..e835b4924 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "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": 9700, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index de697457b..78ded290c 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -94,11 +94,20 @@ struct RoutingState { /// 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) } @@ -206,6 +215,7 @@ impl WindowState { fn begin_marking(&self, ids: &[String], source: &str) { let mut routing = guard(&self.routing); for id in ids { + routing.transfer_marks.remove(id); routing.marking.insert(id.clone(), source.to_string()); } } @@ -216,6 +226,7 @@ impl WindowState { routing.owners.remove(id); routing.lift_suppression(id); routing.marking.remove(id); + routing.transfer_marks.remove(id); self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } @@ -228,6 +239,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); @@ -434,8 +446,8 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { "pty:marked" => { if let Some(id) = id() { let mut routing = guard(&state.routing); - if routing.marking.remove(id).is_some() { - routing.awaiting_replay.insert(id.to_string(), Instant::now()); + 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); @@ -2807,9 +2819,9 @@ fn spawn_arrival_watchdog(app: AppHandle, arrival: &routing::Arrival) { /// 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. An id with no mark — no content yet, or one the sidecar -/// never stamped — missed nothing the source does not hold, and a whole-buffer -/// replay would paint its transcript twice: it goes straight back. +/// 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( @@ -2831,7 +2843,16 @@ fn hand_back_arrival( } } if app.get_webview_window(&arrival.from).is_some() { - let marks = routing::arrival_marks(arrival); + let marks = { + let mut routing = guard(&windows.routing); + let mut marks = serde_json::Map::new(); + for id in &arrival.terminal_ids { + if let Some(mark) = routing.transfer_marks.remove(id) { + marks.insert(id.clone(), JsonValue::from(mark)); + } + } + JsonValue::Object(marks) + }; let (marked, unmarked) = routing::hand_back_ids(arrival, &marks); windows.reassign(&unmarked, &arrival.from, false); windows.reassign(&marked, &arrival.from, true); @@ -2840,7 +2861,7 @@ fn hand_back_arrival( 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; @@ -4374,6 +4395,19 @@ mod tests { ); } + #[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"); diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index e07611b44..1041df20e 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -372,7 +372,7 @@ pub fn arrival_marks(arrival: &Arrival) -> JsonValue { } /// What a hand-back does with an arrival's ids, split by whether `marks` -/// (`arrival_marks`) carries one: the marked ids go back to the source +/// (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. @@ -981,16 +981,15 @@ mod tests { } /// A hand-back replays exactly the marked ids since their marks; an id the - /// content did not mark, or an arrival with no content yet, goes straight - /// back — its source still holds the whole buffer. + /// 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 = arrival_marks(&pending); + let marks = json!({ "t1": 42 }); assert_eq!( hand_back_ids(&pending, &marks), - (vec![], vec!["t1".to_string(), "t2".to_string()]) + (vec!["t1".to_string()], vec!["t2".to_string()]) ); pending.content = Some(json!({ diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index f6aa7ddd0..2c823e26d 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -298,7 +298,7 @@ describe("the source half", () => { 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" }); + 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}` }); @@ -311,6 +311,27 @@ describe("the source half", () => { expect(mocks.writes).toEqual(["since-the-mark"]); }); + 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("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 @@ -331,7 +352,7 @@ describe("the source half", () => { expect(args).toMatchObject({ content: { terminals: { "pane-a": { serialized: "" } } } }); mocks.writes.length = 0; - const failed = emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, reason: "wedged" }); + 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}` }); diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 855569cfb..aea1dfe76 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -88,14 +88,6 @@ const ARRIVAL_TIMEOUT_MS = 3000; */ const inFlight = new Map(); -/** - * The marks the content this Window handed over carries, by Workspace: exactly - * the ids whose bytes since the mark went to the target — or nowhere — and so - * exactly what a hand-back replays into the xterms still here. Recorded before - * `transfer_workspace_content`, because that invoke can itself hand back. - */ -const handedMarks = new Map>(); - async function prepare(workspaceId: WorkspaceId): Promise { const handle = getWallHandle(workspaceId); if (!handle) return null; @@ -119,13 +111,15 @@ async function handOff( // 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) { + inFlight.delete(workspaceId); console.warn(`[workspace-move] ${command} refused; the Workspace stays here`, err); return; // `pendingMarks` unsubscribes itself at the timeout } - inFlight.set(workspaceId, prepared); + if (!inFlight.has(workspaceId)) 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 @@ -134,7 +128,6 @@ async function handOff( if (!inFlight.has(workspaceId)) return; // handed back while we waited const content = await captureTransferContent(terminalIds, marks); if (!inFlight.has(workspaceId)) return; // handed back while serializing - handedMarks.set(workspaceId, marks); try { await invoke("transfer_workspace_content", { workspaceId, content }); } catch (err) { @@ -213,7 +206,6 @@ function handleDeparted(workspaceId: WorkspaceId): void { return; } inFlight.delete(workspaceId); - handedMarks.delete(workspaceId); prepared.commit(); // Moving a Window's last Workspace away closes it — without confirming, // archiving or killing, because nothing ended: the Surfaces are alive @@ -234,13 +226,11 @@ function handleDeparted(workspaceId: WorkspaceId): void { * 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; - const marks = handedMarks.get(workspaceId); - handedMarks.delete(workspaceId); clearWorkspaceTransferring(workspaceId); console.warn(`[workspace-move] ${workspaceId} was not adopted (${reason}); it stays here`); - if (marks?.size) acceptHandBackReplay(workspaceId, [...marks.keys()]); + if (replayIds.length) acceptHandBackReplay(workspaceId, replayIds); } /** A hand-back's replay is one since-mark slice per id over the sidecar's @@ -431,9 +421,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` @@ -496,6 +486,5 @@ export async function bootFromTearOut(platform: PlatformAdapter): Promise Date: Thu, 10 Sep 2026 18:48:46 -0700 Subject: [PATCH 4/9] Retain transfer replay cuts when PTYs exit before settlement --- docs/specs/standalone.md | 2 +- standalone/src-tauri/src/lib.rs | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 2c7ab81d1..a9a1e5daa 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -621,7 +621,7 @@ below reads that record rather than inferring itself from the suppression map. `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 until settlement, and carry replay ids in the failure event**; content + replay and PTY exit until settlement, and carry replay ids in the failure event**; content submission and the source invoke reply may both still be pending. 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`; diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 0bf89335d..8ca7a042d 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -169,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 @@ -226,7 +227,8 @@ impl WindowState { routing.owners.remove(id); routing.lift_suppression(id); routing.marking.remove(id); - routing.transfer_marks.remove(id); + // An exited PTY still has replay bytes; keep its source cut until + // adoption or hand-back settles the arrival. self.suppressed .store(routing.awaiting_replay.len(), Ordering::Relaxed); } @@ -4404,6 +4406,18 @@ 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_marking(&["t1".to_string()], "main"); + guard(&windows.routing).mark_transfer("t1", 42); + windows.forget_pty("t1"); + assert_eq!(guard(&windows.routing).transfer_marks.get("t1"), Some(&42)); + windows.clear_suppression(&["t1".to_string()]); + 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(); From aeed37aee88e411fc0afc2546bdde53420c5cf99 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 18:58:16 -0700 Subject: [PATCH 5/9] Preserve the active transfer when a Workspace is dropped twice --- docs/specs/standalone.md | 4 ++++ scripts/spec-word-budgets.json | 4 ++-- standalone/src/workspace-move.test.ts | 25 +++++++++++++++++++++++++ standalone/src/workspace-move.ts | 9 +++++---- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index be7101584..ea67108ee 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -614,6 +614,10 @@ 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. +- **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 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index e835b4924..d58fda77f 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": 9700, + "docs/specs/standalone.md": 9800, "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": 5600, + "docs/specs/transport.md": 5700, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index 2c823e26d..2fa8c6a17 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -332,6 +332,31 @@ describe("the source half", () => { 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 diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index aea1dfe76..45fb20bbd 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -108,6 +108,7 @@ async function handOff( 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}`); @@ -115,19 +116,19 @@ async function handOff( try { await invoke(command, args); } catch (err) { - inFlight.delete(workspaceId); + if (inFlight.get(workspaceId) === prepared) inFlight.delete(workspaceId); console.warn(`[workspace-move] ${command} refused; the Workspace stays here`, err); return; // `pendingMarks` unsubscribes itself at the timeout } - if (!inFlight.has(workspaceId)) return; // handed back before invoke replied + 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.has(workspaceId)) return; // handed back while we waited + if (inFlight.get(workspaceId) !== prepared) return; // handed back while we waited const content = await captureTransferContent(terminalIds, marks); - if (!inFlight.has(workspaceId)) return; // handed back while serializing + if (inFlight.get(workspaceId) !== prepared) return; // handed back while serializing try { await invoke("transfer_workspace_content", { workspaceId, content }); } catch (err) { From c622ad49fc04e28aa4c4aa4725d8c52364304172 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 19:28:50 -0700 Subject: [PATCH 6/9] Make transfer routing atomic and replay naturally exited buffers --- docs/specs/standalone.md | 8 ++- docs/specs/transport.md | 5 +- lib/src/lib/reconnect.test.ts | 12 ++++ scripts/spec-word-budgets.json | 2 +- standalone/sidecar/pty-core.js | 12 +++- standalone/sidecar/pty-core.test.js | 28 +++++++++ standalone/src-tauri/src/lib.rs | 98 ++++++++++++++++++++--------- 7 files changed, 129 insertions(+), 36 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index cef0b9618..787203e34 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -614,6 +614,9 @@ 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. +- **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 @@ -625,8 +628,9 @@ below reads that record rather than inferring itself from the suppression map. `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 PTY exit until settlement, and carry replay ids in the failure event**; content - submission and the source invoke reply may both still be pending. An id the + 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.** 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). diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 6d5c9f477..26cc2dcbb 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -150,7 +150,10 @@ and it turns on three rules: 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"). Suppression fails open after a bound rather than silencing + "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)` 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/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d58fda77f..426326f87 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5700, + "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/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 503d360b5..ae77ca7e1 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1250,7 +1250,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); @@ -1273,6 +1273,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); @@ -1383,9 +1384,14 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice * (docs/specs/transport.md -> "Reconnection"). */ function list(ids, forWindow, requestId, marks) { - const targets = Array.isArray(ids) ? ids.filter((id) => ptys.has(id)) : [...ptys.keys()]; + // 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 } : {}), diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 7e5a93dc2..4b05aa1a0 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1898,6 +1898,34 @@ test('a mark is ordered in the stream and a since-mark replay is exactly the rem 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 1a3a48caf..c9003205e 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -210,27 +210,51 @@ impl WindowState { .store(routing.awaiting_replay.len(), Ordering::Relaxed); } - /// Open a transfer's marking phase: ownership is the target's, but every - /// byte keeps reaching `source` until the sidecar's `marked` line passes - /// (§Transfer). Suppression begins at that line, not here. - fn begin_marking(&self, ids: &[String], source: &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) } - /// Forget one PTY entirely (a kill, or its exit). - fn forget_pty(&self, id: &str) { + 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); routing.marking.remove(id); - // An exited PTY still has replay bytes; keep its source cut until - // adoption or hand-back settles the arrival. - self.suppressed - .store(routing.awaiting_replay.len(), Ordering::Relaxed); + // 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 @@ -440,7 +464,7 @@ 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 @@ -2812,8 +2836,7 @@ fn begin_arrival( )); } // Ownership moves now; suppression waits for each id's `marked` line. - windows.reassign(&arrival.terminal_ids, &arrival.to, false); - windows.begin_marking(&arrival.terminal_ids, &arrival.from); + windows.begin_transfer(&arrival.terminal_ids, &arrival.from, &arrival.to); routing::queue_arrival(&mut arrivals, arrival.clone()); } // The split point, stamped in the stream by the sidecar and routed to the @@ -2900,19 +2923,8 @@ fn hand_back_arrival( append_log(format!("[window] could not record hand-back: {e}")); } } - let marks = { - let mut routing = guard(&windows.routing); - let mut marks = serde_json::Map::new(); - for id in &arrival.terminal_ids { - if let Some(mark) = routing.transfer_marks.remove(id) { - marks.insert(id.clone(), JsonValue::from(mark)); - } - } - JsonValue::Object(marks) - }; - let (marked, unmarked) = routing::hand_back_ids(arrival, &marks); - windows.reassign(&unmarked, &arrival.from, false); - windows.reassign(&marked, &arrival.from, true); + 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`). @@ -4469,11 +4481,39 @@ mod tests { fn a_pty_exit_keeps_its_cut_until_the_arrival_settles() { let windows = super::WindowState::default(); windows.mint("t1", "main"); - windows.begin_marking(&["t1".to_string()], "main"); + windows.begin_transfer(&["t1".to_string()], "main", "ws-2"); guard(&windows.routing).mark_transfer("t1", 42); - windows.forget_pty("t1"); + windows.exited_pty("t1"); assert_eq!(guard(&windows.routing).transfer_marks.get("t1"), Some(&42)); - windows.clear_suppression(&["t1".to_string()]); + 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")); } From f8c67f0c77e4d674d4382d2304732846602ad66b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 19:49:04 -0700 Subject: [PATCH 7/9] Deliver addressed replay and restore handed-back exit status --- docs/specs/standalone.md | 7 +++++-- scripts/spec-word-budgets.json | 2 +- standalone/src-tauri/src/routing.rs | 6 ++++++ standalone/src/workspace-move.test.ts | 21 +++++++++++++++++++++ standalone/src/workspace-move.ts | 24 +++++++++++++++++++++--- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 00f6a8a70..a4cce7c5f 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -377,7 +377,8 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`, | `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`, `pty:replay` | `data.id` | its owner, never suppressed | +| `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 | @@ -632,7 +633,9 @@ below reads that record rather than inferring itself from the suppression map. **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.** An id the + 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. 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). diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 426326f87..a0f96440b 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 9800, + "docs/specs/standalone.md": 9850, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs index 1041df20e..662a49954 100644 --- a/standalone/src-tauri/src/routing.rs +++ b/standalone/src-tauri/src/routing.rs @@ -159,6 +159,9 @@ pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Rout }, // 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, @@ -695,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":[]}), diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index 2fa8c6a17..81152fcae 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -111,6 +111,7 @@ function payload(overrides: Partial = {}): WorkspaceTr 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. */ @@ -142,6 +143,7 @@ function fakePlatform( 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; @@ -311,6 +313,25 @@ describe("the source half", () => { 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); diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 45fb20bbd..0aeea11b0 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -3,7 +3,8 @@ 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 { flushTerminal } from "dormouse-lib/lib/terminal-registry"; -import { writeReplay } from "dormouse-lib/lib/terminal-report-filter"; +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"; @@ -28,7 +29,7 @@ import { moveWorkspace, setActiveWorkspace, } from "dormouse-lib/lib/workspace-store"; -import type { PlatformAdapter, PtyReplayDetail } 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"; @@ -251,16 +252,33 @@ function acceptHandBackReplay(workspaceId: WorkspaceId, ids: readonly string[]): 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) writeReplay(entry, detail.data); + 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); } From a136c96dde2c5a003263684f6a9c3f6546b74ee0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 19:51:34 -0700 Subject: [PATCH 8/9] Budget combined transfer and shutdown contracts --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index a0f96440b..f048c7d14 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -24,7 +24,7 @@ "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1050, - "docs/specs/standalone.md": 9850, + "docs/specs/standalone.md": 9900, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, From 363ea2a5187a4d91b2533c37e5b82eec133ba6f0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 19:55:31 -0700 Subject: [PATCH 9/9] Finish replayed command watches for exited transfers --- docs/specs/standalone.md | 4 +++- standalone/src/tauri-adapter.test.ts | 14 ++++++++++++++ standalone/src/tauri-adapter.ts | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 8859367f8..d346c1a64 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -639,7 +639,9 @@ below reads that record rather than inferring itself from the suppression map. 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. An id the + 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). 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 96212a813..aef8c9fa9 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -143,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. @@ -177,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); @@ -199,6 +206,16 @@ 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 }); }