Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions web/src/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import {
createReplayQueue,
prepareReplayTail,
scheduleReplay,
shouldDeferCacheReplay,
snapshotStartPosition,
type ReplayHandle,
type ReplayTail,
} from "./terminalReplay";
import { beginForegroundReplay } from "./terminalPrewarm";
import {
Expand Down Expand Up @@ -142,6 +144,10 @@ const TerminalView: Component<Props> = (props) => {
let snapshotEndPosition: number | undefined;
let cacheChunks: Uint8Array[] = [];
let cacheBytes = 0;
// A parked pane's cached tail, prepared but not yet replayed. Kept in memory
// on reload and replayed lazily on first activation (F3), so retained tabs do
// not time-slice the shared replay parser with the active pane.
let deferredCacheReplay: ReplayTail | null = null;
let cacheTimer: ReturnType<typeof setTimeout> | null = null;
// The outputPosition at the last successful persist, so an unchanged ring is
// not re-copied and re-written to IndexedDB.
Expand Down Expand Up @@ -817,23 +823,18 @@ const TerminalView: Component<Props> = (props) => {
const prepared = prepareReplayTail(bytes, cached.outputPosition);
cacheChunks = [bytes];
cacheBytes = bytes.byteLength;
// Adopt the cache's position now so a warm reattach resumes from it even
// if the visible replay is deferred (or later cancelled by a re-park).
outputPosition = prepared.outputPosition;
if (shouldDeferCacheReplay(isParked(), true)) {
// Parked on reload: keep the tail in memory and replay it on the first
// activation, not into the shared FIFO with the active pane (F3).
deferredCacheReplay = prepared;
setReadyToConnect(true);
return;
}
setStatusText("Restoring terminal...");
replay = scheduleReplay(
props.sessionId,
[prepared.data],
(chunk, done) => {
if (!term) {
done();
return;
}
term.write(chunk, done);
},
{
kind: "cache",
droppedBytes: prepared.droppedBytes,
droppedLines: prepared.droppedLines,
},
);
replay = replayCacheTail(prepared);
void replay.done.then(() => {
if (destroyed) return;
outputPosition = prepared.outputPosition;
Expand Down Expand Up @@ -896,6 +897,26 @@ const TerminalView: Component<Props> = (props) => {
connect();
}

/** Replay a prepared cache tail into xterm through the shared replay queue. */
function replayCacheTail(prepared: ReplayTail): ReplayHandle {
return scheduleReplay(
props.sessionId,
[prepared.data],
(chunk, done) => {
if (!term) {
done();
return;
}
term.write(chunk, done);
},
{
kind: "cache",
droppedBytes: prepared.droppedBytes,
droppedLines: prepared.droppedLines,
},
);
}

function connect() {
if (isParked()) return;
if (isSessionGone()) { markSessionGone(); return; }
Expand Down Expand Up @@ -1083,6 +1104,22 @@ const TerminalView: Component<Props> = (props) => {
if (destroyed || !readyToConnect() || isParked()) return;
// Resuming to the foreground: hold the pre-warm gate through this attach.
enterForegroundReplay();
// A tab parked on reload deferred its cache replay (F3); run it now, before
// attaching, so the restored scrollback is on screen when the delta arrives.
const pending = deferredCacheReplay;
if (pending) {
deferredCacheReplay = null;
setStatusText("Restoring terminal...");
replay?.cancel();
replay = replayCacheTail(pending);
void replay.done.then(() => {
if (destroyed || isParked()) return;
outputPosition = pending.outputPosition;
term?.scrollToBottom();
connect();
});
return;
}
setStatusText("Loading terminal...");
connect();
}
Expand Down
23 changes: 23 additions & 0 deletions web/src/__tests__/shouldDeferCacheReplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";

import { shouldDeferCacheReplay } from "../terminalReplay";

describe("shouldDeferCacheReplay", () => {
it("defers only a parked pane that has cached bytes", () => {
// A retained-but-parked tab on reload keeps its cache in memory and replays
// it lazily on activation, not into the shared FIFO with the active pane.
expect(shouldDeferCacheReplay(true, true)).toBe(true);
});

it("does not defer the active pane", () => {
// The pane the user is looking at replays its cache immediately.
expect(shouldDeferCacheReplay(false, true)).toBe(false);
});

it("does not defer when there is nothing cached", () => {
// No cache means nothing to replay: a parked pane with an empty cache just
// waits to connect, it does not enter the deferred path.
expect(shouldDeferCacheReplay(true, false)).toBe(false);
expect(shouldDeferCacheReplay(false, false)).toBe(false);
});
});
17 changes: 17 additions & 0 deletions web/src/terminalReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ export const REPLAY_TAIL_MAX_BYTES = 1 * 1024 * 1024;
/** Keep each xterm parser turn bounded and aligned with the server frame size. */
export const REPLAY_SLICE_BYTES = 64 * 1024;

/**
* On reload, should a pane defer its cached-scrollback replay?
*
* A parked pane — a retained tab that is not the active one, or the unfocused
* half of a split — must NOT replay its cache into the shared replay FIFO on
* reload: N retained tabs replaying at once time-slice the single parser N ways
* and starve the pane the user is actually looking at. A parked pane keeps its
* cache in memory and replays lazily on first activation (`resumeSocket`)
* instead. An active pane with a cache replays immediately.
*/
export function shouldDeferCacheReplay(
parked: boolean,
hasCachedBytes: boolean,
): boolean {
return parked && hasCachedBytes;
}

/**
* The absolute output position at the START of a snapshot payload: the byte
* offset the first snapshot byte sits at. The server reports the position at
Expand Down
Loading