Skip to content

fix(vite-plugin): only answer or tear down WebSocket upgrades with exclusive ownership - #15775

Draft
cjol wants to merge 2 commits into
mainfrom
devin/1790074413-ws-upgrade-ownership
Draft

cjol wants to merge 2 commits into
mainfrom
devin/1790074413-ws-upgrade-ownership

Conversation

@cjol

@cjol cjol commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

This PR fixes #15654 by making the Cloudflare Vite plugin defer to another upgrade listener that has claimed, or could still claim, a WebSocket connection.

Why

  • Node invokes every upgrade listener on Vite's HTTP server. The Cloudflare listener therefore shares requests with Vite HMR and third-party listeners such as Vite DevTools.
  • The previous handler destroyed the socket when the Worker returned no WebSocket. If another listener had already completed the handshake while dispatchFetch() was pending, this closed its connection with code 1006.
  • Leaving every unmatched socket open is not safe either. An upgrade with no other possible owner would remain unanswered and prevent the HTTP server from closing.
  • Socket writes alone cannot identify every owner. A delayed asynchronous listener may not have written yet, and one-shot listeners remove themselves before their callbacks finish. The handler therefore records both the socket's write count and the presence of other listeners when Node emits the upgrade.

Architectural Changes

  • The Cloudflare listener now captures ownership state before other listeners can write to the socket.
  • After Worker dispatch, it answers only if the socket is still writable and no listener has written during this upgrade.
  • When the Worker does not answer, the handler destroys the socket only if no other listener could own it. Preserved, unanswered sockets are tracked and closed during server shutdown.
  • Repeated setup updates the Miniflare dispatch target instead of installing another listener or wrapping the server again.

Code Changes

  • packages/vite-plugin-cloudflare/src/websockets.ts now tracks each upgrade's write baseline and listener ownership, releases unowned Worker WebSockets, and handles server shutdown and restart.
  • .changeset/lucky-pans-listen.md records a patch release for @cloudflare/vite-plugin.

  • 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: This fixes internal dev-server behavior without changing the public API.

@changeset-bot

changeset-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3fc73a8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/vite-plugin Patch

Not sure what this means? Click here to learn what changesets are.

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

@pkg-pr-new

pkg-pr-new Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
@cloudflare/autoconfig

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

@cloudflare/build-output-utils

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

@cloudflare/codemods

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

@cloudflare/config

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

@cloudflare/containers-shared

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

create-cloudflare

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

@cloudflare/deploy-helpers

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

@cloudflare/kv-asset-handler

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

miniflare

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

@cloudflare/pages-functions

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

@cloudflare/pages-shared

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

@cloudflare/unenv-preset

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

@cloudflare/vite-plugin

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

@cloudflare/vitest-plugin

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

@cloudflare/workers-auth

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

@cloudflare/workers-editor-shared

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

@cloudflare/workers-utils

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

wrangler

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

commit: 3fc73a8

Comment on lines +183 to +244
// Sockets left open for another listener, mapped to their write baseline.
// `server.close()` would wait on them forever if nobody answers.
const unresolved = new Map<Duplex, number>();
let closing = false;

const nodeServer = server as Server;
const emit = nodeServer.emit.bind(nodeServer);

// `once()` and `prependOnceListener()` owners remove themselves as they are
// invoked, so whether anyone else could own an upgrade is only knowable
// before the event is dispatched.
nodeServer.emit = function (event: string, ...args: unknown[]) {
if (event === "upgrade") {
const [request, socket] = args as [IncomingMessage, Duplex];
snapshots.set(request, {
hasOtherListeners: nodeServer.listenerCount("upgrade") > 1,
bytesWritten: bytesWritten(socket),
});
}
return emit(event, ...args);
} as Server["emit"];

const close = nodeServer.close.bind(nodeServer);
nodeServer.close = function (callback?: (error?: Error) => void) {
closing = true;
for (const [socket, baseline] of unresolved) {
if (bytesWritten(socket) === baseline) {
socket.destroy();
}
}
unresolved.clear();
return close(callback);
} as Server["close"];

nodeServer.on("listening", () => {
closing = false;
});

