Skip to content

[Bug]: Closing a desktop preview tab from the client skips its unload lifecycle #34923

Open

Description

@coygeek

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 (

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,
});
}
) → the close command using WS_METHODS.previewClose (
close: createEnvironmentRpcCommand(runtime, {
label: "environment-data:preview:close",
tag: WS_METHODS.previewClose,
scheduler: lifecycleScheduler,
concurrency: lifecycleConcurrency,
}),
) → the server handler (

t3code/apps/server/src/ws.ts

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),
), which invokes the tested 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 (

// 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);
};
) state that session closure destroys the desktop webview and drops an unload still in progress, which is why closure must wait for pagehide; the desktop agent-close test (
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)),
);
) pins that ordering. The original #23513 asked whether agent tool, Browser panel and idle closes should run unload handlers, and none of these was declared an intentional exception. This does not require honoring a cancellation prompt or guaranteeing arbitrary asynchronous work during unload.

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 (

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,
) deletes the session and publishes closed. The server mirror (
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;
) calls dropTab, whose desktop branch (
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));
}
) only disconnects. The desktop connection closer (
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));
},
) calls connected.browser.close() and releases the endpoint. In contrast, agent close (
// 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 {};
}
) invokes the unload helper first. Capacity reclamation (
// 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);
};
) goes directly to 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.ts lines 769-789 (unloadDesktopPage and its contract comment), the desktop agent-close test at apps/server/src/preview/ServerBrowser.test.ts lines 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.sh and real-cdp.mjs above, and the call path ws.ts previewClose → PreviewManager.close (apps/server/src/preview/Manager.ts lines 394-425) → dropTab desktop branch (ServerBrowser.ts lines 791-824) → browser.close() (ServerBrowser.ts lines 852-858), with capacity reclamation at ServerBrowser.ts lines 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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions