Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/rules/error-handling.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 9 additions & 0 deletions .claude/rules/javascript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
paths:
- "**/*.mjs"
---

# JavaScript Standards

- Never use inline imports.
- Destructure objects, and messages, especially in function arguments.
12 changes: 0 additions & 12 deletions .claude/rules/nix-shell.md

This file was deleted.

18 changes: 18 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
---
paths:
- "Makefile"
- "tests/**/*.mjs"
---

# Unit Tests and Quality Control

- Always check that the unit tests pass.
Expand All @@ -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.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions scripts/check-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 37 additions & 45 deletions src/job-types/basic.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
});
}
65 changes: 51 additions & 14 deletions src/job-types/persist.mjs
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -9,53 +12,87 @@ import { basic } from "./basic.mjs";
* }} PersistContext
*/

/** @type {Set<string>} */
const running = new Set();

/**
* @param {(context: PersistContext) => unknown} build
*/
export function persist(build) {
const port = workerPort();

/** @type {Set<import("node:child_process").ChildProcess>} */
const runningProcesses = new Set();
/** @type {WeakSet<import("node:child_process").ChildProcess>} */
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);

Comment thread
mcharytoniuk marked this conversation as resolved.
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,
});
});
}

/** @returns {Promise<void>} */
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<string>} */
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,
Expand Down
19 changes: 13 additions & 6 deletions src/job-types/spawner.mjs
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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<import("node:child_process").ChildProcess>} */
const running = new Set();
Expand Down Expand Up @@ -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);
Comment thread
mcharytoniuk marked this conversation as resolved.

return new Promise(function (resolve) {
proc.once("close", function (code) {
Expand Down Expand Up @@ -82,7 +89,7 @@ export function spawner(build) {
* @param {{ background: boolean; exec: string }} input
* @returns {Promise<boolean | void>}
*/
function registerProc({ background, exec }) {
function spawnAndRegister({ background, exec }) {
const [command, ...args] = parseArgsStringToArgv(exec);
const proc = spawn(command, args, {
cwd: baseDirectory,
Expand All @@ -100,15 +107,15 @@ export function spawner(build) {
* @returns {Promise<void>}
*/
async function background(exec) {
await registerProc({ background: true, exec });
await spawnAndRegister({ background: true, exec });
}

/**
* @param {string} exec
* @returns {Promise<boolean | void>}
*/
function command(exec) {
return registerProc({ background: false, exec });
return spawnAndRegister({ background: false, exec });
}

/**
Expand Down
Loading