diff --git a/.changeset/share-compile-cache-with-child-processes.md b/.changeset/share-compile-cache-with-child-processes.md new file mode 100644 index 00000000000..12219e104bd --- /dev/null +++ b/.changeset/share-compile-cache-with-child-processes.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +perf: share the compile cache with child processes and worker threads, such as minimizer workers, so they start warm too diff --git a/packages/webpack-cli/bin/cli.js b/packages/webpack-cli/bin/cli.js index 6e496b42bb9..0ebc85ef4ac 100755 --- a/packages/webpack-cli/bin/cli.js +++ b/packages/webpack-cli/bin/cli.js @@ -4,11 +4,22 @@ // `module.enableCompileCache` is only available on Node.js >= 22.8.0 // eslint-disable-next-line n/no-unsupported-features/node-builtins -const { enableCompileCache } = require("node:module"); +const { constants, enableCompileCache } = require("node:module"); if (enableCompileCache) { try { - enableCompileCache(); + const { status, directory } = enableCompileCache(); + + // Minimizer workers and other child processes start without the cache; + // the variable Node reads at startup hands them the same directory. Only a + // fresh start returns the base directory, which children tag by version. + if ( + // eslint-disable-next-line n/no-unsupported-features/node-builtins + status === constants.compileCacheStatus.ENABLED && + process.env.NODE_COMPILE_CACHE === undefined + ) { + process.env.NODE_COMPILE_CACHE = directory; + } } catch { // Ignore errors } diff --git a/test/build/compile-cache/compile-cache.test.js b/test/build/compile-cache/compile-cache.test.js new file mode 100644 index 00000000000..f094e7871e8 --- /dev/null +++ b/test/build/compile-cache/compile-cache.test.js @@ -0,0 +1,81 @@ +"use strict"; + +const fs = require("node:fs"); +const { enableCompileCache } = require("node:module"); +const os = require("node:os"); +const path = require("node:path"); +const { run } = require("../../utils/test-utils"); + +/** + * Runs the CLI on a configuration that reports the compile cache directory of the + * child process and the worker thread it starts. + * @param {Record} env variables to set on top of a clean environment + * @returns {Promise<{ variable: string | null, child: string | null, worker: string | null }>} what it reported + */ +const report = async (env) => { + const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.startsWith("NODE_COMPILE_CACHE") && name !== "NODE_DISABLE_COMPILE_CACHE", + ), + ); + const { exitCode, stdout } = await run(__dirname, [], { + env: { ...cleanEnv, ...env }, + extendEnv: false, + }); + + expect(exitCode).toBe(0); + + return JSON.parse(stdout.slice(stdout.indexOf("REPORT") + 6).split("\n")[0]); +}; + +describe("compile cache", () => { + // `module.enableCompileCache` is only available on Node.js >= 22.8.0 + if (!enableCompileCache) { + it("should hand nothing on without a compile cache", async () => { + expect(await report({})).toEqual({ variable: null, child: null, worker: null }); + }); + + return; + } + + it("should hand the compile cache to child processes and worker threads", async () => { + const { variable, child, worker } = await report({}); + + expect(typeof variable).toBe("string"); + expect(child.startsWith(variable)).toBe(true); + expect(worker.startsWith(variable)).toBe(true); + }); + + it("should keep a directory the environment already names", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "webpack-cli-compile-cache-")); + + try { + const { variable, child, worker } = await report({ NODE_COMPILE_CACHE: directory }); + + expect(variable).toBe(directory); + expect(child.startsWith(directory)).toBe(true); + expect(worker.startsWith(directory)).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("should hand nothing on when a preload has already enabled the compile cache", async () => { + const { variable, child, worker } = await report({ + NODE_OPTIONS: `--require ${path.resolve(__dirname, "preload.js")}`, + }); + + // The preload's directory is already version-tagged; handing it on would tag it twice. + expect(variable).toBeNull(); + expect(child).toBe(worker); + expect(child.match(/v\d+\.\d+\.\d+-/g)).toHaveLength(1); + }); + + it("should hand nothing on where the compile cache is disabled", async () => { + expect(await report({ NODE_DISABLE_COMPILE_CACHE: "1" })).toEqual({ + variable: null, + child: null, + worker: null, + }); + }); +}); diff --git a/test/build/compile-cache/preload.js b/test/build/compile-cache/preload.js new file mode 100644 index 00000000000..a5c3cad7ca5 --- /dev/null +++ b/test/build/compile-cache/preload.js @@ -0,0 +1,3 @@ +"use strict"; + +require("node:module").enableCompileCache(); diff --git a/test/build/compile-cache/src/index.js b/test/build/compile-cache/src/index.js new file mode 100644 index 00000000000..94ea297bb85 --- /dev/null +++ b/test/build/compile-cache/src/index.js @@ -0,0 +1 @@ +console.log("compile cache"); diff --git a/test/build/compile-cache/webpack.config.js b/test/build/compile-cache/webpack.config.js new file mode 100644 index 00000000000..ba8979a855a --- /dev/null +++ b/test/build/compile-cache/webpack.config.js @@ -0,0 +1,24 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const { Worker } = require("node:worker_threads"); + +// `module.getCompileCacheDir` is only available on Node.js >= 22.8.0 +const script = "(require('node:module').getCompileCacheDir || (() => null))() || null"; + +const child = execFileSync(process.execPath, ["-p", `JSON.stringify(${script})`], { + encoding: "utf8", +}); + +new Worker(`require("node:worker_threads").parentPort.postMessage(${script})`, { + eval: true, +}).once("message", (worker) => { + process.stdout.write( + `REPORT${JSON.stringify({ + variable: process.env.NODE_COMPILE_CACHE || null, + child: JSON.parse(child), + worker, + })}\n`, + () => process.exit(0), + ); +});