Skip to content

fix(extension): harden websocket reconnect lifecycle#19

Open
NianJiuZst wants to merge 3 commits into
Tencent:mainfrom
NianJiuZst:codex/fix-websocket-handshake-races
Open

fix(extension): harden websocket reconnect lifecycle#19
NianJiuZst wants to merge 3 commits into
Tencent:mainfrom
NianJiuZst:codex/fix-websocket-handshake-races

Conversation

@NianJiuZst

@NianJiuZst NianJiuZst commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What problem this solves

The extension had two related connection-state races that could leave it apparently connected while no usable daemon channel existed.

At the transport layer, an unexpected close scheduled a reconnect timer. If keepalive or the user explicitly called connect() before that timer fired, WSTransport opened a replacement socket but did not cancel the pending timer. The timer later opened another socket. In addition, every socket callback wrote to shared state without checking whether that socket was still current. A delayed close from the old socket could therefore set this.socket = null after the new socket was already OPEN. The controller still displayed connected, while send() failed with "cannot send while not connected"; one of the extra sockets could also remain open after disconnect.

At the controller layer, handshake work was guarded by one global handshakeInFlight boolean. If a socket disconnected while its handshake was pending and a new socket connected, the new connection skipped its mandatory handshake. Conversely, a handshake error or timeout only changed the controller state: it left the physical transport connected, so keepalive saw an OPEN socket and never retried.

How this fixes it

Single-current-socket transport

WSTransport now assigns a monotonically increasing generation to every physical socket. Open, message, and close callbacks first verify both the socket object and generation before touching shared state. Events from a replaced socket are ignored.

Explicit connect() cancels a pending backoff timer before starting a new attempt. Reconnect timers call the same deduplicated connect path, and a failed physical attempt can create a new socket while preserving the original connect promise. disconnect() invalidates the current generation before closing the socket, preventing its synchronous or delayed close callback from restarting the reconnect loop.

Per-connection handshake lifecycle

Each connected event now creates its own handshake generation and AbortController. A disconnect or replacement connection aborts the prior handshake immediately and removes its message listener instead of waiting for the ten-second timeout. Results are applied only when they still belong to the current connection generation.

If a current handshake fails, the controller actively disconnects the unusable physical socket and schedules a bounded retry. A protocol-version rejection remains a deliberate terminal disconnect; transient errors no longer leave transport and controller state disagreeing.

User impact

Rapid disable/re-enable actions, keepalive ticks during backoff, delayed browser events, and service-worker reconnects no longer create multiple active daemon sockets. The popup state reflects the socket that send() will actually use. A fresh connection always performs its own handshake, and a handshake failure recovers instead of staying permanently stuck until the extension is manually toggled.

Validation

  • corepack pnpm --filter @browser-skill/extension test
    • 35 test files passed
    • 377 tests passed
  • corepack pnpm --filter @browser-skill/extension compile
  • corepack pnpm ext:build
  • node --test scripts/*.test.mjs
  • corepack pnpm exec stylelint "**/*.css"
  • git diff --check

New regression coverage verifies:

  • explicit connect cancels pending backoff and does not create a third socket;
  • delayed close events from an old socket cannot clear the current socket;
  • outbound traffic continues through the replacement socket;
  • a handshake AbortSignal stops the old waiter immediately;
  • handshake failure disconnects the physical socket and retries;
  • a reconnect starts a new handshake, while a late response for the prior generation is ignored.

CI Baseline Compatibility

This branch also carries the one-line Biome 2.4 formatter output for apps/extension/vitest.config.ts. The same formatting mismatch currently fails the Frontend job on upstream/main; this minimal compatibility commit is intentionally separate from the functional fix and lets this PR run the repository CI suite to completion.

CI Reliability Follow-up

GitHub Actions exposed a pre-existing test-only port allocation race: integration helpers probed an ephemeral port, released it, and then asked the daemon to bind the same number, allowing another process to claim it in between. The resulting Address already in use failure was unrelated to the functional changes. The PR now lets each daemon bind port 0 directly and reads the assigned address from its handle, removing the TOCTOU window across all affected integration helpers.

@NianJiuZst NianJiuZst marked this pull request as ready for review July 10, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant