Files
orca/tests/e2e/terminal-parked-memory.spec.ts
JinjingandOrca a7c8b8e071 fix(terminal): bound SSH & remote hidden-worktree terminal retention (C1) (#10625)
* fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)

SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH
worktree retained every pane forever (C1: renderer heap climbs to the V8
ceiling). SSH bytes transit local main — fact-mode watchers already cover
them, and main keeps a headless model served over pty:getMainBufferSnapshot
that the SSH reattach path never consulted.

- isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded
  through both park verdicts, both selectors, watcher coverage, and the
  watcher start guard. Remote-runtime/fail-open/foreign/null unchanged.
- Parked-SSH reveal paints from main's headless model (dimension-matched,
  ~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a
  non-empty source==='headless' payload — never a blank/stale paint.
- Kill switch: settings.terminalSshViewParking (default on).

DESIGN.md records the approved plan and the H1 magnitude non-claim.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B)

Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the
slice-A switch off) had unlimited retention: the parking cap/TTL only ever
saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's
panes forever. Retention is now memory-bounded, not eligibility-bounded.

- terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min
  TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over
  hidden worktrees ordinary parking can never evict; reuses the hot-retain
  ranking so last-active exemption, deterministic ties, and deadline-driven
  rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount
  would fresh-spawn and orphan the live shell).
- Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto
  (darkness for uncoverable tabs is the accepted cost); buffers captured via
  the sleep-flow registry before the unmount render; retention TTL added to
  the recheck deadlines for budget candidates only.
- Verdict stays out of its own effect deps; policy test asserts idempotence
  and time-monotone membership (flip-loop dwell regression).
- Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C)

The retention budget (slice B) must exempt worktrees holding fail-open or
foreign-worktree ptys — a remount would fresh-spawn and orphan the live
shell — which would leave that class unbounded again. Instead, past the same
45min retention TTL their hidden panes drop to the minimum scrollback tier
(measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is
gone by design, reveal restores the configured cap for future output).

- terminal-hidden-scrollback-demotion.ts: module-state verdict registry
  (parked-watcher pattern) with content-equality notify damping; applied in
  the existing scrollback-rows effect in use-terminal-pane-lifecycle.
- selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone.
- Retention TTL wakeups now also cover exempt worktrees so demotion fires.
- Kill switch: settings.terminalHiddenScrollbackDemotion (default on).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix)

applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling
it from applyReattachPayload (already inside the coordinator when a relay
replay exists) deadlocks on the coordinator's tail chain. The model paint now
mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate +
screen, dimension-matched, escape tail last) and arms the restored-snapshot
seq baseline so deferred/live chunks the snapshot covers dedupe instead of
double-painting. Also falls through (no early return) so reattachPayloadApplied
still latches. Adds the folder-workspace id parity unit case.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1)

Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and
reveal restores marker content at multi-viewport scrollback depth. DESIGN.md
records the as-built deltas (inline paint, force-park shape, last-active
floor) and the residuals so follow-ups aren't lost.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1)

A relay restart empties the replay buffer; the reveal previously painted
nothing even when main's headless model held the session. The reattach now
prefetches the model snapshot when no structural replay exists (SSH-shaped
ptys only) and paints it inside the coordinator; emptiness is judged on the
composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an
alt-screen snapshot with an empty screen frame still paints.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2)

Per the approved contract each slice reverts behind its own switch: slice C
now requires only the master terminalHiddenViewParking plus its own
terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for
demotion candidates even with the budget switch off. No DEFAULT_SETTINGS
entries exist for sibling flags (defaults are the '!== false' optional
pattern), so no explicit defaults are added.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3)

One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park
for its whole worktree, pinning co-located remote-runtime tabs forever. The
worktree now force-parks while exempt tabs keep their mounted panes via a
per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync,
legacy render, and the overlay cold-parking hook). Ordinary parking is
untouched — a worktree with an exempt tab still cannot ordinary-park.
Slice C now also demotes exempt tabs' panes as soon as their worktree
force-parks under the count budget (they are the only panes left mounted).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4)

The last-active exemption means a single hidden un-parkable worktree never
force-parks — and slice C previously only targeted exempt-tab worktrees, so
its panes held full scrollback forever. Demotion now also covers un-parkable
non-exempt worktrees past the retention TTL that are absent from the
force-parked set (last-active spared, or slice B switched off). Membership
stays time-monotone for fixed inputs; covered by new idempotence/monotone
selector tests.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5)

