Skip to content

Commit a767433

Browse files
committed
fix(hot): end SSE requests that arrive after close()
instance.close() left `context.hot` set while `handle()` silently returned, so a request to the hot path after close received no response, no `next()`, and hung until the socket timed out. Detach the SSE intercept on close so requests fall through to the regular middleware, and answer 404 from `handle()` for requests that raced the intercept.
1 parent 5c05907 commit a767433

5 files changed

Lines changed: 70 additions & 1 deletion

File tree

src/hot.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,14 @@ function createHot(compiler, userOptions) {
358358
return {
359359
path,
360360
handle(req, res) {
361-
if (closed) return;
361+
// A request can race `close()` past the middleware intercept — end it
362+
// instead of leaving it hanging without a response.
363+
if (closed) {
364+
res.writeHead(404);
365+
res.end();
366+
367+
return;
368+
}
362369

363370
eventStream.handler(req, res);
364371

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,9 @@ function wdm(compiler, options = {}, isPlugin = false) {
617617
instance.close = (callback = noop) => {
618618
if (filledContext.hot) {
619619
filledContext.hot.close();
620+
// Also detach the SSE intercept so later requests fall through to the
621+
// regular middleware instead of reaching the closed hot instance.
622+
filledContext.hot = undefined;
620623
}
621624
filledContext.watching.close(callback);
622625
};

test/hot.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,30 @@ describe("createHot", () => {
455455
hot.close();
456456
});
457457

458+
it("responds 404 instead of hanging when a client connects after close()", () => {
459+
const compiler = makeFakeCompiler();
460+
const hot = createHot(compiler, {});
461+
462+
hot.close();
463+
464+
/** @type {number | undefined} */
465+
let statusCode;
466+
let ended = false;
467+
const res = {
468+
writeHead: (code) => {
469+
statusCode = code;
470+
},
471+
end: () => {
472+
ended = true;
473+
},
474+
};
475+
476+
hot.handle({}, res);
477+
478+
expect(statusCode).toBe(404);
479+
expect(ended).toBe(true);
480+
});
481+
458482
it("does not re-send the catch-up sync to already connected clients", () => {
459483
const compiler = makeFakeCompiler();
460484
const hot = createHot(compiler, {});

test/middleware.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7033,6 +7033,29 @@ describe.each([
70337033
);
70347034
});
70357035

7036+
it("does not hang hot requests made after instance.close()", async () => {
7037+
const compiler = getCompiler(webpackConfig);
7038+
[server, req, instance] = await frameworkFactory(
7039+
name,
7040+
framework,
7041+
compiler,
7042+
{ hot: true },
7043+
);
7044+
7045+
await waitUntilValid(instance);
7046+
await new Promise((resolve) => {
7047+
instance.close(resolve);
7048+
});
7049+
7050+
// The SSE intercept is detached, so the request must get a regular
7051+
// (non-stream) response instead of hanging forever.
7052+
const response = await req.get("/__webpack_hmr");
7053+
7054+
expect(response.headers["content-type"] || "").not.toMatch(
7055+
/text\/event-stream/,
7056+
);
7057+
});
7058+
70367059
it("does not intercept the default hot path when hot is disabled", async () => {
70377060
const compiler = getCompiler(webpackConfig);
70387061
[server, req, instance] = await frameworkFactory(

types/hot.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ declare const HOT_DEFAULT_HEARTBEAT: number;
7575
* @typedef {object} EventStream
7676
* @property {(req: IncomingMessage, res: ServerResponse) => void} handler attach a new client
7777
* @property {(payload: Payload | { action: string }) => void} publish publish a payload to every client
78+
* @property {(res: ServerResponse, payload: Payload | { action: string }) => void} publishTo publish a payload to a single client
7879
* @property {() => void} close end every client and stop the heartbeat
7980
*/
8081
declare const HOT_DEFAULT_PATH: "/__webpack_hmr";
@@ -225,6 +226,17 @@ type EventStream = {
225226
action: string;
226227
},
227228
) => void;
229+
/**
230+
* publish a payload to a single client
231+
*/
232+
publishTo: (
233+
res: ServerResponse,
234+
payload:
235+
| Payload
236+
| {
237+
action: string;
238+
},
239+
) => void;
228240
/**
229241
* end every client and stop the heartbeat
230242
*/

0 commit comments

Comments
 (0)