From 2fa6392f9303436e2cbdebbdd1395cfd9fac1134 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:31:46 +0000 Subject: [PATCH 1/2] fix: pass plugin-mode listen errors to the watch callback In plugin mode the server is started from the aggregate `done` hook and a `listen()` failure such as `EADDRINUSE` was only logged, so the watch build kept running without a server. Await the listen from each `Compiler`'s async `done` hook (every child of a `MultiCompiler`), so the error reaches the `watch()` callback. Fixes #5782 Claude-Session: https://claude.ai/code/session_013FhutSnStkMpV2mZSNb8M9 --- .changeset/plugin-listen-error.md | 5 ++ lib/Server.js | 48 +++++++++++++++--- test/e2e/api-plugin.test.js | 83 ++++++++++++++++++++----------- 3 files changed, 99 insertions(+), 37 deletions(-) create mode 100644 .changeset/plugin-listen-error.md diff --git a/.changeset/plugin-listen-error.md b/.changeset/plugin-listen-error.md new file mode 100644 index 0000000000..5e419958bd --- /dev/null +++ b/.changeset/plugin-listen-error.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +In plugin mode, a failure to start the server (e.g. `EADDRINUSE` when the port is taken) is now passed to the `watch()` callback, for a `MultiCompiler` too, instead of only being logged while the watch build keeps running. diff --git a/lib/Server.js b/lib/Server.js index a36bec7e35..dbf5f7a85b 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -4551,6 +4551,8 @@ class Server { let setupPromise; /** @type {Promise | undefined} */ let listenPromise; + /** @type {Promise | undefined} */ + let pendingListenPromise; let inWatchMode = false; let listening = false; let stopped = false; @@ -4579,7 +4581,8 @@ class Server { // is how we know a one-shot `compiler.run()` build is not in play. // - `done` on a `MultiCompiler` is the aggregate `SyncHook` that fires once // after every child finishes, so the server starts exactly once. Being a - // `SyncHook`, it can only be `tap`ped; `listen()` runs detached. + // `SyncHook`, it can only be `tap`ped, so `listen()` is awaited from the + // `done` hook of each `Compiler` below. const { hooks } = /** @type {Compiler} */ (compiler); hooks.watchRun.tapPromise(pluginName, () => { @@ -4592,15 +4595,44 @@ class Server { // `watchRun` ran; staying passive lets that build finish and exit. if (listening || !inWatchMode || stopped) return; listening = true; - listenPromise = ensureSetup() - .then(() => { - if (!stopped) return this.listen(); - }) - .catch((error) => { - this.logger.error(error); - }); + listenPromise = ensureSetup().then(() => { + if (!stopped) return this.listen(); + }); + pendingListenPromise = listenPromise; }); + // A `Compiler`'s own `done` is an `AsyncSeriesHook`, so a `listen()` failure + // (e.g. `EADDRINUSE`) rejects the user's `watch()` callback, as `start()` + // does. On a `MultiCompiler` the aggregate `done` above fires from inside + // the last child's `done` (its tap is added when the `MultiCompiler` is + // created, before any plugin), so that child awaits the listen started there. + const doneCompilers = /** @type {MultiCompiler} */ (compiler).compilers || [ + compiler, + ]; + + /** + * @param {Stats} stats stats + * @param {(err?: Error) => void} callback callback + */ + const awaitPendingListen = (stats, callback) => { + if (!pendingListenPromise) { + callback(); + return; + } + + const promise = pendingListenPromise; + + pendingListenPromise = undefined; + promise.then(() => callback(), callback); + }; + + for (const doneCompiler of doneCompilers) { + /** @type {Compiler} */ (doneCompiler).hooks.done.tapAsync( + pluginName, + awaitPendingListen, + ); + } + /** * @returns {Promise} promise */ diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 006443795e..6b7860f465 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -31,6 +31,25 @@ const cssFilePath = path.resolve( "../fixtures/reload-config/main.css", ); +const occupyPort = (portToOccupy) => + new Promise((resolve, reject) => { + const blocker = http.createServer(); + + blocker.once("error", reject); + blocker.listen(portToOccupy, "127.0.0.1", () => resolve(blocker)); + }); + +const watchError = (compiler) => + new Promise((resolve, reject) => { + compiler.watch({}, (err) => { + if (err) { + resolve(err); + } else { + reject(new Error("expected listen to fail")); + } + }); + }); + describe("API (plugin)", () => { it("should work with plugin API", async (t) => { const compiler = webpack(config); @@ -440,40 +459,25 @@ describe("API (plugin)", () => { }); }); - it("should log a listen() failure instead of throwing", async () => { - // The server is started from the `done` hook, which is a `SyncHook`, so - // `listen()` runs detached. A failure there must be logged, not surface as - // an unhandled rejection. + it("should propagate listen errors via the watch callback", async () => { + const blocker = await occupyPort(port); const compiler = webpack(config); - const server = new Server({ port }); + const server = new Server({ host: "127.0.0.1", port }); + server.apply(compiler); - const listenError = new Error("listen failed"); - const listenSpy = spyOn(server, "listen").mockImplementation(() => - Promise.reject(listenError), - ); - const errorSpy = spyOn(server.logger, "error"); + try { + const error = await watchError(compiler); - await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("listen error was not logged")), - 30000, - ); - errorSpy.mockImplementation(() => { - clearTimeout(timer); - resolve(); + expect(error.code).toBe("EADDRINUSE"); + } finally { + await new Promise((resolve) => { + compiler.close(resolve); }); - compiler.watch({}, () => {}); - }); - - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith(listenError); - - listenSpy.mockRestore(); - errorSpy.mockRestore(); - await new Promise((resolve) => { - compiler.close(resolve); - }); + await new Promise((resolve) => { + blocker.close(resolve); + }); + } }); describe("plugin in webpack config", () => { @@ -645,6 +649,27 @@ describe("API (plugin)", () => { }); }); + it("should propagate listen errors via the watch callback", async () => { + const blocker = await occupyPort(port); + const compiler = webpack(multiCompilerConfig); + const server = new Server({ host: "127.0.0.1", port }); + + server.apply(compiler); + + try { + const error = await watchError(compiler); + + expect(error.code).toBe("EADDRINUSE"); + } finally { + await new Promise((resolve) => { + compiler.close(resolve); + }); + await new Promise((resolve) => { + blocker.close(resolve); + }); + } + }); + it("should stop the server only once when all child compilers shut down", async () => { const compiler = webpack(multiCompilerConfig); const server = new Server({ port }); From 3b613f432ebcb9f7f06052ac1e3ff3466c36d27f Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:18:53 +0000 Subject: [PATCH 2/2] test: give each reload test file its own CSS fixture `api-plugin`, `event-source` and `hot-and-live-reload` all rewrote `test/fixtures/reload-config/main.css`, and `node --test` runs files in parallel, so a test could see another file's colour or an extra rebuild. Give `api-plugin` and `event-source` their own copies, as `progress` already does with `reload-config-2`. Claude-Session: https://claude.ai/code/session_013FhutSnStkMpV2mZSNb8M9 --- .gitignore | 2 ++ test/e2e/api-plugin.test.js | 4 +-- test/e2e/event-source.test.js | 4 +-- test/fixtures/reload-config-3/foo.js | 1 + .../reload-config-3/webpack.config.js | 30 +++++++++++++++++++ test/fixtures/reload-config-4/foo.js | 1 + .../reload-config-4/webpack.config.js | 30 +++++++++++++++++++ 7 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/reload-config-3/foo.js create mode 100644 test/fixtures/reload-config-3/webpack.config.js create mode 100644 test/fixtures/reload-config-4/foo.js create mode 100644 test/fixtures/reload-config-4/webpack.config.js diff --git a/.gitignore b/.gitignore index 866d495151..c1d6bd6386 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ test/fixtures/static-config/public/assets/non-exist.txt test/fixtures/watch-files-config/public/assets/non-exist.txt test/fixtures/reload-config/main.css test/fixtures/reload-config-2/main.css +test/fixtures/reload-config-3/main.css +test/fixtures/reload-config-4/main.css test/fixtures/worker-config-dev-server-false/public !/test/fixtures/static-config/public/node_modules test/fixtures/compatibility-config/changing.cjs diff --git a/test/e2e/api-plugin.test.js b/test/e2e/api-plugin.test.js index 6b7860f465..f0bd4f56e6 100644 --- a/test/e2e/api-plugin.test.js +++ b/test/e2e/api-plugin.test.js @@ -11,7 +11,7 @@ import WebSocket from "ws"; import Server from "../../lib/Server.js"; import config from "../fixtures/client-config/webpack.config.js"; import multiCompilerConfig from "../fixtures/multi-compiler-two-configurations/webpack.config.js"; -import reloadConfig from "../fixtures/reload-config/webpack.config.js"; +import reloadConfig from "../fixtures/reload-config-3/webpack.config.js"; import compile from "../helpers/compile.js"; import runBrowser from "../helpers/run-browser.js"; import portsMap from "../ports-map.js"; @@ -28,7 +28,7 @@ const supportsMultiCompilerPlugins = const cssFilePath = path.resolve( __dirname, - "../fixtures/reload-config/main.css", + "../fixtures/reload-config-3/main.css", ); const occupyPort = (portToOccupy) => diff --git a/test/e2e/event-source.test.js b/test/e2e/event-source.test.js index 674a55f10f..d81dd69591 100644 --- a/test/e2e/event-source.test.js +++ b/test/e2e/event-source.test.js @@ -6,7 +6,7 @@ import { expect } from "expect"; import fs from "graceful-fs"; import webpack from "webpack"; import Server from "../../lib/Server.js"; -import reloadConfig from "../fixtures/reload-config/webpack.config.js"; +import reloadConfig from "../fixtures/reload-config-4/webpack.config.js"; import runBrowser from "../helpers/run-browser.js"; import waitFor from "../helpers/wait-for.js"; import portsMap from "../ports-map.js"; @@ -19,7 +19,7 @@ const [port, rejectPort] = portsMap["event-source"]; const cssFilePath = path.resolve( __dirname, - "../fixtures/reload-config/main.css", + "../fixtures/reload-config-4/main.css", ); /** diff --git a/test/fixtures/reload-config-3/foo.js b/test/fixtures/reload-config-3/foo.js new file mode 100644 index 0000000000..1bebc0ca7a --- /dev/null +++ b/test/fixtures/reload-config-3/foo.js @@ -0,0 +1 @@ +import "./main.css"; diff --git a/test/fixtures/reload-config-3/webpack.config.js b/test/fixtures/reload-config-3/webpack.config.js new file mode 100644 index 0000000000..0d36f857a8 --- /dev/null +++ b/test/fixtures/reload-config-3/webpack.config.js @@ -0,0 +1,30 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import HTMLGeneratorPlugin from "../../helpers/html-generator-plugin.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default { + mode: "development", + context: __dirname, + entry: "./foo.js", + stats: "none", + output: { + path: "/", + }, + module: { + rules: [ + { + test: /\.css$/, + use: [{ loader: "style-loader" }, { loader: "css-loader" }], + }, + ], + }, + infrastructureLogging: { + level: "info", + stream: { + write: () => {}, + }, + }, + plugins: [new HTMLGeneratorPlugin()], +}; diff --git a/test/fixtures/reload-config-4/foo.js b/test/fixtures/reload-config-4/foo.js new file mode 100644 index 0000000000..1bebc0ca7a --- /dev/null +++ b/test/fixtures/reload-config-4/foo.js @@ -0,0 +1 @@ +import "./main.css"; diff --git a/test/fixtures/reload-config-4/webpack.config.js b/test/fixtures/reload-config-4/webpack.config.js new file mode 100644 index 0000000000..0d36f857a8 --- /dev/null +++ b/test/fixtures/reload-config-4/webpack.config.js @@ -0,0 +1,30 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import HTMLGeneratorPlugin from "../../helpers/html-generator-plugin.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default { + mode: "development", + context: __dirname, + entry: "./foo.js", + stats: "none", + output: { + path: "/", + }, + module: { + rules: [ + { + test: /\.css$/, + use: [{ loader: "style-loader" }, { loader: "css-loader" }], + }, + ], + }, + infrastructureLogging: { + level: "info", + stream: { + write: () => {}, + }, + }, + plugins: [new HTMLGeneratorPlugin()], +};