Skip to content

test(terminal): pin checkpoint-only cold restore of a large checkpoint - #10500

Merged
brennanb2025 merged 4 commits into
mainfrom
brennanb2025/triage-blank-cold-restore
Jul 25, 2026
Merged

brennanb2025 merged 4 commits into
mainfrom
brennanb2025/triage-blank-cold-restore

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Test only — no production code. Pins two things about checkpoint-only cold restore that had no coverage and have now been misdiagnosed twice.

Triage first: this is not a v1.4.156 regression. A live test reported that checkpoint-only cold restore comes back visually blank at every size tried (1.0, 5.5, 11.2, 20.3 MiB), which would have been a release blocker for v1.4.156. It isn't. The baseline in that test was the PR base (origin/main = v1.4.156); v1.4.155 was never run.

I ran a real A/B: a git worktree checked out at v1.4.155 against origin/main, driving the real HistoryManager.checkpoint() writer and the real HistoryReader.detectColdRestore() reader over a genuinely header-only output.log (9 bytes = LOG_HEADER_BYTES), which is exactly the checkpoint-only route.

checkpoint v1.4.155 origin/main
1.01 MiB OK — 300/300 markers, seed 1016391 OK — 300/300 markers, seed 1016391
5.56 MiB OK — 300/300 markers, seed 5602391 OK — 300/300 markers, seed 5602391
11.30 MiB OK — 300/300 markers, seed 11382391 OK — 300/300 markers, seed 11382391
20.61 MiB OK — 300/300 markers, seed 20754391 OK — 300/300 markers, seed 20754391

Byte-identical on both refs, at the reporter's own sizes, with a 3.5 KB control passing on both to prove the harness. #10179's bounded reader, #9990's async/semaphore rewrite and #10479's 200MB cap are output-equivalent to v1.4.155's bare JSON.parse(readFileSync()).

What actually produces a blank

Blankness tracks the meta.endedAt eligibility gate, not size. Same probe, varying only how the daemon went away:

shutdown 3.5 KB 1.01 MiB 5.56 MiB
crash (endedAt null) restore OK restore OK restore OK
dispose() / closeSession() (endedAt set) blank blank blank

Identical on both refs. A cleanly ended session refuses to cold-restore at any size — by design (history-manager.ts:277: "an unwritten endedAt looks like an unclean shutdown → false cold restore next launch"). That is the size-independent blank that was mistaken for a size cap. Cold restore requires the daemon to die uncleanly; a graceful shutdown runs HistoryManager.dispose() and correctly disqualifies every session.

What this PR adds

The triage surfaced a real coverage gap: #10179's 16MiB read cap sat under what the unbounded checkpoint writer can emit, so a large checkpoint threw, got swallowed to checkpoint=null, and the pane reopened empty. #10479 raised the cap, but nothing pinned the writer→reader round trip the cap had broken — history-reader-memory.test.ts covers the bounded reader at its limit, and its >16MiB case hand-writes checkpoint.json, so it never exercises the real writer.

  • Large-checkpoint round trip through the real writer and reader over a header-only log. Fails at the pre-fix(terminal): stop cold restore dropping all scrollback on large checkpoints #10479 16MiB cap with the exact production symptom (AssertionError: expected null not to be null), passes at the shipped cap.
  • The endedAt gate, pinned at a fixed checkpoint size so the size variable is held constant. Two separate investigations have now read this gate as a size regression.

Screenshots

No visual change — test-only.

Testing

  • pnpm lint — oxlint + oxfmt clean
  • pnpm typecheck — tsc -p config/tsconfig.node.json clean
  • pnpm test — new suite 2 passed (~2.8s); full src/main/daemon/ suite 1036 passed / 5 skipped, no interference
  • pnpm build — not run; no production code changed
  • Added or updated high-quality tests that would catch regressions

Runs in ~2.8s (60s timeout). Temp dirs are created with mkdtempSync and removed in afterEach; verified zero leaked directories across 5 runs including 2 deliberate failures.

Peak RSS of the full src/main/daemon/ suite: 241 MiB without this file, 793 MiB with it. The first cut of the fixture cost 1,244 MiB — see the memory finding below.

AI Review Report

Reviewed with an AI agent whose primary mandate was to disprove the tests — because earlier in this same release cycle a retained oversized-checkpoint test was found to be vacuous (a 200MB NUL-byte fixture fails JSON.parse either way, so it stayed green with the byte cap disabled). A vacuous regression test is worse than none. Every claim below was executed, not read.

Both tests were proven non-vacuous by mutation:

