Skip to content

Commit 07217c5

Browse files
thymikeeclaude
andauthored
fix(daemon): close web sessions on daemon shutdown instead of leaking the browser fleet (#2012)
* fix(daemon): close web sessions on daemon shutdown instead of leaking the browser fleet teardownSessionResources finalized recording, app-log, audio, and perf captures on daemon shutdown, but had no step for an open web session — unlike an ordinary `session close`, which dispatches a platform close for web. A SIGTERM on a daemon holding an open web session left the full agent-browser Chrome fleet (~15 processes) alive until agent-browser's own 5-minute idle timer fired, and a fresh daemon on the same state dir would not reap it either (startup orphan cleanup skips fleets with recent activity). Add a best-effort `web_browser` step to teardownSessionResources that tells agent-browser to close its session-scoped fleet, mirroring the recording step added in #1325. It runs after the other best-effort resource steps (recording, app-log, audio, perf), matching the ordering an ordinary `session close` already uses between its resource cleanup and its platform close. Since teardownSessionResources is shared by both the daemon-shutdown path and the expired-session reap path, both now close the browser immediately instead of leaving it to agent-browser's own idle timer. The per-session daemon-shutdown teardown budget is extended for a web session the same way it already is for an active recording, sized to (and tested against) agent-browser's own per-call CLI timeout, so the shutdown race doesn't give up on a slow close before agent-browser could have finished it. Extract isWebSession() as the single source of truth for "is this a web session", now shared by the new teardown step, the existing ordinary-close gate, and the web-provider request-routing gate, so the three cannot silently drift apart. Fixes #1868 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ot5wg8YUEyWRrphs8bMSJ * test(web): add a live daemon-shutdown lane proving zero owned Chrome processes The unit tests added for #1868 mock the agent-browser CLI call, so they prove teardownSessionResources dispatches `agent-browser close`, not that the managed Chrome fleet actually terminates. Add a second live-E2E scenario to smoke-web-platform.test.ts (same AGENT_DEVICE_WEB_E2E=1 gate as the existing smoke test) that opens a real managed web session, sends the daemon process a real SIGTERM, and asserts zero owned Chrome processes remain within a bounded settle window — kept well under agent-browser's 5-minute idle timer default (left unmodified, unlike the functional smoke test's shortened override) so a pass can only mean the daemon's shutdown teardown actively closed the browser, not that the idle timer coincidentally beat the poll deadline. Also runs the #1781 B1 daemon leak oracle against the same shutdown, wiring it to a web lane as the original issue asked for. I could not fully execute this test in the local sandbox: agent-browser's install step unconditionally fetches Chrome-for-Testing from googlechromelabs.github.io, which this sandbox's network policy blocks (confirmed via the proxy status endpoint, not assumed) even after installing Node 24 and pointing AGENT_BROWSER_EXECUTABLE_PATH at the sandbox's pre-installed Chromium. The repo's own CI already runs AGENT_DEVICE_WEB_E2E=1 with real network access (.github/workflows/ci.yml, "Execute live web smoke" step), which is where this new scenario will actually execute and get validated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ot5wg8YUEyWRrphs8bMSJ * fix(daemon): stop exporting stopSessionWebBrowser, its only caller is local CI's fallow dead-code gate (fallow audit, diff-scoped) flagged it as an unused export: unlike its siblings in this file, it has no second caller in the ordinary-close path (session close already reaches the browser through dispatchTargetedPlatformClose), so exporting it served no purpose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ot5wg8YUEyWRrphs8bMSJ * fix(test): make the web-shutdown lane's idle-timer proof and cleanup real Two problems in the shutdown lane added for #1868: 1. createWebSmokeContext() unconditionally set AGENT_BROWSER_IDLE_TIMEOUT_MS to 30s for every caller, including the new shutdown scenario, whose own poll window is 45s. A reverted fix (no active browser close on shutdown) could still pass: agent-browser's own 30s idle timer would reap the fleet on its own well inside the 45s window, independent of whatever the daemon's teardown did or didn't do. The comment claiming production 5-minute behavior was simply false. Parameterize the context so only the functional smoke test opts into the shortened idle timeout; the shutdown lane now omits the override entirely, leaving the real 5-minute default in place (pinned by the existing `resolveAgentBrowserIdleTimeoutMs({})` case in agent-browser-lifecycle.test.ts) — comfortably past the 45s poll, so a pass can only mean the daemon actively closed the browser. 2. The scenario's `finally` only closed the fixture HTTP server. A failed assertion (including the exact failure mode this test exists to catch) left the daemon process and any still-running Chrome processes on the runner with nothing cleaning them up. Move cleanup authority fully into `finally`: stopProcessForTakeover on the daemon pid (a no-op if it already exited) and the same orphan sweep daemon startup runs for any leftover managed-browser processes, both best-effort and independent of how far the try block got, mirroring cleanupWebSmoke's AggregateError shape so a cleanup failure never swallows the assertion failure it ran alongside. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ot5wg8YUEyWRrphs8bMSJ * fix(test): make the web-shutdown lane's cleanup actually forceful Two more real gaps in the shutdown lane: 1. The finally block's browser-process cleanup called cleanupManagedAgentBrowserOrphans, which exists specifically to leave an actively-used fleet alone: it checks recent activity against AGENT_BROWSER_IDLE_TIMEOUT_MS and skips killing anything inside that window. Now that the previous commit restored a real (minutes-long) idle window for this lane, that guard would have suppressed the exact cleanup this test needs in the exact scenario it exists to catch — a reverted fix leaving Chrome alive would see this "cleanup" silently no-op rather than reap the leftover processes. Replaced it with forceKillManagedBrowserProcesses: the same listHostProcesses/summarizeAgentBrowserProcesses/expandProcessTree/ stopPidsWithEscalation primitives cleanupManagedAgentBrowserOrphans itself uses internally, called directly without its open-session or idle-activity skip guards, so this safety net reaps whatever the test's own fleet still owns regardless of how recently it was used. 2. The idle-timeout override the shutdown lane passes to createWebSmokeContext was an omitted env var, relying on knowledge of agent-browser's own default living elsewhere. Own the value directly instead: WEB_SHUTDOWN_IDLE_TIMEOUT_MS is computed as a fixed offset above the lane's own poll deadline, so neither a future change to the functional smoke test's override nor to agent-browser's default can silently invalidate the "idle timer can't have fired" claim this test's pass depends on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ot5wg8YUEyWRrphs8bMSJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b6feaf commit 07217c5

10 files changed

Lines changed: 510 additions & 25 deletions

src/__tests__/eager-closure-budgets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export const HUB_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
273273
'src/core/command-descriptor/registry.ts': 66,
274274
'src/core/command-descriptor/platform-execution-entry.ts': 3,
275275
'src/core/interactors/register-builtins.ts': 73,
276-
'src/daemon/session-teardown.ts': 89,
276+
'src/daemon/session-teardown.ts': 90,
277277
});
278278

