Skip to content

fix(vite-plugin): don't mistake pipelined HTTP responses for claimed WebSockets - #16516

Closed
dedsec-terminal wants to merge 1 commit into
cloudflare:mainfrom
dedsec-terminal:fix/vite-plugin-ws-pipelined
Closed

dedsec-terminal wants to merge 1 commit into
cloudflare:mainfrom
dedsec-terminal:fix/vite-plugin-ws-pipelined

Conversation

@dedsec-terminal

@dedsec-terminal dedsec-terminal commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #15659 (refs #15654).

The ownership check added in #15659 compared socket.bytesWritten against a baseline captured when the upgrade handler started. On a keep-alive connection, an in-flight HTTP response from an earlier pipelined request can finish while dispatchFetch is still running. Its bytes increase bytesWritten, so the handler treats the socket as claimed and drops a valid Worker WebSocket upgrade.

ws attaches a data listener when it takes ownership of the socket, while an ordinary HTTP response only writes bytes. Detect ownership from the listener count instead.

The regression test pipelines GET /page and GET /ws on one connection, delays the /page response until after the upgrade handler starts, and asserts the Worker's 101 is delivered. It fails with the write-count check and passes with this change.

The same write-count check exists in the v1 backport #16504, so it needs the same change.

All 13 websocket tests pass; tsc --build, oxlint, and oxfmt --check are clean.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: internal bug fix with no API change

Devin Review

The write-count check treated an in-flight HTTP response on a pipelined keep-alive connection as another upgrade listener's handshake, dropping a valid Worker WebSocket. Detect ownership from the socket's data listener instead, which is only attached once a listener takes over the connection.

Add a regression test that pipelines a normal request and an upgrade on one connection while the Worker's 101 response is pending.

Signed-off-by: dedsec-terminal <dedsec.terminal@gmail.com>
Copilot AI balanced review requested due to automatic review settings October 27, 2026 12:15
@changeset-bot

changeset-bot Bot commented Oct 27, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5bd192a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Hi @dedsec-terminal. Thanks for taking the time to contribute to Workers SDK.

This PR has been automatically closed because you are not currently a registered (vouched) collaborator for this repository.

AI tools now let our team generate proposed code changes quickly. Reviewing changes, checking that they solve the right problem, and maintaining code quality over time still take substantial care and effort. We therefore limit PRs to registered collaborators so we can focus that effort where it is most useful.

For the majority of external contributors, the most effective way to help is through issues: describing a problem or use case, providing a minimal reproduction, investigating its cause, or helping us verify a fix. Those contributions help us understand what needs to change and are very welcome.

We will take an initial look at this PR and decide whether it is something we want to invest time in reviewing and maintaining. We may reopen it if we decide to proceed, but we cannot guarantee that it will be reopened or receive a full review.

@github-actions github-actions Bot closed this Oct 27, 2026
@github-project-automation github-project-automation Bot moved this from Untriaged to Done in workers-sdk Oct 27, 2026
@workers-devprod
workers-devprod requested review from a team and NuroDev and removed request for a team October 27, 2026 12:16
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • packages/vite-plugin-cloudflare/src/tests/websockets.spec.ts: [@cloudflare/wrangler]
  • packages/vite-plugin-cloudflare/src/websockets.ts: [@cloudflare/wrangler]

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment on lines 56 to +57
const isClaimed = () =>
socket.destroyed ||
((socket as unknown as Socket).bytesWritten ?? 0) > bytesWrittenAtStart;
socket.destroyed || socket.listenerCount("data") > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Raw upgrade owners lose their sockets

When another listener writes an upgrade response but uses readable instead of data, isClaimed() reports false. The plugin destroys that connection or sends a second handshake, depending on the Worker response.

Learn more

The HTTP server sends each upgrade event to every registered listener. A listener can own the connection by writing an HTTP upgrade response and consuming subsequent input through readable, without ever installing a data listener. The new check then treats that socket as unclaimed in both the no-WebSocket and WebSocket branches, destroying an owned connection or issuing a second handshake. The previous write-count check caught these written handshakes but incorrectly counted pending pipelined HTTP responses.

Example: A Vite plugin writes HTTP/1.1 101 Switching Protocols and registers socket.on('readable', ...) for its protocol. If the Worker returns 404, this handler destroys the plugin's upgraded socket; if the Worker returns a WebSocket, it writes another 101.

Recommended fix: Track ownership using a mechanism that can distinguish the upgrade listener's response from preceding pipelined HTTP responses, without relying exclusively on data listeners. Test an owner that writes its handshake and reads via readable alongside the pipelined-response case.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 56 to +57
const isClaimed = () =>
socket.destroyed ||
((socket as unknown as Socket).bytesWritten ?? 0) > bytesWrittenAtStart;
socket.destroyed || socket.listenerCount("data") > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Passive listeners block Worker WebSockets

When another upgrade listener observes socket data without answering, isClaimed() reports ownership. A valid Worker WebSocket then receives no handshake and remains pending.

Learn more

An upgrade listener can register a data listener to inspect incoming bytes without sending an HTTP response or claiming the socket. This check cannot distinguish that observer from a WebSocket owner. When the Worker supplies a WebSocket, the existing isClaimed() branch returns without upgrading or destroying the connection, leaving the client waiting. The former write-count check allowed the Worker handshake because no competing response was written.

Example: A Vite extension attaches socket.on('data', inspectBytes) during every upgrade event, but handles only /inspector. A request to /worker-ws reaches a Worker returning status 101, yet this plugin skips its handshake because the inspector callback is still registered.