Whole-worktree background mounts (browser-automation bootstrap lease, mobile
mounts, agent wakes) open a ~3s self-clearing measure window that previously
deleted hiddenSince — every remount restarted the 30s hysteresis and the
45min retention TTL, so a periodically re-mounted force-parked worktree
never re-parked. The measure window still pauses parking/eviction verdicts
(all selectors skip measuring candidates); only the clock survives, so the
prior verdict resumes as soon as the window closes. Visible and
portal-holding worktrees still reset the clock.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a)

Pad the session with ~180KB of output after the numbered markers so the
earliest marker falls outside the relay's 100KiB rolling replay buffer while
staying inside main's ~5k-row headless model; asserting marker_1 after
reveal now proves the headless-model paint rather than passing under the
relay fallback.

Co-authored-by: Orca <help@stably.ai>

* docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7)

One contract matching the code: status IMPLEMENTED around force-park (not
the unmount proposal), real kill-switch names with coupling + revert
matrices, the true retention-floor formula with measured per-pane and
demotion numbers, an explicit when-OOM-is-still-possible paragraph naming
the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock
constraint inside the slice-A section, stable-signal phrasing instead of a
capability latch, fail-open AND foreign-worktree exemption class, verified
cites, and a planned/landed/follow-up test matrix.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8)

isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty —
while the coverage veto that makes a worktree a retention candidate walks
every pane. A split tab whose second leaf held an unrestorable pty therefore
failed coverage (→ force-park target) yet looked exempt-free, so force-park
unmounted it and orphaned the live shell. The exemption now resolves panes
through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in
the union for the no-layout/no-capture case.

Also from the same review round:
- force-park's capture passes includeLocalBuffers:false like every other
  shutdownBufferCaptures caller; it was serializing up to 512KB/pane of
  scrollback into the store inside a fix meant to bound renderer heap.
- Terminal.tsx unmount resets the scrollback-demotion registry — module
  state with no reset path, read by a pane effect that runs before the host
  effect that would clear it, so a stale verdict trimmed restore replays.
- memoize watcher coverage per tab within the parking pass; the retention
  candidates re-asked it for every mounted worktree, not just the parked few.

* docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2)

pendingSideEffects grew without bound under background timer throttling
(~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with
oldest-first eviction: titles drop (last-wins), a pending bell latches
onto the next survivor, agent-status payloads collapse onto the survivor
keeping the newest 16 (last-wins store state, KB-scale strings).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)

Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so
OSC 133;D and Command Code scrape signals went dark while parked. New
parked-terminal-command-status.ts ports the store-level subset: git-UI
nudge on every command finish, same-turn status-row drop for SSH PTYs
(exact mounted-path parity — the foreground tracker refuses SSH ids),
and the Command Code working seed / 1500ms done settle. Byte mode scans
the same shared parsers for authority-off parity. Local-PTY status drops
stay with the mounted pane: they need pty-connection's process-confirm
ladder to tell a leaked nested-shell 133;D from a real agent exit.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b)

ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config →
getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer
only) so a spec can shrink the force-park budget to 1. The Docker-gated
spec opens two remote worktrees on one relay target (second pre-seeded
remote repo), disables terminalSshViewParking to make both un-parkable,
hides both behind the local context, and proves the older one force-parks
while the last-active exemption spares the newest; re-activating the
evicted worktree restores the marker tail via relay replay.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane)