279279
function toRows(

src/daemon/__tests__/session-teardown-import-closure.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { eagerClosureOf } from '../../__tests__/eager-import-closure.fixtures.ts
66
* Session teardown runs for every closed session on every platform, so its eager
77
* import closure is paid by all of them. The android cleanup helpers
88
* (`perf.ts`, `snapshot-helper.ts`) only matter when the corresponding capture
9-
* actually ran on the session; they load through function-scoped `await import`
10-
* behind their existing guards (the register-builtins interactor lazy pattern).
9+
* actually ran on the session, and the web managed-browser provider
10+
* (`agent-browser-provider.ts`) only matters for a web session; they load through
11+
* function-scoped `await import` behind their existing guards (the register-builtins
12+
* interactor lazy pattern).
1113
*
12-
* This pins that seam: restoring either helper to a static import puts it back
13-
* in the eager closure and fails here.
14+
* This pins that seam: restoring any of them to a static import puts it back in the
15+
* eager closure and fails here.
1416
*/
1517

1618
const srcRoot = path.resolve(import.meta.dirname, '../..');
@@ -19,6 +21,7 @@ const LAZY_ANDROID_HELPERS = [
1921
/platforms[/\\]android[/\\]perf\.ts$/,
2022
/platforms[/\\]android[/\\]snapshot-helper\.ts$/,
2123
];
24+
const LAZY_WEB_HELPERS = [/platforms[/\\]web[/\\]agent-browser-provider\.ts$/];
2225

2326
test('the session teardown eager import closure never evaluates the android cleanup helpers', () => {
2427
const offenders = eagerClosureOf(teardownFile).filter((file) =>
@@ -33,6 +36,20 @@ test('the session teardown eager import closure never evaluates the android clea
3336
).toEqual([]);
3437
});
3538

39+
test('the session teardown eager import closure never evaluates the web managed-browser provider', () => {
40+
const offenders = eagerClosureOf(teardownFile).filter((file) =>
41+
LAZY_WEB_HELPERS.some((pattern) => pattern.test(file)),
42+
);
43+
44+
expect(
45+
offenders.map((file) => path.relative(srcRoot, file)),
46+
'Load the agent-browser web provider on demand instead: teardown is shared by every ' +
47+
'platform, and eagerly evaluating it drags its whole subtree (agent-browser tool ' +
48+
'resolution, selectors, snapshot/network normalization) into sessions that never opened a ' +
49+
'web session.',
50+
).toEqual([]);
51+
});
52+
3653
test('the teardown closure walk is reachable and the dynamic seam exists', () => {
3754
// Non-vacuity: a resolver bug that returned an empty closure would make the
3855
// guard above pass while proving nothing, so pin a real lower bound…

src/daemon/handlers/__tests__/session-teardown-resources.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
sessionCloseShutdownFixture,
44
type SessionState,
55
} from './session-close-shutdown.fixtures.ts';
6+
import { installFakeManagedAgentBrowser } from '../../../platforms/web/__tests__/test-utils.ts';
67

78
const {
89
AppError,
@@ -12,6 +13,7 @@ const {
1213
makeSessionStore,
1314
mockCleanupAndroidNativePerfSession,
1415
mockCleanupAppleXctracePerfCapture,
16+
mockRunCmd,
1517
mockStopAndroidSnapshotHelperSessionForDevice,
1618
mockStopIosRunnerSession,
1719
mockStopIosRunnerSession: stopIosRunnerSession,
@@ -23,10 +25,15 @@ const {
2325
resetSessionCloseShutdownMocks,
2426
screenRecordingResourceStore,
2527
teardownSessionResources,
28+
WEB_DESKTOP_DEVICE,
2629
} = sessionCloseShutdownFixture;
2730

2831
beforeEach(resetSessionCloseShutdownMocks);
2932

33+
function agentBrowserJsonResult(value: unknown, exitCode = 0) {
34+
return { stdout: JSON.stringify(value), stderr: '', exitCode };
35+
}
36+
3037
test('daemon session teardown stops active Apple xctrace perf capture', async () => {
3138
const sessionName = 'ios-active-xctrace-teardown-session';
3239
const activeCapture = {
@@ -326,3 +333,65 @@ test('daemon session teardown attempts every resource after an earlier cleanup r
326333
// The later resource still runs despite the earlier rejection.
327334
expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledWith(session.device);
328335
});
336+
337+
test('daemon session teardown closes an open web session immediately, not on agent-browser idle timeout', async () => {
338+
const sessionName = 'web-active-session-teardown-session';
339+
const sessionStore = makeSessionStore();
340+
installFakeManagedAgentBrowser(sessionStore.resolveDaemonStateDir());
341+
const session = makeSession(sessionName, WEB_DESKTOP_DEVICE);
342+
// Teardown always runs while the session it is tearing down is still in the store (session
343+
// deletion happens after), which is what keeps the provider-startup orphan sweep from treating
344+
// this session's own browser as an orphan and scanning real host processes for it.
345+
sessionStore.set(sessionName, session);
346+
mockRunCmd.mockResolvedValue(agentBrowserJsonResult({ success: true, data: {} }));
347+
348+
await teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore });
349+
350+
// A SIGTERM daemon shutdown (or an expired-session reap) tells agent-browser to close its
351+
// fleet right away, the same way an explicit `session close` does, instead of leaving the
352+
// Chrome processes to agent-browser's own multi-minute idle timer.
353+
expect(mockRunCmd).toHaveBeenCalledTimes(1);
354+
const [, args] = mockRunCmd.mock.calls[0] as [string, string[]];
355+
expect(args).toEqual(['close', '--json', '--session', sessionName]);
356+
});
357+
358+
test('daemon session teardown surfaces a web close failure through the cleanup-failure channel', async () => {
359+
const sessionName = 'web-close-failure-teardown-session';
360+
const sessionStore = makeSessionStore();
361+
installFakeManagedAgentBrowser(sessionStore.resolveDaemonStateDir());
362+
const session = makeSession(sessionName, WEB_DESKTOP_DEVICE);
363+
sessionStore.set(sessionName, session);
364+
mockRunCmd.mockResolvedValue(
365+
agentBrowserJsonResult({ success: false, error: 'no active browser session' }),
366+
);
367+
368+
await expect(
369+
teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }),
370+
).rejects.toMatchObject({
371+
code: 'COMMAND_FAILED',
372+
details: expect.objectContaining({
373+
reason: 'session_cleanup_incomplete',
374+
failedSteps: ['web_browser'],
375+
}),
376+
});
377+
});
378+
379+
test('daemon session teardown never dispatches a web close for a non-web session', async () => {
380+
const sessionName = 'android-non-web-teardown-session';
381+
const session = makeSession(sessionName, {
382+
platform: 'android',
383+
id: 'emulator-5554',
384+
name: 'Pixel',
385+
kind: 'emulator',
386+
booted: true,
387+
});
388+
389+
await teardownSessionResources({
390+
appLog: 'already-settled',
391+
session,
392+
sessionName,
393+
sessionStore: makeSessionStore(),
394+
});
395+
396+
expect(mockRunCmd).not.toHaveBeenCalled();
397+
});

src/daemon/handlers/session-close.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
recordRepairPlatformClose,
1616
} from '../session-replay-transaction.ts';
1717
import { isAuthoringArmedSession } from '../session-script-publication-capability.ts';
18-
import { type SessionCleanupFailure } from '../session-teardown.ts';
18+
import { isWebSession, type SessionCleanupFailure } from '../session-teardown.ts';
1919
import { clearDeviceClaim } from '../device-claims.ts';
2020
import { applicationLifecycleExecutionFromRequest } from '../application-lifecycle-execution.ts';
2121
import { hasRuntimeTransportHints } from './session-runtime.ts';
@@ -392,7 +392,7 @@ async function closeAppWithoutEndingSession(params: {
392392
}
393393

394394
function shouldDispatchPlatformClose(req: DaemonRequest, session: SessionState): boolean {
395-
return hasCloseTarget(req) || session.device.platform === 'web';
395+
return hasCloseTarget(req) || isWebSession(session);
396396
}
397397

398398
function hasCloseTarget(req: DaemonRequest): boolean {

src/daemon/request-router.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts';
5656
import { canRunReplayScopedAction } from './daemon-command-registry.ts';
5757
import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts';
58+
import { isWebSession } from './session-teardown.ts';
5859
import { openWebSessionNames } from './web-session-names.ts';
5960
import { inferFillText } from './action-utils.ts';
6061
import { createPlatformRequestScope } from './platform-request-scope.ts';
@@ -351,7 +352,10 @@ const createDefaultWebProvider =
351352
});
352353

353354
function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean {
354-
return scope.existingSession?.device.platform === 'web' || scope.req.flags?.platform === 'web';
355+
return (
356+
(scope.existingSession !== undefined && isWebSession(scope.existingSession)) ||
357+
scope.req.flags?.platform === 'web'
358+
);
355359
}
356360

357361
function unauthorizedResponse(): DaemonResponse {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import path from 'node:path';
2+
import { afterEach, expect, test, vi } from 'vitest';
3+
4+
vi.mock('../../utils/exec.ts', async (importOriginal) => {
5+
const actual = await importOriginal<typeof import('../../utils/exec.ts')>();
6+
return { ...actual, runCmd: vi.fn() };
7+
});
8+
9+
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
10+
import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts';
11+
import { AGENT_BROWSER_TIMEOUT_MS } from '../../platforms/web/agent-browser-provider.ts';
12+
import { installFakeManagedAgentBrowser } from '../../platforms/web/__tests__/test-utils.ts';
13+
import { runCmd } from '../../utils/exec.ts';
14+
import { SessionStore } from '../session-store.ts';
15+
import type { SessionState } from '../types.ts';
16+
import {
17+
resolveDaemonSessionTeardownTimeoutMs,
18+
teardownDaemonSessionForShutdown,
19+
WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS,
20+
} from './daemon-runtime.ts';
21+
22+
const mockRunCmd = vi.mocked(runCmd);
23+
24+
afterEach(() => {
25+
vi.useRealTimers();
26+
vi.clearAllMocks();
27+
});
28+
29+
function makeWebSession(name: string): SessionState {
30+
return { name, device: WEB_DESKTOP_DEVICE, createdAt: Date.now(), actions: [] };
31+
}
32+
33+
test('daemon session teardown budget extends for an open web session', () => {
34+
const webSession = makeWebSession('budget-web-session');
35+
const androidSession: SessionState = {
36+
name: 'budget-android-session',
37+
device: {
38+
platform: 'android',
39+
id: 'emulator-5554',
40+
name: 'Pixel',
41+
kind: 'emulator',
42+
booted: true,
43+
},
44+
createdAt: Date.now(),
45+
actions: [],
46+
};
47+
48+
expect(
49+
resolveDaemonSessionTeardownTimeoutMs(webSession) -
50+
resolveDaemonSessionTeardownTimeoutMs(androidSession),
51+
).toBe(WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS);
52+
});
53+
54+
// Pins the "mirrors AGENT_BROWSER_TIMEOUT_MS" comment on WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS
55+
// as a checked invariant rather than a claim nothing enforces: if agent-browser-provider.ts's own
56+
// per-call timeout changes, this budget must move with it in the same PR.
57+
test('the web-close teardown budget stays pinned to one agent-browser CLI call timeout', () => {
58+
expect(WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS).toBe(AGENT_BROWSER_TIMEOUT_MS);
59+
});
60+
61+
// The agent-browser CLI call this step makes has its own 30s internal timeout
62+
// (AGENT_BROWSER_TIMEOUT_MS in agent-browser-provider.ts), well past the 5s base teardown
63+
// budget every other resource shares; without the extended budget above, a close that takes
64+
// longer than 5s (closing ~15 Chrome processes under load is plausible) would be abandoned by
65+
// the daemon's own race before agent-browser ever got to finish it.
66+
test('daemon shutdown awaits a slow web close inside its extended budget', async () => {
67+
vi.useFakeTimers();
68+
const root = mkdtempForTestSync('agent-device-shutdown-web-close-slow-');
69+
installFakeManagedAgentBrowser(root);
70+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
71+
const session = makeWebSession('shutdown-slow-web-session');
72+
sessionStore.set(session.name, session);
73+
mockRunCmd.mockImplementation(
74+
async () =>
75+
await new Promise((resolve) => {
76+
setTimeout(
77+
() =>
78+
resolve({
79+
stdout: JSON.stringify({ success: true, data: {} }),
80+
stderr: '',
81+
exitCode: 0,
82+
}),
83+
20_000,
84+
);
85+
}),
86+
);
87+
const stderrChunks: string[] = [];
88+
89+
const teardown = teardownDaemonSessionForShutdown({
90+
session,
91+
sessionStore,
92+
stateDir: root,
93+
stderr: { write: (chunk) => stderrChunks.push(chunk) },
94+
});
95+
await vi.advanceTimersByTimeAsync(20_000);
96+
await teardown;
97+
98+
expect(stderrChunks.join('')).not.toMatch(/timed out/);
99+
expect(sessionStore.get(session.name)).toBeUndefined();
100+
});
101+
102+
// #1868: SIGTERM (or any other daemon-shutdown teardown) must tell agent-browser to close its
103+
// fleet right away instead of leaving the ~15 Chrome processes for agent-browser's own multi-
104+
// minute idle timer. This is the daemon-shutdown-entry-point counterpart to
105+
// daemon-runtime-recording-teardown.test.ts's coverage of the #1325 recording step.
106+
test('daemon shutdown closes an open web session immediately, without waiting for close', async () => {
107+
const root = mkdtempForTestSync('agent-device-shutdown-web-close-');
108+
installFakeManagedAgentBrowser(root);
109+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
110+
const session = makeWebSession('shutdown-web-session');
111+
// Teardown runs while the session it is tearing down is still in the store (session deletion
112+
// happens after), which is also what keeps agent-browser's provider-startup orphan sweep from
113+
// treating this session's own browser as an orphan and scanning real host processes for it.
114+
sessionStore.set(session.name, session);
115+
mockRunCmd.mockResolvedValue({
116+
stdout: JSON.stringify({ success: true, data: {} }),
117+
stderr: '',
118+
exitCode: 0,
119+
});
120+
const stderrChunks: string[] = [];
121+
122+
await teardownDaemonSessionForShutdown({
123+
session,
124+
sessionStore,
125+
stateDir: root,
126+
stderr: { write: (chunk) => stderrChunks.push(chunk) },
127+
});
128+
129+
expect(stderrChunks.join('')).toBe('');
130+
expect(sessionStore.get(session.name)).toBeUndefined();
131+
const closeCall = mockRunCmd.mock.calls.find(([, args]) => (args as string[]).includes('close'));
132+
expect(closeCall?.[1]).toEqual(['close', '--json', '--session', session.name]);
133+
});
134+
135+
test('daemon shutdown reports a web close failure on stderr instead of losing it silently', async () => {
136+
const root = mkdtempForTestSync('agent-device-shutdown-web-close-failure-');
137+
installFakeManagedAgentBrowser(root);
138+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
139+
const session = makeWebSession('shutdown-web-close-failure-session');
140+
sessionStore.set(session.name, session);
141+
mockRunCmd.mockResolvedValue({
142+
stdout: JSON.stringify({ success: false, error: 'no active browser session' }),
143+
stderr: '',
144+
exitCode: 1,
145+
});
146+
const stderrChunks: string[] = [];
147+
148+
await teardownDaemonSessionForShutdown({
149+
session,
150+
sessionStore,
151+
stateDir: root,
152+
stderr: { write: (chunk) => stderrChunks.push(chunk) },
153+
});
154+
155+
// Best-effort: the failure is surfaced, but it never blocks the session from being torn down.
156+
expect(stderrChunks.join('')).toMatch(/web_browser/);
157+
expect(sessionStore.get(session.name)).toBeUndefined();
158+
});

src/daemon/server/daemon-runtime.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { LeaseRegistry } from '../lease-registry.ts';
1717
import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts';
1818
import { clearDaemonShutdownReport, writeDaemonShutdownReport } from '../daemon-shutdown-report.ts';
1919
import { createRequestHandler } from '../request-router.ts';
20-
import { stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts';
20+
import { isWebSession, stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts';
2121
import { finalizeDaemonSessionApplicationLifecycle } from '../application-lifecycle-recovery.ts';
2222
import { runtimeHintValues } from '../handlers/session-runtime.ts';
2323
import { closeDaemonServers } from './server-shutdown.ts';
@@ -66,6 +66,10 @@ import { createScreenRecordingAdmissionLedger } from '../screen-recording-admiss
6666

6767
const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000;
6868
export const SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS = 11_000;
69+
// Mirrors AGENT_BROWSER_TIMEOUT_MS in platforms/web/agent-browser-provider.ts: the ceiling that
70+
// module already places on one `agent-browser` CLI call, so the race below never gives up on the
71+
// web-close step while the close it started is still running within its own enforced limit.
72+
export const WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS = 30_000;
6973
const DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS = 1_000;
7074
const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000;
7175
const DAEMON_PROVIDER_RELEASE_DRAIN_TIMEOUT_MS = 2_000;
@@ -75,14 +79,17 @@ type WritableOutput = {
7579
};
7680

7781
/**
78-
* Per-session teardown budget for daemon shutdown. The base budget covers ordinary resources;
79-
* an active durable recording gets an additional owner-finalization budget so the daemon cannot
80-
* exit while its runtime handle is still producing the terminal media artifact. The base portion
81-
* remains available to cleanup steps that follow recording finalization.
82+
* Per-session teardown budget for daemon shutdown. The base budget covers ordinary resources; an
83+
* active durable recording or an open web session gets an additional owner-finalization budget so
84+
* the daemon cannot exit while its runtime handle is still producing the terminal media artifact,
85+
* or while agent-browser is still closing its Chrome fleet. The base portion remains available to
86+
* cleanup steps that follow recording finalization or the web close.
8287
*/
8388
export function resolveDaemonSessionTeardownTimeoutMs(session: SessionState): number {
84-
if (!session.screenRecording) return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS;
85-
return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS + SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS;
89+
let timeoutMs = DAEMON_SESSION_TEARDOWN_TIMEOUT_MS;
90+
if (session.screenRecording) timeoutMs += SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS;
91+
if (isWebSession(session)) timeoutMs += WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS;
92+
return timeoutMs;
8693
}
8794

8895
async function settleDaemonTeardownStep(params: {

0 commit comments

Comments
 (0)