Recommended fix: Base ownership on evidence of another handler taking responsibility for this specific upgrade, not on the mere presence of a socket reader. Preserve the case where an ordinary HTTP response to an earlier pipelined request is still in flight.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 56 to +57
const isClaimed = () =>
socket.destroyed ||
((socket as unknown as Socket).bytesWritten ?? 0) > bytesWrittenAtStart;
socket.destroyed || socket.listenerCount("data") > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Changeset describes outdated ownership behavior

The existing patch changeset says a written response protects the socket. The new check uses data listeners instead; update the release note to match.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@github-project-automation github-project-automation Bot moved this from Done to Untriaged in workers-sdk Oct 27, 2026
@petebacondarwin

Copy link
Copy Markdown
Contributor

clanker review

@petebacondarwin petebacondarwin left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the changeset, please

@github-project-automation github-project-automation Bot moved this from Untriaged to In Review in workers-sdk Oct 27, 2026
@clanky-the-clanker

Copy link
Copy Markdown

💬 Approved with comments — The listener-count ownership check fixes the pipelined-response false positive, and the regression test covers that case. The only follow-ups are a stale code comment, stale changeset wording and one edge case worth knowing about. All are INFO.

Findings: 3 info

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #16516.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## INFO (3)

### [Code Quality] The unreleased changeset still describes ownership as "another listener has written a response". Update it to match the new destroyed / `data`-listener check.
- **File:** .changeset/witty-pandas-serve.md

### [Code Quality] A listener that answers with `socket.end(...)` without destroying the socket or attaching a `data` listener is no longer seen as owning the socket. The Worker path could then destroy the socket and cut that response short. No such listener exists in this repo today.
- **File:** packages/vite-plugin-cloudflare/src/websockets.ts:56

### [Documentation] The comment above `prependListener` still mentions the removed write-count baseline. Update it to match the listener-count check.
- **File:** packages/vite-plugin-cloudflare/src/websockets.ts:41

📋 Code Quality: 💬 2 info

The fix is correct and the regression test covers it. Two INFO notes: the changeset wording is now out of date, and one edge case is no longer detected.

Recommendation: 💬 Optional Nits

  • [INFO] The unreleased changeset still describes ownership as "another listener has written a response". Update it to match the new destroyed / data-listener check. (.changeset/witty-pandas-serve.md)
  • [INFO] A listener that answers with socket.end(...) without destroying the socket or attaching a data listener is no longer seen as owning the socket. The Worker path could then destroy the socket and cut that response short. No such listener exists in this repo today. (packages/vite-plugin-cloudflare/src/websockets.ts:56)
View full review
  • Changeset wording (.changeset/witty-pandas-serve.md): this changeset hasn't been released yet. It still says the handler leaves the socket alone "once another listener has written a response". The check now looks at whether the socket is destroyed or has a data listener. Update that sentence so the release notes describe what the code does.

  • Edge case no longer detected (packages/vite-plugin-cloudflare/src/websockets.ts:56-57): a listener could answer with socket.end("HTTP/1.1 4xx ...") without destroying the socket or attaching a data listener. The new check would not treat that socket as claimed, so the Worker path could later call socket.destroy() and cut that response short. No listener in this repo does that, so this is just something to be aware of.

Correctness: Node's HTTP server removes its own data listener before it emits upgrade. So a count above zero means another handler has taken the socket. ws attaches that listener in setSocket during the handshake, and its abort path destroys the socket, which the socket.destroyed check still catches. The test pipelines /page and /ws on one connection, delays /page, and checks that the Worker's 101 arrives. That is the bug described, and the test gets expect from the test context as the repo's conventions require.


📝 Documentation: 💬 1 info

A code comment still describes the write-count baseline this change removes.

Recommendation: 💬 Optional Nits

  • [INFO] The comment above prependListener still mentions the removed write-count baseline. Update it to match the listener-count check. (packages/vite-plugin-cloudflare/src/websockets.ts:41)
View full review
  • packages/vite-plugin-cloudflare/src/websockets.ts:41-43: the comment above prependListener still says "Prepend so the write-count baseline below is captured before another upgrade listener can answer the same request." That baseline no longer exists. Reword the comment to give the current reason for prepending, or remove it.

Reviewed commit: 5bd192aa

@pkg-pr-new

pkg-pr-new Bot commented Oct 27, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@16516

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@16516

@cloudflare/codemods

npm i https://pkg.pr.new/@cloudflare/codemods@16516

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@16516

@cloudflare/containers-shared

npm i https://pkg.pr.new/@cloudflare/containers-shared@16516

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@16516

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@16516

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@16516

miniflare

npm i https://pkg.pr.new/miniflare@16516

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@16516

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@16516

@cloudflare/runtime-types

npm i https://pkg.pr.new/@cloudflare/runtime-types@16516

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@16516

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@16516

@cloudflare/vitest-plugin

npm i https://pkg.pr.new/@cloudflare/vitest-plugin@16516

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@16516

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@16516

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@16516

wrangler

npm i https://pkg.pr.new/wrangler@16516

commit: 5bd192a

@petebacondarwin

Copy link
Copy Markdown
Contributor

Actually, I think we cannot reliably guess the claiming owner of a websocket due to how Node.js websockets work.
Instead, I am going to put up a PR that removes the #15659 work and simply looks at the request path - assuming that /__devtools/__ws is the one that we don't own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants