@williamthorsen/nmr 0.46.2 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "0.47.0",
4
+ "date": "2026-10-15",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Sets `maxWorkers` on every project built by `defineVitestConfig` and `defineRootVitestConfig` to one worker per 4 GiB of total memory, bounded by one fewer than the core count, in place of Vitest's default of one fewer than the core count, which does not take memory into account; machines with 2 or fewer cores keep 3 workers. (#1044)",
12
+ "hash": "8c761e2b054daf91ba362d3f3e878329d35d2eb4",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Applies the `--maxWorkers` (or `--max-workers`) flag to every project, which Vitest does not do itself, making the flag override both the derived count and a `maxWorkers` set through the `project` or `tiers` seam, while the `VITEST_MAX_WORKERS` environment variable still overrides the flag. (#1044)",
17
+ "hash": "8c761e2b054daf91ba362d3f3e878329d35d2eb4",
18
+ "entry": 2
19
+ },
20
+ {
21
+ "description": "Adds a machine lock that makes a heavy command (`check`, `ci`, and every `test` command except `test:watch`) wait, printing `nmr: waiting for …`, until the heavy command already running on the machine finishes; a nested command and a `-R` or `-F` fan-out take the lock once, and a cached pass does not wait. (#1045)",
22
+ "hash": "2797c36e73b431c848a5819909aae7f3de6e0af6",
23
+ "entry": 1
24
+ },
25
+ {
26
+ "description": "Adds the `--no-lock` flag and the `NMR_NO_LOCK=1` environment variable, which run a command and the commands nested in it without waiting for the machine lock, for example on a self-hosted CI machine that runs several runners in parallel. (#1045)",
27
+ "hash": "2797c36e73b431c848a5819909aae7f3de6e0af6",
28
+ "entry": 2
29
+ },
30
+ {
31
+ "description": "Extends the `nmr` rulebook to state that a run printing `nmr: waiting for …` is waiting for the machine lock rather than stuck, and that an agent must neither kill it nor rerun it with `--no-lock`. (#1045)",
32
+ "hash": "2797c36e73b431c848a5819909aae7f3de6e0af6",
33
+ "entry": 3
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ },
2
39
  {
3
40
  "version": "0.46.2",
4
41
  "date": "2026-10-14",
@@ -8939,7 +8939,7 @@ function formatPaths(paths) {
8939
8939
  ${paths.map((path7) => ` ${path7}`).join("\n")}`;
8940
8940
  }
8941
8941
  function getMinVersion() {
8942
- const pickedFields = { "version": "0.46.2" };
8942
+ const pickedFields = { "version": "0.47.0" };
8943
8943
  if (typeof pickedFields["version"] !== "string") {
8944
8944
  throw new TypeError("nmr/package.json: 'version' must be a string");
8945
8945
  }
@@ -86,7 +86,7 @@
86
86
  "path": "kits/default.ts"
87
87
  },
88
88
  {
89
- "hash": "43858381",
89
+ "hash": "a06b470a",
90
90
  "kind": "inline",
91
91
  "path": "../package.json",
92
92
  "paths": [
@@ -189,7 +189,7 @@
189
189
  "readyupVersion": "0.40.0",
190
190
  "source": "kits/default.ts",
191
191
  "sourceHash": "aaeb1a84",
192
- "targetHash": "32ebc3f7"
192
+ "targetHash": "125a39cc"
193
193
  }
194
194
  ]
195
195
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.47.0 — 2026-10-15
6
+
7
+ ### 🎉 Features
8
+
9
+ - Sets `maxWorkers` on every project built by `defineVitestConfig` and `defineRootVitestConfig` to one worker per 4 GiB of total memory, bounded by one fewer than the core count, in place of Vitest's default of one fewer than the core count, which does not take memory into account; machines with 2 or fewer cores keep 3 workers. (#1044)
10
+ - Applies the `--maxWorkers` (or `--max-workers`) flag to every project, which Vitest does not do itself, making the flag override both the derived count and a `maxWorkers` set through the `project` or `tiers` seam, while the `VITEST_MAX_WORKERS` environment variable still overrides the flag. (#1044)
11
+ - Adds a machine lock that makes a heavy command (`check`, `ci`, and every `test` command except `test:watch`) wait, printing `nmr: waiting for …`, until the heavy command already running on the machine finishes; a nested command and a `-R` or `-F` fan-out take the lock once, and a cached pass does not wait. (#1045)
12
+ - Adds the `--no-lock` flag and the `NMR_NO_LOCK=1` environment variable, which run a command and the commands nested in it without waiting for the machine lock, for example on a self-hosted CI machine that runs several runners in parallel. (#1045)
13
+ - Extends the `nmr` rulebook to state that a run printing `nmr: waiting for …` is waiting for the machine lock rather than stuck, and that an agent must neither kill it nor rerun it with `--no-lock`. (#1045)
14
+
5
15
  ## 0.46.2 — 2026-10-14
6
16
 
7
17
  ### 🐛 Bug fixes
package/README.md CHANGED
@@ -5,11 +5,15 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.46.2 (2026-10-14)
8
+ ## Release notes — v0.47.0 (2026-10-15)
9
9
 
10
- ### 🐛 Bug fixes
10
+ ### 🎉 Features
11
11
 
12
- - Fixes the issue that `checkTestFileConventions` failed with a bare `TypeError` when `node_modules` contained a second `@vitest/runner` copy, and replaces that error with one that names the resolved `vitest` and gives `pnpm install --force` as the remedy. (#1041)
12
+ - Sets `maxWorkers` on every project built by `defineVitestConfig` and `defineRootVitestConfig` to one worker per 4 GiB of total memory, bounded by one fewer than the core count, in place of Vitest's default of one fewer than the core count, which does not take memory into account; machines with 2 or fewer cores keep 3 workers. (#1044)
13
+ - Applies the `--maxWorkers` (or `--max-workers`) flag to every project, which Vitest does not do itself, making the flag override both the derived count and a `maxWorkers` set through the `project` or `tiers` seam, while the `VITEST_MAX_WORKERS` environment variable still overrides the flag. (#1044)
14
+ - Adds a machine lock that makes a heavy command (`check`, `ci`, and every `test` command except `test:watch`) wait, printing `nmr: waiting for …`, until the heavy command already running on the machine finishes; a nested command and a `-R` or `-F` fan-out take the lock once, and a cached pass does not wait. (#1045)
15
+ - Adds the `--no-lock` flag and the `NMR_NO_LOCK=1` environment variable, which run a command and the commands nested in it without waiting for the machine lock, for example on a self-hosted CI machine that runs several runners in parallel. (#1045)
16
+ - Extends the `nmr` rulebook to state that a run printing `nmr: waiting for …` is waiting for the machine lock rather than stuck, and that an agent must neither kill it nor rerun it with `--no-lock`. (#1045)
13
17
  <!-- /section:release-notes -->
14
18
 
15
19
  ## Installation
@@ -180,6 +184,17 @@ $ nmr ci
180
184
 
181
185
  [Check-result cache](docs/check-cache.md) covers what is cached, what a hit requires, replaying and reading a recorded run, and bypassing or configuring the cache.
182
186
 
187
+ ## Machine lock
188
+
189
+ Tests and checks peak at several gigabytes, so nmr runs one heavy command at a time on a machine. A second `check`, `ci`, or `test` from any repository waits for the first rather than competing with it for memory:
190
+
191
+ ```console
192
+ $ nmr check
193
+ nmr: waiting for test in /Users/me/repos/other-repo (pid 41872) to finish
194
+ ```
195
+
196
+ The lock lives in `/tmp/nmr-<uid>/`, or in `NMR_LOCK_DIR`; a sandboxed session needs write access to it. `nmr --no-lock <command>` or `NMR_NO_LOCK=1` runs without waiting. [Machine lock](docs/machine-lock.md) covers which commands take it, nesting and fan-outs, reclaiming a lock that its holder left behind, and CI.
197
+
183
198
  ## Shared configs and helpers
184
199
 
185
200
  ### Shared Vitest config
@@ -283,6 +298,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
283
298
  - [Scripts and configuration](docs/scripts.md): Override tiers, script values, hooks, `defineConfig` fields, `devBin`, and the default commands
284
299
  - [What nmr reports](docs/reporting.md): Verdicts, `--json`, output style, and verbosity
285
300
  - [Check-result cache](docs/check-cache.md): What is skipped, what a hit requires, and replaying, reading, and bypassing recorded runs
301
+ - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
286
302
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
287
303
  - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, and `ensure-prepublish-hooks`
288
304
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
@@ -14,4 +14,5 @@ delivery: ambient
14
14
  - `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it. Output that you need in full is a recording to read rather than a command to run again. It runs nothing, and it is held to the tree rather than to the terminal: A recording made by a piped run prints for a reader at one. A composite prints its constituents' excerpts, not their transcripts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. When there is nothing to show, `--log` says so on one line, names which case it is, and exits non-zero.
15
15
  - Every command that nmr runs reports one line -- `PASS <scope>: <command>: passed in 12.4s`, `FAIL … failed in 1.2s (exit 1)`, `SKIP … passed 4m ago on this tree`, or `NOOP … skipped, the override is empty` -- and a composite reports alongside every command into which it expands. Those are the words that a pipe and a CI log get; a terminal gets ✅, ❌, ⏩, and ⚪ in their place, and `--output-style rich` or `NMR_OUTPUT_STYLE=rich` forces the emoji on any stream. The lines print in every verbosity, so `nmr -q <command>` is the low-noise way to run rather than a silent one: It withholds the output of the commands that nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. nmr reads its own flags only before the command name and passes everything after the name to the commands that run: `nmr ci -q` hands `-q` to `nmr-fmt`, which rejects it. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
16
  - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing that the test reaches -- `tool` (a process that the test starts, or a program other than the package's own code that the test makes do its work, including an embedded engine, which the package installs and the test runs in-process, such as PGlite), `localhost` (a service already running on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --sources` reports both and warns that the check is missing.
17
- - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
17
+ - A heavy command (`check`, `ci`, and the `test` commands but `test:watch`) may print `nmr: waiting for <command> in <dir> (pid <pid>) to finish` and wait: nmr runs one at a time on the machine, across every repository. The run is not stuck, and it continues once the holder ends. Do not kill it, and do not rerun it with `--no-lock` to get past the wait, which defeats the memory protection that the lock exists for. A warning that nmr is `running without the machine lock` names a directory that the sandbox cannot write.
18
+ - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; `machine-lock.md` for which commands take the machine lock, where it lives, and its bypass; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
package/dist/esm/help.js CHANGED
@@ -35,6 +35,7 @@ export function generateHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
35
35
  ' --json Report one JSON object per command, withholding command output',
36
36
  ' --log Print the recorded run instead of running (up to 256 KiB)',
37
37
  ' --no-cache Run even if this tree already passed; record the result',
38
+ ' --no-lock Run without waiting for another heavy command on this machine',
38
39
  ` ${OUTPUT_STYLE_FLAG} <style> Output style: auto (default), plain, or rich`,
39
40
  ' -?, --help Show this help',
40
41
  ' -V, --version Show version number',
@@ -0,0 +1,64 @@
1
+ import type { Writable } from 'node:stream';
2
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
3
+ /** How often a holder refreshes the lock file's mtime. */
4
+ export declare const HEARTBEAT_INTERVAL_MS = 10000;
5
+ /**
6
+ * The commands that take the machine lock: the ones whose memory peak makes two concurrent runs slower than two in
7
+ * sequence. `test:watch` is absent because it runs until it is stopped, and holding the lock would block every other
8
+ * run for as long.
9
+ */
10
+ export declare const HEAVY_COMMANDS: ReadonlySet<string>;
11
+ /** Overrides the directory that contains the lock file. */
12
+ export declare const LOCK_DIR_ENV_VAR = "NMR_LOCK_DIR";
13
+ /** Passes the holder's token to the processes below it, which run under its hold rather than waiting on it. */
14
+ export declare const LOCK_TOKEN_ENV_VAR = "NMR_MACHINE_LOCK_TOKEN";
15
+ /** Set to `1` to run without the lock, for the whole chain. */
16
+ export declare const NO_LOCK_ENV_VAR = "NMR_NO_LOCK";
17
+ /** How often a waiter checks the lock again. */
18
+ export declare const POLL_INTERVAL_MS = 1000;
19
+ /**
20
+ * Age at which an unrefreshed lock file is abandoned. It covers a holder whose pid was reused by another process,
21
+ * and a file left half-written, neither of which a liveness check detects.
22
+ */
23
+ export declare const STALE_AFTER_MS = 60000;
24
+ /** A taken lock, or the hold-free stand-in for a run that does not take one. */
25
+ export interface MachineLock {
26
+ /**
27
+ * Whether the chain runs without the lock: the bypass is set, or the lock directory cannot be written. The processes
28
+ * below then skip the lock rather than each trying it again and repeating the warning.
29
+ */
30
+ isChainUnlocked: boolean;
31
+ release: () => Promise<void>;
32
+ /** The token in the lock file, when this call took the lock. */
33
+ token: string | undefined;
34
+ /** Whether the call waited for another holder before returning. */
35
+ waited: boolean;
36
+ }
37
+ /**
38
+ * Takes the machine lock, waiting while another process holds it. Resolves to a hold-free lock without waiting when
39
+ * the bypass is set, when this process runs under an ancestor's hold, or when the lock directory cannot be written.
40
+ */
41
+ export declare function acquireMachineLock(options: {
42
+ command: string;
43
+ dir: string;
44
+ env: NodeJS.ProcessEnv;
45
+ stderr: Writable;
46
+ heartbeatIntervalMs?: number;
47
+ lockDir?: string;
48
+ pollIntervalMs?: number;
49
+ staleAfterMs?: number;
50
+ }): Promise<MachineLock>;
51
+ /**
52
+ * Renders the warning for a `--no-lock` that appears after the command name, which makes it an argument to the
53
+ * command rather than a flag to nmr.
54
+ */
55
+ export declare function formatMisplacedNoLockWarning(command: string, style: OutputStyle): string;
56
+ /**
57
+ * Returns the directory that contains the lock file: one per user under `/tmp`, which a sandboxed session and a
58
+ * terminal resolve alike, where `TMPDIR` does not. Windows does not have `/tmp` or a uid.
59
+ */
60
+ export declare function resolveLockDir(options: {
61
+ env: NodeJS.ProcessEnv;
62
+ platform: NodeJS.Platform;
63
+ uid: number | undefined;
64
+ }): string;
@@ -0,0 +1,229 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
8
+ import { isObject } from "./helpers/type-guards.js";
9
+ const LOCK_FILE = 'heavy.lock';
10
+ const RECLAIM_GUARD = 'heavy.lock.reclaim';
11
+ const RECLAIM_GUARD_STALE_AFTER_MS = 10_000;
12
+ export const HEARTBEAT_INTERVAL_MS = 10_000;
13
+ export const HEAVY_COMMANDS = new Set([
14
+ 'check',
15
+ 'check:strict',
16
+ 'ci',
17
+ 'root:check',
18
+ 'root:test',
19
+ 'root:test:all',
20
+ 'root:test:tool',
21
+ 'root:test:unit',
22
+ 'test',
23
+ 'test:all',
24
+ 'test:coverage',
25
+ 'test:tool',
26
+ 'test:unit',
27
+ ]);
28
+ export const LOCK_DIR_ENV_VAR = 'NMR_LOCK_DIR';
29
+ export const LOCK_TOKEN_ENV_VAR = 'NMR_MACHINE_LOCK_TOKEN';
30
+ export const NO_LOCK_ENV_VAR = 'NMR_NO_LOCK';
31
+ export const POLL_INTERVAL_MS = 1_000;
32
+ export const STALE_AFTER_MS = 60_000;
33
+ export async function acquireMachineLock(options) {
34
+ const { command, dir, env, stderr } = options;
35
+ if (env[NO_LOCK_ENV_VAR] === '1') {
36
+ return createHoldFreeLock({ isChainUnlocked: true, waited: false });
37
+ }
38
+ const lockDir = options.lockDir ?? resolveLockDir({ env, platform: process.platform, uid: process.getuid?.() });
39
+ if (await isUnderAncestorHold(lockDir, env)) {
40
+ return createHoldFreeLock({ isChainUnlocked: false, waited: false });
41
+ }
42
+ const lockPath = path.join(lockDir, LOCK_FILE);
43
+ try {
44
+ await fs.mkdir(lockDir, { recursive: true, mode: 0o700 });
45
+ }
46
+ catch (error) {
47
+ reportUnwritableDir(lockDir, error, stderr);
48
+ return createHoldFreeLock({ isChainUnlocked: true, waited: false });
49
+ }
50
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
51
+ const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
52
+ const staleAfterMs = options.staleAfterMs ?? STALE_AFTER_MS;
53
+ const record = {
54
+ command,
55
+ dir,
56
+ pid: process.pid,
57
+ startedAt: new Date().toISOString(),
58
+ token: randomUUID(),
59
+ };
60
+ let waited = false;
61
+ let reportedHolder;
62
+ for (;;) {
63
+ try {
64
+ await fs.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' });
65
+ return createHeldLock({ heartbeatIntervalMs, lockPath, token: record.token, waited });
66
+ }
67
+ catch (error) {
68
+ if (readErrorCode(error) !== 'EEXIST') {
69
+ reportUnwritableDir(lockDir, error, stderr);
70
+ return createHoldFreeLock({ isChainUnlocked: true, waited });
71
+ }
72
+ }
73
+ const state = await readLockState(lockPath);
74
+ if (state === undefined) {
75
+ continue;
76
+ }
77
+ if (isStale(state, staleAfterMs)) {
78
+ await reclaimStaleLock({ lockDir, lockPath, observed: state, staleAfterMs });
79
+ continue;
80
+ }
81
+ const holderIdentity = state.holder?.token ?? 'unreadable';
82
+ if (holderIdentity !== reportedHolder) {
83
+ stderr.write(`${formatWaitingLine(state.holder, lockPath)}\n`);
84
+ reportedHolder = holderIdentity;
85
+ }
86
+ waited = true;
87
+ await delay(pollIntervalMs);
88
+ }
89
+ }
90
+ export function formatMisplacedNoLockWarning(command, style) {
91
+ return (`${STATUS_GLYPHS[style].warning.text} --no-lock after the command name is passed to \`${command}\`, ` +
92
+ `not read by nmr. Did you mean \`nmr --no-lock ${command}\`?`);
93
+ }
94
+ export function resolveLockDir(options) {
95
+ const { env, platform, uid } = options;
96
+ const override = env[LOCK_DIR_ENV_VAR];
97
+ if (override !== undefined && override !== '') {
98
+ return override;
99
+ }
100
+ if (platform === 'win32' || uid === undefined) {
101
+ return path.join(os.tmpdir(), 'nmr');
102
+ }
103
+ return `/tmp/nmr-${uid}`;
104
+ }
105
+ function createHeldLock(options) {
106
+ const { heartbeatIntervalMs, lockPath, token, waited } = options;
107
+ const heartbeat = setInterval(() => void refreshMtime(lockPath), heartbeatIntervalMs);
108
+ heartbeat.unref();
109
+ let isReleased = false;
110
+ return {
111
+ isChainUnlocked: false,
112
+ async release() {
113
+ if (isReleased)
114
+ return;
115
+ isReleased = true;
116
+ clearInterval(heartbeat);
117
+ const state = await readLockState(lockPath);
118
+ if (state?.holder?.token === token) {
119
+ await fs.rm(lockPath, { force: true });
120
+ }
121
+ },
122
+ token,
123
+ waited,
124
+ };
125
+ }
126
+ function createHoldFreeLock(options) {
127
+ return { ...options, release: () => Promise.resolve(), token: undefined };
128
+ }
129
+ function formatWaitingLine(holder, lockPath) {
130
+ if (holder === undefined) {
131
+ return `nmr: waiting for the machine lock at ${lockPath}`;
132
+ }
133
+ return `nmr: waiting for ${holder.command} in ${holder.dir} (pid ${holder.pid}) to finish`;
134
+ }
135
+ function isHolderRecord(value) {
136
+ return (isObject(value) &&
137
+ typeof value['command'] === 'string' &&
138
+ typeof value['dir'] === 'string' &&
139
+ typeof value['pid'] === 'number' &&
140
+ typeof value['startedAt'] === 'string' &&
141
+ typeof value['token'] === 'string');
142
+ }
143
+ async function isUnderAncestorHold(lockDir, env) {
144
+ const inheritedToken = env[LOCK_TOKEN_ENV_VAR];
145
+ if (inheritedToken === undefined || inheritedToken === '') {
146
+ return false;
147
+ }
148
+ const state = await readLockState(path.join(lockDir, LOCK_FILE));
149
+ return state?.holder?.token === inheritedToken;
150
+ }
151
+ function isProcessGone(pid) {
152
+ try {
153
+ process.kill(pid, 0);
154
+ return false;
155
+ }
156
+ catch (error) {
157
+ return readErrorCode(error) === 'ESRCH';
158
+ }
159
+ }
160
+ function isStale(state, staleAfterMs) {
161
+ if (Date.now() - state.mtimeMs > staleAfterMs) {
162
+ return true;
163
+ }
164
+ return state.holder !== undefined && isProcessGone(state.holder.pid);
165
+ }
166
+ function parseHolder(content) {
167
+ try {
168
+ const value = JSON.parse(content);
169
+ return isHolderRecord(value) ? value : undefined;
170
+ }
171
+ catch {
172
+ return undefined;
173
+ }
174
+ }
175
+ function readErrorCode(error) {
176
+ return isObject(error) && typeof error['code'] === 'string' ? error['code'] : undefined;
177
+ }
178
+ async function readLockState(lockPath) {
179
+ try {
180
+ const [content, stats] = await Promise.all([fs.readFile(lockPath, 'utf8'), fs.stat(lockPath)]);
181
+ return { holder: parseHolder(content), mtimeMs: stats.mtimeMs };
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ }
187
+ async function reclaimStaleLock(options) {
188
+ const { lockDir, lockPath, observed, staleAfterMs } = options;
189
+ const guardPath = path.join(lockDir, RECLAIM_GUARD);
190
+ try {
191
+ await fs.mkdir(guardPath);
192
+ }
193
+ catch {
194
+ await removeAbandonedGuard(guardPath);
195
+ return;
196
+ }
197
+ try {
198
+ const current = await readLockState(lockPath);
199
+ if (current !== undefined && current.holder?.token === observed.holder?.token && isStale(current, staleAfterMs)) {
200
+ await fs.rm(lockPath, { force: true });
201
+ }
202
+ }
203
+ finally {
204
+ await fs.rm(guardPath, { force: true, recursive: true });
205
+ }
206
+ }
207
+ async function refreshMtime(lockPath) {
208
+ const now = new Date();
209
+ try {
210
+ await fs.utimes(lockPath, now, now);
211
+ }
212
+ catch {
213
+ }
214
+ }
215
+ async function removeAbandonedGuard(guardPath) {
216
+ try {
217
+ const stats = await fs.stat(guardPath);
218
+ if (Date.now() - stats.mtimeMs > RECLAIM_GUARD_STALE_AFTER_MS) {
219
+ await fs.rm(guardPath, { force: true, recursive: true });
220
+ }
221
+ }
222
+ catch {
223
+ }
224
+ }
225
+ function reportUnwritableDir(lockDir, error, stderr) {
226
+ const code = readErrorCode(error) ?? 'unknown error';
227
+ stderr.write(`nmr: running without the machine lock: cannot write ${lockDir} (${code}). ` +
228
+ `Grant write access to it, or set ${LOCK_DIR_ENV_VAR} to a writable directory.\n`);
229
+ }
@@ -15,6 +15,7 @@ import { findClosestName } from "./helpers/findClosestName.js";
15
15
  import { isHookName } from "./helpers/hook-name.js";
16
16
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
17
17
  import { composeTranscript } from "./helpers/transcript.js";
18
+ import { acquireMachineLock, formatMisplacedNoLockWarning, HEAVY_COMMANDS, LOCK_TOKEN_ENV_VAR, NO_LOCK_ENV_VAR, } from "./machine-lock.js";
18
19
  import { isTerminalStream, OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG, resolveOutputStyles } from "./output-style.js";
19
20
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
20
21
  import { assembleReplay } from "./replay-assembly.js";
@@ -121,6 +122,7 @@ async function planRun(invocation) {
121
122
  env,
122
123
  format: invocation.format,
123
124
  shouldBypassCache,
125
+ shouldBypassLock: parsedArgs.shouldBypassLock,
124
126
  passthrough: parsedArgs.passthrough,
125
127
  runId,
126
128
  snapshot,
@@ -135,7 +137,15 @@ async function planRun(invocation) {
135
137
  };
136
138
  const delegation = composeDelegation({ childEnv, command, parsedArgs });
137
139
  if (delegation !== undefined) {
138
- const result = await runDelegation({ context, delegation, parsedArgs, runOptions, stderr });
140
+ const lock = await takeMachineLock(invocation);
141
+ let result;
142
+ try {
143
+ const heldDelegation = { ...delegation, env: addLockEnv(delegation.env, lock) };
144
+ result = await runDelegation({ context, delegation: heldDelegation, parsedArgs, runOptions, stderr });
145
+ }
146
+ finally {
147
+ await lock?.release();
148
+ }
139
149
  return {
140
150
  settled: true,
141
151
  result: { ...result, isUnknownCommand: result.exitCode !== 0 && !isDefinedInAnyScope(command, context) },
@@ -250,6 +260,7 @@ const BOOLEAN_FLAGS = new Map([
250
260
  ['--json', 'shouldEmitJson'],
251
261
  ['--log', 'shouldPrintLog'],
252
262
  ['--no-cache', 'shouldBypassCache'],
263
+ ['--no-lock', 'shouldBypassLock'],
253
264
  ['-q', 'quiet'],
254
265
  ['--quiet', 'quiet'],
255
266
  ['-R', 'recursive'],
@@ -260,18 +271,28 @@ const BOOLEAN_FLAGS = new Map([
260
271
  ['--workspace-root', 'isWorkspaceRoot'],
261
272
  ]);
262
273
  function buildChildEnv(options) {
263
- const { env, format, passthrough, runId, shouldBypassCache, snapshot, styles, verbosity } = options;
274
+ const { env, format, passthrough, runId, shouldBypassCache, shouldBypassLock, snapshot, styles, verbosity } = options;
264
275
  const bypassesCache = shouldBypassCache || passthrough.length > 0;
265
276
  return {
266
277
  ...env,
267
278
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
268
279
  ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
280
+ ...(shouldBypassLock && { [NO_LOCK_ENV_VAR]: '1' }),
269
281
  [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
270
282
  [OUTPUT_STYLE_ENV_VAR]: styles.stdout,
271
283
  [REPORT_FORMAT_ENV_VAR]: format,
272
284
  [RUN_ID_ENV_VAR]: runId,
273
285
  };
274
286
  }
287
+ function addLockEnv(env, lock) {
288
+ if (lock?.token !== undefined) {
289
+ return { ...env, [LOCK_TOKEN_ENV_VAR]: lock.token };
290
+ }
291
+ if (lock?.isChainUnlocked === true) {
292
+ return { ...env, [NO_LOCK_ENV_VAR]: '1' };
293
+ }
294
+ return env;
295
+ }
275
296
  function acceptsArguments(step) {
276
297
  return step.kind === 'opaque' || step.shouldDeclineArguments !== true;
277
298
  }
@@ -638,6 +659,7 @@ function parseArgs(args) {
638
659
  quiet: false,
639
660
  recursive: false,
640
661
  shouldBypassCache: false,
662
+ shouldBypassLock: false,
641
663
  shouldEmitJson: false,
642
664
  shouldPrintLog: false,
643
665
  shouldShowHelp: false,
@@ -764,6 +786,46 @@ function resolveReportingVerbosity(options) {
764
786
  const { format, ...ladder } = options;
765
787
  return format === 'json' ? 'quiet' : resolveVerbosity(ladder);
766
788
  }
789
+ async function readGate(plan) {
790
+ const { key, snapshot } = plan;
791
+ const { context, env, stderr, stdout } = plan.invocation;
792
+ if (key === undefined || snapshot === undefined) {
793
+ return undefined;
794
+ }
795
+ const quiet = plan.runOptions.quiet === true;
796
+ return {
797
+ buildOutputBefore: await readBuildOutputState(context.monorepoRoot, context.config),
798
+ key,
799
+ retentionKey: computeRetentionKey({
800
+ channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
801
+ env,
802
+ passKey: key,
803
+ }),
804
+ snapshot,
805
+ };
806
+ }
807
+ async function recallPass(plan, gate) {
808
+ const { anchorDir, command, context, env, stderr } = plan.invocation;
809
+ const { monorepoRoot } = context;
810
+ const recalledPass = await lookUpRecordedPass({
811
+ anchorDir,
812
+ buildOutput: gate.buildOutputBefore,
813
+ command,
814
+ env,
815
+ key: gate.key,
816
+ monorepoRoot,
817
+ retentionKey: gate.retentionKey,
818
+ stderr,
819
+ });
820
+ if (recalledPass === undefined) {
821
+ return undefined;
822
+ }
823
+ const { entry, ...recall } = recalledPass;
824
+ if (recall.replay !== undefined) {
825
+ await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: plan.runId, stderr });
826
+ }
827
+ return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
828
+ }
767
829
  async function recordPass(options) {
768
830
  const { anchorDir, command, env, monorepoRoot, snapshot, stderr } = options;
769
831
  const currentRead = resolveTreeSnapshot({ monorepoRoot, env: {} });
@@ -862,70 +924,61 @@ async function runDelegation(options) {
862
924
  return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
863
925
  }
864
926
  async function runGated(plan) {
865
- const { commandString, key, snapshot } = plan;
927
+ const { commandString, key } = plan;
866
928
  const { anchorDir, command, context, env, stderr, stdout } = plan.invocation;
867
929
  const { config, monorepoRoot } = context;
868
- const quiet = plan.runOptions.quiet === true;
869
- const gate = key !== undefined && snapshot !== undefined
870
- ? {
871
- buildOutputBefore: await readBuildOutputState(monorepoRoot, config),
872
- key,
873
- retentionKey: computeRetentionKey({
874
- channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
875
- env,
876
- passKey: key,
877
- }),
878
- snapshot,
879
- }
880
- : undefined;
930
+ let gate = await readGate(plan);
881
931
  if (gate !== undefined && !plan.shouldBypassCache) {
882
- const recalledPass = await lookUpRecordedPass({
883
- anchorDir,
884
- buildOutput: gate.buildOutputBefore,
885
- command,
886
- env,
887
- key: gate.key,
888
- monorepoRoot,
889
- retentionKey: gate.retentionKey,
890
- stderr,
891
- });
892
- if (recalledPass !== undefined) {
893
- const { entry, ...recall } = recalledPass;
894
- if (recall.replay !== undefined) {
895
- await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: plan.runId, stderr });
896
- }
897
- return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
932
+ const recalled = await recallPass(plan, gate);
933
+ if (recalled !== undefined) {
934
+ return recalled;
898
935
  }
899
936
  }
900
- if (plan.overrideNotice !== undefined) {
901
- stdout.write(plan.overrideNotice);
937
+ const lock = await takeMachineLock(plan.invocation);
938
+ try {
939
+ if (lock?.waited === true && key !== undefined) {
940
+ gate = await readGate(plan);
941
+ if (gate !== undefined && !plan.shouldBypassCache) {
942
+ const recalled = await recallPass(plan, gate);
943
+ if (recalled !== undefined) {
944
+ return recalled;
945
+ }
946
+ }
947
+ }
948
+ if (plan.overrideNotice !== undefined) {
949
+ stdout.write(plan.overrideNotice);
950
+ }
951
+ const runOptions = { ...plan.runOptions, env: addLockEnv(plan.runOptions.env ?? env, lock) };
952
+ const startedAtMs = Date.now();
953
+ const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, runOptions);
954
+ const durationMs = Date.now() - startedAtMs;
955
+ if (exitCode === 0 && gate !== undefined) {
956
+ await recordPass({
957
+ anchorDir,
958
+ buildOutputBefore: gate.buildOutputBefore,
959
+ command,
960
+ commandString,
961
+ config,
962
+ durationMs,
963
+ env,
964
+ key: gate.key,
965
+ monorepoRoot,
966
+ ownSteps: plan.ownSteps,
967
+ retainedOutput,
968
+ retentionKey: gate.retentionKey,
969
+ runId: plan.runId,
970
+ snapshot: gate.snapshot,
971
+ stderr,
972
+ });
973
+ }
974
+ return {
975
+ exitCode,
976
+ outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
977
+ };
902
978
  }
903
- const startedAtMs = Date.now();
904
- const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, plan.runOptions);
905
- const durationMs = Date.now() - startedAtMs;
906
- if (exitCode === 0 && gate !== undefined) {
907
- await recordPass({
908
- anchorDir,
909
- buildOutputBefore: gate.buildOutputBefore,
910
- command,
911
- commandString,
912
- config,
913
- durationMs,
914
- env,
915
- key: gate.key,
916
- monorepoRoot,
917
- ownSteps: plan.ownSteps,
918
- retainedOutput,
919
- retentionKey: gate.retentionKey,
920
- runId: plan.runId,
921
- snapshot: gate.snapshot,
922
- stderr,
923
- });
979
+ finally {
980
+ await lock?.release();
924
981
  }
925
- return {
926
- exitCode,
927
- outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
928
- };
929
982
  }
930
983
  function formatMisplacedFlagHint(args, passthrough, style) {
931
984
  const { flagNames, movedTokens, remainingTokens } = splitMisplacedFlags(passthrough);
@@ -955,6 +1008,21 @@ function isTopLevelInvocation(env) {
955
1008
  const runId = env[RUN_ID_ENV_VAR];
956
1009
  return runId === undefined || runId === '';
957
1010
  }
1011
+ async function takeMachineLock(invocation) {
1012
+ const { anchorDir, command, env, parsedArgs, stderr, styles } = invocation;
1013
+ if (!HEAVY_COMMANDS.has(command) || parsedArgs.shouldPrintLog) {
1014
+ return undefined;
1015
+ }
1016
+ if (parsedArgs.passthrough.includes('--no-lock')) {
1017
+ stderr.write(`${formatMisplacedNoLockWarning(command, styles.stderr)}\n`);
1018
+ }
1019
+ return acquireMachineLock({
1020
+ command,
1021
+ dir: anchorDir,
1022
+ env: parsedArgs.shouldBypassLock ? { ...env, [NO_LOCK_ENV_VAR]: '1' } : env,
1023
+ stderr,
1024
+ });
1025
+ }
958
1026
  function readMisplacedFlagLength(args, index) {
959
1027
  const arg = args[index] ?? '';
960
1028
  if (FILTER_FLAGS.has(arg)) {
@@ -0,0 +1,11 @@
1
+ export interface MachineCapacity {
2
+ coreCount: number;
3
+ totalMemoryBytes: number;
4
+ }
5
+ /** Returns the worker count for a machine: one per `GIB_PER_WORKER` of memory, bounded by one fewer than the cores. */
6
+ export declare function deriveDefaultMaxWorkers({ coreCount, totalMemoryBytes }: MachineCapacity): number;
7
+ /**
8
+ * Returns the value of the last `--maxWorkers` argument: a number, or a percentage string that Vitest resolves against
9
+ * the core count. An argument whose value is neither counts as absent, which leaves Vitest to report it.
10
+ */
11
+ export declare function readMaxWorkersFlag(args: readonly string[]): number | string | undefined;
@@ -0,0 +1,35 @@
1
+ const SMALL_MACHINE_MAX_CORES = 2;
2
+ const SMALL_MACHINE_MAX_WORKERS = 3;
3
+ const GIB_PER_WORKER = 4;
4
+ const BYTES_PER_GIB = 2 ** 30;
5
+ const MAX_WORKERS_FLAGS = ['--maxWorkers', '--max-workers'];
6
+ const PERCENTAGE_PATTERN = /^\d+%$/;
7
+ const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
8
+ export function deriveDefaultMaxWorkers({ coreCount, totalMemoryBytes }) {
9
+ if (coreCount <= SMALL_MACHINE_MAX_CORES)
10
+ return SMALL_MACHINE_MAX_WORKERS;
11
+ const memoryBound = Math.max(1, Math.floor(totalMemoryBytes / (GIB_PER_WORKER * BYTES_PER_GIB)));
12
+ return Math.min(coreCount - 1, memoryBound);
13
+ }
14
+ export function readMaxWorkersFlag(args) {
15
+ let rawValue;
16
+ args.forEach((arg, index) => {
17
+ if (MAX_WORKERS_FLAGS.includes(arg)) {
18
+ rawValue = args[index + 1];
19
+ return;
20
+ }
21
+ const flag = MAX_WORKERS_FLAGS.find((name) => arg.startsWith(`${name}=`));
22
+ if (flag !== undefined)
23
+ rawValue = arg.slice(flag.length + 1);
24
+ });
25
+ return parseMaxWorkersValue(rawValue);
26
+ }
27
+ function parseMaxWorkersValue(rawValue) {
28
+ if (rawValue === undefined)
29
+ return undefined;
30
+ if (POSITIVE_INTEGER_PATTERN.test(rawValue))
31
+ return Number(rawValue);
32
+ if (PERCENTAGE_PATTERN.test(rawValue))
33
+ return rawValue;
34
+ return undefined;
35
+ }
@@ -5,14 +5,13 @@ import { defaultExclude, mergeConfig } from 'vitest/config';
5
5
  import { listGitIgnoredPaths } from "./git-ignored-paths.js";
6
6
  import { isObject } from "./helpers/type-guards.js";
7
7
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
8
+ import { deriveDefaultMaxWorkers, readMaxWorkersFlag } from "./vitest-max-workers.js";
8
9
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
9
10
  import { getWorkspacePackageDirs } from "./workspace.js";
10
11
  import { toWorkspacePackageGlobs } from "./workspace-globs.js";
11
12
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
12
13
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
13
14
  const TIER_TIMEOUT_MS = 30_000;
14
- const SMALL_MACHINE_MAX_CORES = 2;
15
- const SMALL_MACHINE_MAX_WORKERS = 3;
16
15
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
17
16
  const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
18
17
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
@@ -99,7 +98,11 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
99
98
  })),
100
99
  ];
101
100
  const collectionExclude = buildCollectionExclude(layers, ignoredPathsRoot);
102
- const maxWorkers = resolveDefaultMaxWorkers();
101
+ const defaultMaxWorkers = deriveDefaultMaxWorkers({
102
+ coreCount: os.availableParallelism(),
103
+ totalMemoryBytes: os.totalmem(),
104
+ });
105
+ const flagMaxWorkers = readMaxWorkersFlag(process.argv.slice(2));
103
106
  const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
104
107
  return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
105
108
  const project = {
@@ -108,7 +111,7 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
108
111
  test: {
109
112
  exclude: [...collectionExclude, ...exclude, ...extraExclude],
110
113
  include,
111
- ...(maxWorkers !== undefined && { maxWorkers }),
114
+ maxWorkers: defaultMaxWorkers,
112
115
  name,
113
116
  ...(shouldIsolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
114
117
  ...(timeoutMs !== undefined && { hookTimeout: timeoutMs, testTimeout: timeoutMs }),
@@ -118,7 +121,9 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
118
121
  for (const layer of layers) {
119
122
  mergedProject = applyLayer(mergedProject, layer, name);
120
123
  }
121
- return mergedProject;
124
+ return flagMaxWorkers === undefined
125
+ ? mergedProject
126
+ : mergeConfig(mergedProject, { test: { maxWorkers: flagMaxWorkers } });
122
127
  });
123
128
  }
124
129
  function applyLayer(project, layer, tier) {
@@ -175,9 +180,6 @@ function formatOptionKeys(keys) {
175
180
  .map((key) => `\`${key}\``)
176
181
  .join(', ');
177
182
  }
178
- function resolveDefaultMaxWorkers() {
179
- return os.availableParallelism() <= SMALL_MACHINE_MAX_CORES ? SMALL_MACHINE_MAX_WORKERS : undefined;
180
- }
181
183
  function resolveFlag(layers, name, isOnByDefault) {
182
184
  let resolvedFlag = isOnByDefault;
183
185
  for (const layer of layers) {
@@ -0,0 +1,61 @@
1
+ # Machine lock
2
+
3
+ nmr runs one heavy command at a time on a machine. A heavy command started while another is running, from any repository or worktree, waits for it to finish rather than competing with it for memory:
4
+
5
+ ```console
6
+ $ nmr check
7
+ nmr: waiting for test in /Users/me/repos/other-repo (pid 41872) to finish
8
+ ✅ my-repo: check: passed in 1m 12s
9
+ ```
10
+
11
+ The waiting line goes to stderr in every verbosity and report format. The time spent waiting does not count in the verdict's duration.
12
+
13
+ ## Which commands take the lock
14
+
15
+ `check`, `check:strict`, `ci`, `root:check`, `test`, `test:all`, `test:coverage`, `test:tool`, `test:unit`, `root:test`, `root:test:all`, `root:test:tool`, and `root:test:unit`, at any scope. The set is fixed.
16
+
17
+ - **`test:watch` does not take the lock.** It runs until it is stopped, and would block every other run for as long.
18
+ - **A composite takes the lock when it reaches a heavy step.** `prepush` runs its audit unlocked, then waits, if it has to, before `ci`.
19
+ - **A command nested in a heavy one runs under its hold.** The holder passes its token to the processes that it starts, so `ci` running `check:strict` running `test` takes the lock once.
20
+ - **A fan-out takes the lock once.** `nmr -R test` and `nmr -F <pattern> test` hold it in the delegating process, so the packages that pnpm runs in parallel do not wait on one another.
21
+ - **A cached pass does not wait.** nmr consults the [check-result cache](check-cache.md) before it takes the lock, and a command that the cache serves never waits. A run that did wait consults the cache again, because the run that it waited on may have recorded a pass for the same tree.
22
+ - **`--log` does not take the lock**, because it runs nothing.
23
+
24
+ ## Where the lock lives
25
+
26
+ | Platform | Directory |
27
+ | ------------ | ----------------------- |
28
+ | Linux, macOS | `/tmp/nmr-<uid>/` |
29
+ | Windows | `<os.tmpdir()>/nmr/` |
30
+ | Any | `$NMR_LOCK_DIR`, if set |
31
+
32
+ The lock is the file `heavy.lock` in that directory. It names its holder's command, directory, and pid. The directory is `/tmp` rather than `TMPDIR` because a sandboxed agent session and a terminal shell set `TMPDIR` differently, and would not see each other's lock. `NMR_LOCK_DIR` changes the location for every invocation that sees it, so a machine that sets it sets it in every environment that runs nmr.
33
+
34
+ **A sandbox needs write access to the directory.** When nmr cannot write it, nmr warns and runs the command without the lock:
35
+
36
+ ```console
37
+ nmr: running without the machine lock: cannot write /tmp/nmr-501 (EPERM). Grant write access to it, or set NMR_LOCK_DIR to a writable directory.
38
+ ```
39
+
40
+ On macOS `/tmp` is a link to `/private/tmp`, and a sandbox that matches resolved paths, such as Claude Code's, needs the grant for `/private/tmp/nmr-<uid>`.
41
+
42
+ ## A lock that its holder left behind
43
+
44
+ A holder removes the lock when its command ends, whether the command passed or failed. A holder that is interrupted or killed leaves the lock behind, and the next run reclaims it: at once when the holder's pid no longer exists, and otherwise once the holder has stopped refreshing the file for a minute, which covers a pid that another process has since taken.
45
+
46
+ ## Bypassing the lock
47
+
48
+ | Step | Effect |
49
+ | ------------------------- | ---------------------------------------------------------- |
50
+ | `nmr --no-lock <command>` | Runs without waiting. Applies to the whole chain. |
51
+ | `NMR_NO_LOCK=1` | The same, for every nmr invocation that sees the variable. |
52
+
53
+ `--no-lock` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr reports this rather than silently ignoring the bypass.
54
+
55
+ **CI.** A GitHub-hosted runner runs one job per machine, so the lock never makes it wait. A self-hosted machine that runs several runner instances serializes their heavy jobs, which protects its memory as the lock does on a workstation, and a job's wait counts against its timeout. Set `NMR_NO_LOCK=1` in the runners' environment to run them in parallel instead.
56
+
57
+ **A test suite that runs nmr.** A suite run by `nmr test` runs under that command's hold only while the processes that it starts inherit `NMR_MACHINE_LOCK_TOKEN`. A test that starts a heavy command with an environment of its own waits on the suite that started it, so it sets `NMR_NO_LOCK=1`.
58
+
59
+ ## Reserved environment variables
60
+
61
+ `NMR_MACHINE_LOCK_TOKEN` is nmr's own: The holder passes it to the processes that it starts, which run under its hold. nmr honors an inherited token only while the lock file still contains it, so a process that outlives its run waits like any other.
package/docs/vitest.md CHANGED
@@ -125,11 +125,25 @@ export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSo
125
125
 
126
126
  **The boundary is Vite's resolution, not the package's role.** Because Node does not apply the `source` condition, a workspace package that an externalized dependency imports resolves through Node to its build output. Nothing else in a suite resolves that way.
127
127
 
128
- ### Worker count on small machines
128
+ ### Worker count
129
129
 
130
- On a machine with 2 or fewer cores, the factory gives every project `maxWorkers: 3`. Vitest's run-mode default is one fewer worker than the core count, so a 2-core CI runner, GitHub's standard runner for a private repo, would otherwise run every test file serially on one worker. Three workers are faster there, because the main process transforms files while the workers run them. On a machine with more cores, the projects do not set `maxWorkers`, and Vitest's own default applies.
130
+ The factory gives every project a worker count derived from the machine, one worker per 4 GiB of total memory, bounded by one fewer than the core count:
131
131
 
132
- Where the default applies, Vitest's `--maxWorkers` flag has no effect, because Vitest does not forward that flag to projects. Override the default with the `VITEST_MAX_WORKERS` environment variable, with `--no-file-parallelism` for a single worker, or with `maxWorkers` in the `project` seam.
132
+ ```
133
+ min(availableParallelism - 1, max(1, floor(totalmem / 4 GiB)))
134
+ ```
135
+
136
+ Vitest's own default, one fewer worker than the core count, does not take memory into account. On a 10-core, 16 GB machine it starts 9 workers per run, and several concurrent runs push the machine into swap; the derived count there is 4. The 4 GiB budget leaves room for concurrent runs and other workloads, rather than measuring one worker's footprint. The divisor is total memory, because `os.freemem()` on macOS excludes cached pages and reports near zero.
137
+
138
+ On a machine with 2 or fewer cores, every project gets 3 workers, whatever the memory. A 2-core CI runner, GitHub's standard runner for a private repo, would otherwise run every test file serially on one worker. Three workers are faster there, because the main process transforms files while the workers run them.
139
+
140
+ Each of these overrides the derived count, the highest first:
141
+
142
+ 1. The `VITEST_MAX_WORKERS` environment variable, which Vitest applies after the config.
143
+ 2. `--maxWorkers` (or `--max-workers`) on the command line, as a count or a percentage of the cores. Vitest does not forward the flag to projects, so the factory reads it from the command line and applies it to every project.
144
+ 3. `maxWorkers` in the `project` seam.
145
+
146
+ `--no-file-parallelism` runs a single worker whatever the count.
133
147
 
134
148
  ## Resolving through tsconfig paths
135
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [