diff --git a/packages/server/src/terminal/terminal-restore.test.ts b/packages/server/src/terminal/terminal-restore.test.ts index 868b7fe155..ad008e1bd7 100644 --- a/packages/server/src/terminal/terminal-restore.test.ts +++ b/packages/server/src/terminal/terminal-restore.test.ts @@ -13,15 +13,24 @@ import { describe("terminal restore policy", () => { test("resolves bounded restore snapshot options", () => { - expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull(); - expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined(); - expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({ + expect( + resolveTerminalRestoreSnapshotOptions({ restore: { mode: "live" }, chunked: false }), + ).toBeNull(); + expect( + resolveTerminalRestoreSnapshotOptions({ restore: { mode: "full-snapshot" }, chunked: false }), + ).toBeUndefined(); + expect( + resolveTerminalRestoreSnapshotOptions({ + restore: { mode: "visible-snapshot" }, + chunked: false, + }), + ).toEqual({ scrollbackLines: 200, }); expect( resolveTerminalRestoreSnapshotOptions({ - mode: "visible-snapshot", - scrollbackLines: 999, + restore: { mode: "visible-snapshot", scrollbackLines: 999 }, + chunked: false, }), ).toEqual({ scrollbackLines: 500 }); }); diff --git a/packages/server/src/terminal/terminal-restore.ts b/packages/server/src/terminal/terminal-restore.ts index 4320f450ee..c0ee7b1105 100644 --- a/packages/server/src/terminal/terminal-restore.ts +++ b/packages/server/src/terminal/terminal-restore.ts @@ -33,18 +33,25 @@ export function resolveRestoreAfterOutputOverflow( }; } -export function resolveTerminalRestoreSnapshotOptions( - restore: TerminalRestoreOptions, -): TerminalReplayOptions | null | undefined { +export function resolveTerminalRestoreSnapshotOptions({ + restore, + chunked, +}: { + restore: TerminalRestoreOptions; + chunked: boolean; +}): TerminalReplayOptions | null | undefined { if (restore.mode === "live") { return null; } - if (restore.mode === "visible-snapshot") { - return { - scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines), - }; + if (restore.mode === "full-snapshot") { + return chunked ? { scrollbackLines: restore.scrollbackLines } : undefined; } - return undefined; + const scrollbackLines = restore.scrollbackLines ?? DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; + return { + scrollbackLines: chunked + ? scrollbackLines + : Math.min(scrollbackLines, MAX_VISIBLE_RESTORE_SCROLLBACK_LINES), + }; } export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Array }): Uint8Array { @@ -55,13 +62,6 @@ export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Arr }); } -function resolveVisibleRestoreScrollbackLines(value: number | undefined): number { - if (typeof value !== "number") { - return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; - } - return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES); -} - /** The daemon's only old-app adapter. Capable delivery never assembles the replay. */ export function createTerminalRestoreDelivery(input: { slot: number; diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index d4928ab5a6..ccb152aa23 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -782,10 +782,49 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { }; } + test.each([false, true])( + "preserves visible history defaults and explicit requests across delivery (chunked=%s)", + async (chunked) => { + const replayOpcodes = chunked + ? [ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, + ] + : [TerminalStreamOpcode.Restore]; + for (const [restore, expected] of [ + [{ mode: "visible-snapshot" }, [{ scrollbackLines: 200 }]], + [{ mode: "visible-snapshot", scrollbackLines: 0 }, [{ scrollbackLines: 0 }]], + [{ mode: "visible-snapshot", scrollbackLines: 5 }, [{ scrollbackLines: 5 }]], + [{ mode: "visible-snapshot", scrollbackLines: 500 }, [{ scrollbackLines: 500 }]], + [ + { mode: "visible-snapshot", scrollbackLines: 10000 }, + [{ scrollbackLines: chunked ? 10000 : 500 }], + ], + [{ mode: "full-snapshot" }, [chunked ? { scrollbackLines: undefined } : undefined]], + [{ mode: "live" }, []], + ] as const) { + const fixture = await setup(() => 0, chunked, undefined, restore); + expect(fixture.replayOptions).toEqual(expected); + fixture.ready(); + await waitForCoalescerFlush(); + expect(fixture.replayOptions).toEqual([...expected, ...expected]); + expect(fixture.frames.map((frame) => frame.opcode)).toEqual( + restore.mode === "live" ? [] : replayOpcodes, + ); + fixture.pushOutput("after-restore"); + await waitForCoalescerFlush(); + expect(fixture.frames.at(-1)?.opcode).toBe(TerminalStreamOpcode.Output); + expect(new TextDecoder().decode(fixture.frames.at(-1)!.payload)).toBe("after-restore"); + } + }, + ); + test.each([false, true])( "bounds recovery without changing attach demand (chunked=%s)", async (chunked) => { for (const [restore, history] of [ + [{ mode: "visible-snapshot" }, 500], [{ mode: "visible-snapshot", scrollbackLines: 10000 }, 500], [{ mode: "full-snapshot" }, 500], [{ mode: "visible-snapshot", scrollbackLines: 5 }, 5], diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 7c919839d1..653524e54a 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -1085,10 +1085,7 @@ export class TerminalSessionController { restore: TerminalRestoreOptions, ): Promise { const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true; - const snapshotOptions = - chunked && restore.mode !== "live" - ? { scrollbackLines: restore.scrollbackLines } - : resolveTerminalRestoreSnapshotOptions(restore); + const snapshotOptions = resolveTerminalRestoreSnapshotOptions({ restore, chunked }); if (snapshotOptions === null) { return { shouldContinue: true, replay: null }; }