return function upgrade(request: IncomingMessage, socket: Duplex): Upgrade {
const snapshot = snapshots.get(request) ?? {
hasOtherListeners: nodeServer.listenerCount("upgrade") > 1,
bytesWritten: bytesWritten(socket),
};
// `bytesWritten` is cumulative for the connection, so a reused keep-alive
// socket needs the baseline taken when this upgrade started.
const isClaimed = () => bytesWritten(socket) > snapshot.bytesWritten;

return {
isAnswerable() {
return !isClaimed() && !socket.destroyed && socket.writable;
},
release() {
if (isClaimed() || socket.destroyed) {
return;
}
if (!snapshot.hasOtherListeners || closing) {
socket.destroy();
return;
}
unresolved.set(socket, snapshot.bytesWritten);
socket.once("close", () => unresolved.delete(socket));
},

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.

Claimed sockets are returned before entering unresolved, while the shutdown wrapper only destroys unclaimed entries. As a result, an upgrade that another listener has already completed (for example DevTools) remains open and makes httpServer.close() wait indefinitely, blocking Vite shutdown/restart. Track every socket left to another possible owner, and destroy all tracked sockets during shutdown.

Suggested change
// Sockets left open for another listener, mapped to their write baseline.
// `server.close()` would wait on them forever if nobody answers.
const unresolved = new Map<Duplex, number>();
let closing = false;
const nodeServer = server as Server;
const emit = nodeServer.emit.bind(nodeServer);
// `once()` and `prependOnceListener()` owners remove themselves as they are
// invoked, so whether anyone else could own an upgrade is only knowable
// before the event is dispatched.
nodeServer.emit = function (event: string, ...args: unknown[]) {
if (event === "upgrade") {
const [request, socket] = args as [IncomingMessage, Duplex];
snapshots.set(request, {
hasOtherListeners: nodeServer.listenerCount("upgrade") > 1,
bytesWritten: bytesWritten(socket),
});
}
return emit(event, ...args);
} as Server["emit"];
const close = nodeServer.close.bind(nodeServer);
nodeServer.close = function (callback?: (error?: Error) => void) {
closing = true;
for (const [socket, baseline] of unresolved) {
if (bytesWritten(socket) === baseline) {
socket.destroy();
}
}
unresolved.clear();
return close(callback);
} as Server["close"];
nodeServer.on("listening", () => {
closing = false;
});
return function upgrade(request: IncomingMessage, socket: Duplex): Upgrade {
const snapshot = snapshots.get(request) ?? {
hasOtherListeners: nodeServer.listenerCount("upgrade") > 1,
bytesWritten: bytesWritten(socket),
};
// `bytesWritten` is cumulative for the connection, so a reused keep-alive
// socket needs the baseline taken when this upgrade started.
const isClaimed = () => bytesWritten(socket) > snapshot.bytesWritten;
return {
isAnswerable() {
return !isClaimed() && !socket.destroyed && socket.writable;
},
release() {
if (isClaimed() || socket.destroyed) {
return;
}
if (!snapshot.hasOtherListeners || closing) {
socket.destroy();
return;
}
unresolved.set(socket, snapshot.bytesWritten);
socket.once("close", () => unresolved.delete(socket));
},
// Sockets left open for another listener. `server.close()` would wait on
// them forever if nobody answers, or if their owner keeps them alive.
const unresolved = new Set<Duplex>();
let closing = false;
const nodeServer = server as Server;
const emit = nodeServer.emit.bind(nodeServer);
// `once()` and `prependOnceListener()` owners remove themselves as they are
// invoked, so whether anyone else could own an upgrade is only knowable
// before the event is dispatched.
nodeServer.emit = function (event: string, ...args: unknown[]) {
if (event === "upgrade") {
const [request, socket] = args as [IncomingMessage, Duplex];
snapshots.set(request, {
hasOtherListeners: nodeServer.listenerCount("upgrade") > 1,
bytesWritten: bytesWritten(socket),
});
}
return emit(event, ...args);
} as Server["emit"];
const close = nodeServer.close.bind(nodeServer);
nodeServer.close = function (callback?: (error?: Error) => void) {
closing = true;
for (const socket of unresolved) {
socket.destroy();
}
unresolved.clear();
return close(callback);
} as Server["close"];
nodeServer.on("listening", () => {
closing = false;
});
return function upgrade(request: IncomingMessage, socket: Duplex): Upgrade {
const snapshot = snapshots.get(request) ?? {
hasOtherListeners: nodeServer.listenerCount("upgrade") > 1,
bytesWritten: bytesWritten(socket),
};
// `bytesWritten` is cumulative for the connection, so a reused keep-alive
// socket needs the baseline taken when this upgrade started.
const isClaimed = () => bytesWritten(socket) > snapshot.bytesWritten;
return {
isAnswerable() {
return !isClaimed() && !socket.destroyed && socket.writable;
},
release() {
if (socket.destroyed) {
return;
}
if (closing || (!isClaimed() && !snapshot.hasOtherListeners)) {
socket.destroy();
return;
}
if (snapshot.hasOtherListeners) {
unresolved.add(socket);
socket.once("close", () => unresolved.delete(socket));
}
},
};
};

@ask-bonk

ask-bonk Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

I'm Bonk, and I've done a quick review of your PR.

Prevents the Cloudflare WebSocket handler from closing upgrades claimed by other Vite listeners.

  1. High: Claimed third-party WebSockets are not tracked for shutdown, so they can block Vite server close/restart indefinitely. Posted a suggestion comment.

github run

devin-ai-integration[bot]

This comment was marked as resolved.

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

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

vite-plugin-cloudflare: dev server destroys WebSockets that don't announce a "vite" subprotocol

2 participants