Two bugs in fetch.ts caused the upgraded connection to be closed or
reassigned before connectWebSocket() could hijack it:
1. prepareRequest() checked Connection !== "upgrade" case-sensitively.
A request with Connection: Upgrade (capital U) was silently rewritten
to Connection: close, making ClientConnection auto-close the transport
after receiving the 101 (shouldClose returned true, no body → immediate
close). Fixed by lowercasing before the comparison.
2. rawFetch() called onPrevBodyFinished().then(done(false)) even for 101
responses. A 101 has no body so the promise resolves as a microtask,
firing done(false) — and thus pool.releaseConnection() — before the
caller can call connectWebSocket(). With keepAlive:false the pool
closes the connection immediately; with keepAlive:N it puts it back in
the idle pool where a concurrent waiter can grab it. Fixed by calling
done(true) synchronously for 101, which calls rejectConnection()
instead, removing the connection from the pool without closing it.
rawFetchStream() had the same issue in its finally block when the
caller breaks before calling connectWebSocket(); fixed likewise.
Tests added in websocket.test.ts covering all four affected paths:
- fetch() + connectWebSocket(res, key)
- makeFetch(keepAlive:false) + connectWebSocket(res, key)
- pool race: second request after WebSocket hijack gets a fresh conn
- fetchStream() + connectWebSocket(r, key) inside the loop
- makeFetch().stream + connectWebSocket(r, key)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>