Skip to content

Decide browser mode session reload and reconnect behavior #10537

@hi-ogawa

Description

@hi-ogawa

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:

  1. Let the command open the browser preview tab.
  2. Confirm browser mode runs and the UI shows the test result.
  3. Reload the browser tab.
  4. The UI still appears to work and keeps showing the same test result.
  5. 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:

  1. Do not support reconnect after orchestrator websocket close.
  2. Support only early reload/reconnect before a session starts running tests.
  3. Support reload/reconnect while tests may be running.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions