Describe the bug
In browser mode, the tester can hang forever if the first tester websocket stays in CONNECTING long enough for the client to start awaiting waitForConnection(), then drops before open, and Vitest reconnects. This problem was triggered when running vitest through browserstack. I'm not sure what exactly triggers it, but something around the way local tunnel networking is setup causes it to consistently end up in a wrong state.
What I expect is for waitForConnection() to follow the current websocket attempt across reconnect, or at least not leave existing waiters stuck on the first failed attempt.
What actually happens is that the tester iframe loads, the browser reconnects, but the original waiter can stay attached to the first socket's unresolved openPromise, so the tester never processes prepare or execute and the run stalls indefinitely.
From inspecting Vitest 4.1.4, this appears to come from @vitest/browser storing a per-attempt openPromise inside registerWS(). When reconnect replaces client.ws, it also replaces openPromise, but callers that were already awaiting the old promise are still stuck on the first socket attempt. In the built tester bundle, message handling starts by awaiting client.waitForConnection(), which is why the iframe can reconnect and still never start running tests.
Reproduction
Reproduction repository: https://github.com/jkieboom/vitest-waitforconnection-repro
The reproduction is a minimal standalone Vitest browser-mode project. It uses a local reverse proxy in front of the Vitest server. In the broken case, that proxy forwards everything normally except the first tester __vitest_browser_api__ websocket upgrade: it holds that socket in CONNECTING for 1500 ms and then drops it before the handshake completes. The browser then reconnects naturally, but the original waitForConnection() waiter remains stuck.
Steps:
npm install
npx playwright install chromium
node ./run-repro.mjs
Expected output:
- the
control case exits successfully
- the
broken case should also eventually proceed after reconnect
Actual output:
- the
control case exits successfully
- the
broken case logs that the first tester websocket was dropped and then hangs until the harness timeout kills it
I reproduced this consistently across repeated local runs.
System Info
System:
OS: macOS 26.4.1
CPU: (12) arm64 Apple M2 Pro
Memory: 130.44 MB / 32.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.12.0 - /Users/jess7953/.nvm/versions/node/v24.12.0/bin/node
npm: 11.6.2 - /Users/jess7953/.nvm/versions/node/v24.12.0/bin/npm
pnpm: 10.33.0 - /Users/jess7953/.nvm/versions/node/v24.12.0/bin/pnpm
Browsers:
Chrome: 147.0.7727.56
Chrome Canary: 149.0.7793.0
Edge: 147.0.3912.60
Firefox: 149.0.2
Safari: 26.4
Safari Technology Preview: 26.0
npmPackages:
@vitest/browser: 4.1.4 => 4.1.4
@vitest/browser-playwright: 4.1.4 => 4.1.4
vitest: 4.1.4 => 4.1.4
Used Package Manager
npm
Validations
Describe the bug
In browser mode, the tester can hang forever if the first tester websocket stays in
CONNECTINGlong enough for the client to start awaitingwaitForConnection(), then drops beforeopen, and Vitest reconnects. This problem was triggered when running vitest through browserstack. I'm not sure what exactly triggers it, but something around the way local tunnel networking is setup causes it to consistently end up in a wrong state.What I expect is for
waitForConnection()to follow the current websocket attempt across reconnect, or at least not leave existing waiters stuck on the first failed attempt.What actually happens is that the tester iframe loads, the browser reconnects, but the original waiter can stay attached to the first socket's unresolved
openPromise, so the tester never processesprepareorexecuteand the run stalls indefinitely.From inspecting Vitest 4.1.4, this appears to come from
@vitest/browserstoring a per-attemptopenPromiseinsideregisterWS(). When reconnect replacesclient.ws, it also replacesopenPromise, but callers that were already awaiting the old promise are still stuck on the first socket attempt. In the built tester bundle, message handling starts by awaitingclient.waitForConnection(), which is why the iframe can reconnect and still never start running tests.Reproduction
Reproduction repository: https://github.com/jkieboom/vitest-waitforconnection-repro
The reproduction is a minimal standalone Vitest browser-mode project. It uses a local reverse proxy in front of the Vitest server. In the broken case, that proxy forwards everything normally except the first tester
__vitest_browser_api__websocket upgrade: it holds that socket inCONNECTINGfor 1500 ms and then drops it before the handshake completes. The browser then reconnects naturally, but the originalwaitForConnection()waiter remains stuck.Steps:
Expected output:
controlcase exits successfullybrokencase should also eventually proceed after reconnectActual output:
controlcase exits successfullybrokencase logs that the first tester websocket was dropped and then hangs until the harness timeout kills itI reproduced this consistently across repeated local runs.
System Info
Used Package Manager
npm
Validations