diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md new file mode 100644 index 0000000..8cc7db0 --- /dev/null +++ b/.claude/rules/error-handling.md @@ -0,0 +1,9 @@ +--- +paths: + - "src/**/*" +--- + +# Error Handling + +- Operations that should not happen must result in a hard error. +- Never silently ignore issues. When a operation that cannot be completed is requested, that needs to be an error (for example using a worker that does not exist, or trying to invoke an action that is not registered). diff --git a/.claude/rules/javascript.md b/.claude/rules/javascript.md new file mode 100644 index 0000000..d357c14 --- /dev/null +++ b/.claude/rules/javascript.md @@ -0,0 +1,9 @@ +--- +paths: + - "**/*.mjs" +--- + +# JavaScript Standards + +- Never use inline imports. +- Destructure objects, and messages, especially in function arguments. diff --git a/.claude/rules/nix-shell.md b/.claude/rules/nix-shell.md deleted file mode 100644 index 7f582f5..0000000 --- a/.claude/rules/nix-shell.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -paths: - - "**/shell.nix" ---- - -# Nix Shell Standards - -- shell.nix must follow idiomatic NixOS ways of providing packages -- shell.nix must be minimal, and only providing project dependencies that would not be able to run natively otherwise -- shell.nix must not contain any workarounds -- shell.nix must not contain any kind of ELF patching -- shell.nix must not contain any kind of monkey patching diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index c60bf4b..103be69 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,3 +1,9 @@ +--- +paths: + - "Makefile" + - "tests/**/*.mjs" +--- + # Unit Tests and Quality Control - Always check that the unit tests pass. @@ -12,3 +18,15 @@ - Tests must exercise actual functionality and observable behavior. Never write a test purely to hit lines for the sake of coverage. - Design tests deliberately before writing them. Identify the feature or branch under test, then write the smallest test that verifies it. - Coverage gaps signal missing tests, never permission to exclude files. Write the test instead of suppressing the gap. + +# On Monkeypatching, and Mocks + +- Never use monkeypatching. +- Never use mocks (you can use fixtures, however). +- Testing environment you prepare must be ephemeral. + +# Test Suite Budget + +- The entire test suite has a hard budget of 10 seconds to run. If tests take longer to run, treat that as a bug. +- When optimizing tests to fit within a budget, make sure to preserve all the tests, and all the functionalities. You must only optimize the performance. +- Individual tests have a budget of at most 1 second to run. If they do not, treat that as a bug, and optimize the performance. diff --git a/Makefile b/Makefile index 4fc5c90..3d5b23a 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ format: $(NODE_MODULES) .PHONY: test test: $(NODE_MODULES) - node --test '$(TEST_GLOB)' + timeout 10 node --test --test-timeout=1000 '$(TEST_GLOB)' .PHONY: type-coverage type-coverage: $(NODE_MODULES) diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs index bf8b0c6..0569c61 100644 --- a/scripts/check-coverage.mjs +++ b/scripts/check-coverage.mjs @@ -3,10 +3,14 @@ import { spawnSync } from "node:child_process"; const DEFAULT_TEST_GLOB = "tests/**/*.test.mjs"; export function checkCoverage(testGlob = DEFAULT_TEST_GLOB) { - const result = spawnSync("npx", ["c8", "node", "--test", testGlob], { - shell: false, - stdio: "inherit", - }); + const result = spawnSync( + "npx", + ["c8", "node", "--test", "--test-timeout=1000", testGlob], + { + shell: false, + stdio: "inherit", + }, + ); if (result.error) { throw result.error; diff --git a/src/job-types/basic.mjs b/src/job-types/basic.mjs index 3392fab..2ed8815 100644 --- a/src/job-types/basic.mjs +++ b/src/job-types/basic.mjs @@ -1,10 +1,8 @@ -import assert from "node:assert/strict"; -import { parentPort } from "node:worker_threads"; - import { autogeneratedComment } from "../helpers/autogenerated-comment.mjs"; import { printSubtreeList } from "../helpers/print-subtree-list.mjs"; import { resetConsole } from "../helpers/reset-console.mjs"; import { sleep } from "../helpers/sleep.mjs"; +import { workerPort } from "../libs/worker-port.mjs"; /** * @typedef {object} BasicContext @@ -21,46 +19,40 @@ import { sleep } from "../helpers/sleep.mjs"; * @param {(context: BasicContext) => unknown} build */ export function basic(build) { - assert(parentPort, "basic() must run in a worker thread"); - - const port = parentPort; - - port.on( - "message", - /** @param {import("../libs/keep-worker-alive.mjs").BuildMessage} message */ - async function (message) { - const { baseDirectory, buildId, name } = message; - - /** @param {boolean} success */ - function reportSuccess(success) { - port.postMessage({ - baseDirectory, - buildId, - success, - }); - } - - try { - reportSuccess( - false !== - (await build({ - autogeneratedComment: autogeneratedComment(name), - buildId, - baseDirectory, - name, - printSubtreeList, - resetConsole, - sleep, - })), - ); - } catch (error) { - console.error( - `Build ${buildId} failed because of uncaught exception:`, - error, - ); - - reportSuccess(false); - } - }, - ); + const port = workerPort(); + + port.onMessage(async function (message) { + const { baseDirectory, buildId, name } = message; + + /** @param {boolean} success */ + function reportBuildResult(success) { + port.postBuildResult({ + baseDirectory, + buildId, + success, + }); + } + + try { + reportBuildResult( + false !== + (await build({ + autogeneratedComment: autogeneratedComment(name), + buildId, + baseDirectory, + name, + printSubtreeList, + resetConsole, + sleep, + })), + ); + } catch (error) { + console.error( + `Build ${buildId} failed because of uncaught exception:`, + error, + ); + + reportBuildResult(false); + } + }); } diff --git a/src/job-types/persist.mjs b/src/job-types/persist.mjs index 14ee244..3647ac4 100644 --- a/src/job-types/persist.mjs +++ b/src/job-types/persist.mjs @@ -1,6 +1,9 @@ +import assert from "node:assert/strict"; import spawn from "cross-spawn"; import { parseArgsStringToArgv } from "string-argv"; +import { DuplicateKeepAliveError } from "../libs/duplicate-keep-alive-error.mjs"; +import { workerPort } from "../libs/worker-port.mjs"; import { basic } from "./basic.mjs"; /** @@ -9,34 +12,51 @@ import { basic } from "./basic.mjs"; * }} PersistContext */ -/** @type {Set} */ -const running = new Set(); - /** * @param {(context: PersistContext) => unknown} build */ export function persist(build) { + const port = workerPort(); + + /** @type {Set} */ + const runningProcesses = new Set(); + /** @type {WeakSet} */ + const intentionallyKilled = new WeakSet(); + /** * @param {{ args: string[]; baseDirectory: string; command: string }} input */ - function run({ args, baseDirectory, command }) { + function startWithRestart({ args, baseDirectory, command }) { const proc = spawn(command, args, { cwd: baseDirectory, stdio: "inherit", }); - proc.once("spawn", function () { - console.debug(`jarmuz: Process(${proc.pid}) was spawned.`); - }); + runningProcesses.add(proc); + + const pid = proc.pid; + + assert(typeof pid === "number"); + + console.debug(`jarmuz: Process(${pid}) was spawned.`); + port.postChildSpawned(pid); proc.once("close", function (code) { + runningProcesses.delete(proc); + + if (intentionallyKilled.has(proc)) { + console.debug(`jarmuz: Process(${pid}) was killed.`); + + return; + } + console.debug( null === code - ? `jarmuz: Process(${proc.pid}) was killed; restarting` - : `jarmuz: Process(${proc.pid}) exited with code ${code}; restarting`, + ? `jarmuz: Process(${pid}) was killed; restarting.` + : `jarmuz: Process(${pid}) exited with code ${code}; restarting.`, ); - run({ + startWithRestart({ args, baseDirectory, command, @@ -44,18 +64,35 @@ export function persist(build) { }); } + /** @returns {Promise} */ + async function abort() { + for (const proc of Array.from(runningProcesses)) { + intentionallyKilled.add(proc); + + await new Promise(function (resolve) { + proc.once("close", resolve); + proc.kill("SIGTERM"); + }); + } + } + return basic(async function ({ buildId, baseDirectory, ...rest }) { + await abort(); + + /** @type {Set} */ + const startedThisBuild = new Set(); + /** @param {string} exec */ function keepAlive(exec) { - if (running.has(exec)) { - return; + if (startedThisBuild.has(exec)) { + throw new DuplicateKeepAliveError(exec); } - running.add(exec); + startedThisBuild.add(exec); const [command, ...args] = parseArgsStringToArgv(exec); - return run({ + startWithRestart({ args, baseDirectory, command, diff --git a/src/job-types/spawner.mjs b/src/job-types/spawner.mjs index 46d30e3..a328ff9 100644 --- a/src/job-types/spawner.mjs +++ b/src/job-types/spawner.mjs @@ -1,7 +1,9 @@ +import assert from "node:assert/strict"; import spawn from "cross-spawn"; import { exec as nodeExec } from "node:child_process"; import { parseArgsStringToArgv } from "string-argv"; +import { workerPort } from "../libs/worker-port.mjs"; import { basic } from "./basic.mjs"; /** @@ -20,6 +22,8 @@ import { basic } from "./basic.mjs"; * @param {(context: SpawnerContext) => unknown} build */ export function spawner(build) { + const port = workerPort(); + let abortController = new AbortController(); /** @type {Set} */ const running = new Set(); @@ -51,9 +55,12 @@ export function spawner(build) { function register({ background, proc }) { running.add(proc); - proc.once("spawn", function () { - console.debug(`jarmuz: Process(${proc.pid}) was spawned.`); - }); + const pid = proc.pid; + + assert(typeof pid === "number"); + + console.debug(`jarmuz: Process(${pid}) was spawned.`); + port.postChildSpawned(pid); return new Promise(function (resolve) { proc.once("close", function (code) { @@ -82,7 +89,7 @@ export function spawner(build) { * @param {{ background: boolean; exec: string }} input * @returns {Promise} */ - function registerProc({ background, exec }) { + function spawnAndRegister({ background, exec }) { const [command, ...args] = parseArgsStringToArgv(exec); const proc = spawn(command, args, { cwd: baseDirectory, @@ -100,7 +107,7 @@ export function spawner(build) { * @returns {Promise} */ async function background(exec) { - await registerProc({ background: true, exec }); + await spawnAndRegister({ background: true, exec }); } /** @@ -108,7 +115,7 @@ export function spawner(build) { * @returns {Promise} */ function command(exec) { - return registerProc({ background: false, exec }); + return spawnAndRegister({ background: false, exec }); } /** diff --git a/src/libs/child-process-registry.mjs b/src/libs/child-process-registry.mjs new file mode 100644 index 0000000..718dc92 --- /dev/null +++ b/src/libs/child-process-registry.mjs @@ -0,0 +1,65 @@ +import { killProcess } from "./kill-process.mjs"; +import { WorkerAlreadyRegisteredError } from "./worker-already-registered-error.mjs"; +import { WorkerNotRegisteredError } from "./worker-not-registered-error.mjs"; + +/** + * @typedef {object} ChildProcessRegistry + * @property {(workerName: string) => void} registerWorker + * @property {(workerName: string, pid: number) => void} addChild + * @property {(workerName: string) => void} killWorker + * @property {() => void} killAll + */ + +/** @returns {ChildProcessRegistry} */ +export function childProcessRegistry() { + /** @type {Map>} */ + const byWorker = new Map(); + + /** @param {string} workerName */ + function killByWorker(workerName) { + const pids = byWorker.get(workerName); + + if (pids === undefined) { + throw new WorkerNotRegisteredError(workerName); + } + + for (const pid of pids) { + killProcess(pid); + } + + byWorker.delete(workerName); + } + + return Object.freeze({ + /** @param {string} workerName */ + registerWorker(workerName) { + if (byWorker.has(workerName)) { + throw new WorkerAlreadyRegisteredError(workerName); + } + + byWorker.set(workerName, new Set()); + }, + /** + * @param {string} workerName + * @param {number} pid + */ + addChild(workerName, pid) { + const pids = byWorker.get(workerName); + + if (pids === undefined) { + throw new WorkerNotRegisteredError(workerName); + } + + pids.add(pid); + }, + /** @param {string} workerName */ + killWorker(workerName) { + killByWorker(workerName); + }, + killAll() { + for (const workerName of Array.from(byWorker.keys())) { + killByWorker(workerName); + } + }, + }); +} diff --git a/src/libs/duplicate-keep-alive-error.mjs b/src/libs/duplicate-keep-alive-error.mjs new file mode 100644 index 0000000..1f2191f --- /dev/null +++ b/src/libs/duplicate-keep-alive-error.mjs @@ -0,0 +1,14 @@ +export class DuplicateKeepAliveError extends Error { + /** @type {string} */ + exec; + + /** @type {string} */ + name = "DuplicateKeepAliveError"; + + /** @param {string} exec */ + constructor(exec) { + super(`Duplicate keepAlive call within a build for exec: "${exec}"`); + + this.exec = exec; + } +} diff --git a/src/libs/jarmuz.mjs b/src/libs/jarmuz.mjs index 23a7121..6739c6e 100644 --- a/src/libs/jarmuz.mjs +++ b/src/libs/jarmuz.mjs @@ -2,10 +2,15 @@ import chokidar from "chokidar"; import { minimatch } from "minimatch"; import { nanoid } from "nanoid"; +import { childProcessRegistry } from "./child-process-registry.mjs"; import { managePipeline } from "./manage-pipeline.mjs"; import { manageWorkers } from "./manage-workers.mjs"; import { scheduler } from "./scheduler.mjs"; +function exitOnSignal() { + process.exit(); +} + /** * @typedef {object} JarmuzOptions * @property {string} [baseDirectory] @@ -51,8 +56,16 @@ export function jarmuz({ workers: new Map(), }; + const registry = childProcessRegistry(); + + process.once("exit", function () { + registry.killAll(); + }); + process.once("SIGINT", exitOnSignal); + process.once("SIGTERM", exitOnSignal); + const schedule = scheduler(state); - const workers = manageWorkers(baseDirectory, state); + const workers = manageWorkers(baseDirectory, state, registry); const pipelineManager = managePipeline(state, schedule, pipeline); for (const name of pipeline) { diff --git a/src/libs/keep-worker-alive.mjs b/src/libs/keep-worker-alive.mjs index 8013d08..b85468f 100644 --- a/src/libs/keep-worker-alive.mjs +++ b/src/libs/keep-worker-alive.mjs @@ -3,42 +3,25 @@ import { Worker } from "node:worker_threads"; import { TerminatedWorkerError } from "./terminated-worker-error.mjs"; -/** - * Inbound build message: jarmuż -> worker. - * - * @typedef {object} BuildMessage - * @property {string} baseDirectory - * @property {string} buildId - * @property {string} name - */ - -/** - * Outbound build result: worker -> jarmuż. - * - * @typedef {object} BuildResult - * @property {string} baseDirectory - * @property {string} buildId - * @property {boolean} success - */ - /** * Handle to a kept-alive worker thread, returned by `keepWorkerAlive`. * * @typedef {object} WorkerHandle - * @property {(data: BuildMessage) => void} postMessage + * @property {(data: import("./worker-port.mjs").BuildMessage) => void} postMessage * @property {() => Promise} terminate */ /** * @param {{ + * onMessage: (message: unknown) => void; * path: string; - * onMessage: (message: BuildResult) => void; + * registry: import("./child-process-registry.mjs").ChildProcessRegistry; * }} options * @returns {WorkerHandle} */ -export function keepWorkerAlive({ path, onMessage }) { +export function keepWorkerAlive({ onMessage, path, registry }) { const name = basename(path, ".mjs"); - const state = { isTerminated: false }; + let isTerminated = false; /** @type {Worker} */ let worker; @@ -47,38 +30,54 @@ export function keepWorkerAlive({ path, onMessage }) { worker = new Worker(path, { name, }); + registry.registerWorker(name); worker.once("exit", function (code) { console.error( - state.isTerminated + isTerminated ? `jarmuz: Worker(${name}) terminated with exit code ${code}.` : `jarmuz: Worker(${name}) stopped with exit code ${code}. Restarting...`, ); - worker.off("message", onMessage); + worker.off("message", dispatch); + registry.killWorker(name); - if (!state.isTerminated) { + if (!isTerminated) { spawnWorker(); } }); - worker.on("message", onMessage); + worker.on("message", dispatch); + + isTerminated = false; + } + + /** @param {unknown} rawMessage */ + function dispatch(rawMessage) { + /** @type {{ type?: string; pid: number }} */ + const message = /** @type {{ type?: string; pid: number }} */ (rawMessage); + + if (message.type === "child-spawned") { + registry.addChild(name, message.pid); + + return; + } - state.isTerminated = false; + onMessage(rawMessage); } spawnWorker(); return Object.freeze({ - /** @param {BuildMessage} data */ + /** @param {import("./worker-port.mjs").BuildMessage} data */ postMessage(data) { - if (state.isTerminated) { + if (isTerminated) { throw new TerminatedWorkerError(name); } worker.postMessage(data); }, terminate() { - state.isTerminated = true; - worker.off("message", onMessage); + isTerminated = true; + worker.off("message", dispatch); return worker.terminate(); }, diff --git a/src/libs/kill-process.mjs b/src/libs/kill-process.mjs new file mode 100644 index 0000000..6fb234b --- /dev/null +++ b/src/libs/kill-process.mjs @@ -0,0 +1,10 @@ +/** @param {number} pid */ +export function killProcess(pid) { + try { + process.kill(pid, "SIGTERM"); + } catch (error) { + if (/** @type {NodeJS.ErrnoException} */ (error).code !== "ESRCH") { + throw error; + } + } +} diff --git a/src/libs/manage-pipeline.mjs b/src/libs/manage-pipeline.mjs index 597210c..303146a 100644 --- a/src/libs/manage-pipeline.mjs +++ b/src/libs/manage-pipeline.mjs @@ -39,7 +39,7 @@ export function managePipeline(state, scheduler, pipeline) { return; } - scheduler.unique(name, function () { + scheduler.debounce(name, function () { if (hasPendingPredecessor(state, predecessors)) { state.pending.delete(name); } else { diff --git a/src/libs/manage-workers.mjs b/src/libs/manage-workers.mjs index c8c3f18..803dd40 100644 --- a/src/libs/manage-workers.mjs +++ b/src/libs/manage-workers.mjs @@ -18,23 +18,36 @@ import { keepWorkerAlive } from "./keep-worker-alive.mjs"; /** * @param {string} baseDirectory * @param {import("./jarmuz.mjs").State} state + * @param {import("./child-process-registry.mjs").ChildProcessRegistry} registry */ -export function manageWorkers(baseDirectory, state) { +export function manageWorkers(baseDirectory, state, registry) { /** @param {StartOptions} options */ function start({ name, onFailure, onSuccess }) { state.workers.set( name, keepWorkerAlive({ - path: `${baseDirectory}/jarmuz/worker-${name}.mjs`, - onMessage({ baseDirectory, buildId, success }) { + onMessage(rawMessage) { + /** @type {import("./worker-port.mjs").BuildResultMessage} */ + const message = /** @type {import("./worker-port.mjs").BuildResultMessage} */ ( + rawMessage + ); + state.pending.delete(name); - if (success) { - onSuccess({ baseDirectory, buildId }); + if (message.success) { + onSuccess({ + baseDirectory: message.baseDirectory, + buildId: message.buildId, + }); } else { - onFailure({ baseDirectory, buildId }); + onFailure({ + baseDirectory: message.baseDirectory, + buildId: message.buildId, + }); } }, + path: `${baseDirectory}/jarmuz/worker-${name}.mjs`, + registry, }), ); } diff --git a/src/libs/scheduler.mjs b/src/libs/scheduler.mjs index 0a48ceb..25e24d7 100644 --- a/src/libs/scheduler.mjs +++ b/src/libs/scheduler.mjs @@ -1,6 +1,6 @@ /** * @typedef {object} Scheduler - * @property {(name: string, callback: () => void) => void} unique + * @property {(name: string, callback: () => void) => void} debounce */ /** @@ -13,7 +13,7 @@ export function scheduler(state) { * @param {string} name * @param {() => void} callback */ - unique(name, callback) { + debounce(name, callback) { if (state.pending.has(name)) { clearTimeout(state.pending.get(name)); state.pending.delete(name); diff --git a/src/libs/worker-already-registered-error.mjs b/src/libs/worker-already-registered-error.mjs new file mode 100644 index 0000000..e25a4f3 --- /dev/null +++ b/src/libs/worker-already-registered-error.mjs @@ -0,0 +1,14 @@ +export class WorkerAlreadyRegisteredError extends Error { + /** @type {string} */ + workerName; + + /** @type {string} */ + name = "WorkerAlreadyRegisteredError"; + + /** @param {string} workerName */ + constructor(workerName) { + super(`Worker is already registered: "${workerName}"`); + + this.workerName = workerName; + } +} diff --git a/src/libs/worker-not-registered-error.mjs b/src/libs/worker-not-registered-error.mjs new file mode 100644 index 0000000..dd75e7e --- /dev/null +++ b/src/libs/worker-not-registered-error.mjs @@ -0,0 +1,14 @@ +export class WorkerNotRegisteredError extends Error { + /** @type {string} */ + workerName; + + /** @type {string} */ + name = "WorkerNotRegisteredError"; + + /** @param {string} workerName */ + constructor(workerName) { + super(`Worker is not registered: "${workerName}"`); + + this.workerName = workerName; + } +} diff --git a/src/libs/worker-port.mjs b/src/libs/worker-port.mjs new file mode 100644 index 0000000..87d9719 --- /dev/null +++ b/src/libs/worker-port.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { parentPort } from "node:worker_threads"; + +/** + * @typedef {object} BuildMessage + * @property {string} baseDirectory + * @property {string} buildId + * @property {string} name + */ + +/** + * @typedef {object} BuildResult + * @property {string} baseDirectory + * @property {string} buildId + * @property {boolean} success + */ + +/** + * @typedef {object} BuildResultMessage + * @property {string} baseDirectory + * @property {string} buildId + * @property {boolean} success + * @property {"build-result"} type + */ + +/** + * @typedef {object} ChildSpawnedMessage + * @property {number} pid + * @property {"child-spawned"} type + */ + +/** + * @typedef {BuildResultMessage | ChildSpawnedMessage} WorkerMessage + */ + +/** + * @typedef {object} WorkerPort + * @property {(result: BuildResult) => void} postBuildResult + * @property {(pid: number) => void} postChildSpawned + * @property {(handler: (message: BuildMessage) => void) => void} onMessage + */ + +/** @returns {WorkerPort} */ +export function workerPort() { + assert(parentPort, "workerPort() must run in a worker thread"); + + const port = parentPort; + + return Object.freeze({ + /** @param {BuildResult} result */ + postBuildResult({ baseDirectory, buildId, success }) { + port.postMessage({ + baseDirectory, + buildId, + success, + type: "build-result", + }); + }, + /** @param {number} pid */ + postChildSpawned(pid) { + port.postMessage({ + pid, + type: "child-spawned", + }); + }, + /** @param {(message: BuildMessage) => void} handler */ + onMessage(handler) { + port.on("message", handler); + }, + }); +} diff --git a/tests/child-process-registry-add-child-throws-when-worker-not-registered.test.mjs b/tests/child-process-registry-add-child-throws-when-worker-not-registered.test.mjs new file mode 100644 index 0000000..2811c34 --- /dev/null +++ b/tests/child-process-registry-add-child-throws-when-worker-not-registered.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { WorkerNotRegisteredError } from "../src/libs/worker-not-registered-error.mjs"; + +test("child-process-registry add-child throws when worker not registered", function () { + const registry = childProcessRegistry(); + + assert.throws( + function () { + registry.addChild("never-registered", 12345); + }, + function (error) { + return ( + error instanceof WorkerNotRegisteredError && + error.workerName === "never-registered" + ); + }, + ); +}); diff --git a/tests/child-process-registry-kill-all-kills-every-tracked-pid.test.mjs b/tests/child-process-registry-kill-all-kills-every-tracked-pid.test.mjs new file mode 100644 index 0000000..64103c1 --- /dev/null +++ b/tests/child-process-registry-kill-all-kills-every-tracked-pid.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { spawnIdleProcess } from "./support/spawn-idle-process.mjs"; + +test("child-process-registry kill-all kills every tracked PID", async function (t) { + const idleProcessA = spawnIdleProcess(); + const idleProcessB = spawnIdleProcess(); + + t.after(function () { + idleProcessA.kill("SIGKILL"); + idleProcessB.kill("SIGKILL"); + }); + + await Promise.all([once(idleProcessA, "spawn"), once(idleProcessB, "spawn")]); + assert.equal(typeof idleProcessA.pid, "number"); + assert.equal(typeof idleProcessB.pid, "number"); + + const registry = childProcessRegistry(); + registry.registerWorker("worker-a"); + registry.registerWorker("worker-b"); + registry.addChild("worker-a", idleProcessA.pid); + registry.addChild("worker-b", idleProcessB.pid); + + registry.killAll(); + + await Promise.all([once(idleProcessA, "exit"), once(idleProcessB, "exit")]); + assert.equal(idleProcessA.signalCode, "SIGTERM"); + assert.equal(idleProcessB.signalCode, "SIGTERM"); +}); diff --git a/tests/child-process-registry-kill-worker-kills-only-that-workers-pids.test.mjs b/tests/child-process-registry-kill-worker-kills-only-that-workers-pids.test.mjs new file mode 100644 index 0000000..5b4ae4c --- /dev/null +++ b/tests/child-process-registry-kill-worker-kills-only-that-workers-pids.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { spawnIdleProcess } from "./support/spawn-idle-process.mjs"; + +test("child-process-registry kill-worker kills only that worker's PIDs", async function (t) { + const idleProcessA1 = spawnIdleProcess(); + const idleProcessA2 = spawnIdleProcess(); + const idleProcessB = spawnIdleProcess(); + + t.after(function () { + idleProcessA1.kill("SIGKILL"); + idleProcessA2.kill("SIGKILL"); + idleProcessB.kill("SIGKILL"); + }); + + await Promise.all([ + once(idleProcessA1, "spawn"), + once(idleProcessA2, "spawn"), + once(idleProcessB, "spawn"), + ]); + assert.equal(typeof idleProcessA1.pid, "number"); + assert.equal(typeof idleProcessA2.pid, "number"); + assert.equal(typeof idleProcessB.pid, "number"); + + const registry = childProcessRegistry(); + registry.registerWorker("worker-a"); + registry.registerWorker("worker-b"); + registry.addChild("worker-a", idleProcessA1.pid); + registry.addChild("worker-a", idleProcessA2.pid); + registry.addChild("worker-b", idleProcessB.pid); + + registry.killWorker("worker-a"); + + await Promise.all([ + once(idleProcessA1, "exit"), + once(idleProcessA2, "exit"), + ]); + assert.equal(idleProcessA1.signalCode, "SIGTERM"); + assert.equal(idleProcessA2.signalCode, "SIGTERM"); + assert.equal(idleProcessB.exitCode, null); + assert.equal(idleProcessB.signalCode, null); +}); diff --git a/tests/child-process-registry-kill-worker-throws-when-worker-not-registered.test.mjs b/tests/child-process-registry-kill-worker-throws-when-worker-not-registered.test.mjs new file mode 100644 index 0000000..c0f513a --- /dev/null +++ b/tests/child-process-registry-kill-worker-throws-when-worker-not-registered.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { WorkerNotRegisteredError } from "../src/libs/worker-not-registered-error.mjs"; + +test("child-process-registry kill-worker throws when worker not registered", function () { + const registry = childProcessRegistry(); + + assert.throws( + function () { + registry.killWorker("never-registered"); + }, + function (error) { + return ( + error instanceof WorkerNotRegisteredError && + error.workerName === "never-registered" + ); + }, + ); +}); diff --git a/tests/child-process-registry-kill-worker-waits-out-a-slow-shutdown.test.mjs b/tests/child-process-registry-kill-worker-waits-out-a-slow-shutdown.test.mjs new file mode 100644 index 0000000..85f88cd --- /dev/null +++ b/tests/child-process-registry-kill-worker-waits-out-a-slow-shutdown.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; + +const delayedShutdownScript = fileURLToPath( + new URL("./fixtures/scripts/delayed-shutdown-on-sigterm.mjs", import.meta.url), +); + +test("child-process-registry kill-worker waits out a child with a slow SIGTERM shutdown", async function (t) { + const tempDirectory = await makeTempDirectory(); + const pidFile = join(tempDirectory.path, "pid.txt"); + + await writeFile(pidFile, ""); + + t.after(function () { + return tempDirectory.cleanup(); + }); + + const delayedShutdownProcess = spawn(process.execPath, [ + delayedShutdownScript, + pidFile, + ]); + + t.after(function () { + if ( + delayedShutdownProcess.exitCode === null && + delayedShutdownProcess.signalCode === null + ) { + delayedShutdownProcess.kill("SIGKILL"); + } + }); + + await once(delayedShutdownProcess, "spawn"); + assert.equal(typeof delayedShutdownProcess.pid, "number"); + + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }); + const reportedPid = Number((await readFile(pidFile, "utf8")).trim()); + assert.equal(reportedPid, delayedShutdownProcess.pid); + + const registry = childProcessRegistry(); + registry.registerWorker("delayed-worker"); + registry.addChild("delayed-worker", delayedShutdownProcess.pid); + + const start = process.hrtime.bigint(); + registry.killWorker("delayed-worker"); + + await once(delayedShutdownProcess, "exit"); + const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000; + + assert.equal(delayedShutdownProcess.exitCode, 0); + assert.equal(delayedShutdownProcess.signalCode, null); + assert.ok( + elapsedMs >= 90, + "expected the delayed-shutdown process to take ~100ms to shut down after SIGTERM (got " + + elapsedMs + + "ms)", + ); +}); diff --git a/tests/child-process-registry-register-worker-throws-on-duplicate.test.mjs b/tests/child-process-registry-register-worker-throws-on-duplicate.test.mjs new file mode 100644 index 0000000..3ed9ba0 --- /dev/null +++ b/tests/child-process-registry-register-worker-throws-on-duplicate.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { WorkerAlreadyRegisteredError } from "../src/libs/worker-already-registered-error.mjs"; + +test("child-process-registry register-worker throws on duplicate", function () { + const registry = childProcessRegistry(); + + registry.registerWorker("worker-a"); + + assert.throws( + function () { + registry.registerWorker("worker-a"); + }, + function (error) { + return ( + error instanceof WorkerAlreadyRegisteredError && + error.workerName === "worker-a" + ); + }, + ); +}); diff --git a/tests/child-process-registry-tolerates-an-already-dead-pid.test.mjs b/tests/child-process-registry-tolerates-an-already-dead-pid.test.mjs new file mode 100644 index 0000000..bb60728 --- /dev/null +++ b/tests/child-process-registry-tolerates-an-already-dead-pid.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { spawnIdleProcess } from "./support/spawn-idle-process.mjs"; + +test("child-process-registry tolerates an already-dead PID", async function () { + const idleProcess = spawnIdleProcess(); + + await once(idleProcess, "spawn"); + assert.equal(typeof idleProcess.pid, "number"); + + const registry = childProcessRegistry(); + registry.registerWorker("worker-a"); + registry.addChild("worker-a", idleProcess.pid); + + idleProcess.kill("SIGKILL"); + await once(idleProcess, "exit"); + + registry.killWorker("worker-a"); +}); diff --git a/tests/fixtures/entry-watch-schedule-initial.mjs b/tests/fixtures/entry-watch-schedule-initial.mjs new file mode 100644 index 0000000..f5efeed --- /dev/null +++ b/tests/fixtures/entry-watch-schedule-initial.mjs @@ -0,0 +1,15 @@ +import { jarmuz } from "../../src/index.mjs"; + +const pipeline = JSON.parse(process.env.JARMUZ_PIPELINE); +const watch = JSON.parse(process.env.JARMUZ_WATCH); + +jarmuz({ + baseDirectory: process.env.JARMUZ_BASE_DIRECTORY, + once: false, + pipeline, + watch, +}).decide(function ({ initial, schedule }) { + if (initial) { + schedule(pipeline[0]); + } +}); diff --git a/tests/fixtures/projects/bad-job/jarmuz/worker-bad.mjs b/tests/fixtures/projects/bad-job/jarmuz/worker-bad.mjs new file mode 100644 index 0000000..08743db --- /dev/null +++ b/tests/fixtures/projects/bad-job/jarmuz/worker-bad.mjs @@ -0,0 +1,9 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); + + return false; +}); diff --git a/tests/fixtures/projects/persist-keepalive/jarmuz/worker-server.mjs b/tests/fixtures/projects/persist-keepalive/jarmuz/worker-server.mjs new file mode 100644 index 0000000..62001b2 --- /dev/null +++ b/tests/fixtures/projects/persist-keepalive/jarmuz/worker-server.mjs @@ -0,0 +1,11 @@ +import { persist } from "jarmuz/job-types"; + +persist(async function ({ keepAlive }) { + keepAlive( + process.execPath + + " " + + process.env.JARMUZ_PID_SCRIPT + + " " + + process.env.JARMUZ_PID_FILE, + ); +}); diff --git a/tests/fixtures/projects/persist-then-next/jarmuz/worker-keep-alive-server.mjs b/tests/fixtures/projects/persist-then-next/jarmuz/worker-keep-alive-server.mjs new file mode 100644 index 0000000..62001b2 --- /dev/null +++ b/tests/fixtures/projects/persist-then-next/jarmuz/worker-keep-alive-server.mjs @@ -0,0 +1,11 @@ +import { persist } from "jarmuz/job-types"; + +persist(async function ({ keepAlive }) { + keepAlive( + process.execPath + + " " + + process.env.JARMUZ_PID_SCRIPT + + " " + + process.env.JARMUZ_PID_FILE, + ); +}); diff --git a/tests/fixtures/projects/persist-then-next/jarmuz/worker-next.mjs b/tests/fixtures/projects/persist-then-next/jarmuz/worker-next.mjs new file mode 100644 index 0000000..0087313 --- /dev/null +++ b/tests/fixtures/projects/persist-then-next/jarmuz/worker-next.mjs @@ -0,0 +1,7 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); +}); diff --git a/tests/fixtures/projects/spawner-background/jarmuz/worker-build.mjs b/tests/fixtures/projects/spawner-background/jarmuz/worker-build.mjs new file mode 100644 index 0000000..6b3ff7e --- /dev/null +++ b/tests/fixtures/projects/spawner-background/jarmuz/worker-build.mjs @@ -0,0 +1,11 @@ +import { spawner } from "jarmuz/job-types"; + +spawner(async function ({ background }) { + background( + process.execPath + + " " + + process.env.JARMUZ_PID_SCRIPT + + " " + + process.env.JARMUZ_PID_FILE, + ); +}); diff --git a/tests/fixtures/projects/touch-file/jarmuz/worker-touch-file.mjs b/tests/fixtures/projects/touch-file/jarmuz/worker-touch-file.mjs new file mode 100644 index 0000000..0087313 --- /dev/null +++ b/tests/fixtures/projects/touch-file/jarmuz/worker-touch-file.mjs @@ -0,0 +1,7 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); +}); diff --git a/tests/fixtures/projects/trigger-job/jarmuz/worker-trigger-job.mjs b/tests/fixtures/projects/trigger-job/jarmuz/worker-trigger-job.mjs new file mode 100644 index 0000000..08743db --- /dev/null +++ b/tests/fixtures/projects/trigger-job/jarmuz/worker-trigger-job.mjs @@ -0,0 +1,9 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); + + return false; +}); diff --git a/tests/fixtures/projects/two-stage-pipeline/jarmuz/worker-first.mjs b/tests/fixtures/projects/two-stage-pipeline/jarmuz/worker-first.mjs new file mode 100644 index 0000000..0087313 --- /dev/null +++ b/tests/fixtures/projects/two-stage-pipeline/jarmuz/worker-first.mjs @@ -0,0 +1,7 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); +}); diff --git a/tests/fixtures/projects/two-stage-pipeline/jarmuz/worker-second.mjs b/tests/fixtures/projects/two-stage-pipeline/jarmuz/worker-second.mjs new file mode 100644 index 0000000..0087313 --- /dev/null +++ b/tests/fixtures/projects/two-stage-pipeline/jarmuz/worker-second.mjs @@ -0,0 +1,7 @@ +import { appendFile } from "node:fs/promises"; + +import { basic } from "jarmuz/job-types"; + +basic(async function ({ name }) { + await appendFile(process.env.JARMUZ_RESULT_FILE, name); +}); diff --git a/tests/fixtures/scripts/create-lock-or-report.mjs b/tests/fixtures/scripts/create-lock-or-report.mjs deleted file mode 100644 index ed342d3..0000000 --- a/tests/fixtures/scripts/create-lock-or-report.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { appendFile, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; - -const [, , lockFile, resultFile, pidFile] = process.argv; - -try { - await writeFile(lockFile, "", { flag: "wx" }); - await writeFile(pidFile, String(process.pid)); - await appendFile(resultFile, "ok"); - - createServer().listen(0); -} catch (error) { - await appendFile(resultFile, error.code === "EEXIST" ? "conflict" : "error"); -} diff --git a/tests/fixtures/scripts/delayed-shutdown-on-sigterm.mjs b/tests/fixtures/scripts/delayed-shutdown-on-sigterm.mjs new file mode 100644 index 0000000..f81201e --- /dev/null +++ b/tests/fixtures/scripts/delayed-shutdown-on-sigterm.mjs @@ -0,0 +1,14 @@ +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; + +const server = createServer(); +server.listen(0); + +process.on("SIGTERM", function () { + setTimeout(function () { + server.close(); + process.exit(0); + }, 100); +}); + +await writeFile(process.argv[2], String(process.pid)); diff --git a/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs b/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs index 7289416..b708687 100644 --- a/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs +++ b/tests/fixtures/workers/worker-persist-keepalive-dedup.mjs @@ -4,27 +4,15 @@ import { fileURLToPath } from "node:url"; import { persist } from "../../../src/job-types/persist.mjs"; import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; -const createLockScript = join( +const pidScript = join( dirname(fileURLToPath(import.meta.url)), "..", "scripts", - "create-lock-or-report.mjs", + "write-pid-and-stay-alive.mjs", ); -let alreadyBuilt = false; - persist(async function ({ keepAlive }) { - if (alreadyBuilt) { - return; - } - - alreadyBuilt = true; - - const exec = - `${process.execPath} ${createLockScript} ` + - `${process.env.JARMUZ_LOCK_FILE} ` + - `${process.env.JARMUZ_RESULT_FILE} ` + - `${process.env.JARMUZ_PID_FILE}`; + const exec = `${process.execPath} ${pidScript} ${process.env.JARMUZ_PID_FILE}`; keepAlive(exec); keepAlive(exec); diff --git a/tests/fixtures/workers/worker-persist-keepalive-restart-on-retrigger.mjs b/tests/fixtures/workers/worker-persist-keepalive-restart-on-retrigger.mjs new file mode 100644 index 0000000..3507804 --- /dev/null +++ b/tests/fixtures/workers/worker-persist-keepalive-restart-on-retrigger.mjs @@ -0,0 +1,18 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { persist } from "../../../src/job-types/persist.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +const pidScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "write-pid-and-stay-alive.mjs", +); + +persist(async function ({ keepAlive, baseDirectory }) { + keepAlive(`${process.execPath} ${pidScript} ${join(baseDirectory, "pid.txt")}`); +}); + +exitWorkerOnDrain(); diff --git a/tests/fixtures/workers/worker-spawner-background-pid.mjs b/tests/fixtures/workers/worker-spawner-background-pid.mjs new file mode 100644 index 0000000..085be54 --- /dev/null +++ b/tests/fixtures/workers/worker-spawner-background-pid.mjs @@ -0,0 +1,20 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { spawner } from "../../../src/job-types/spawner.mjs"; +import { exitWorkerOnDrain } from "../../support/exit-worker-on-drain.mjs"; + +const pidScript = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "write-pid-and-stay-alive.mjs", +); + +spawner(async function ({ background, baseDirectory }) { + background( + process.execPath + " " + pidScript + " " + join(baseDirectory, "pid.txt"), + ); +}); + +exitWorkerOnDrain(); diff --git a/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs b/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs index 82fa7d9..755e4c1 100644 --- a/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs +++ b/tests/jarmuz-defaults-base-directory-to-the-working-directory.test.mjs @@ -4,14 +4,11 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; test("jarmuz defaults the base directory to the working directory", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [{ name: "touch-file", source: touchFileWorkerSource }], - }); + const consumerProject = await copyConsumerProject("touch-file"); const resultFile = join(consumerProject.baseDirectory, "result.txt"); t.after(function () { diff --git a/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs b/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs index 753ccb1..d51a906 100644 --- a/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs +++ b/tests/jarmuz-once-mode-exits-1-when-a-job-fails.test.mjs @@ -3,14 +3,11 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { failingWorkerSource } from "./support/consumer-worker-sources.mjs"; test("jarmuz once mode exits with code 1 when a job fails", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [{ name: "bad", source: failingWorkerSource }], - }); + const consumerProject = await copyConsumerProject("bad-job"); const resultFile = join(consumerProject.baseDirectory, "result.txt"); t.after(function () { diff --git a/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs b/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs index 5afae9d..8d53bce 100644 --- a/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs +++ b/tests/jarmuz-once-mode-runs-the-pipeline-after-the-watcher-is-ready.test.mjs @@ -4,17 +4,11 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; test("jarmuz once mode runs the whole pipeline after the watcher is ready", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [ - { name: "first", source: touchFileWorkerSource }, - { name: "second", source: touchFileWorkerSource }, - ], - }); + const consumerProject = await copyConsumerProject("two-stage-pipeline"); const resultFile = join(consumerProject.baseDirectory, "result.txt"); t.after(function () { diff --git a/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs b/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs index 9070404..3df1d36 100644 --- a/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs +++ b/tests/jarmuz-runs-the-decider-immediately-with-the-initial-flag.test.mjs @@ -4,14 +4,11 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; test("jarmuz runs the decider immediately with the initial flag", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [{ name: "touch-file", source: touchFileWorkerSource }], - }); + const consumerProject = await copyConsumerProject("touch-file"); const resultFile = join(consumerProject.baseDirectory, "result.txt"); const deciderResultFile = join(consumerProject.baseDirectory, "decider.json"); diff --git a/tests/jarmuz-uses-the-provided-base-directory.test.mjs b/tests/jarmuz-uses-the-provided-base-directory.test.mjs index 41d071f..c081623 100644 --- a/tests/jarmuz-uses-the-provided-base-directory.test.mjs +++ b/tests/jarmuz-uses-the-provided-base-directory.test.mjs @@ -4,15 +4,12 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { makeTempDirectory } from "./support/temp-directory.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; test("jarmuz uses the provided base directory instead of the working directory", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [{ name: "touch-file", source: touchFileWorkerSource }], - }); + const consumerProject = await copyConsumerProject("touch-file"); const workingDirectory = await makeTempDirectory(); const resultFile = join(consumerProject.baseDirectory, "result.txt"); diff --git a/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs b/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs index cbaa1e8..9633cb5 100644 --- a/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs +++ b/tests/jarmuz-watch-mode-schedules-jobs-on-matching-changes.test.mjs @@ -3,15 +3,12 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { touchFileWorkerSource } from "./support/consumer-worker-sources.mjs"; import { waitForFileContent } from "./support/wait-for-file-content.mjs"; test("jarmuz watch mode schedules jobs on changes that match a pattern", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [{ name: "touch-file", source: touchFileWorkerSource }], - }); + const consumerProject = await copyConsumerProject("touch-file"); const resultFile = join(consumerProject.baseDirectory, "result.txt"); await writeFile(resultFile, ""); diff --git a/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs b/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs index 7f17b5c..e4ff05d 100644 --- a/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs +++ b/tests/jarmuz-watch-mode-skips-changes-matching-ignore-patterns.test.mjs @@ -4,15 +4,12 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { makeConsumerProject } from "./support/consumer-project.mjs"; +import { copyConsumerProject } from "./support/consumer-project.mjs"; import { runNodeScript } from "./support/run-node-script.mjs"; -import { failingWorkerSource } from "./support/consumer-worker-sources.mjs"; import { waitForFileContent } from "./support/wait-for-file-content.mjs"; test("jarmuz watch mode skips changes that match the ignore patterns", async function (t) { - const consumerProject = await makeConsumerProject({ - workers: [{ name: "trigger-job", source: failingWorkerSource }], - }); + const consumerProject = await copyConsumerProject("trigger-job"); const resultFile = join(consumerProject.baseDirectory, "result.txt"); await writeFile(resultFile, ""); diff --git a/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs b/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs index cf63b5a..243e20d 100644 --- a/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs +++ b/tests/keep-worker-alive-delivers-a-posted-message-to-the-worker.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; test("keepWorkerAlive delivers a posted message to the worker", async function (t) { @@ -11,14 +12,23 @@ test("keepWorkerAlive delivers a posted message to the worker", async function ( new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), ), onMessage: resolve, + registry: childProcessRegistry(), }); t.after(function () { return handle.terminate(); }); - handle.postMessage({ ping: "hello" }); + handle.postMessage({ + baseDirectory: "/tmp", + buildId: "build-1", + name: "worker-echo", + }); }); - assert.deepEqual(await received, { ping: "hello" }); + assert.deepEqual(await received, { + baseDirectory: "/tmp", + buildId: "build-1", + name: "worker-echo", + }); }); diff --git a/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs b/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs index 5ea2f4b..a2193f6 100644 --- a/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs +++ b/tests/keep-worker-alive-restarts-the-worker-after-an-unexpected-exit.test.mjs @@ -4,6 +4,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; import { makeTempDirectory } from "./support/temp-directory.mjs"; @@ -32,6 +33,7 @@ test("keepWorkerAlive restarts the worker after an unexpected exit", async funct new URL("./fixtures/workers/worker-crashes-once.mjs", import.meta.url), ), onMessage: resolve, + registry: childProcessRegistry(), }); }); diff --git a/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs b/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs index ad06bc4..41463a2 100644 --- a/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs +++ b/tests/keep-worker-alive-stops-and-rejects-posting-after-terminate.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; import { TerminatedWorkerError } from "../src/libs/terminated-worker-error.mjs"; @@ -11,13 +12,18 @@ test("keepWorkerAlive stops the worker and rejects posting after terminate", asy new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), ), onMessage: function () {}, + registry: childProcessRegistry(), }); await handle.terminate(); assert.throws( function () { - handle.postMessage({ ping: "hello" }); + handle.postMessage({ + baseDirectory: "/tmp", + buildId: "build-1", + name: "worker-echo", + }); }, function (error) { return ( diff --git a/tests/keep-worker-alive-terminate-reaps-the-workers-background-child.test.mjs b/tests/keep-worker-alive-terminate-reaps-the-workers-background-child.test.mjs new file mode 100644 index 0000000..0db0c33 --- /dev/null +++ b/tests/keep-worker-alive-terminate-reaps-the-workers-background-child.test.mjs @@ -0,0 +1,63 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; +import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; +import { waitForPidGone } from "./support/wait-for-pid-gone.mjs"; + +test("keep-worker-alive terminate reaps the worker's background child", async function (t) { + const tempDirectory = await makeTempDirectory(); + const pidFile = join(tempDirectory.path, "pid.txt"); + + await writeFile(pidFile, ""); + + let bgPid; + + t.after(async function () { + if (bgPid !== undefined) { + killProcess(bgPid); + } + await tempDirectory.cleanup(); + }); + + const registry = childProcessRegistry(); + + let resolveBuildResult; + const buildResultPromise = new Promise(function (resolve) { + resolveBuildResult = resolve; + }); + + const handle = keepWorkerAlive({ + onMessage(message) { + resolveBuildResult(message); + }, + path: fileURLToPath( + new URL( + "./fixtures/workers/worker-spawner-background-pid.mjs", + import.meta.url, + ), + ), + registry, + }); + + handle.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "build", + }); + + await buildResultPromise; + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }); + + bgPid = Number((await readFile(pidFile, "utf8")).trim()); + + await handle.terminate(); + await waitForPidGone(bgPid); +}); diff --git a/tests/kill-process-rethrows-non-esrch-errors.test.mjs b/tests/kill-process-rethrows-non-esrch-errors.test.mjs new file mode 100644 index 0000000..3ec030c --- /dev/null +++ b/tests/kill-process-rethrows-non-esrch-errors.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { killProcess } from "../src/libs/kill-process.mjs"; + +test("killProcess re-throws errors that aren't ESRCH", function () { + assert.throws(function () { + killProcess(NaN); + }); +}); diff --git a/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs b/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs index 074ed19..7ba85c6 100644 --- a/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs +++ b/tests/manage-pipeline-callback-drops-job-when-a-predecessor-becomes-pending.test.mjs @@ -13,10 +13,10 @@ test("manage-pipeline callback drops the job when a predecessor becomes pending assert.equal(state.pending.has("bundle"), true); - schedule.unique("compile", function () {}); + schedule.debounce("compile", function () {}); await new Promise(function (resolve) { - schedule.unique("debounce-sentinel", resolve); + schedule.debounce("debounce-sentinel", resolve); }); assert.equal(state.pending.has("bundle"), false); diff --git a/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs b/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs index f380079..6ef08cf 100644 --- a/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs +++ b/tests/manage-pipeline-callback-posts-the-build-message-to-the-worker.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; import { managePipeline } from "../src/libs/manage-pipeline.mjs"; import { scheduler } from "../src/libs/scheduler.mjs"; @@ -16,6 +17,7 @@ test("manage-pipeline callback posts the build message to the registered worker" new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), ), onMessage: resolve, + registry: childProcessRegistry(), }); t.after(function () { diff --git a/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs b/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs index f86b820..464761f 100644 --- a/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs +++ b/tests/manage-pipeline-schedule-skips-when-a-predecessor-is-pending.test.mjs @@ -9,7 +9,7 @@ test("manage-pipeline schedule skips when an earlier pipeline job is pending", f const schedule = scheduler(state); const pipeline = managePipeline(state, schedule, ["compile", "bundle"]); - schedule.unique("compile", function () {}); + schedule.debounce("compile", function () {}); pipeline.schedule("/project", "bundle", "build-1"); diff --git a/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs b/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs index 30e64bc..c819b6e 100644 --- a/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs +++ b/tests/manage-pipeline-schedule-successor-runs-the-next-job.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { keepWorkerAlive } from "../src/libs/keep-worker-alive.mjs"; import { managePipeline } from "../src/libs/manage-pipeline.mjs"; import { scheduler } from "../src/libs/scheduler.mjs"; @@ -19,6 +20,7 @@ test("manage-pipeline scheduleSuccessor runs the next job in the pipeline", asyn new URL("./fixtures/workers/worker-echo.mjs", import.meta.url), ), onMessage: resolve, + registry: childProcessRegistry(), }); t.after(function () { diff --git a/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs b/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs index 661fc54..ddb2676 100644 --- a/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs +++ b/tests/manage-workers-invokes-on-failure-for-a-failed-build.test.mjs @@ -2,12 +2,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { manageWorkers } from "../src/libs/manage-workers.mjs"; test("manageWorkers invokes onFailure and clears pending for a failed build", async function (t) { const baseDirectory = fileURLToPath(new URL("./fixtures", import.meta.url)); const state = { pending: new Map(), workers: new Map() }; - const workers = manageWorkers(baseDirectory, state); + const workers = manageWorkers(baseDirectory, state, childProcessRegistry()); t.after(function () { workers.stopAll(); diff --git a/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs b/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs index 197df5e..f0a7984 100644 --- a/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs +++ b/tests/manage-workers-invokes-on-success-for-a-successful-build.test.mjs @@ -2,12 +2,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { manageWorkers } from "../src/libs/manage-workers.mjs"; test("manageWorkers invokes onSuccess and clears pending for a successful build", async function (t) { const baseDirectory = fileURLToPath(new URL("./fixtures", import.meta.url)); const state = { pending: new Map(), workers: new Map() }; - const workers = manageWorkers(baseDirectory, state); + const workers = manageWorkers(baseDirectory, state, childProcessRegistry()); t.after(function () { workers.stopAll(); diff --git a/tests/manage-workers-stop-all-terminates-every-worker.test.mjs b/tests/manage-workers-stop-all-terminates-every-worker.test.mjs index b5cf25d..19510a4 100644 --- a/tests/manage-workers-stop-all-terminates-every-worker.test.mjs +++ b/tests/manage-workers-stop-all-terminates-every-worker.test.mjs @@ -2,12 +2,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { childProcessRegistry } from "../src/libs/child-process-registry.mjs"; import { manageWorkers } from "../src/libs/manage-workers.mjs"; test("manageWorkers stopAll terminates every worker and empties the registry", function () { const baseDirectory = fileURLToPath(new URL("./fixtures", import.meta.url)); const state = { pending: new Map(), workers: new Map() }; - const workers = manageWorkers(baseDirectory, state); + const workers = manageWorkers(baseDirectory, state, childProcessRegistry()); workers.start({ name: "reports-success", diff --git a/tests/persist-keepalive-pipeline-progresses-to-the-next-stage.test.mjs b/tests/persist-keepalive-pipeline-progresses-to-the-next-stage.test.mjs new file mode 100644 index 0000000..b4a0d04 --- /dev/null +++ b/tests/persist-keepalive-pipeline-progresses-to-the-next-stage.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { copyConsumerProject } from "./support/consumer-project.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; + +const pidScript = fileURLToPath( + new URL("./fixtures/scripts/write-pid-and-stay-alive.mjs", import.meta.url), +); + +test("persist keepAlive pipeline progresses to the next stage", async function (t) { + const consumerProject = await copyConsumerProject("persist-then-next"); + const pidFile = join(consumerProject.baseDirectory, "pid.txt"); + const resultFile = join(consumerProject.baseDirectory, "result.txt"); + + await writeFile(pidFile, ""); + await writeFile(resultFile, ""); + + let bgPid; + + t.after(async function () { + if (bgPid !== undefined) { + killProcess(bgPid); + } + await consumerProject.cleanup(); + }); + + const { closed } = runNodeScript( + fileURLToPath(new URL("./fixtures/entry-once.mjs", import.meta.url)), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_PID_FILE: pidFile, + JARMUZ_PID_SCRIPT: pidScript, + JARMUZ_PIPELINE: JSON.stringify(["keep-alive-server", "next"]), + JARMUZ_RESULT_FILE: resultFile, + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + const { code } = await closed; + + assert.equal(code, 0); + assert.equal(await readFile(resultFile, "utf8"), "next"); + + bgPid = Number((await readFile(pidFile, "utf8")).trim()); + assert.equal(Number.isFinite(bgPid), true); +}); diff --git a/tests/persist-keepalive-process-dies-when-jarmuz-exits-on-sigterm.test.mjs b/tests/persist-keepalive-process-dies-when-jarmuz-exits-on-sigterm.test.mjs new file mode 100644 index 0000000..b62d76c --- /dev/null +++ b/tests/persist-keepalive-process-dies-when-jarmuz-exits-on-sigterm.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { copyConsumerProject } from "./support/consumer-project.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; +import { waitForPidGone } from "./support/wait-for-pid-gone.mjs"; + +const pidScript = fileURLToPath( + new URL("./fixtures/scripts/write-pid-and-stay-alive.mjs", import.meta.url), +); + +test("persist keepAlive process dies when jarmuz exits on SIGTERM", async function (t) { + const consumerProject = await copyConsumerProject("persist-keepalive"); + const pidFile = join(consumerProject.baseDirectory, "pid.txt"); + + await writeFile(pidFile, ""); + + let bgPid; + + t.after(async function () { + if (bgPid !== undefined) { + killProcess(bgPid); + } + await consumerProject.cleanup(); + }); + + const { child, closed } = runNodeScript( + fileURLToPath( + new URL("./fixtures/entry-watch-schedule-initial.mjs", import.meta.url), + ), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_PID_FILE: pidFile, + JARMUZ_PID_SCRIPT: pidScript, + JARMUZ_PIPELINE: JSON.stringify(["server"]), + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }); + bgPid = Number((await readFile(pidFile, "utf8")).trim()); + assert.equal(Number.isFinite(bgPid), true); + + child.kill("SIGTERM"); + await closed; + + await waitForPidGone(bgPid); +}); diff --git a/tests/persist-keepalive-restarts-on-retrigger.test.mjs b/tests/persist-keepalive-restarts-on-retrigger.test.mjs new file mode 100644 index 0000000..b36aa37 --- /dev/null +++ b/tests/persist-keepalive-restarts-on-retrigger.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { createWorker } from "./support/create-worker.mjs"; +import { drainWorker } from "./support/drain-worker.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; +import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForBuildResult } from "./support/wait-for-build-result.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; + +test("persist keepAlive restarts the kept-alive process on a pipeline retrigger", async function (t) { + const tempDirectory = await makeTempDirectory(); + const pidFile = join(tempDirectory.path, "pid.txt"); + + await writeFile(pidFile, ""); + + const worker = createWorker("worker-persist-keepalive-restart-on-retrigger"); + + let bgPid; + + t.after(async function () { + if (bgPid !== undefined) { + killProcess(bgPid); + } + await worker.terminate(); + await tempDirectory.cleanup(); + }); + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-1", + name: "server", + }); + + await waitForBuildResult(worker); + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }); + const firstPid = Number((await readFile(pidFile, "utf8")).trim()); + bgPid = firstPid; + + worker.postMessage({ + baseDirectory: tempDirectory.path, + buildId: "build-2", + name: "server", + }); + + await waitForBuildResult(worker); + await waitForFileContent(pidFile, function (content) { + const reportedPid = Number(content.trim()); + return Number.isFinite(reportedPid) && reportedPid !== firstPid; + }); + const secondPid = Number((await readFile(pidFile, "utf8")).trim()); + bgPid = secondPid; + + assert.notEqual(secondPid, firstPid); + + await drainWorker(worker); +}); diff --git a/tests/persist-keepalive-ignores-a-duplicate-exec-string.test.mjs b/tests/persist-keepalive-throws-on-duplicate-exec-within-build.test.mjs similarity index 60% rename from tests/persist-keepalive-ignores-a-duplicate-exec-string.test.mjs rename to tests/persist-keepalive-throws-on-duplicate-exec-within-build.test.mjs index 77118f1..45f8b41 100644 --- a/tests/persist-keepalive-ignores-a-duplicate-exec-string.test.mjs +++ b/tests/persist-keepalive-throws-on-duplicate-exec-within-build.test.mjs @@ -5,36 +5,30 @@ import { test } from "node:test"; import { createWorker } from "./support/create-worker.mjs"; import { drainWorker } from "./support/drain-worker.mjs"; -import { killProcess } from "./support/kill-process.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; import { makeTempDirectory } from "./support/temp-directory.mjs"; +import { waitForBuildResult } from "./support/wait-for-build-result.mjs"; import { waitForFileContent } from "./support/wait-for-file-content.mjs"; -import { waitForMessage } from "./support/wait-for-message.mjs"; -test("persist keepAlive ignores a duplicate exec string", async function (t) { +test("persist keepAlive throws on a duplicate exec within a build", async function (t) { const tempDirectory = await makeTempDirectory(); - const lockFile = join(tempDirectory.path, "lock.txt"); - const resultFile = join(tempDirectory.path, "result.txt"); const pidFile = join(tempDirectory.path, "pid.txt"); - await writeFile(resultFile, ""); await writeFile(pidFile, ""); const worker = createWorker("worker-persist-keepalive-dedup", { env: { ...process.env, - JARMUZ_LOCK_FILE: lockFile, - JARMUZ_RESULT_FILE: resultFile, JARMUZ_PID_FILE: pidFile, }, }); - let childPid; + let bgPid; t.after(async function () { - if (childPid !== undefined) { - killProcess(childPid); + if (bgPid !== undefined) { + killProcess(bgPid); } - await worker.terminate(); await tempDirectory.cleanup(); }); @@ -45,15 +39,11 @@ test("persist keepAlive ignores a duplicate exec string", async function (t) { name: "server", }); - await waitForMessage(worker); - - const result = await waitForFileContent(resultFile, function (content) { - return content.length > 0; - }); + const result = await waitForBuildResult(worker); - assert.equal(result, "ok"); + assert.equal(result.success, false); - childPid = Number( + bgPid = Number( await waitForFileContent(pidFile, function (content) { return content.length > 0; }), diff --git a/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs b/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs index 348f89a..faebe53 100644 --- a/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs +++ b/tests/scheduler-reschedule-cancels-the-earlier-callback.test.mjs @@ -9,12 +9,12 @@ test("scheduler reschedule cancels the earlier callback so only the latest runs" let firstCallbackRan = false; - schedule.unique("compile", function () { + schedule.debounce("compile", function () { firstCallbackRan = true; }); await new Promise(function (resolve) { - schedule.unique("compile", resolve); + schedule.debounce("compile", resolve); }); assert.equal(firstCallbackRan, false); diff --git a/tests/scheduler-runs-callback-after-debounce-window.test.mjs b/tests/scheduler-runs-callback-after-debounce-window.test.mjs index 07fb748..0354fa0 100644 --- a/tests/scheduler-runs-callback-after-debounce-window.test.mjs +++ b/tests/scheduler-runs-callback-after-debounce-window.test.mjs @@ -8,7 +8,7 @@ test("scheduler runs a uniquely scheduled callback after the debounce window", a const schedule = scheduler(state); await new Promise(function (resolve) { - schedule.unique("compile", resolve); + schedule.debounce("compile", resolve); }); assert.equal(state.pending.has("compile"), true); diff --git a/tests/spawner-background-process-dies-when-jarmuz-exits-on-sigterm.test.mjs b/tests/spawner-background-process-dies-when-jarmuz-exits-on-sigterm.test.mjs new file mode 100644 index 0000000..e5b2511 --- /dev/null +++ b/tests/spawner-background-process-dies-when-jarmuz-exits-on-sigterm.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { copyConsumerProject } from "./support/consumer-project.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; +import { runNodeScript } from "./support/run-node-script.mjs"; +import { waitForFileContent } from "./support/wait-for-file-content.mjs"; +import { waitForPidGone } from "./support/wait-for-pid-gone.mjs"; + +const pidScript = fileURLToPath( + new URL("./fixtures/scripts/write-pid-and-stay-alive.mjs", import.meta.url), +); + +test("spawner background process dies when jarmuz exits on SIGTERM", async function (t) { + const consumerProject = await copyConsumerProject("spawner-background"); + const pidFile = join(consumerProject.baseDirectory, "pid.txt"); + + await writeFile(pidFile, ""); + + let bgPid; + + t.after(async function () { + if (bgPid !== undefined) { + killProcess(bgPid); + } + await consumerProject.cleanup(); + }); + + const { child, closed } = runNodeScript( + fileURLToPath( + new URL("./fixtures/entry-watch-schedule-initial.mjs", import.meta.url), + ), + { + cwd: consumerProject.baseDirectory, + env: { + ...process.env, + JARMUZ_BASE_DIRECTORY: consumerProject.baseDirectory, + JARMUZ_PID_FILE: pidFile, + JARMUZ_PID_SCRIPT: pidScript, + JARMUZ_PIPELINE: JSON.stringify(["build"]), + JARMUZ_WATCH: JSON.stringify([consumerProject.watchedDirectory]), + }, + }, + ); + + await waitForFileContent(pidFile, function (content) { + return content.length > 0; + }); + bgPid = Number((await readFile(pidFile, "utf8")).trim()); + assert.equal(Number.isFinite(bgPid), true); + + child.kill("SIGTERM"); + await closed; + + await waitForPidGone(bgPid); +}); diff --git a/tests/spawner-runs-background-process-without-waiting.test.mjs b/tests/spawner-runs-background-process-without-waiting.test.mjs index 91e3434..e507065 100644 --- a/tests/spawner-runs-background-process-without-waiting.test.mjs +++ b/tests/spawner-runs-background-process-without-waiting.test.mjs @@ -7,7 +7,7 @@ import { createWorker } from "./support/create-worker.mjs"; import { drainWorker } from "./support/drain-worker.mjs"; import { makeTempDirectory } from "./support/temp-directory.mjs"; import { waitForFileContent } from "./support/wait-for-file-content.mjs"; -import { waitForMessage } from "./support/wait-for-message.mjs"; +import { waitForBuildResult } from "./support/wait-for-build-result.mjs"; test("spawner runs a background process without waiting for it to finish", async function (t) { const tempDirectory = await makeTempDirectory(); @@ -30,7 +30,7 @@ test("spawner runs a background process without waiting for it to finish", async name: "server", }); - const result = await waitForMessage(worker); + const result = await waitForBuildResult(worker); assert.equal(result.success, true); diff --git a/tests/spawner-sigkills-running-process-on-next-build.test.mjs b/tests/spawner-sigkills-running-process-on-next-build.test.mjs index 0223e72..094f4e8 100644 --- a/tests/spawner-sigkills-running-process-on-next-build.test.mjs +++ b/tests/spawner-sigkills-running-process-on-next-build.test.mjs @@ -5,10 +5,10 @@ import { join } from "node:path"; import { test } from "node:test"; import { createWorker } from "./support/create-worker.mjs"; -import { killProcess } from "./support/kill-process.mjs"; +import { killProcess } from "../src/libs/kill-process.mjs"; import { makeTempDirectory } from "./support/temp-directory.mjs"; import { waitForFileContent } from "./support/wait-for-file-content.mjs"; -import { waitForMessage } from "./support/wait-for-message.mjs"; +import { waitForBuildResult } from "./support/wait-for-build-result.mjs"; test("spawner SIGKILLs a still-running process on the next build", async function (t) { const tempDirectory = await makeTempDirectory(); @@ -37,7 +37,7 @@ test("spawner SIGKILLs a still-running process on the next build", async functio name: "server", }); - await waitForMessage(worker); + await waitForBuildResult(worker); childPid = Number( await waitForFileContent(pidFile, function (content) { diff --git a/tests/support/consumer-project.mjs b/tests/support/consumer-project.mjs index 53364b8..68dd790 100644 --- a/tests/support/consumer-project.mjs +++ b/tests/support/consumer-project.mjs @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,21 +9,22 @@ const repositoryRoot = join( "..", ); -export async function makeConsumerProject({ workers }) { +export async function copyConsumerProject(projectName) { + const sourceDirectory = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "fixtures", + "projects", + projectName, + ); + const baseDirectory = await mkdtemp(join(tmpdir(), "jarmuz-project-")); const watchedDirectory = join(baseDirectory, "watched"); - await mkdir(join(baseDirectory, "jarmuz")); + await cp(sourceDirectory, baseDirectory, { recursive: true }); await mkdir(join(baseDirectory, "node_modules")); - await mkdir(watchedDirectory); await symlink(repositoryRoot, join(baseDirectory, "node_modules", "jarmuz")); - - for (const { name, source } of workers) { - await writeFile( - join(baseDirectory, "jarmuz", `worker-${name}.mjs`), - source, - ); - } + await mkdir(watchedDirectory, { recursive: true }); return { baseDirectory, diff --git a/tests/support/consumer-worker-sources.mjs b/tests/support/consumer-worker-sources.mjs deleted file mode 100644 index c84c0ca..0000000 --- a/tests/support/consumer-worker-sources.mjs +++ /dev/null @@ -1,19 +0,0 @@ -export const touchFileWorkerSource = `import { appendFile } from "node:fs/promises"; - -import { basic } from "jarmuz/job-types"; - -basic(async function ({ name }) { - await appendFile(process.env.JARMUZ_RESULT_FILE, name); -}); -`; - -export const failingWorkerSource = `import { appendFile } from "node:fs/promises"; - -import { basic } from "jarmuz/job-types"; - -basic(async function ({ name }) { - await appendFile(process.env.JARMUZ_RESULT_FILE, name); - - return false; -}); -`; diff --git a/tests/support/kill-process.mjs b/tests/support/kill-process.mjs deleted file mode 100644 index 51d6296..0000000 --- a/tests/support/kill-process.mjs +++ /dev/null @@ -1,9 +0,0 @@ -export function killProcess(pid) { - try { - process.kill(pid, "SIGKILL"); - } catch (error) { - if (error.code !== "ESRCH") { - throw error; - } - } -} diff --git a/tests/support/run-persist-restart-scenario.mjs b/tests/support/run-persist-restart-scenario.mjs index 8a2ef60..f4e8969 100644 --- a/tests/support/run-persist-restart-scenario.mjs +++ b/tests/support/run-persist-restart-scenario.mjs @@ -5,7 +5,7 @@ import { createWorker } from "./create-worker.mjs"; import { drainWorker } from "./drain-worker.mjs"; import { makeTempDirectory } from "./temp-directory.mjs"; import { waitForFileContent } from "./wait-for-file-content.mjs"; -import { waitForMessage } from "./wait-for-message.mjs"; +import { waitForBuildResult } from "./wait-for-build-result.mjs"; export async function runPersistRestartScenario(t, fixtureName) { const tempDirectory = await makeTempDirectory(); @@ -28,7 +28,7 @@ export async function runPersistRestartScenario(t, fixtureName) { name: "server", }); - await waitForMessage(worker); + await waitForBuildResult(worker); await waitForFileContent(resultFile, function (content) { return content.split("\n").filter(Boolean).length >= 2; diff --git a/tests/support/run-worker-build.mjs b/tests/support/run-worker-build.mjs index 695e37f..e05b702 100644 --- a/tests/support/run-worker-build.mjs +++ b/tests/support/run-worker-build.mjs @@ -1,13 +1,13 @@ import { createWorker } from "./create-worker.mjs"; import { drainWorker } from "./drain-worker.mjs"; -import { waitForMessage } from "./wait-for-message.mjs"; +import { waitForBuildResult } from "./wait-for-build-result.mjs"; export async function runWorkerBuild(fixtureName, message, options = {}) { const worker = createWorker(fixtureName, options); worker.postMessage(message); - const result = await waitForMessage(worker); + const result = await waitForBuildResult(worker); await drainWorker(worker); diff --git a/tests/support/spawn-idle-process.mjs b/tests/support/spawn-idle-process.mjs new file mode 100644 index 0000000..5024cb5 --- /dev/null +++ b/tests/support/spawn-idle-process.mjs @@ -0,0 +1,8 @@ +import { spawn } from "node:child_process"; + +export function spawnIdleProcess() { + return spawn(process.execPath, [ + "-e", + "setInterval(function () {}, 1000000);", + ]); +} diff --git a/tests/support/wait-for-build-result.mjs b/tests/support/wait-for-build-result.mjs new file mode 100644 index 0000000..343018d --- /dev/null +++ b/tests/support/wait-for-build-result.mjs @@ -0,0 +1,20 @@ +export function waitForBuildResult(emitter) { + return new Promise(function (resolve) { + function handleMessage(message) { + if ( + message !== null && + typeof message === "object" && + message.type === "build-result" + ) { + emitter.off("message", handleMessage); + resolve({ + baseDirectory: message.baseDirectory, + buildId: message.buildId, + success: message.success, + }); + } + } + + emitter.on("message", handleMessage); + }); +} diff --git a/tests/support/wait-for-message.mjs b/tests/support/wait-for-message.mjs deleted file mode 100644 index 5b90587..0000000 --- a/tests/support/wait-for-message.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export function waitForMessage(emitter) { - return new Promise(function (resolve) { - emitter.once("message", resolve); - }); -} diff --git a/tests/support/wait-for-pid-gone.mjs b/tests/support/wait-for-pid-gone.mjs new file mode 100644 index 0000000..835de3b --- /dev/null +++ b/tests/support/wait-for-pid-gone.mjs @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; + +export function waitForPidGone(pid) { + return new Promise(function (resolve, reject) { + function probe() { + try { + process.kill(pid, 0); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ESRCH" + ) { + resolve(); + return; + } + reject(error); + return; + } + + // The PID exists at the OS level. When a child of a terminated worker + // thread dies, libuv has no live handle to drive the SIGCHLD reap, so + // the kernel leaves it as a zombie until the parent process exits. For + // test purposes the process is effectively gone — accept the zombie + // state as "dead" by reading /proc//status on Linux. + try { + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + + if (status.includes("\nState:\tZ")) { + resolve(); + return; + } + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + resolve(); + return; + } + reject(error); + return; + } + + setImmediate(probe); + } + + probe(); + }); +}