mutation result
TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES → 16 * 1024 * 1024 test 1 fails: expected null not to be null
endedAt gate deleted (history-reader.ts:70) test 2 fails: expected true to be false
writeSync forced unavailable both fail (see finding below)

It fails for the right reason. An instrumented probe confirmed the throw at the reverted cap is NodeFileReadTooLargeError: File too large: 46.1MB exceeds 16MB limit — the byte cap specifically, not JSON.parse and not the other bound in that same read path. The structural-token limit is not in play: assertJsonTextStructureWithinLimits skips string contents, and the checkpoint is a flat 11-key object, so its token count stays in the dozens no matter how large the payload gets. That bound targets structural amplification, which history-reader-memory.test.ts covers separately.

The fixture is realistic, not filler. At the shipped cap the file parses into a real TerminalCheckpointFile with all 11 top-level keys (snapshotAnsi, scrollbackAnsi, oscLinks, rehydrateSequences, cwd, cols, rows, modes, scrollbackLines, generation, checkpointedAt), and all 300/300 distinct markers survive the round trip — checkpoint.json 46.13 MiB, seed 26,381,501 chars. output.log is exactly 9 bytes = LOG_HEADER_BYTES and rehydrateSequences is empty, so log replay contributes nothing and the checkpoint-only route is genuine.

It measures what the user actually sees. daemon-pty-adapter.ts:57-62 getRecoveredHistorySeed composes exactly rehydrateSequences + snapshotAnsi on the non-alt-screen branch, which the test pins with expect(modes.alternateScreen).toBe(false).

Defects found and fixed

1. A silent-vacuity hole in the endedAt test (also flagged by CodeRabbit, 45e178d). writeLargeScrollback ignored writeSync's boolean, which returns false if xterm's private _core.writeSync ever goes away. The large-checkpoint test self-guards (fails expected 416 to be greater than 16777216), but the endedAt gate is size-independent, so that test silently passed on a 416-byte checkpoint — pinning the gate over no scrollback at all. Fixed with an accumulator plus a single assertion in the helper; both tests now fail loudly.

2. The fixture raised the whole daemon suite's peak memory 5.2x (33edb2b). Despite the comment claiming SGR-per-cell, the filler coloured per line, so the serialized seed only tracked plain-text size and needed 26k buffer rows to clear 16MiB. The xterm buffer costs rows × cols, so this one file took src/main/daemon/ from 241 MiB to 1,244 MiB peak RSS — a real CI OOM risk immediately after #10299 bounded shared readers for that same reason. Carrying the bytes in SGR runs instead of rows reaches a larger seed from 5.5k rows:

before after
suite peak RSS 1,244 MiB 793 MiB
seed margin over 16MiB 1.19x (20.0 MiB) 1.57x (25.16 MiB)
checkpoint.json margin 1.20x (20.16 MiB) 2.88x (46.1 MiB)
file runtime 3.4s 2.8s

So it is cheaper and better-pinned, and the fixture now matches its own comment and is more representative of real coloured agent output. All three mutations were re-verified against the new fixture.

3. Review follow-ups (370d10d). Two independent reviewers returned LAND-READY; one reproduced the memory finding above with matching numbers. Their non-blocking points, applied: import getHistorySessionDirName rather than hand-rolling encodeURIComponent (equivalent today, but that helper exists to absorb encoding changes, so the copy would silently diverge from the writer it checks); index FILLER_ROWS by its own length so growing the array cannot leave rows unused; and drop the timeout from 300_000 to 60_000, since the file runs in ~2.8s and vitest's own default is 30s — a 5-minute ceiling turns a hung regression into a stalled job rather than a failure.

One suggested change I applied only on its secondary rationale: taking the snapshot, disposing, then checkpointing is exception-safe, but it is not the memory saving it was proposed as. Peak RSS is set at getSnapshot(), where the live buffer and the serialized strings coexist, so disposing before checkpoint() cannot move it — measured ~800 MiB either way across three runs each. I also declined renaming test 2, whose "at the same checkpoint size" refers to holding size constant across the two branches, which is accurate as written.

Cross-platform (macOS/Linux/Windows): getHistorySessionDirName is encodeURIComponent (history-paths.ts), so the test's manual encoding matches production and the POSIX-looking SESSION_ID is encoded to a valid Windows path segment — the reason that helper exists. All paths go through path.join; temp dirs come from os.tmpdir(). No shell invocation, no shortcuts, no labels, no Electron-specific behavior touched.

Considered and declined as gold-plating: asserting all 300/300 markers rather than first/last — the regression class is all-or-nothing, and seed.length > 16MiB already catches content loss loudly, including a future writer-side trim.

