From b398d32680e3c345aff763d05c427d611865d73d Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:03:39 +0300 Subject: [PATCH 1/5] fix: settle startup failures and clean up owned server resources --- .changeset/server-startup-cleanup.md | 5 ++++ lib/Server.js | 37 ++++++++++++++-------------- lib/servers/WebsocketServer.js | 18 +++++++++----- 3 files changed, 35 insertions(+), 25 deletions(-) create mode 100644 .changeset/server-startup-cleanup.md diff --git a/.changeset/server-startup-cleanup.md b/.changeset/server-startup-cleanup.md new file mode 100644 index 0000000000..771b92c688 --- /dev/null +++ b/.changeset/server-startup-cleanup.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Reject occupied TCP and IPC startup attempts, clean up only owned WebSocket upgrade listeners, wait for pending plugin startup during shutdown, and report the configured Bonjour protocol correctly. diff --git a/lib/Server.js b/lib/Server.js index 38577856c9..d4824c64db 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2570,17 +2570,6 @@ class Server { }); }, ); - - /** @type {S} */ - (this.server).on( - "error", - /** - * @param {Error} error error - */ - (error) => { - throw error; - }, - ); } /** @@ -3005,7 +2994,7 @@ class Server { if (this.options.bonjour) { const bonjourProtocol = /** @type {BonjourOptions} */ - (this.options.bonjour).type || this.isTlsServer ? "https" : "http"; + (this.options.bonjour).type || (this.isTlsServer ? "https" : "http"); this.logger.info( `Broadcasting "${bonjourProtocol}" with subtype of "webpack" via ZeroConf DNS (Bonjour)`, @@ -3558,7 +3547,8 @@ class Server { socket.connect( { path: /** @type {string} */ (this.options.ipc) }, () => { - throw new Error(`IPC "${this.options.ipc}" is already used`); + socket.destroy(); + reject(new Error(`IPC "${this.options.ipc}" is already used`)); }, ); }) @@ -3586,9 +3576,11 @@ class Server { : { host: this.options.host, port: this.options.port }; await /** @type {Promise} */ ( - new Promise((resolve) => { - /** @type {S} */ - (this.server).listen(listenOptions, () => { + new Promise((resolve, reject) => { + const server = /** @type {S} */ (this.server); + server.once("error", reject); + server.listen(listenOptions, () => { + server.removeListener("error", reject); resolve(); }); }) @@ -3748,6 +3740,8 @@ class Server { /** @type {Promise | undefined} */ let setupPromise; + /** @type {Promise | undefined} */ + let listenPromise; let inWatchMode = false; let listening = false; let stopped = false; @@ -3787,10 +3781,12 @@ class Server { hooks.done.tap(pluginName, () => { // `done` also fires for a one-shot `compiler.run()` build, where no // `watchRun` ran; staying passive lets that build finish and exit. - if (listening || !inWatchMode) return; + if (listening || !inWatchMode || stopped) return; listening = true; - ensureSetup() - .then(() => this.listen()) + listenPromise = ensureSetup() + .then(() => { + if (!stopped) return this.listen(); + }) .catch((error) => { this.logger.error(error); }); @@ -3802,6 +3798,9 @@ class Server { const onShutdown = async () => { if (stopped) return; stopped = true; + // Startup errors are reported by watchRun or the done handler above. + // Wait for pending startup before releasing the resources it creates. + await Promise.allSettled([setupPromise, listenPromise]); await this.stop(); }; diff --git a/lib/servers/WebsocketServer.js b/lib/servers/WebsocketServer.js index cc3f7b635b..e07b5dc1dd 100644 --- a/lib/servers/WebsocketServer.js +++ b/lib/servers/WebsocketServer.js @@ -29,15 +29,16 @@ export default class WebsocketServer extends BaseServer { this.implementation = new WsServer(options); - /** @type {import("http").Server} */ - (this.server.server).on( - "upgrade", + if (isNoServerMode) { + const httpServer = /** @type {import("http").Server} */ ( + this.server.server + ); /** * @param {import("http").IncomingMessage} req request * @param {import("stream").Duplex} sock socket * @param {Buffer} head head */ - (req, sock, head) => { + const handleUpgrade = (req, sock, head) => { if (!this.implementation.shouldHandle(req)) { return; } @@ -45,8 +46,13 @@ export default class WebsocketServer extends BaseServer { this.implementation.handleUpgrade(req, sock, head, (connection) => { this.implementation.emit("connection", connection, req); }); - }, - ); + }; + + httpServer.on("upgrade", handleUpgrade); + this.implementation.on("close", () => { + httpServer.removeListener("upgrade", handleUpgrade); + }); + } this.implementation.on( "error", From c1df4d80f6f2792ecb319a5c4c5ca5a86ec7390b Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:54:06 +0200 Subject: [PATCH 2/5] Clean up resources when startup fails --- lib/Server.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Server.js b/lib/Server.js index d4824c64db..7e7c464c5d 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3504,7 +3504,13 @@ class Server { */ async start() { await this.setup(); - await this.listen(); + + try { + await this.listen(); + } catch (error) { + await this.stop(); + throw error; + } } /** From e5ea9d3549dc3d9ffe172f80dd0f03c1a36b803f Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:54:10 +0200 Subject: [PATCH 3/5] Test cleanup after listen failure --- test/e2e/api.test.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index f8928ded60..3fce4c55a2 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -2,7 +2,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, it, mock } from "node:test"; import { fileURLToPath } from "node:url"; import { expect } from "expect"; -import { fn } from "jest-mock"; +import { fn, spyOn } from "jest-mock"; import webpack from "webpack"; import Server from "../../lib/Server.js"; import config from "../fixtures/client-config/webpack.config.js"; @@ -193,6 +193,22 @@ describe("API", () => { }); }); + it("should clean up initialized resources when listening fails", async () => { + const compiler = webpack(config); + const server = new Server({ port }, compiler); + const listenError = new Error("listen failed"); + const listenSpy = spyOn(server, "listen").mockRejectedValue(listenError); + const stopSpy = spyOn(server, "stop"); + + await expect(server.start()).rejects.toBe(listenError); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(server.server).toBeUndefined(); + + listenSpy.mockRestore(); + stopSpy.mockRestore(); + }); + it("should work when using configured manually", async (t) => { const compiler = webpack({ ...config, From 66cd2176862c3288defb42899f1a56630b91edce Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:07:12 +0200 Subject: [PATCH 4/5] Clean up resources when setup fails --- lib/Server.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 7e7c464c5d..1731bfbc84 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3503,9 +3503,8 @@ class Server { * @returns {Promise} */ async start() { - await this.setup(); - try { + await this.setup(); await this.listen(); } catch (error) { await this.stop(); From 78371ccaa5ef2d14643ac88da5e50a61153af258 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:07:15 +0200 Subject: [PATCH 5/5] Test setup failure cleanup --- test/e2e/api.test.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index 3fce4c55a2..2f72ef936c 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -209,6 +209,27 @@ describe("API", () => { stopSpy.mockRestore(); }); + it("should clean up initialized resources when setup fails", async () => { + const compiler = webpack(config); + const server = new Server({ port }, compiler); + const setupError = new Error("setup failed"); + const close = fn((callback) => callback()); + const setupSpy = spyOn(server, "setup").mockImplementation(async () => { + server.server = { close }; + throw setupError; + }); + const stopSpy = spyOn(server, "stop"); + + await expect(server.start()).rejects.toBe(setupError); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(server.server).toBeUndefined(); + + setupSpy.mockRestore(); + stopSpy.mockRestore(); + }); + it("should work when using configured manually", async (t) => { const compiler = webpack({ ...config,