The first draft added a second remote repo mid-session, whose pane pty
spawn misroutes to the local daemon with the remote cwd (pre-existing
multi-repo issue, reproducible without any retention override — a seeded
local repo plus one remote repo shows the same misroute). The spec now
budgets across three worktrees of the ONE connected repo, created through
the product createWorktree path (an external git-worktree-add only lands
as a detected worktree needing adoption) and polled through the relay's
transient post-connect reconnect window. Verified green on the local
Docker lane in 20.8s.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): prevent remount thrashing during post-measure cool-down (

Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a
background-measure window (so TTL/ranking stay honest), but re-park waits for a
full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down,
every ~3s measure lease on a past-deadline worktree thrashes remount/reattach.

Core changes:
- Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure
  cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates
  until cool-down expires.
- Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts
  (used by SSH reattach + daemon restore paths).
- Add SSH model snapshot timeout (750ms) with fallback to relay replay.
- Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts;
  add cool-down deadline to scheduling.
- useTerminalTabColdParking: implement matching measure-clock contract with per-tab
  cool-down gate to keep tab deadlines synced with worktree retention clock.
- Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted
  worktrees (pane births during demotion must take the demoted tier at create).
- Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget,
  terminalHiddenScrollbackDemotion.

* fix(terminal): detect Command Code completion in parked mid-turn panes

Seed the byte watcher with in-flight turn state from agent status: the
watcher is recreated per park cycle with no startup command to arm it,
and the banner scrolled away before parking. Also memoize
eviction-exempt checks and use SSH PTY ID builder in tests.

* fix(terminal): flush pending command-code settles on reveal remount

When a parked pane reveals mid-Command Code turn, the new detector
cannot re-observe the already-passed idle composer. Cancelling the settle
leaves the row stranded at 'working', so dispose now flushes the pending
settle instead.

Extract readInFlightCommandCodeTurn to shared space and seed detectors
with in-flight turns so remounts complete mid-flight commands. Also
memoize SSH model probes to prevent double timeouts on reattach.

* fix(terminal): remove scrollback demotion (C1 slice C)

The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic.

* test(terminal): assert bounded probe during stalled reveal

Add assertion to verify that a stalled reveal operation makes exactly one
`getMainBufferSnapshot` call, ensuring retry logic doesn't introduce
redundant probes that would extend the timeout window before relay fallback.

* fix(terminal): implement C1 retention budget for hidden parked worktrees

Addresses OOM regressions in hidden parked terminals by force-evicting
worktrees past a retention budget: at most 12 mounted while hidden, none
past 45 minutes (absolute, not exempted by last-active). Eviction is
least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep
their panes to avoid orphaning shells; worktrees are force-parked even
if they contain exempts, and their buffers released elsewhere. SSH/remote
worktrees serialize buffers pre-eviction for reveal; local worktrees keep
daemon snapshots. Command Code's done-settle window is transferred across
park/reveal boundaries so the row cannot strand at 'working'. Model probe
on SSH reattach is scoped to park-reveal only, not ordinary reconnects.
Includes new E2E suite proving the budget actually releases memory.

* memoize eviction-exempt terminal tabs to avoid redundant store reads

Each tab's exemption check re-reads the store and walks the layout tree.
Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs
for a worktree in a single pass, then memoize the result in Terminal.tsx
and useTerminalTabColdParking. This prevents O(n) store reads when checking
exemptions across multiple tabs and ensures the set remains stable across
unrelated re-renders.

* refactor: reformat hidden-worktree retention comments

Reflow to 80-character lines and remove internal ticket references
(C1, C1 slice C).

* fix(lint): split overlay slot and eviction-exempt tabs under max-lines

Static analysis failed because TerminalPaneOverlayLayer (401) and
terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the
slot component and eviction-exempt helpers into dedicated modules.

* test(terminal): stabilize retention budget e2e control arm

Stage un-parkable remote pty ids only after both worktrees are hidden, and
keep re-staging during the control-arm poll so a late updateTabPtyId cannot
flip the decoy back to park-restorable and ordinary-park it before budget
engages.

* test(terminal): pin retention e2e decoy to a mounted pane snapshot

Use the active pane-identity snapshot for the decoy tab instead of all
worktree tabs, and re-assert un-parkable ids after the control-arm hold so
a deferred/empty tab id cannot fail the budget-off mounted-count check.

* fix: memoize terminal eviction exemptions on layout leaf PTYs

Splits add leaf panes to the layout store without changing the tabs
array. A memo keyed only on tabs misses this change, leaving new panes
unexempted for unmount. Include layout leaf PTYs in the exemption memo
key so it recalculates when splits occur or PTYs are re-minted.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-29 00:47:18 -07:00

781 lines
32 KiB
TypeScript

import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
// Why: production cold-park hysteresis is 30s. The fast-park env override is
// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as
// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs.
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) },
// Why: without this switch Chromium quantizes performance.memory and only
// refreshes it every ~20 minutes, so both scenarios report the same stale
// launch-time bucket instead of a comparable heap figure.
orcaAppExtraArgs: ['--enable-precise-memory-info']
})
// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit
// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS
// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the
// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden
// past hotRetainMs before the retain-count limit is even consulted. The one
// exception is the last-active (most-recently-hidden) tab, which is exempt
// from parking so returning to it is instant — so 7 of the 8 park.
const SCROLLBACK_TAB_COUNT = 8
const SCROLLBACK_LINE_COUNT = 3000
const PARK_SETTLE_MS = 2_000
const HEAP_SAMPLE_COUNT = 5
const HEAP_SAMPLE_INTERVAL_MS = 250
// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of
// scrollback each, then waits out the parking window — well past the default
// 120s per-test budget.
const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000
// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes
// each xterm hold realistic narrow+wide buffer rows, so released parked-tab
// memory reflects real agent output rather than uniform filler.
function writeScrollbackFillScript(
scriptPath: string,
runId: string,
lineCount: number = SCROLLBACK_LINE_COUNT
): void {
const script = [
`const tabIndex = process.argv[2] ?? '0'`,
`const wide = '統合端末記憶計測'`,
`const emoji = ['🟢', '🟡', '🔵', '🟣']`,
`const lines = []`,
`for (let i = 0; i < ${lineCount}; i += 1) {`,
` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`,
` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`,
` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`,
`}`,
`process.stdout.write(lines.join('\\n') + '\\n')`,
`process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')`
].join('\n')
writeFileSync(scriptPath, `${script}\n`)
}
// Why: the spec lands ahead of the feature wiring in some merge orders. Skip
// (rather than fail) when the app under test does not expose the parking
// debug handle, mirroring terminal-hidden-view-parking.spec.ts.
async function skipUnlessParkingWired(page: Page): Promise<void> {
const deadline = Date.now() + 2_000
let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
while (!present && Date.now() < deadline) {
await page.waitForTimeout(250)
present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
}
test.skip(
!present,
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
)
}
type TerminalTabViewState = {
hasManager: boolean
paneCount: number
}
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so
// a missing manager is the observable signal that the tab's xterm was parked.
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
return {
hasManager: manager !== undefined,
paneCount: manager?.getPanes?.().length ?? 0
}
}, tabId)
}
async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise<number> {
return page.evaluate(
(tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length,
tabIds
)
}
// Why: the last-active tab per worktree is exempt from parking so returning to
// it is instant, so one of the hidden scrollback tabs stays mounted. The
// exempt one is the most-recently-hidden — here the last scrollback tab filled
// before the visible tab was created — so we expect exactly one still mounted.
async function waitForTabsParkedExceptLastActive(page: Page, tabIds: string[]): Promise<void> {
await expect
.poll(() => countMountedPaneManagers(page, tabIds), {
timeout: Math.max(30_000, PARKING_DELAY_MS * 10),
message: 'hidden scrollback tabs did not park down to the last-active exemption'
})
.toBe(1)
}
type ScrollbackTab = {
tabId: string
ptyId: string
}
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<ScrollbackTab> {
const tabId = await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('createActiveTerminalTab: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'newly created terminal tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
const snapshot = await waitForPaneIdentitySnapshot(page, 1)
const ptyId = snapshot.panes[0]?.ptyId
if (snapshot.tabId !== tabId || !ptyId) {
throw new Error('createActiveTerminalTab: new tab did not bind a PTY')
}
return { tabId, ptyId }
}
async function fillActiveTerminalWithScrollback(
page: Page,
ptyId: string,
scriptPath: string,
tabIndex: number,
runId: string
): Promise<void> {
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`)
await expect
.poll(() => getTerminalContent(page, 4_000), {
timeout: 30_000,
message: `scrollback fill marker for tab ${tabIndex} did not render`
})
.toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`)
}
type ScrollbackTabSetup = {
worktreeId: string
scrollbackTabs: ScrollbackTab[]
}
// Why: each tab generates its scrollback while visible, so every xterm holds
// the full buffer before going hidden — the hidden-delivery gate never gets a
// chance to drop the output the memory comparison depends on.
async function setUpScrollbackTabs(
page: Page,
scriptPath: string,
runId: string
): Promise<ScrollbackTabSetup> {
const worktreeId = await waitForActiveWorktree(page)
await skipUnlessParkingWired(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1)
const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
if (!baselinePtyId) {
throw new Error('parked memory spec: baseline terminal tab did not bind a PTY')
}
const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId)
for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) {
const tab = await createActiveTerminalTab(page, worktreeId)
scrollbackTabs.push(tab)
await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId)
}
return { worktreeId, scrollbackTabs }
}
type ParkedMemoryMetrics = {
heapUsedMB: number
liveTerminals: number
livePaneManagers: number
}
// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best
// effort) and take the min of several settled samples — the min reflects
// retained heap instead of allocation noise between collections. Note xterm
// buffer rows are typed-array backing stores outside the V8 heap, so the
// liveTerminals/livePaneManagers counts are the strong release signal and the
// heap figure tracks only the on-heap share.
async function sampleParkedMemoryMetrics(page: Page): Promise<ParkedMemoryMetrics> {
await page.waitForTimeout(PARK_SETTLE_MS)
try {
const session = await page.context().newCDPSession(page)
await session.send('HeapProfiler.collectGarbage')
await session.detach()
} catch {
// GC over CDP is a measurement-fidelity improvement, not a gate.
}
let minHeapBytes: number | null = null
for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) {
const heapBytes = await page.evaluate(() => {
const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory
return memory?.usedJSHeapSize ?? null
})
if (heapBytes !== null) {
minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes)
}
await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS)
}
if (minHeapBytes === null) {
throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable')
}
const liveCounts = await page.evaluate(() => ({
liveTerminals: document.querySelectorAll('.xterm').length,
livePaneManagers: window.__paneManagers?.size ?? 0
}))
return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts }
}
function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string {
return [
`panes=${SCROLLBACK_TAB_COUNT}`,
`parkedTabs=${parkedTabs}`,
`heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`,
`liveTerminals=${metrics.liveTerminals}`,
`livePaneManagers=${metrics.livePaneManagers}`
].join(' ')
}
test.describe('Terminal parked memory', () => {
test('releases renderer terminal memory when hidden tabs park', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId)
try {
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
// A fresh 9th tab hides all 8 scrollback tabs. The last one filled is the
// most-recently-hidden, so it stays warm under the last-active exemption;
// the other 7 park.
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
const lastActiveTab = scrollbackTabs.at(-1)
if (!lastActiveTab) {
throw new Error('parked memory spec: no scrollback tabs were created')
}
const parkableTabs = scrollbackTabs.slice(0, -1)
await waitForTabsParkedExceptLastActive(
orcaPage,
scrollbackTabs.map((tab) => tab.tabId)
)
const metrics = await sampleParkedMemoryMetrics(orcaPage)
testInfo.annotations.push({
type: 'opencode-parked-memory',
description: formatParkedMemoryAnnotation(metrics, parkableTabs.length)
})
// Structural assertions: the 7 non-last-active tabs parked (managers
// gone); the visible tab and the exempt last-active tab keep theirs.
for (const tab of parkableTabs) {
expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false)
}
expect((await readTerminalTabViewState(orcaPage, lastActiveTab.tabId)).hasManager).toBe(true)
const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId)
expect(visibleState.hasManager).toBe(true)
expect(visibleState.paneCount).toBeGreaterThan(0)
// Why: design invariant 5 — renderer terminal views scale with mounted
// panes; only the visible tab and the exempt last-active tab keep an
// xterm and pane manager, everything else parks.
expect(metrics.livePaneManagers).toBe(2)
} finally {
rmSync(scriptPath, { force: true })
}
})
test('retains terminal views when parking is disabled', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
// Why: settings.terminalHiddenViewParking === false is the design-doc
// kill switch. updateSettings persists it through window.api.settings.set
// and updates the store slice the cold-park hook subscribes to — the same
// mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env
// wiring is needed.
await orcaPage.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parked memory spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: false })
})
await expect
.poll(
() =>
orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking),
{ timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' }
)
.toBe(false)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId)
try {
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId)
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
// Why: with parking enabled these tabs park within ~1x the collapsed
// delay (the first test proves the machinery in this app build), so
// surviving 3x the delay shows the kill switch held.
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT)
const metrics = await sampleParkedMemoryMetrics(orcaPage)
testInfo.annotations.push({
type: 'opencode-parked-memory-disabled',
description: formatParkedMemoryAnnotation(metrics, 0)
})
// Structural assertions: every hidden tab keeps its pane manager and
// xterm; nothing parked even after the settle + sampling window.
for (const tab of scrollbackTabs) {
const state = await readTerminalTabViewState(orcaPage, tab.tabId)
expect(state.hasManager).toBe(true)
expect(state.paneCount).toBeGreaterThan(0)
}
expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true)
expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1)
expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1)
} finally {
rmSync(scriptPath, { force: true })
}
})
})
// ─────────────────────── C1 retention budget outcome ───────────────────────
// The tests above assert the parking MECHANISM. This one asserts the OUTCOME
// the retention budget exists for: hidden worktrees ordinary parking can never
// evict actually release their buffers once the budget engages, with the
// budget flip as the only change between the two samples.
//
// Honest caveats:
// - The un-parkable class is staged by rewriting tabsByWorktree[*].ptyId to a
// remote-runtime-shaped id. That is a fidelity proxy: park-restorability and
// eviction-exemption are decided from that field alone, but the transports
// underneath stay real LOCAL PTYs — this is not a live remote runtime.
// - The primary gate is retained xterm buffer CELLS (deterministic), not RSS.
// xterm rows are typed arrays outside the V8 heap, so usedJSHeapSize misses
// most of what is released; renderer RSS is recorded and only gated as
// non-growth because it moves with GC timing and compositor allocations.
const RETENTION_TAB_COUNT = 4
const RETENTION_FILL_LINE_COUNT = 12_000
const RETENTION_SCROLLBACK_ROWS = 25_000
// Why 12: xterm packs each cell as 3 uint32s in the BufferLine typed array.
const XTERM_BYTES_PER_CELL = 12
// Why 40: this staging measures ~87 MB of retained buffer, so half of that is a
// floor that fails loudly if the fill silently stops producing scrollback.
const MIN_STAGED_BUFFER_MB = 40
// Why 2s: force-park is a synchronous verdict re-run on the setting flip and
// measured 23-25ms locally; anything near a timer/TTL wait blows this budget.
const MAX_FORCE_PARK_EVICTION_MS = 2_000
// Why 0.05: only the exempt decoy's unfilled pane may survive (~0.1% of the
// baseline). One retained filled pane would be ~25%, so this fails on a partial
// eviction instead of passing it.
const MAX_RETAINED_CELL_FRACTION = 0.05
// Why a band, not zero: RSS is sampled and moved only -0.7 to -3.0 MB on a
// ~453 MB baseline across runs, so "must not grow" would flake on noise. 5 MB
// still fails loudly if an eviction starts planting 512KB/pane capture strings.
const RENDERER_RSS_NOISE_MB = 5
const RETENTION_TEST_TIMEOUT_MS = 420_000
const UNPARKABLE_PTY_PREFIX = 'remote:e2e-retention-'
type RetainedBufferSample = {
cells: number
rows: number
panes: number
}
// Why walk the buffers instead of trusting a heap delta: xterm rows live in
// typed arrays outside the V8 heap, so retained cells are the only
// deterministic measure of what a force-park actually released.
async function readRetainedTerminalBufferCells(page: Page): Promise<RetainedBufferSample> {
return page.evaluate(() => {
let cells = 0
let rows = 0
let panes = 0
for (const manager of window.__paneManagers?.values() ?? []) {
for (const pane of manager.getPanes?.() ?? []) {
const buffer = pane.terminal?.buffer?.active
if (!buffer) {
continue
}
panes += 1
rows += buffer.length
cells += buffer.length * pane.terminal.cols
}
}
return { cells, rows, panes }
})
}
// Why host-side RSS: the renderer process' resident set is the figure the C1
// crash reports are measured in, and performance.memory cannot see it.
async function readRendererResidentMb(page: Page): Promise<number | null> {
return page.evaluate(async () => {
const snapshot = await window.api?.memory?.getSnapshot?.()
const bytes = snapshot?.app?.renderer?.memory
return typeof bytes === 'number' && bytes > 0 ? bytes / (1024 * 1024) : null
})
}
type RetentionMemorySample = {
buffers: RetainedBufferSample
bufferMb: number
heapUsedMb: number
rendererMb: number | null
livePaneManagers: number
}
async function readRetentionMemorySample(page: Page): Promise<RetentionMemorySample> {
const metrics = await sampleParkedMemoryMetrics(page)
const buffers = await readRetainedTerminalBufferCells(page)
return {
buffers,
bufferMb: (buffers.cells * XTERM_BYTES_PER_CELL) / (1024 * 1024),
heapUsedMb: metrics.heapUsedMB,
rendererMb: await readRendererResidentMb(page),
livePaneManagers: metrics.livePaneManagers
}
}
function formatRetentionSample(label: string, sample: RetentionMemorySample): string {
return [
`${label}.cells=${sample.buffers.cells}`,
`${label}.rows=${sample.buffers.rows}`,
`${label}.panes=${sample.buffers.panes}`,
`${label}.bufferMB=${sample.bufferMb.toFixed(1)}`,
`${label}.heapMB=${sample.heapUsedMb.toFixed(1)}`,
`${label}.rendererRssMB=${sample.rendererMb === null ? 'n/a' : sample.rendererMb.toFixed(1)}`,
`${label}.paneManagers=${sample.livePaneManagers}`
].join(' ')
}
// Why rewrite only tab.ptyId: canPark* eligibility is decided from it, so a
// remote-runtime-shaped id makes the worktree un-parkable. Layout leaf maps
// stay on real local transports so live panes keep their bindings; watcher
// coverage may still read those locals, but eligibility already fails.
async function stageUnparkableWorktreeTabs(page: Page, worktreeId: string): Promise<number> {
return page.evaluate(
({ worktreeId, prefix }) => {
const store = window.__store
if (!store) {
throw new Error('stageUnparkableWorktreeTabs: window.__store is unavailable')
}
const state = store.getState()
const tabs = state.tabsByWorktree[worktreeId] ?? []
if (tabs.length === 0) {
throw new Error(`stageUnparkableWorktreeTabs: ${worktreeId} has no terminal tabs`)
}
const staged = tabs.map((tab) =>
typeof tab.ptyId === 'string' && tab.ptyId.startsWith(prefix)
? tab
: { ...tab, ptyId: `${prefix}${tab.id}` }
)
;(store as unknown as { setState: (partial: unknown) => void }).setState({
tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: staged }
})
return staged.length
},
{ worktreeId, prefix: UNPARKABLE_PTY_PREFIX }
)
}
// Why: bind/reconcile can rewrite tab.ptyId after staging. Poll so ordinary
// parking cannot regain park-restorable eligibility mid control-arm wait.
async function waitForUnparkableWorktreeTabs(page: Page, worktreeId: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
({ worktreeId, prefix }) => {
const tabs = window.__store?.getState().tabsByWorktree[worktreeId] ?? []
return (
tabs.length > 0 &&
tabs.every((tab) => typeof tab.ptyId === 'string' && tab.ptyId.startsWith(prefix))
)
},
{ worktreeId, prefix: UNPARKABLE_PTY_PREFIX }
),
{
timeout: 5_000,
message: `worktree ${worktreeId} did not keep un-parkable remote: pty ids after staging`
}
)
.toBe(true)
}
// Why not getTerminalContent: it serializes the whole scrollback, which is
// megabytes of string per poll tick at RETENTION_SCROLLBACK_ROWS. The fill
// marker is the last line written, so scanning the buffer tail is enough.
async function waitForFillMarkerInTab(page: Page, tabId: string, marker: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
({ tabId, marker }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const buffer = pane?.terminal?.buffer?.active
if (!buffer) {
return false
}
const firstRow = Math.max(0, buffer.length - 200)
for (let row = buffer.length - 1; row >= firstRow; row -= 1) {
if (buffer.getLine(row)?.translateToString(true).includes(marker) === true) {
return true
}
}
return false
},
{ tabId, marker }
),
{ timeout: 90_000, message: `scrollback fill marker ${marker} did not render` }
)
.toBe(true)
}
async function updateTerminalSettings(
page: Page,
patch: { terminalHiddenWorktreeRetentionBudget?: boolean; terminalScrollbackRows?: number }
): Promise<void> {
await page.evaluate(async (patch) => {
const store = window.__store
if (!store) {
throw new Error('updateTerminalSettings: window.__store is unavailable')
}
await store.getState().updateSettings(patch)
}, patch)
}
async function waitForRetentionBudgetSetting(page: Page, enabled: boolean): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
() => window.__store?.getState().settings?.terminalHiddenWorktreeRetentionBudget
),
{ timeout: 5_000, message: `terminalHiddenWorktreeRetentionBudget did not become ${enabled}` }
)
.toBe(enabled)
}
test.describe('Terminal hidden worktree retention budget', () => {
test.use({
orcaAppExtraEnv: {
ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS),
// Why limit=1: the retention TTL is absolute production timing (45min) and
// the parking-delay override deliberately no longer shrinks it, so the
// COUNT CAP is the only knob a test can drive. With a budget of 1 the
// newest hidden un-parkable worktree takes the last-active exemption and
// the older one force-parks — cap and exemption proven in one run.
ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
},
orcaAppExtraArgs: ['--enable-precise-memory-info']
})
test('releases un-parkable hidden worktree buffers only once the retention budget engages', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(RETENTION_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
const victimWorktreeId = await waitForActiveWorktree(orcaPage)
await skipUnlessParkingWired(orcaPage)
const decoyWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(worktreeId) => worktreeId !== victimWorktreeId
)
if (!decoyWorktreeId) {
throw new Error('retention budget spec: the fixture seeded only one worktree')
}
// Budget OFF for the whole staging phase: that is the control arm proving
// ordinary parking can never evict this class, and it makes the release
// below attributable to the flip alone.
await updateTerminalSettings(orcaPage, {
terminalHiddenWorktreeRetentionBudget: false,
terminalScrollbackRows: RETENTION_SCROLLBACK_ROWS
})
await waitForRetentionBudgetSetting(orcaPage, false)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-retention-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId, RETENTION_FILL_LINE_COUNT)
try {
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const baselineSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
if (!baselinePtyId) {
throw new Error('retention budget spec: baseline terminal tab did not bind a PTY')
}
const victimTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
for (let tabIndex = 0; tabIndex < RETENTION_TAB_COUNT; tabIndex += 1) {
if (tabIndex > 0) {
victimTabs.push(await createActiveTerminalTab(orcaPage, victimWorktreeId))
}
const tab = victimTabs[tabIndex]
await sendToTerminal(
orcaPage,
tab.ptyId,
`node ${JSON.stringify(scriptPath)} ${tabIndex}\r`
)
await waitForFillMarkerInTab(
orcaPage,
tab.tabId,
`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`
)
// Why stage after every fill rather than once at the end: each later
// fill takes seconds, and ordinary TAB-level parking would evict the
// already-hidden earlier tabs inside that window.
await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
}
// Hiding the victim first makes the decoy the more-recently-hidden
// candidate, so the cap's last-active exemption lands on the decoy.
await switchToWorktree(orcaPage, decoyWorktreeId)
await expect
.poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId), {
timeout: 5_000,
message: 'decoy worktree did not become active before staging'
})
.toBe(decoyWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
// Why the active snapshot (not getWorktreeTabs alone): only tabs that
// actually bound a PaneManager can prove retention; empty/deferred ids
// would make the control arm look like a budget failure.
const decoySnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
const decoyTabIds = [decoySnapshot.tabId]
expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(1)
// Leaving the terminal view hides BOTH worktrees while keeping them
// mounted (App.tsx hides the workbench, it does not unmount it).
await orcaPage.evaluate(() => {
window.__store?.getState().setActiveView('tasks')
})
// Why stage AFTER hide: while a pane is visible/active, bind can rewrite
// our remote: fake ids back onto tab/layout state, so the decoy looks
// park-restorable and ordinary parking unmounts it during the control
// arm. Staging only once both are hidden keeps classification stable.
await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
await stageUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
await waitForUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
await waitForUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
const victimTabIds = victimTabs.map((tab) => tab.tabId)
expect(victimTabIds).toHaveLength(RETENTION_TAB_COUNT)
// Control arm: stay hidden past the ordinary parking window with budget
// still off. Re-stage inside the poll so a late bind cannot restore
// park-restorable+coverable ids and unmount mid-wait.
const controlArmStartedAt = Date.now()
await expect
.poll(
async () => {
await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
await stageUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
const victimMounted = await countMountedPaneManagers(orcaPage, victimTabIds)
const decoyMounted = await countMountedPaneManagers(orcaPage, decoyTabIds)
const heldLongEnough = Date.now() - controlArmStartedAt >= PARKING_DELAY_MS * 4
return {
victimMounted,
decoyMounted,
heldLongEnough
}
},
{
timeout: Math.max(30_000, PARKING_DELAY_MS * 10),
message:
'control arm: un-parkable hidden worktrees did not stay mounted for the parking window'
}
)
.toEqual({
victimMounted: RETENTION_TAB_COUNT,
decoyMounted: 1,
heldLongEnough: true
})
await waitForUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
await waitForUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
const before = await readRetentionMemorySample(orcaPage)
expect(before.bufferMb).toBeGreaterThan(MIN_STAGED_BUFFER_MB)
const flipStartedAt = Date.now()
await updateTerminalSettings(orcaPage, { terminalHiddenWorktreeRetentionBudget: true })
await waitForRetentionBudgetSetting(orcaPage, true)
await expect
.poll(() => countMountedPaneManagers(orcaPage, victimTabIds), {
timeout: 30_000,
message: 'retention budget did not force-park the older hidden un-parkable worktree'
})
.toBe(0)
const evictionMs = Date.now() - flipStartedAt
const after = await readRetentionMemorySample(orcaPage)
testInfo.annotations.push({
type: 'terminal-retention-budget-memory',
description: [
`stagedTabs=${RETENTION_TAB_COUNT}`,
`scrollbackRows=${RETENTION_SCROLLBACK_ROWS}`,
formatRetentionSample('before', before),
formatRetentionSample('after', after),
`releasedBufferMB=${(before.bufferMb - after.bufferMb).toFixed(1)}`,
`evictionMs=${evictionMs}`
].join(' ')
})
// Primary gate: the staged buffers are gone, not merely hidden.
expect(after.buffers.cells).toBeLessThan(before.buffers.cells * MAX_RETAINED_CELL_FRACTION)
// The decoy holds the cap's last-active exemption, so it stays mounted —
// this is the same run proving the cap did not simply evict everything.
expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(decoyTabIds.length)
// Secondary only: freed typed arrays return to the allocator's free lists,
// not the OS, so RSS fell just 0.7-3.0 MB locally while 87 MB of buffer was
// released — a strict non-growth assertion would be reading sampling noise.
// What this CAN catch is the inverse regression the capture path risks:
// force-park planting serialized scrollback in the store as it evicts.
if (before.rendererMb !== null && after.rendererMb !== null) {
expect(after.rendererMb).toBeLessThanOrEqual(before.rendererMb + RENDERER_RSS_NOISE_MB)
}
expect(evictionMs).toBeLessThan(MAX_FORCE_PARK_EVICTION_MS)
} finally {
rmSync(scriptPath, { force: true })
}
})
})