Skip to content

Commit a14f2a4

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 a23dcec commit a14f2a4

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
@@ -616,6 +616,9 @@ function wdm(compiler, options = {}, isPlugin = false) {
616616
instance.close = (callback = noop) => {
617617
if (filledContext.hot) {
618618
filledContext.hot.close();
619+
// Also detach the SSE intercept so later requests fall through to the
620+
// regular middleware instead of reaching the closed hot instance.
621+
filledContext.hot = undefined;
619622
}
620623
filledContext.watching.close(callback);
621624
};

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
@@ -7010,6 +7010,29 @@ describe.each([
70107010
);
70117011
});
70127012

7013+
it("does not hang hot requests made after instance.close()", async () => {
7014+
const compiler = getCompiler(webpackConfig);
7015+
[server, req, instance] = await frameworkFactory(
7016+
name,
7017+
framework,
7018+
compiler,
7019+
{ hot: true },
7020+
);
7021+
7022+
await waitUntilValid(instance);
7023+
await new Promise((resolve) => {
7024+
instance.close(resolve);
7025+
});
7026+
7027+
// The SSE intercept is detached, so the request must get a regular
7028+
// (non-stream) response instead of hanging forever.
7029+
const response = await req.get("/__webpack_hmr");
7030+
7031+
expect(response.headers["content-type"] || "").not.toMatch(
7032+
/text\/event-stream/,
7033+
);
7034+
});
7035+
70137036
it("does not intercept the default hot path when hot is disabled", async () => {
70147037
const compiler = getCompiler(webpackConfig);
70157038
[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)