Security Audit

Low risk: test-only, no production code, no new dependencies. No command execution, no IPC, no auth, no secrets, no network. Input handling is confined to a synthetic in-process ANSI fixture. Path handling writes only under a mkdtempSync directory removed in afterEach; the rmSync(..., { recursive: true, force: true }) targets are derived from that temp root, not from user input. No follow-up needed.

Notes

  • This exercises the main-side path down to ColdRestoreInfo. I did not drive two full Electron builds through a real UI, so a purely renderer-side blank is not excluded by this evidence. Given the main-side output is byte-identical across the refs, any such blank would not be a v1.4.156 regression in the restore read path.
  • One genuine v1.4.156-introduced behaviour change I found and verified but did not fix here, as it is out of scope and not the reported defect: ColdRestorePayloadCache's new 16MiB bound makes an oversized payload self-evict during its own set() (measured: oversizedSurvivesAlone=false, and it evicts prior entries too), where v1.4.155 used an unbounded Map. Both call sites appear to tolerate it — the spawn path returns the payload directly, and the sleep path guards with has() and re-reads from disk on wake — so I believe it self-heals, but I have not end-to-end verified the wake path. Worth a follow-up.
  • terminal-history-file-limits.ts notes that trimming the snapshot writer-side is the real fix for oversized checkpoints; the 200MB cap bounds a corrupt/runaway file, not retention.

Triaging a report that checkpoint-only cold restore renders blank at every
checkpoint size found no v1.4.156 regression: an A/B of v1.4.155 against
origin/main returned byte-identical ColdRestoreInfo at 1.01, 5.56, 11.30 and
20.61 MiB, all 300 marker lines intact on both refs. Blankness tracks the
meta.endedAt eligibility gate, not size — a cleanly ended session refuses to
cold-restore at any size, which is by design and unchanged between the refs.

What the triage did surface is a coverage gap. #10179's 16MiB checkpoint read
cap sat under what the unbounded writer emits, so a large checkpoint threw,
was swallowed to checkpoint=null, and the pane reopened empty; #10479 raised
the cap but nothing pinned the round trip it had broken. history-reader-memory
covers the bounded reader at its limit, not writer→reader recovery.

Adds that round trip through the real HistoryManager writer and HistoryReader
over a header-only log, and pins the endedAt gate that has now been mistaken
for a size regression twice. Fails at the pre-#10479 cap with the exact
production symptom (detectColdRestore returns null).
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Vitest suite for large checkpoint-only cold restoration. The tests generate colorized scrollback with marker lines, checkpoint the session, verify output.log remains header-only while checkpoint.json exceeds 16 MiB, and validate restored content. They also compare cold-restore detection for unclean versus clean session termination.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: tests pin checkpoint-only cold restore behavior for a large checkpoint.
Description check ✅ Passed The description matches the required template and includes summary, screenshots, testing, AI review, security audit, and notes with cross-platform coverage.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3215f050-dd06-4a10-b3a4-25f0ef3db755

📥 Commits

Reviewing files that changed from the base of the PR and between 6c03ecf and 61774ae.

📒 Files selected for processing (1)
  • src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts

writeLargeScrollback ignored writeSync's boolean, which is false when
xterm's private _core.writeSync goes away. The size assertions catch that
in the large-checkpoint test (416 bytes vs 16MiB), but the endedAt gate is
size-independent, so that test silently passed on an empty snapshot —
pinning the gate over no scrollback at all.
…800MiB

The filler colored per line, not per cell as its comment claimed, so the
serialized seed only tracked plain-text size and needed 26k buffer rows to
clear 16MiB. The xterm buffer costs rows x cols, which made this single file
raise the whole src/main/daemon/ suite's peak RSS 5.2x (241MiB -> 1244MiB) —
a real OOM risk on CI right after #10299 bounded readers for that reason.

Carrying the bytes in SGR runs instead of rows reaches a larger seed from
5.5k rows: suite peak RSS 1244MiB -> 793MiB, and the margins improve too
(seed 25.16MiB = 1.57x the threshold vs 1.19x before, checkpoint.json
46.1MiB = 2.88x vs 1.20x). Also makes the fixture match its own comment and
be more representative of real colored agent output.

Re-verified all three mutations still fail: cap at 16MiB -> "expected null
not to be null"; endedAt gate deleted -> test 2 fails; writeSync
unavailable -> both fail.
…first

Review follow-ups, all test-only:
- Import getHistorySessionDirName instead of hand-rolling encodeURIComponent.
  Equivalent today, but that helper exists to absorb encoding changes, so the
  hand-rolled copy would silently diverge from the writer it is checking.
