Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Use a fresh clone at the commit named under Runtime or environment with the repository dependencies installed; the script needs vp from node_modules/.bin and rg on PATH. Save the script below as repro.sh at the clone root and run it as shown. It copies the existing apps/server/src/preview/ServerBrowser.test.ts fixture into one temporary focused test file, drives the production services through the repository's existing browser and channel doubles, isolates HOME, TMPDIR and T3CODE_HOME under a temporary fixture directory, and removes the copied test on exit.
tmp=$(mktemp -d)
VERIFY_SCRATCH_ROOT="$tmp" sh ./repro.sh
rm -rf "$tmp"repro.sh
#!/bin/sh
set -eu
checkout=$(git rev-parse --show-toplevel)
scratch_root=${VERIFY_SCRATCH_ROOT:-${TMPDIR:-/tmp}}
fixture=$(mktemp -d "$scratch_root/verifier-unload.XXXXXX")
probe="$checkout/apps/server/src/preview/ServerBrowser.verifier.test.ts"
trap 'rm -f "$probe"; rm -rf "$fixture"' EXIT INT TERM
mkdir -p "$fixture/home" "$fixture/tmp" "$fixture/t3home"
export HOME="$fixture/home" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3home"
export PATH="$checkout/node_modules/.bin:$PATH"
cp "$checkout/apps/server/src/preview/ServerBrowser.test.ts" "$probe"
cat >> "$probe" <<'TS'
it.live("verifier lifecycle: client close bypasses desktop unload", () =>
Effect.scoped(Effect.gen(function* () {
const broker = yield* Broker.PreviewAutomationBroker;
const manager = yield* Manager.PreviewManager;
yield* ServerBrowser.ServerBrowser;
yield* Effect.yieldNow;
desktopRendersNext = true;
const opened = yield* broker.invoke<PreviewAutomationStatus>({ scope, operation: "open", input: { reuseExistingTab: false, open: false } });
const tabId = PreviewTabId.make(opened.tabId!);
const { context, browser } = desktopConnections[0]!;
context.page.beforeUnload = "listen";
// ws.ts previewClose calls this same public service with these fields.
yield* manager.close({ threadId: testThread.threadId, tabId });
yield* Effect.promise(() => desktopReleased.promise);
const result = { pageCloseCalls: context.page.close.mock.calls.length, exposeFunctionCalls: context.page.exposeFunction.mock.calls.length, desktopDisconnects: browser.close.mock.calls.length, modeledUnloadEvents: context.page.unloadEvents, sessionCount: (yield* manager.list({ threadId: testThread.threadId })).sessions.length };
console.log("CLIENT_CLOSE", JSON.stringify(result));
expect(result).toEqual({ pageCloseCalls: 0, exposeFunctionCalls: 0, desktopDisconnects: 1, modeledUnloadEvents: [], sessionCount: 0 });
})).pipe(Effect.provide(layer)),
);
it.live("verifier lifecycle: agent close control waits for desktop unload", () =>
Effect.scoped(Effect.gen(function* () {
const broker = yield* Broker.PreviewAutomationBroker;
const manager = yield* Manager.PreviewManager;
yield* ServerBrowser.ServerBrowser;
yield* Effect.yieldNow;
desktopRendersNext = true;
const opened = yield* broker.invoke<PreviewAutomationStatus>({ scope, operation: "open", input: { reuseExistingTab: false, open: false } });
const tabId = PreviewTabId.make(opened.tabId!);
const { context, browser } = desktopConnections[0]!;
const page = context.page;
page.beforeUnload = "listen";
yield* broker.invoke({ scope, tabId, operation: "close", input: {} });
const result = { pageCloseCalls: page.close.mock.calls.length, exposeFunctionCalls: page.exposeFunction.mock.calls.length, desktopDisconnects: browser.close.mock.calls.length, modeledUnloadEvents: page.unloadEvents };
console.log("AGENT_CONTROL", JSON.stringify(result));
expect(result).toEqual({ pageCloseCalls: 1, exposeFunctionCalls: 1, desktopDisconnects: 1, modeledUnloadEvents: ["beforeunload", "pagehide", "visibilitychange"] });
})).pipe(Effect.provide(layer)),
);
it.live("verifier lifecycle: capacity eviction bypasses desktop unload", () =>
Effect.scoped(Effect.gen(function* () {
const broker = yield* Broker.PreviewAutomationBroker;
const manager = yield* Manager.PreviewManager;
yield* ServerBrowser.ServerBrowser;
yield* Effect.yieldNow;
const now = vi.spyOn(Date, "now").mockReturnValue(0);
yield* Effect.addFinalizer(() => Effect.sync(() => now.mockRestore()));
const open = (session: string, at: number) => {
now.mockReturnValue(at); desktopRendersNext = true;
return broker.invoke<PreviewAutomationStatus>({ scope: asSession(session), operation: "open", input: { reuseExistingTab: false, open: false } });
};
const ids: string[] = [];
for (let index = 0; index < 32; index++) ids.push((yield* open(`owner-${Math.floor(index / 8)}`, index)).tabId!);
const { context, browser } = desktopConnections[0]!;
context.page.beforeUnload = "listen";
const replacement = yield* open("new-owner", 31 * 60 * 1000);
yield* Effect.promise(() => desktopReleased.promise);
const { sessions } = yield* manager.list({ threadId: testThread.threadId });
const result = { pageCloseCalls: context.page.close.mock.calls.length, exposeFunctionCalls: context.page.exposeFunction.mock.calls.length, desktopDisconnects: browser.close.mock.calls.length, modeledUnloadEvents: context.page.unloadEvents, evictedMissing: !sessions.some(session => session.tabId === ids[0]), replacementPresent: sessions.some(session => session.tabId === replacement.tabId), releasedCount: releasedDesktopTabs.length };
console.log("CAPACITY_EVICTION", JSON.stringify(result));
expect(result).toEqual({ pageCloseCalls: 0, exposeFunctionCalls: 0, desktopDisconnects: 1, modeledUnloadEvents: [], evictedMissing: true, replacementPresent: true, releasedCount: 1 });
})).pipe(Effect.provide(layer)),
);
TS
vp test run apps/server/src/preview/ServerBrowser.verifier.test.ts -t 'verifier lifecycle' --silent=false --disableConsoleIntercept > "$fixture/test.log" 2>&1 || { cat "$fixture/test.log"; exit 1; }
sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g' "$fixture/test.log" | rg 'CLIENT_CLOSE|AGENT_CONTROL|CAPACITY_EVICTION|Tests '
rg -q 'CLIENT_CLOSE' "$fixture/test.log"
rg -q 'AGENT_CONTROL' "$fixture/test.log"
rg -q 'CAPACITY_EVICTION' "$fixture/test.log"
echo 'PASS: production client-close and capacity paths skip unload requests; agent-close control requests and waits for unload.'The first case opens one desktop-rendered tab through the real automation broker, then calls the exported PreviewManager.close({ threadId, tabId }) service with the same fields the previewClose RPC handler passes. It needs no idle threshold, full tab budget, pending action, dialog or timeout. The second case closes the same kind of tab with the owning agent's broker close operation as a working control. The third opens 32 hidden desktop tabs across four owners, advances the fixture clock beyond 30 minutes, and opens a tab from a fifth owner to trigger capacity reclamation.
The client caller chain is apps/web/src/components/preview/PreviewView.tsx → closePreviewSession (
t3code/apps/web/src/components/preview/closePreviewSession.ts
Lines 22 to 38 in d9805b2
| export async function closePreviewSession<E>( | |
| input: ClosePreviewSessionInput<E>, | |
| ): Promise<AtomCommandResult<void, E>> { | |
| beginPreviewSessionClose(input.threadRef, input.tabId); | |
| const result = await input.closePreview({ | |
| environmentId: input.threadRef.environmentId, | |
| input: { threadId: input.threadRef.threadId, tabId: input.tabId }, | |
| }); | |
| if (result._tag === "Failure") { | |
| cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId); | |
| } else if (input.snapshot) { | |
| useClosedViewStore.getState().remember({ | |
| kind: "browser", | |
| threadRef: input.threadRef, | |
| snapshot: input.snapshot, | |
| }); | |
| } |
WS_METHODS.previewClose (t3code/packages/client-runtime/src/state/preview.ts
Lines 60 to 65 in d9805b2
| close: createEnvironmentRpcCommand(runtime, { | |
| label: "environment-data:preview:close", | |
| tag: WS_METHODS.previewClose, | |
| scheduler: lifecycleScheduler, | |
| concurrency: lifecycleConcurrency, | |
| }), |
Lines 3145 to 3150 in d9805b2
| [WS_METHODS.previewOpen]: (input) => previewManager.open(input), | |
| [WS_METHODS.previewResize]: (input) => previewManager.resize(input), | |
| [WS_METHODS.previewAdjust]: (input) => previewManager.adjust(input), | |
| [WS_METHODS.previewClose]: (input) => previewManager.close(input), | |
| [WS_METHODS.previewList]: (input) => previewManager.list(input), | |
| [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), |
PreviewManager.close directly. No real desktop Close button was clicked.
A separate real-Chromium check, below, imports the production ServerBrowserContexts class, connects with its connectDesktopPage, and closes that connection the way production teardown does. Run it with Node from the clone root, passing the clone root as its argument: node real-cdp.mjs "$PWD". It uses playwright-core's Chromium unless CHROMIUM_EXECUTABLE is set, and serves a local page on 127.0.0.1.
real-cdp.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import assert from 'node:assert/strict';
const checkout = process.argv[2];
if (!checkout) throw new Error('Pass checkout root as first argument');
const require = createRequire(path.join(checkout, 'apps/server/package.json'));
const { chromium } = require('playwright-core');
const executablePath = process.env.CHROMIUM_EXECUTABLE ?? chromium.executablePath();
const root = await fs.mkdtemp(path.join(process.env.VERIFY_SCRATCH_ROOT ?? process.env.TMPDIR ?? '/tmp', 'real-cdp-unload.'));
for (const dir of ['home', 'tmp', 't3home']) await fs.mkdir(path.join(root, dir));
process.env.HOME = path.join(root, 'home');
process.env.TMPDIR = path.join(root, 'tmp');
process.env.T3CODE_HOME = path.join(root, 't3home');
const server = http.createServer((req, res) => { res.setHeader('Content-Type','text/html'); res.end('<button>activate</button>'); });
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const url = `http://127.0.0.1:${server.address().port}`;
let context;
try {
const { ServerBrowserContexts } = await import(pathToFileURL(path.join(checkout, 'apps/server/src/preview/ServerBrowserContexts.ts')));
context = await chromium.launchPersistentContext(path.join(root, 'profile'), { executablePath, headless: true, args: ['--remote-debugging-port=0'] });
const [port, wsPath] = (await fs.readFile(path.join(root, 'profile/DevToolsActivePort'), 'utf8')).trim().split('\n');
const endpoint = `ws://127.0.0.1:${port}${wsPath}`;
const owner = new ServerBrowserContexts({ profilesDir: path.join(root, 'unused'), executable: async () => executablePath });
const install = async (page, key) => {
await page.goto(url);
await page.evaluate(key => {
localStorage.setItem(key, '[]');
for (const name of ['beforeunload','pagehide','visibilitychange']) addEventListener(name, () => {
const events = JSON.parse(localStorage.getItem(key)); events.push(name); localStorage.setItem(key, JSON.stringify(events));
});
}, key);
await page.locator('button').click();
};
const observed = context.pages()[0];
await install(observed, 'disconnect');
const disconnected = await owner.connectDesktopPage(endpoint);
await disconnected.browser.close();
const failure = { stillOpen: !observed.isClosed(), events: await observed.evaluate(() => JSON.parse(localStorage.getItem('disconnect'))) };
console.log('REAL_CDP_DISCONNECT', JSON.stringify(failure));
assert.deepEqual(failure, { stillOpen: true, events: [] });
const controlConnection = await owner.connectDesktopPage(endpoint);
await install(controlConnection.page, 'graceful');
const closed = observed.waitForEvent('close');
await controlConnection.page.close({ runBeforeUnload: true });
await closed;
const inspector = await context.newPage(); await inspector.goto(url);
const control = { stillOpen: !observed.isClosed(), events: await inspector.evaluate(() => JSON.parse(localStorage.getItem('graceful'))) };
console.log('REAL_PAGE_CLOSE_CONTROL', JSON.stringify(control));
assert.equal(control.stillOpen, false);
assert(control.events.includes('beforeunload'));
assert(control.events.includes('pagehide'));
console.log('RUNTIME', JSON.stringify({ node: process.version, platform: process.platform, arch: process.arch, playwright: require('playwright-core/package.json').version, chromium: context.browser().version() }));
await controlConnection.browser.close();
await owner.close();
console.log('PASS: production connectDesktopPage disconnect leaves real page open with zero lifecycle writes; graceful-close control writes beforeunload and pagehide.');
} finally {
await context?.close();
await new Promise((resolve,reject) => server.close(error => error ? reject(error) : resolve()));
await fs.rm(root, { recursive: true, force: true });
}Expected behavior
Closing a desktop-rendered preview session should request its unload lifecycle before the desktop removes the webview, consistently with agent close. The unload helper unloadDesktopPage and its contract comment (
t3code/apps/server/src/preview/ServerBrowser.ts
Lines 769 to 789 in d9805b2
| // Closing the session makes the desktop destroy its webview, which drops an unload still in | |
| // progress, and `runBeforeUnload` resolves before it runs. So wait for the page's `pagehide`. | |
| const unloadDesktopPage = async (page: Page) => { | |
| const unloaded = Promise.withResolvers<void>(); | |
| const timeout = setTimeout(unloaded.resolve, PAGE_CLOSE_GRACE_MS); | |
| const binding = `__t3PreviewUnloaded${NodeCrypto.randomUUID().replaceAll("-", "")}`; | |
| void (async () => { | |
| await page.exposeFunction(binding, unloaded.resolve); | |
| await page.evaluate((name) => { | |
| const target = globalThis as unknown as { | |
| addEventListener: (type: string, listener: () => void) => void; | |
| }; | |
| target.addEventListener("pagehide", () => | |
| Reflect.apply(Reflect.get(target, name), target, []), | |
| ); | |
| }, binding); | |
| await page.close({ runBeforeUnload: true }); | |
| })().catch(unloaded.resolve); | |
| await unloaded.promise; | |
| clearTimeout(timeout); | |
| }; |
t3code/apps/server/src/preview/ServerBrowser.test.ts
Lines 1268 to 1306 in d9805b2
| it.live("closing a desktop tab waits for its page to unload before the desktop removes it", () => | |
| Effect.scoped( | |
| Effect.gen(function* () { | |
| const { broker } = yield* ready; | |
| const manager = yield* Manager.PreviewManager; | |
| desktopRendersNext = true; | |
| const opened = yield* broker.invoke<PreviewAutomationStatus>({ | |
| scope, | |
| operation: "open", | |
| input: { reuseExistingTab: false, open: false }, | |
| }); | |
| const tabId = PreviewTabId.make(opened.tabId!); | |
| const { context, browser } = desktopConnections[0]!; | |
| const page = context.page; | |
| // The desktop removes the webview once the session closes, dropping an unload in progress. | |
| const events = yield* manager.subscribeEvents; | |
| const sessionClosed = Promise.withResolvers<void>(); | |
| yield* Effect.gen(function* () { | |
| while ((yield* PubSub.take(events)).type !== "closed"); | |
| page.removed = true; | |
| sessionClosed.resolve(); | |
| }).pipe(Effect.forkScoped); | |
| page.beforeUnload = "listen"; | |
| page.unloadGate = Promise.withResolvers<void>(); | |
| const closing = yield* broker | |
| .invoke({ scope, tabId, operation: "close", input: {} }) | |
| .pipe(Effect.forkScoped); | |
| // The page finishes unloading once the server listens for it, or too late if it never does. | |
| yield* Effect.promise(() => | |
| Promise.race([page.pagehideWatched.promise, sessionClosed.promise]), | |
| ); | |
| expect(browser.close).not.toHaveBeenCalled(); | |
| page.unloadGate.resolve(); | |
| yield* Fiber.join(closing); | |
| expect(page.unloadEvents).toEqual(["beforeunload", "pagehide", "visibilitychange"]); | |
| expect(browser.close).toHaveBeenCalledTimes(1); | |
| }), | |
| ).pipe(Effect.provide(layer)), | |
| ); |
Actual behavior
Summary. Closing a desktop-rendered preview tab through the client close route (previewClose, which closePreviewSession uses) removes the session and disconnects the tab's CDP connection without ever requesting the page's unload: no page.close({ runBeforeUnload: true }) call and no pagehide binding. The explicit agent close (t3_preview_close) on the same kind of tab does request and wait for unload, as #23537 made it do. Automatic capacity reclamation (freeIdleAgentSlot) takes the same bypass under narrower conditions. A page that relies on unload handlers for final writes or telemetry therefore gets no graceful close request on these paths. This is a service-level reproduction plus a real-Chromium disconnect check; no native Electron run was made and no actual user data loss was reproduced.
Observed in the service reproduction: client close removes the session and disconnects its desktop browser once, while calling page.close zero times and installing no pagehide binding (exposeFunction zero times). The agent-close control calls page.close({ runBeforeUnload: true }) once and installs the binding, and the browser double records beforeunload, pagehide and visibilitychange. Capacity reclamation shows the same zero-call bypass, but needs the narrower preconditions: a full 32-tab server budget, a caller below its per-owner limit of eight, and a hidden agent-owned tab idle for more than 30 minutes. The double's event list is modeled evidence; the call counts are direct observations of the production services.
Source path: the manager close implementation (
t3code/apps/server/src/preview/Manager.ts
Lines 394 to 425 in d9805b2
| const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( | |
| function* (input) { | |
| const createdAt = yield* currentIsoTimestamp; | |
| yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | |
| const eventsToEmit: PreviewEvent[] = []; | |
| const sessions = new Map(state.sessions); | |
| const targets = input.tabId | |
| ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( | |
| (entry): entry is PreviewSessionState => entry !== undefined, | |
| ) | |
| : sessionsForThread(state, input.threadId); | |
| let revision = state.revision; | |
| for (const target of targets) { | |
| revision += 1; | |
| sessions.delete(compositeKey(target.threadId, target.tabId)); | |
| eventsToEmit.push({ | |
| type: "closed", | |
| threadId: target.threadId, | |
| tabId: target.tabId, | |
| createdAt, | |
| serverEpoch, | |
| revision, | |
| }); | |
| } | |
| if (eventsToEmit.length === 0) { | |
| return Effect.succeed([undefined, state] as const); | |
| } | |
| return Effect.as( | |
| Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), { | |
| discard: true, | |
| }), | |
| [undefined, { sessions, revision }] as const, |
closed. The server mirror (t3code/apps/server/src/preview/ServerBrowser.ts
Lines 2480 to 2488 in d9805b2
| const key = tabKey(event.threadId, event.tabId); | |
| const tab = tabs.get(key); | |
| if (event.type === "closed" && !tab && pendingTabs.has(key)) closedPendingTabs.add(key); | |
| if (!tab) return; | |
| if (event.type === "closed") { | |
| closedSessions.add(key); | |
| dropTab(tab, false); | |
| closedSessions.delete(key); | |
| return; |
dropTab, whose desktop branch (t3code/apps/server/src/preview/ServerBrowser.ts
Lines 791 to 824 in d9805b2
| const dropTab = (tab: ServerTab, closeSession: boolean) => { | |
| const key = tabKey(tab.threadId, tab.tabId); | |
| if (tabs.get(key) !== tab) return; | |
| tabs.delete(key); | |
| tab.closing = true; | |
| tab.viewerAttached?.resolve(); | |
| clearAbortedNavigation(tab); | |
| // A desktop page outlives the connection unless its session closed with it. | |
| const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; | |
| for (const viewer of tab.viewers) viewer.push({ _tag: end }); | |
| void tab.control.close().catch(constVoid); | |
| if (tab.desktop) void tab.desktop.close().catch(constVoid); | |
| else { | |
| const page = tab.page; | |
| // `runBeforeUnload` returns before the page closes, so closing an isolated context | |
| // must wait for it. A pending dialog would block `beforeunload` from running at all. | |
| const closed = page.isClosed() | |
| ? Promise.resolve() | |
| : new Promise<void>((resolve) => page.once("close", () => resolve())); | |
| // A renderer that never finishes unloading would outlive its tab, so force it closed. | |
| const forceClose = setTimeout(() => void page.close().catch(constVoid), PAGE_CLOSE_GRACE_MS); | |
| void closed.then(() => clearTimeout(forceClose)); | |
| void page | |
| .close({ runBeforeUnload: tab.dialog === null }) | |
| .catch(() => page.close()) | |
| .catch(constVoid); | |
| if (tab.isolatedContext) void closed.then(() => page.context().close()).catch(constVoid); | |
| } | |
| void tab.recording?.encoder.close().catch(constVoid); | |
| void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid); | |
| reportLiveTabs(); | |
| if (closeSession) { | |
| runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore)); | |
| } |
t3code/apps/server/src/preview/ServerBrowser.ts
Lines 852 to 858 in d9805b2
| const connected = await contexts.connectDesktopPage(endpoint); | |
| return { | |
| page: connected.page, | |
| close: async () => { | |
| await connected.browser.close().catch(constVoid); | |
| await runPromise(Scope.close(scope, Exit.void)); | |
| }, |
connected.browser.close() and releases the endpoint. In contrast, agent close (t3code/apps/server/src/preview/ServerBrowser.ts
Lines 2275 to 2295 in d9805b2
| // Closing must unblock an action waiting on a dialog, without queueing behind it. | |
| if (request.operation === "close") { | |
| if (tab.control.agentId !== request.agentSessionId) | |
| throw new BrowserControlInterrupted({ | |
| message: "Only the agent session that opened this tab can close it.", | |
| reason: "agentMismatch", | |
| }); | |
| if (tab.control.controller !== null) | |
| throw new BrowserControlInterrupted({ | |
| message: "A human controls this tab.", | |
| reason: "humanControl", | |
| }); | |
| void tab.control.close().catch(constVoid); | |
| if (tab.desktop && tab.dialog === null) { | |
| tab.closing = true; | |
| await unloadDesktopPage(tab.page); | |
| } | |
| await runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId })); | |
| dropTab(tab, false); | |
| return {}; | |
| } |
t3code/apps/server/src/preview/ServerBrowser.ts
Lines 1504 to 1522 in d9805b2
| // The desktop draws its tabs in its own webview, so they never get a streamed viewer. | |
| const isVisible = (tab: ServerTab) => | |
| tab.viewers.size > 0 || (tab.desktop !== null && desktopChannel.isShown(tab)); | |
| const idleAgentTabs = () => { | |
| const cutoff = Date.now() - AGENT_TAB_IDLE_MS; | |
| return [...tabs.values()].filter( | |
| (tab) => tab.control.agentId !== null && !isVisible(tab) && tab.usedAt < cutoff, | |
| ); | |
| }; | |
| // The desktop app renders every server tab, so idle desktop agent tabs would otherwise | |
| // hold the server's budget forever. Hidden ones give up their slots, longest idle first, | |
| // only when an agent needs one and has room for it. | |
| const freeIdleAgentSlot = (agentSessionId: string) => { | |
| if (!serverAtTabLimit() || agentAtTabLimit(agentSessionId)) return; | |
| const longestIdle = idleAgentTabs().sort((left, right) => left.usedAt - right.usedAt)[0]; | |
| if (longestIdle) dropTab(longestIdle, true); | |
| }; |
dropTab. Routine idle sweeping excludes desktop tabs, so it is not affected.
Observed in the real-Chromium check: after the production connection's browser.close(), the page is still open and its lifecycle localStorage record is empty. Reconnecting and calling page.close({ runBeforeUnload: true }) closes it and records beforeunload, pagehide and visibilitychange. The disconnect itself therefore supplies no graceful unload. Native Electron guest removal was not exercised; that step rests on the production lifecycle chain and the contract comment above. The client also removes the session optimistically before the RPC result, and the desktop renders webviews only for current sessions.
Hypothesis, not verified: unload coordination lives only in the explicit agent-close operation, while the other desktop session-close callers still use generic teardown.
Evidence
- Expected source:
apps/server/src/preview/ServerBrowser.tslines 769-789 (unloadDesktopPageand its contract comment), the desktop agent-close test atapps/server/src/preview/ServerBrowser.test.tslines 1268-1306, and the scope question in [Bug]: Closing a server preview tab never fires beforeunload or pagehide #23513. - Failure source: observed output of
repro.shandreal-cdp.mjsabove, and the call pathws.tspreviewClose →PreviewManager.close(apps/server/src/preview/Manager.tslines 394-425) →dropTabdesktop branch (ServerBrowser.tslines 791-824) →browser.close()(ServerBrowser.tslines 852-858), with capacity reclamation atServerBrowser.tslines 1504-1522. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The service reproduction uses the repository's existing browser and channel doubles; the real-Chromium check covers CDP disconnect semantics only. No native Electron, packaged app or real UI click run was made, and no actual draft or login loss was reproduced. The pre-existing delayed-unload desktop agent-close test also passes at the baseline (1 passed, 68 skipped).
Runtime or environment: commit d9805b2 (version 0.1.9); macOS arm64, Node v26.12.1, playwright-core 1.64.1, Chrome for Testing 158.0.8127.18 (Playwright build 1254). On current main the server close chain, ServerBrowserContexts.ts, ws.ts, ElectronBrowserHost.tsx, the client-runtime preview state and closePreviewSession.ts are unchanged; HostedBrowserWebview.tsx and PreviewView.tsx changed only in mouse back/forward and webview focus handling.
Restoration check
With the repro above, the client-close case passes when closing the desktop tab through PreviewManager.close requests the page's unload (page.close with runBeforeUnload: true or an equivalent that delivers pagehide) and waits for it before the desktop connection is torn down, and the capacity-reclamation case does the same for the evicted tab. The agent-close control must keep requesting and waiting for unload, client close must still remove the session, and reclamation must still free the slot for the new owner.
References
- Closed [Bug]: Closing a server preview tab never fires beforeunload or pagehide #23513 ([Bug]: Closing a server preview tab never fires beforeunload or pagehide #23513) and merged fix(server): closing preview tabs runs beforeunload handlers #23537 (fix(server): closing preview tabs runs beforeunload handlers #23537): fix(server): closing preview tabs runs beforeunload handlers #23537 fixed the explicit agent close; its final evidence comment says "The ordinary desktop UI Close button remains a separate unresolved path".
- Related, not duplicates: [Bug]: Server browser: a browser action that never finishes wedges its tab, evicts the preview host and stops other tabs' setting changes #32849 concerns a wedged mirror that prevents closes; [Bug]: Windows snapshot/evaluate timeouts evict the shared server-browser host and abort another open #16921 concerns shared-host eviction after timeouts. Neither condition is needed here.
Impact
Minor bug or occasional failure
Version or commit
0.1.9, commit d9805b2 (server close chain unchanged on main at 8024e50)
Environment
macOS arm64, Node v26.12.1, playwright-core 1.64.1, Chrome for Testing 158.0.8127.18 (Playwright build 1254). Service reproduction with the repository's existing browser/channel doubles; no native Electron or packaged-app run.
Logs or stack traces
CLIENT_CLOSE {"pageCloseCalls":0,"exposeFunctionCalls":0,"desktopDisconnects":1,"modeledUnloadEvents":[],"sessionCount":0}
AGENT_CONTROL {"pageCloseCalls":1,"exposeFunctionCalls":1,"desktopDisconnects":1,"modeledUnloadEvents":["beforeunload","pagehide","visibilitychange"]}
CAPACITY_EVICTION {"pageCloseCalls":0,"exposeFunctionCalls":0,"desktopDisconnects":1,"modeledUnloadEvents":[],"evictedMissing":true,"replacementPresent":true,"releasedCount":1}
Tests 3 passed | 69 skipped (72)
PASS: production client-close and capacity paths skip unload requests; agent-close control requests and waits for unload.
REAL_CDP_DISCONNECT {"stillOpen":true,"events":[]}
REAL_PAGE_CLOSE_CONTROL {"stillOpen":false,"events":["beforeunload","pagehide","visibilitychange"]}
RUNTIME {"node":"v26.12.1","platform":"darwin","arch":"arm64","playwright":"1.64.1","chromium":"158.0.8127.18"}
PASS: production connectDesktopPage disconnect leaves real page open with zero lifecycle writes; graceful-close control writes beforeunload and pagehide.Screenshots, recordings, or supporting files
No response
Workaround
For a tab owned by the active agent and not controlled by a human, ask the owning agent to close it with its explicit preview-close tool (t3_preview_close) instead of the client close; that path is the passing control. Staying below the server's 32-tab capacity avoids only the automatic-reclamation route.
Activity
fix(web): desktop keeps a closing server tab's webview until the serv…