diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index afa59ab4e1d..0b7fce6881d 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -293,7 +293,8 @@ storage live in the profile partition and survive, but an unsubmitted form or an SPA's in-memory state does not. A page is never parked while a paired client is streaming it, while a command against it is in flight, while its initial load is still running, or while it is waiting on a certificate -decision. A navigation that has still not finished by the load timeout is +decision. A stream ending counts as use, so a viewer who closes the pane gets +the full idle window before the page parks, even if they only watched. A navigation that has still not finished by the load timeout is deliberately reclaimable — otherwise one stalled page per create could hold a renderer forever, which is the failure this exists to prevent; waking it simply retries the address. Parking never closes a page: the page stays open until diff --git a/src/main/runtime/orca-runtime-browser-parked-targeting.test.ts b/src/main/runtime/orca-runtime-browser-parked-targeting.test.ts index cfa08ab89e4..68c3436156c 100644 --- a/src/main/runtime/orca-runtime-browser-parked-targeting.test.ts +++ b/src/main/runtime/orca-runtime-browser-parked-targeting.test.ts @@ -233,6 +233,36 @@ describe('headless parked-page targeting', () => { expect(fake.wakeCalls).toEqual([]) }) + it('counts a stream ending as page use so the idle clock restarts then', async () => { + // Why: streaming pins the page but frames are not activity, so a client + // that only watched for longer than the idle window would otherwise have + // the page parked ~one recheck after closing the pane. + const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') + const { startBrowserScreencast } = await import('../browser/browser-screencast-stream') + let resolveDone!: () => void + const done = new Promise((resolve) => { + resolveDone = resolve + }) + vi.mocked(startBrowserScreencast).mockResolvedValue({ + stop: vi.fn(() => resolveDone()), + done + } as never) + const fake = createFakeHeadlessHost(['live-a'], []) + const commands = new RuntimeBrowserCommands(fake.host) + + const started = await commands.browserScreencast( + { worktree: 'id:wt-1', page: 'live-a', format: 'jpeg' }, + { sendBinary: vi.fn() } + ) + const wakesBeforeStop = fake.wakeCalls.length + + started.session.stop() + await started.session.done + + await vi.waitFor(() => expect(fake.wakeCalls.length).toBeGreaterThan(wakesBeforeStop)) + expect(fake.wakeCalls.at(-1)).toBe('live-a') + }) + it('reports a listed-index overrun against the merged listing', async () => { const { RuntimeBrowserCommands } = await import('./orca-runtime-browser') const fake = createFakeHeadlessHost(['live-a'], ['parked-b']) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 84bf8389b9f..763a745f34c 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -578,6 +578,12 @@ export class RuntimeBrowserCommands { let stoppingPromise: Promise | null = null const clearPageGate = (): void => { this.activeScreencastPageIds.delete(browserPageId) + // Why (STA-4341): the stream pin kept this page resident while a client + // watched without issuing commands, so its activity stamp can be + // arbitrarily stale the instant the pin lifts — stale enough to park it + // on the next 30s recheck. Viewing counts as use: restamp so the idle + // window runs from when the viewer left, not from their last command. + void this.markHeadlessBrowserPageUsed(browserPageId).catch(() => {}) if (this.activeScreencastsByPageId.get(browserPageId) === activeRecord) { this.activeScreencastsByPageId.delete(browserPageId) }