- Index FILLER_ROWS by its own length so growing the array cannot leave rows
  unused.
- 300_000ms -> 60_000ms. The file runs in ~2.8s and vitest's own default is
  30s; a 5-minute ceiling turns a hung regression into a stalled job rather
  than a failure. 60s keeps Windows headroom.
- Take the snapshot, dispose, then checkpoint, so a throwing checkpoint()
  cannot leak the buffer. Note this is memory-neutral, not a saving: peak RSS
  is set at getSnapshot(), where the buffer and the serialized strings
  coexist, and measured ~800MiB either way.

Mutations re-verified: cap at 16MiB -> "expected null not to be null";
endedAt gate deleted -> test 2 fails; writeSync unavailable -> both fail.
@brennanb2025
brennanb2025 merged commit a0971e0 into main Jul 25, 2026
7 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
stablyai#10500)

* test(terminal): pin checkpoint-only cold restore of a large checkpoint

Triaging a report that checkpoint-only cold restore renders blank at every
checkpoint size found no v1.4.156 regression: an A/B of v1.4.155 against
origin/main returned byte-identical ColdRestoreInfo at 1.01, 5.56, 11.30 and
20.61 MiB, all 300 marker lines intact on both refs. Blankness tracks the
meta.endedAt eligibility gate, not size — a cleanly ended session refuses to
cold-restore at any size, which is by design and unchanged between the refs.

What the triage did surface is a coverage gap. stablyai#10179's 16MiB checkpoint read
cap sat under what the unbounded writer emits, so a large checkpoint threw,
was swallowed to checkpoint=null, and the pane reopened empty; stablyai#10479 raised
the cap but nothing pinned the round trip it had broken. history-reader-memory
covers the bounded reader at its limit, not writer→reader recovery.

Adds that round trip through the real HistoryManager writer and HistoryReader
over a header-only log, and pins the endedAt gate that has now been mistaken
for a size regression twice. Fails at the pre-stablyai#10479 cap with the exact
production symptom (detectColdRestore returns null).

* test(terminal): fail loudly when writeSync is unavailable

writeLargeScrollback ignored writeSync's boolean, which is false when
xterm's private _core.writeSync goes away. The size assertions catch that
in the large-checkpoint test (416 bytes vs 16MiB), but the endedAt gate is
size-independent, so that test silently passed on an empty snapshot —
pinning the gate over no scrollback at all.

* test(terminal): cut large-checkpoint fixture peak RSS from 1.2GiB to 800MiB

The filler colored per line, not per cell as its comment claimed, so the
serialized seed only tracked plain-text size and needed 26k buffer rows to
clear 16MiB. The xterm buffer costs rows x cols, which made this single file
raise the whole src/main/daemon/ suite's peak RSS 5.2x (241MiB -> 1244MiB) —
a real OOM risk on CI right after stablyai#10299 bounded readers for that reason.

Carrying the bytes in SGR runs instead of rows reaches a larger seed from
5.5k rows: suite peak RSS 1244MiB -> 793MiB, and the margins improve too
(seed 25.16MiB = 1.57x the threshold vs 1.19x before, checkpoint.json
46.1MiB = 2.88x vs 1.20x). Also makes the fixture match its own comment and
be more representative of real colored agent output.

Re-verified all three mutations still fail: cap at 16MiB -> "expected null
not to be null"; endedAt gate deleted -> test 2 fails; writeSync
unavailable -> both fail.

* test(terminal): tighten timeout, reuse prod dir-name helper, dispose first

Review follow-ups, all test-only:
- Import getHistorySessionDirName instead of hand-rolling encodeURIComponent.
  Equivalent today, but that helper exists to absorb encoding changes, so the
  hand-rolled copy would silently diverge from the writer it is checking.
- Index FILLER_ROWS by its own length so growing the array cannot leave rows
  unused.
- 300_000ms -> 60_000ms. The file runs in ~2.8s and vitest's own default is
  30s; a 5-minute ceiling turns a hung regression into a stalled job rather
  than a failure. 60s keeps Windows headroom.
- Take the snapshot, dispose, then checkpoint, so a throwing checkpoint()
  cannot leak the buffer. Note this is memory-neutral, not a saving: peak RSS
  is set at getSnapshot(), where the buffer and the serialized strings
  coexist, and measured ~800MiB either way.

Mutations re-verified: cap at 16MiB -> "expected null not to be null";
endedAt gate deleted -> test 2 fails; writeSync unavailable -> both fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant