@vitest-agent/plugin 2.2.1 → 2.2.3
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.
- package/README.md +1 -1
- package/index.d.ts +13 -0
- package/package.json +7 -7
- package/plugin.js +27 -2
- package/utils/discover-projects.js +20 -0
- package/utils/is-test-shaped-package.js +40 -0
- package/utils/run-script-lock.js +268 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Vitest plugin that turns your test suite into a live data source for LLM coding
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **`AgentPlugin`** — drop into `vitest.config.ts`; auto-detects human, agent and CI executors and adapts console output accordingly
|
|
12
|
-
- **Project discovery** — `AgentPlugin.discover()` scans workspace packages, returns `{ projects, tags }` ready for `test.projects` and `test.tags`; classification tags apply at collection time, so every test declaration form inherits them, including wrapper testers like `@effect/vitest`'s `it.effect
|
|
12
|
+
- **Project discovery** — `AgentPlugin.discover()` scans workspace packages, returns `{ projects, tags }` ready for `test.projects` and `test.tags`; classification tags apply at collection time, so every test declaration form inherits them, including wrapper testers like `@effect/vitest`'s `it.effect`; a package that looks test-shaped but ends up with no project warns on stderr instead of silently running nothing
|
|
13
13
|
- **Coverage presets** — `COVERAGE_LEVELS` and `COVERAGE_LEVELS_PER_FILE` return dual-output `{ thresholds, coverageTargets }` objects; `COVERAGE_AUTOUPDATE` tolerance functions plug into Vitest's native `autoUpdate`
|
|
14
14
|
- **Failure classification** — persists per-test errors, computes failure signatures, classifies tests as stable, new-failure, persistent, flaky or recovered across runs
|
|
15
15
|
- **Custom reporters** — pass any `VitestAgentReporterFactory` as the `reporter` option; the default wires `DefaultVitestAgentReporter` from `@vitest-agent/reporter`
|
package/index.d.ts
CHANGED
|
@@ -351,6 +351,19 @@ declare namespace AgentPlugin {
|
|
|
351
351
|
* Designed for use in Vitest `globalSetup` files to run build steps or
|
|
352
352
|
* other preparatory scripts without polluting agent stdout.
|
|
353
353
|
*
|
|
354
|
+
* Concurrent invocations of the same command from the same workspace
|
|
355
|
+
* (e.g. two `vitest` runs started in one checkout at once) serialize
|
|
356
|
+
* through a file-based advisory lock (issue #191): the first caller
|
|
357
|
+
* runs the command; a concurrent caller blocks until the lock frees,
|
|
358
|
+
* then skips its own run when the winner's build is still fresh
|
|
359
|
+
* (`DEFAULT_BUILT_RECENTLY_MS`) rather than repeating it.
|
|
360
|
+
*
|
|
361
|
+
* Serialization is best-effort by design: a waiter that is still
|
|
362
|
+
* blocked after `DEFAULT_LOCK_WAIT_TIMEOUT_MS` gives up and runs the
|
|
363
|
+
* command anyway, unserialized, rather than hanging the caller's test
|
|
364
|
+
* run forever. See `utils/run-script-lock.ts` for that escape valve
|
|
365
|
+
* and for the two-tier (pid probe, then age) stale-takeover rule.
|
|
366
|
+
*
|
|
354
367
|
* On failure the captured stderr and stdout are written to their respective
|
|
355
368
|
* streams before rethrowing, so the error is still visible to humans and
|
|
356
369
|
* surfaced in CI logs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitest-agent/plugin",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Vitest plugin for the vitest-agent ecosystem: owns persistence, classification, baselines, trends, and dispatches rendering to a configurable reporter.",
|
|
6
6
|
"keywords": [
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@effect/platform-node": "4.0.0-beta.107",
|
|
43
43
|
"@effect/sql-sqlite-node": "4.0.0-beta.107",
|
|
44
|
-
"@effected/workspaces": "^0.
|
|
45
|
-
"@vitest-agent/cli": "2.1.
|
|
46
|
-
"@vitest-agent/mcp": "2.1
|
|
47
|
-
"@vitest-agent/reporter": "2.0.
|
|
48
|
-
"@vitest-agent/sdk": "2.
|
|
44
|
+
"@effected/workspaces": "^0.13.0",
|
|
45
|
+
"@vitest-agent/cli": "2.1.3",
|
|
46
|
+
"@vitest-agent/mcp": "2.2.1",
|
|
47
|
+
"@vitest-agent/reporter": "2.0.21",
|
|
48
|
+
"@vitest-agent/sdk": "2.3.1",
|
|
49
49
|
"effect": "4.0.0-beta.107",
|
|
50
|
-
"magic-string": "^1.
|
|
50
|
+
"magic-string": "^1.2.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"@vitest/coverage-istanbul": "^4.1.0",
|
package/plugin.js
CHANGED
|
@@ -7,6 +7,7 @@ import { DefaultDiscoverStrategy } from "./utils/discover-strategy.js";
|
|
|
7
7
|
import { discoverProjects } from "./utils/discover-projects.js";
|
|
8
8
|
import { injectTags } from "./utils/inject-tags.js";
|
|
9
9
|
import { isBenignViteSourceMapWarning } from "./utils/is-benign-vite-source-map-warning.js";
|
|
10
|
+
import { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock } from "./utils/run-script-lock.js";
|
|
10
11
|
import { stripConsoleReporters } from "./utils/strip-console-reporters.js";
|
|
11
12
|
import { execSync } from "node:child_process";
|
|
12
13
|
import { AgentConsoleMode, CiConsoleMode, CoverageLevel, EnvironmentDetector, EnvironmentDetectorLive, HumanConsoleMode, SRC_DIR, TEST_DIR, formatFatalError, isTestFileName, resolveLogLevel } from "@vitest-agent/sdk";
|
|
@@ -37,7 +38,8 @@ function resolveConsoleMode(options, executor, _env) {
|
|
|
37
38
|
if (executor === "human" && Schema.is(HumanConsoleMode)(override)) return override;
|
|
38
39
|
if (executor === "agent" && Schema.is(AgentConsoleMode)(override)) return override;
|
|
39
40
|
if (executor !== "human" && executor !== "agent" && Schema.is(CiConsoleMode)(override)) return override;
|
|
40
|
-
|
|
41
|
+
const accepted = executor === "human" ? HumanConsoleMode.literals : executor === "agent" ? AgentConsoleMode.literals : CiConsoleMode.literals;
|
|
42
|
+
process.stderr.write(`[vitest-agent:plugin] ignoring invalid VITEST_AGENT_CONSOLE="${override}" for ${executor} executor; accepted for ${executor}: ${accepted.join(" | ")}\n`);
|
|
41
43
|
}
|
|
42
44
|
const console = options.console;
|
|
43
45
|
if (executor === "human") return console?.human ?? "passthrough";
|
|
@@ -98,7 +100,7 @@ const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
|
|
|
98
100
|
*
|
|
99
101
|
* @public
|
|
100
102
|
*/
|
|
101
|
-
const CURRENT_PLUGIN_VERSION = "2.2.
|
|
103
|
+
const CURRENT_PLUGIN_VERSION = "2.2.3";
|
|
102
104
|
const TEST_FILE_DIR_RE = new RegExp(`/(?:${SRC_DIR}|${TEST_DIR})/`);
|
|
103
105
|
const isTestFile = (id) => isTestFileName(id) && TEST_FILE_DIR_RE.test(id);
|
|
104
106
|
/**
|
|
@@ -308,14 +310,37 @@ function makeDiscoverBuilder(options) {
|
|
|
308
310
|
});
|
|
309
311
|
}
|
|
310
312
|
_AgentPlugin.discover = discover;
|
|
313
|
+
/**
|
|
314
|
+
* Parses a `VITEST_AGENT_RUNSCRIPT_*` override env var, falling back to
|
|
315
|
+
* `fallback` for anything that is not a whole positive integer at or above
|
|
316
|
+
* `minimum`. Test-support: lets the concurrency e2e suite use short timeouts
|
|
317
|
+
* instead of the production-sized defaults — but a typo there (`"200ms"`,
|
|
318
|
+
* `"-1"`, `"0"`) must degrade to the default rather than to an
|
|
319
|
+
* instantly-stale lock or a busy-spinning waiter.
|
|
320
|
+
*/
|
|
321
|
+
function readRunScriptLockEnvOverride(name, fallback, minimum = 1) {
|
|
322
|
+
return parseLockTimingOverride(process.env[name], fallback, minimum);
|
|
323
|
+
}
|
|
311
324
|
function runScript(command) {
|
|
325
|
+
const lock = acquireRunScriptLock({
|
|
326
|
+
cwd: process.cwd(),
|
|
327
|
+
command,
|
|
328
|
+
staleMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_LOCK_STALE_MS", DEFAULT_LOCK_STALE_MS),
|
|
329
|
+
waitTimeoutMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_LOCK_WAIT_TIMEOUT_MS", DEFAULT_LOCK_WAIT_TIMEOUT_MS),
|
|
330
|
+
pollMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_LOCK_POLL_MS", 200, 10),
|
|
331
|
+
builtRecentlyMs: readRunScriptLockEnvOverride("VITEST_AGENT_RUNSCRIPT_BUILT_RECENTLY_MS", DEFAULT_BUILT_RECENTLY_MS)
|
|
332
|
+
});
|
|
333
|
+
if (lock.recentlyBuilt) return;
|
|
312
334
|
try {
|
|
313
335
|
execSync(command, { stdio: "pipe" });
|
|
336
|
+
markRunScriptDone(lock);
|
|
314
337
|
} catch (error) {
|
|
315
338
|
const execError = error;
|
|
316
339
|
if (execError.stderr?.length) process.stderr.write(execError.stderr);
|
|
317
340
|
if (execError.stdout?.length) process.stdout.write(execError.stdout);
|
|
318
341
|
throw error;
|
|
342
|
+
} finally {
|
|
343
|
+
releaseRunScriptLock(lock);
|
|
319
344
|
}
|
|
320
345
|
}
|
|
321
346
|
_AgentPlugin.runScript = runScript;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toPosixPath } from "./to-posix-path.js";
|
|
2
2
|
import { DefaultDiscoverStrategy } from "./discover-strategy.js";
|
|
3
|
+
import { isTestShapedPackage } from "./is-test-shaped-package.js";
|
|
3
4
|
import { NON_DISCOVERABLE_DIRS, SRC_DIR, TEST_DIR } from "@vitest-agent/sdk";
|
|
4
5
|
import { isAbsolute, join, normalize, relative } from "node:path";
|
|
5
6
|
import { readdir, stat } from "node:fs/promises";
|
|
@@ -8,6 +9,24 @@ import { nodeSyncOps } from "@effected/workspaces/node-sync";
|
|
|
8
9
|
|
|
9
10
|
//#region src/utils/discover-projects.ts
|
|
10
11
|
const DISCOVERY_LAST_SCAN_SYMBOL = Symbol.for("vitest-agent:discovery:last-scan-at");
|
|
12
|
+
const warnedDeclinedPackagePaths = /* @__PURE__ */ new Set();
|
|
13
|
+
async function warnIfDeclinedPackageIsTestShaped(pkg) {
|
|
14
|
+
const normPath = normalize(pkg.path);
|
|
15
|
+
if (warnedDeclinedPackagePaths.has(normPath)) return;
|
|
16
|
+
warnedDeclinedPackagePaths.add(normPath);
|
|
17
|
+
let testShaped;
|
|
18
|
+
try {
|
|
19
|
+
testShaped = await isTestShapedPackage(pkg.path);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
warnedDeclinedPackagePaths.delete(normPath);
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
if (!testShaped) {
|
|
25
|
+
warnedDeclinedPackagePaths.delete(normPath);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
process.stderr.write(`[vitest-agent] warning: package "${pkg.name}" has a ${TEST_DIR}/ directory or ${SRC_DIR}/ test files, but its tests were not wired into a Vitest project. Run \`npx vitest-agent agent check-test-path <path>\` to diagnose why.\n`);
|
|
29
|
+
}
|
|
11
30
|
function recordDiscoveryScanTimestamp() {
|
|
12
31
|
globalThis[DISCOVERY_LAST_SCAN_SYMBOL] = (/* @__PURE__ */ new Date()).toISOString();
|
|
13
32
|
}
|
|
@@ -107,6 +126,7 @@ async function discoverProjects(options) {
|
|
|
107
126
|
workspaceRoot: root
|
|
108
127
|
});
|
|
109
128
|
if (config !== null) configs.push(config);
|
|
129
|
+
else await warnIfDeclinedPackageIsTestShaped(pkg);
|
|
110
130
|
workspaceNames.add(pkg.name);
|
|
111
131
|
workspacePaths.add(normalize(pkg.path));
|
|
112
132
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { findTestFiles } from "./find-test-files.js";
|
|
2
|
+
import { SRC_DIR, TEST_DIR, TEST_FILE_GLOB_SUFFIX } from "@vitest-agent/sdk";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
//#region src/utils/is-test-shaped-package.ts
|
|
7
|
+
async function isDirectory(p) {
|
|
8
|
+
try {
|
|
9
|
+
return (await stat(p)).isDirectory();
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* True when `pkgPath` looks like it was meant to hold tests: a `__test__/`
|
|
16
|
+
* directory exists at the package root (regardless of what's inside it), or
|
|
17
|
+
* `src/` contains at least one file matching the discoverable test-file
|
|
18
|
+
* naming convention (`TEST_FILE_GLOB_SUFFIX`).
|
|
19
|
+
*
|
|
20
|
+
* Reuses `SRC_DIR` / `TEST_DIR` / `TEST_FILE_GLOB_SUFFIX` from
|
|
21
|
+
* `@vitest-agent/sdk`'s `utils/test-location.ts` — the single source of
|
|
22
|
+
* truth for the test-layout rule — and the existing `findTestFiles` walker,
|
|
23
|
+
* rather than re-deriving the shape independently.
|
|
24
|
+
*
|
|
25
|
+
* A `__test__/` directory counts on existence alone, not on matching file
|
|
26
|
+
* content: the failure mode this predicate exists to catch (issue #229) IS a
|
|
27
|
+
* `__test__/` directory whose files don't match the naming convention (wrong
|
|
28
|
+
* suffix, wrong extension, typo) — `DefaultDiscoverStrategy.buildProject`
|
|
29
|
+
* already declined those packages by finding zero matching files, so
|
|
30
|
+
* requiring a match here would make the predicate blind to exactly the case
|
|
31
|
+
* it needs to catch.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
async function isTestShapedPackage(pkgPath) {
|
|
35
|
+
if (await isDirectory(join(pkgPath, TEST_DIR))) return true;
|
|
36
|
+
return (await findTestFiles(pkgPath, [`${SRC_DIR}/**/${TEST_FILE_GLOB_SUFFIX}`])).length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
export { isTestShapedPackage };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
//#region src/utils/run-script-lock.ts
|
|
7
|
+
/**
|
|
8
|
+
* File-based advisory lock backing `AgentPlugin.runScript` (issue #191,
|
|
9
|
+
* sub-item B). Two concurrent `vitest` invocations in one checkout used
|
|
10
|
+
* to both run the `globalSetup` build and race — this lock serializes
|
|
11
|
+
* them: the first process to acquire the lock runs the command and
|
|
12
|
+
* marks a "done" timestamp on success; a concurrent process polls,
|
|
13
|
+
* and once the marker is fresh enough (`builtRecentlyMs`) it trusts
|
|
14
|
+
* the winner's build and skips its own.
|
|
15
|
+
*
|
|
16
|
+
* A stale-lock takeover exists because a process that crashed or was
|
|
17
|
+
* killed mid-build leaves the lock file behind forever otherwise —
|
|
18
|
+
* every future `vitest` invocation would hang. Takeover is a two-tier
|
|
19
|
+
* rule, never age alone:
|
|
20
|
+
*
|
|
21
|
+
* 1. The lock file records the owner's pid. Once the lock is older
|
|
22
|
+
* than `staleMs`, that pid is probed with `process.kill(pid, 0)`.
|
|
23
|
+
* A live owner (probe succeeds, or fails `EPERM` — the process
|
|
24
|
+
* exists but belongs to another user) is NEVER taken over, no
|
|
25
|
+
* matter how old the lock is: a slow-but-live build must not have
|
|
26
|
+
* its lock stolen and the command double-run.
|
|
27
|
+
* 2. Only a dead owner (`ESRCH`), or a lock file whose owner record is
|
|
28
|
+
* missing/corrupt (in which case age is all we have to go on),
|
|
29
|
+
* is taken over.
|
|
30
|
+
*
|
|
31
|
+
* Release is ownership-gated for the same reason: `acquireRunScriptLock`
|
|
32
|
+
* stamps a random per-acquisition nonce into the lock file, and
|
|
33
|
+
* `releaseRunScriptLock` deletes the file only when the nonce on disk is
|
|
34
|
+
* still its own. Without that, an original owner that lost its lock to a
|
|
35
|
+
* takeover would delete the *takeover* owner's lock in its `finally`,
|
|
36
|
+
* letting a third process in while the second is still building.
|
|
37
|
+
*
|
|
38
|
+
* A generous `waitTimeoutMs` safety valve exists so a genuinely slow
|
|
39
|
+
* (but still live) build doesn't hang the waiter forever either: past
|
|
40
|
+
* that timeout the waiter gives up, `acquireRunScriptLock` returns
|
|
41
|
+
* `{ acquired: false, recentlyBuilt: false }`, and the caller
|
|
42
|
+
* (`AgentPlugin.runScript`) goes ahead and runs its own command
|
|
43
|
+
* unserialized. That reproduces the original race for that one pair of
|
|
44
|
+
* processes rather than blocking indefinitely — a deliberate,
|
|
45
|
+
* documented escape valve, not an oversight.
|
|
46
|
+
*
|
|
47
|
+
* @packageDocumentation
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* A build whose owning process is *gone* and whose lock is older than
|
|
51
|
+
* this is presumed abandoned (crashed process, killed job) and taken
|
|
52
|
+
* over. Age alone never triggers a takeover — the recorded pid is
|
|
53
|
+
* probed first, and a live owner keeps its lock past this age (see the
|
|
54
|
+
* module doc comment). One minute comfortably covers the `globalSetup`
|
|
55
|
+
* build steps this lock is built for; the pid probe is what protects
|
|
56
|
+
* the longer ones.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_LOCK_STALE_MS = 6e4;
|
|
59
|
+
/** How long a waiter sleeps between polls of an active lock. */
|
|
60
|
+
const DEFAULT_LOCK_POLL_MS = 200;
|
|
61
|
+
/** How long a waiter blocks before giving up and running its own build independently. */
|
|
62
|
+
const DEFAULT_LOCK_WAIT_TIMEOUT_MS = 6e5;
|
|
63
|
+
/** A done-marker fresher than this is trusted without re-running the command. */
|
|
64
|
+
const DEFAULT_BUILT_RECENTLY_MS = 3e4;
|
|
65
|
+
/**
|
|
66
|
+
* Strictly parses a millisecond timing supplied through one of the
|
|
67
|
+
* `VITEST_AGENT_RUNSCRIPT_*` env overrides, falling back to `fallback`
|
|
68
|
+
* for anything that is not a whole positive integer at or above
|
|
69
|
+
* `minimum`.
|
|
70
|
+
*
|
|
71
|
+
* `Number.parseInt` is deliberately not used on its own: it happily
|
|
72
|
+
* accepts `"200ms"` (→ 200) and `"-1"` (→ a negative stale window,
|
|
73
|
+
* which makes every lock instantly stale) and `"0"` for a poll interval
|
|
74
|
+
* (a tight spin loop). An unusable override falls back to the default
|
|
75
|
+
* rather than silently degrading the lock.
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
function parseLockTimingOverride(raw, fallback, minimum = 1) {
|
|
80
|
+
if (raw === void 0) return fallback;
|
|
81
|
+
const trimmed = raw.trim();
|
|
82
|
+
if (!/^\d+$/.test(trimmed)) return fallback;
|
|
83
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
84
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback;
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the directory `runScript` locks and done-markers live in:
|
|
89
|
+
* `XDG_DATA_HOME` when set (matching `@vitest-agent/sdk`'s `data.db`
|
|
90
|
+
* resolution convention), else the same `~/.local/share` fallback that
|
|
91
|
+
* convention documents.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
function resolveRunScriptLockDir(env = process.env, home = homedir()) {
|
|
96
|
+
const xdg = env.XDG_DATA_HOME;
|
|
97
|
+
const base = xdg !== void 0 && xdg.length > 0 ? xdg : join(home, ".local", "share");
|
|
98
|
+
return join(base, "vitest-agent", "runscript-locks");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Deterministic, filesystem-safe key for one (workspace, command) pair
|
|
102
|
+
* so different `globalSetup` commands in the same checkout don't share
|
|
103
|
+
* a lock, while repeat invocations of the *same* command in the *same*
|
|
104
|
+
* workspace do.
|
|
105
|
+
*/
|
|
106
|
+
function computeLockKey(cwd, command) {
|
|
107
|
+
return createHash("sha256").update(`${cwd}\n${command}`).digest("hex").slice(0, 16);
|
|
108
|
+
}
|
|
109
|
+
/** Reads the owner record from a lock file. Returns `null` when the file is missing, unreadable, or not a well-formed owner record (e.g. observed mid-write by its creator). */
|
|
110
|
+
function readLockOwner(lockPath) {
|
|
111
|
+
let raw;
|
|
112
|
+
try {
|
|
113
|
+
raw = readFileSync(lockPath, "utf8");
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
if (typeof parsed?.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
120
|
+
if (typeof parsed.nonce !== "string" || parsed.nonce.length === 0) return null;
|
|
121
|
+
return {
|
|
122
|
+
pid: parsed.pid,
|
|
123
|
+
nonce: parsed.nonce,
|
|
124
|
+
startedAt: String(parsed.startedAt ?? "")
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Signal-0 liveness probe. `EPERM` means the process exists but is
|
|
132
|
+
* owned by another user — still alive, and emphatically not ours to
|
|
133
|
+
* take over. Anything else (`ESRCH`) means it is gone.
|
|
134
|
+
*/
|
|
135
|
+
function isProcessAlive(pid) {
|
|
136
|
+
try {
|
|
137
|
+
process.kill(pid, 0);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return err.code === "EPERM";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function defaultSleep(ms) {
|
|
144
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
145
|
+
}
|
|
146
|
+
function isRecentlyBuilt(doneMarkerPath, builtRecentlyMs) {
|
|
147
|
+
try {
|
|
148
|
+
return Date.now() - statSync(doneMarkerPath).mtimeMs < builtRecentlyMs;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function lockAgeMs(lockPath) {
|
|
154
|
+
try {
|
|
155
|
+
return Date.now() - statSync(lockPath).mtimeMs;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Acquires (or observes) the advisory lock for one (cwd, command) pair.
|
|
162
|
+
* Blocks the calling thread (via `sleep`) while another process holds
|
|
163
|
+
* a non-stale lock, up to `waitTimeoutMs`.
|
|
164
|
+
*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
167
|
+
function acquireRunScriptLock(options) {
|
|
168
|
+
const { cwd, command, lockDir = resolveRunScriptLockDir(), staleMs = DEFAULT_LOCK_STALE_MS, waitTimeoutMs = DEFAULT_LOCK_WAIT_TIMEOUT_MS, pollMs = 200, builtRecentlyMs = DEFAULT_BUILT_RECENTLY_MS, sleep = defaultSleep } = options;
|
|
169
|
+
mkdirSync(lockDir, { recursive: true });
|
|
170
|
+
const key = computeLockKey(cwd, command);
|
|
171
|
+
const lockPath = join(lockDir, `${key}.lock`);
|
|
172
|
+
const doneMarkerPath = join(lockDir, `${key}.done`);
|
|
173
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
174
|
+
for (;;) {
|
|
175
|
+
if (isRecentlyBuilt(doneMarkerPath, builtRecentlyMs)) return {
|
|
176
|
+
lockPath,
|
|
177
|
+
doneMarkerPath,
|
|
178
|
+
acquired: false,
|
|
179
|
+
recentlyBuilt: true,
|
|
180
|
+
ownerNonce: null
|
|
181
|
+
};
|
|
182
|
+
let fd = null;
|
|
183
|
+
try {
|
|
184
|
+
fd = openSync(lockPath, "wx");
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err.code !== "EEXIST") throw err;
|
|
187
|
+
}
|
|
188
|
+
if (fd !== null) {
|
|
189
|
+
const ownerNonce = randomBytes(12).toString("hex");
|
|
190
|
+
const record = {
|
|
191
|
+
pid: process.pid,
|
|
192
|
+
nonce: ownerNonce,
|
|
193
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
writeSync(fd, JSON.stringify(record));
|
|
197
|
+
} catch (err) {
|
|
198
|
+
try {
|
|
199
|
+
rmSync(lockPath, { force: true });
|
|
200
|
+
} catch {}
|
|
201
|
+
throw err;
|
|
202
|
+
} finally {
|
|
203
|
+
try {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
lockPath,
|
|
209
|
+
doneMarkerPath,
|
|
210
|
+
acquired: true,
|
|
211
|
+
recentlyBuilt: false,
|
|
212
|
+
ownerNonce
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const age = lockAgeMs(lockPath);
|
|
216
|
+
if (age !== null && age > staleMs) {
|
|
217
|
+
const owner = readLockOwner(lockPath);
|
|
218
|
+
if (owner === null || !isProcessAlive(owner.pid)) {
|
|
219
|
+
try {
|
|
220
|
+
rmSync(lockPath, { force: true });
|
|
221
|
+
} catch {}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (Date.now() > deadline) return {
|
|
226
|
+
lockPath,
|
|
227
|
+
doneMarkerPath,
|
|
228
|
+
acquired: false,
|
|
229
|
+
recentlyBuilt: false,
|
|
230
|
+
ownerNonce: null
|
|
231
|
+
};
|
|
232
|
+
sleep(pollMs);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Releases a lock this process acquired. No-op when `lock.acquired` is
|
|
237
|
+
* `false` (this process never owned it), and — critically — also a
|
|
238
|
+
* no-op when the lock file on disk no longer carries this
|
|
239
|
+
* acquisition's nonce: that means the lock was taken over and now
|
|
240
|
+
* belongs to somebody else's in-flight build. Deleting it there would
|
|
241
|
+
* admit a third process while the second is still running the command.
|
|
242
|
+
*
|
|
243
|
+
* @public
|
|
244
|
+
*/
|
|
245
|
+
function releaseRunScriptLock(lock) {
|
|
246
|
+
if (!lock.acquired || lock.ownerNonce === null) return;
|
|
247
|
+
const owner = readLockOwner(lock.lockPath);
|
|
248
|
+
if (owner === null || owner.nonce !== lock.ownerNonce) return;
|
|
249
|
+
try {
|
|
250
|
+
rmSync(lock.lockPath, { force: true });
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Marks a successful build, so a waiter's next `isRecentlyBuilt` check
|
|
255
|
+
* can skip re-running the command. Best-effort: a write failure just
|
|
256
|
+
* means the next process won't short-circuit, not a correctness
|
|
257
|
+
* problem.
|
|
258
|
+
*
|
|
259
|
+
* @public
|
|
260
|
+
*/
|
|
261
|
+
function markRunScriptDone(lock) {
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(lock.doneMarkerPath, String(Date.now()));
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
//#endregion
|
|
268
|
+
export { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock, resolveRunScriptLockDir };
|