Currently browser mode orchestrator session management isn't consistent and that's been partly affecting API security surface #10499. This issue documents current behavior and what to decide on. The following is a rough write up by AI:
On main, browser preview can appear to survive a tab reload, but browser execution is already broken after that reload. A concrete repro:
pnpm -C test/ui test-fixtures --root fixtures/browser-preview
Steps:
- Let the command open the browser preview tab.
- Confirm browser mode runs and the UI shows the test result.
- Reload the browser tab.
- The UI still appears to work and keeps showing the same test result.
- Click "re-run test".
Observed result: the rerun freezes, and the tester frame shows Invalid session ID.
The misleading part is that the orchestrator UI still renders after reload, so the page looks alive. But the next browser execution creates tester HTML for the same sessionId, and tester resolution rejects it because the live session was already destroyed.
This came up while reviewing browser orchestrator hardening, but the stale-UI-then-broken-rerun behavior is not specific to that PR. The same lifecycle shape exists on main.
The browser session store currently has two separate records:
sessions: a private map of live session state used by getSession(sessionId).
sessionIds: a public set used as an allowlist by browser RPC validation.
The issue starts in packages/vitest/src/node/browser/sessions.ts:7, where both stores exist independently.
Current Behavior
Opening a browser page registers a session ID and then synchronously creates the live session before handing the URL to the browser provider:
packages/vitest/src/node/project.ts:635
const url = new URL('/__vitest_test__/', origin)
url.searchParams.set('sessionId', sessionId)
const otelCarrier = this.vitest._traces.getContextCarrier()
this.vitest._browserSessions.sessionIds.add(sessionId)
const sessionPromise = this.vitest._browserSessions.createSession(
sessionId,
this,
pool,
{ otelCarrier },
)
The pool path also adds the same ID before calling _openBrowserPage(), which appears redundant because _openBrowserPage() adds it again:
packages/vitest/src/node/pools/browser.ts:263
const sessionId = crypto.randomUUID()
this.project.vitest._browserSessions.sessionIds.add(sessionId)
Browser RPC validates incoming websocket requests against sessionIds, not the live sessions map:
packages/browser/src/node/rpc.ts:63
if (!sessions.sessionIds.has(sessionId)) {
// reject unknown session id
}
On orchestrator websocket close, Vitest destroys the live session:
packages/browser/src/node/rpc.ts:95
ws.on('close', () => {
// ...
if (type === 'orchestrator') {
sessions.destroySession(sessionId)
}
})
But destroySession() only deletes from the live map and leaves sessionIds intact:
packages/vitest/src/node/browser/sessions.ts:15
destroySession(sessionId: string): void {
this.sessions.delete(sessionId)
}
There is reconnect-looking code in RPC:
packages/browser/src/node/rpc.ts:70
if (type === 'orchestrator') {
const session = sessions.getSession(sessionId)
// it's possible the session was already resolved by the preview provider,
// but we still mark the websocket connection when the page reconnects
session?.connected()
}
However, after destroySession(sessionId), getSession(sessionId) returns undefined, so both connected() and the later ready() call become no-ops. There is no apparent path in the HTML or RPC reconnect flow that recreates the session state. The old sessionIds entry only lets the websocket pass the allowlist check; it does not restore the session.
Problem
The user-facing bug is that reload leaves browser preview in a stale state: the UI can render old results, but browser-mode execution is no longer functional. Underneath, the codebase has an ambiguous model:
- If reconnect is not supported,
sessionIds is misleading because RPC accepts IDs whose live session state has already been destroyed.
- If reconnect is supported, destroying the session on orchestrator websocket close makes the reconnect path incomplete.
This ambiguity matters for browser mode and UI behavior, especially around page reload, copied session URLs, preview provider usage, and security hardening that relies on session-bound orchestrator URLs.
Decision Needed
We should decide what browser orchestrator reconnect means, if anything:
- Do not support reconnect after orchestrator websocket close.
- Support only early reload/reconnect before a session starts running tests.
- Support reload/reconnect while tests may be running.
- Support copied/reopened session URLs after the original provider page has closed.
Option 1: Do Not Support Reconnect
This is the simplest and probably most consistent with the current implementation.
Required changes:
- Treat the live
sessions map as the source of truth for RPC validation.
- Remove or redesign
sessionIds.
- Delete the redundant pool-level
sessionIds.add(sessionId).
- Make
destroySession(sessionId) invalidate the session everywhere, or remove sessionIds so there is no second lifecycle.
- Remove or rewrite the stale reconnect comment in RPC.
- Add tests that invalid or closed session IDs cannot connect.
Possible result:
- Browser orchestrator URLs are capabilities only while the live session exists.
- Reload after the orchestrator websocket closes is not supported.
- Security hardening stays straightforward because there is one live-session authority.
Option 2: Support Only Early Reload Before Tests Start
This might be feasible, but it needs explicit lifecycle rules.
Required changes:
- Keep the live session object across temporary orchestrator websocket close.
- Decide when a disconnected session expires.
- Let a replacement orchestrator websocket call
connected() and ready() for the same session.
- Ensure the previous orchestrator RPC is removed and replaced cleanly.
- Avoid destroying the session immediately on websocket close, or distinguish temporary disconnect from terminal close.
- Add tests for reload before tests start.
Possible result:
- Manual reload of an opened browser runner page can recover while no test execution is in progress.
- The implementation remains bounded, but it needs timeout or ownership rules to avoid leaked sessions.
Option 3: Support Reload During Active Test Execution
This looks substantially harder.
Required changes:
- Define what happens to in-flight RPC calls when the orchestrator disappears.
- Decide whether the active test should fail, retry, or resume.
- Rebuild or preserve tester iframe state, queued files, mock registrations, CDP handlers, trace state, and provider page state.
- Replace stale orchestrator RPC clients without corrupting the browser pool queue.
- Add tests for reload during active execution and for cleanup after failed reconnect.
Possible result:
- This would be a real browser-session resume feature, not just reconnect.
- It is probably too much complexity unless there is a strong user-facing requirement.
Option 4: Support Reopened Or Copied Session URLs
This seems least desirable from a security-hardening perspective.
Required changes:
- Treat session IDs as durable capabilities beyond the provider page lifecycle.
- Recreate or recover session state from only the session ID.
- Define expiration and ownership semantics.
- Ensure copied URLs cannot access stale project state longer than intended.
Possible result:
- More permissive behavior, but likely not worth the security and lifecycle complexity.
Suggested Direction
Prefer Option 1 unless there is an explicit product requirement for reload/reconnect.
The current implementation already behaves like reconnect is unsupported after session disposal: destroySession() removes the live session state, and no reconnect path recreates it. Cleaning up the model would make the behavior easier to reason about and would align better with browser orchestrator hardening.
The immediate follow-up could be small:
- Replace RPC validation against
sessionIds with validation against getSession(sessionId).
- Remove the duplicate pool-level
sessionIds.add(sessionId).
- Remove
sessionIds if no remaining call site needs it, or rename it if it still represents a separate concept.
- Update comments and tests to clarify that closed sessions cannot reconnect.
Related Hardening PR
This was noticed while reviewing fix(browser)!: require sessionId for orchestrator html request:
#10522
That PR can remain scoped to requiring sessionId for orchestrator HTML. The reconnect/session lifecycle cleanup should be evaluated separately because it affects broader browser session semantics.
Currently browser mode orchestrator session management isn't consistent and that's been partly affecting API security surface #10499. This issue documents current behavior and what to decide on. The following is a rough write up by AI:
On
main, browser preview can appear to survive a tab reload, but browser execution is already broken after that reload. A concrete repro:Steps:
Observed result: the rerun freezes, and the tester frame shows
Invalid session ID.The misleading part is that the orchestrator UI still renders after reload, so the page looks alive. But the next browser execution creates tester HTML for the same
sessionId, and tester resolution rejects it because the live session was already destroyed.This came up while reviewing browser orchestrator hardening, but the stale-UI-then-broken-rerun behavior is not specific to that PR. The same lifecycle shape exists on
main.The browser session store currently has two separate records:
sessions: a private map of live session state used bygetSession(sessionId).sessionIds: a public set used as an allowlist by browser RPC validation.The issue starts in packages/vitest/src/node/browser/sessions.ts:7, where both stores exist independently.
Current Behavior
Opening a browser page registers a session ID and then synchronously creates the live session before handing the URL to the browser provider:
packages/vitest/src/node/project.ts:635
The pool path also adds the same ID before calling
_openBrowserPage(), which appears redundant because_openBrowserPage()adds it again:packages/vitest/src/node/pools/browser.ts:263
Browser RPC validates incoming websocket requests against
sessionIds, not the livesessionsmap:packages/browser/src/node/rpc.ts:63
On orchestrator websocket close, Vitest destroys the live session:
packages/browser/src/node/rpc.ts:95
But
destroySession()only deletes from the live map and leavessessionIdsintact:packages/vitest/src/node/browser/sessions.ts:15
There is reconnect-looking code in RPC:
packages/browser/src/node/rpc.ts:70
However, after
destroySession(sessionId),getSession(sessionId)returnsundefined, so bothconnected()and the laterready()call become no-ops. There is no apparent path in the HTML or RPC reconnect flow that recreates the session state. The oldsessionIdsentry only lets the websocket pass the allowlist check; it does not restore the session.Problem
The user-facing bug is that reload leaves browser preview in a stale state: the UI can render old results, but browser-mode execution is no longer functional. Underneath, the codebase has an ambiguous model:
sessionIdsis misleading because RPC accepts IDs whose live session state has already been destroyed.This ambiguity matters for browser mode and UI behavior, especially around page reload, copied session URLs, preview provider usage, and security hardening that relies on session-bound orchestrator URLs.
Decision Needed
We should decide what browser orchestrator reconnect means, if anything:
Option 1: Do Not Support Reconnect
This is the simplest and probably most consistent with the current implementation.
Required changes:
sessionsmap as the source of truth for RPC validation.sessionIds.sessionIds.add(sessionId).destroySession(sessionId)invalidate the session everywhere, or removesessionIdsso there is no second lifecycle.Possible result:
Option 2: Support Only Early Reload Before Tests Start
This might be feasible, but it needs explicit lifecycle rules.
Required changes:
connected()andready()for the same session.Possible result:
Option 3: Support Reload During Active Test Execution
This looks substantially harder.
Required changes:
Possible result:
Option 4: Support Reopened Or Copied Session URLs
This seems least desirable from a security-hardening perspective.
Required changes:
Possible result:
Suggested Direction
Prefer Option 1 unless there is an explicit product requirement for reload/reconnect.
The current implementation already behaves like reconnect is unsupported after session disposal:
destroySession()removes the live session state, and no reconnect path recreates it. Cleaning up the model would make the behavior easier to reason about and would align better with browser orchestrator hardening.The immediate follow-up could be small:
sessionIdswith validation againstgetSession(sessionId).sessionIds.add(sessionId).sessionIdsif no remaining call site needs it, or rename it if it still represents a separate concept.Related Hardening PR
This was noticed while reviewing
fix(browser)!: require sessionId for orchestrator html request:#10522
That PR can remain scoped to requiring
sessionIdfor orchestrator HTML. The reconnect/session lifecycle cleanup should be evaluated separately because it affects broader browser session semantics.