@zhuxixi/pi-agent-board 0.4.2 → 0.4.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 +15 -1
- package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +614 -0
- package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +32 -0
- package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +97 -0
- package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +35 -0
- package/package.json +4 -2
- package/runner/job-runner.mjs +28 -2
- package/src/core/follow-up-queue.mjs +23 -5
- package/src/core/locks.mjs +79 -23
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ If the board reports `node-pty unavailable`, press `!` in the dashboard for diag
|
|
|
101
101
|
npm install
|
|
102
102
|
npm run typecheck
|
|
103
103
|
npm test
|
|
104
|
+
npm run test:coverage
|
|
104
105
|
npm run pack:dry
|
|
105
106
|
```
|
|
106
107
|
|
|
@@ -110,7 +111,20 @@ Run all checks with:
|
|
|
110
111
|
npm run verify
|
|
111
112
|
```
|
|
112
113
|
|
|
113
|
-
`npm run verify` runs typecheck, tests, and a dry npm pack.
|
|
114
|
+
`npm run verify` runs typecheck, tests, coverage, and a dry npm pack.
|
|
115
|
+
|
|
116
|
+
### QA baseline
|
|
117
|
+
|
|
118
|
+
Every push and PR runs the same checks in CI (`.github/workflows/ci.yml`, Node 22 + 24),
|
|
119
|
+
and `main` branch protection requires both CI checks to pass before merging.
|
|
120
|
+
|
|
121
|
+
Coverage is enforced by `c8` with thresholds configured in `.c8rc.json`
|
|
122
|
+
(lines ≥ 85%, functions ≥ 80%, branches ≥ 70%). The TS UI layer
|
|
123
|
+
(`src/ui/*.ts`, `src/commands/*.ts`) is covered by a smoke test
|
|
124
|
+
(`test/ui-smoke.test.mjs`) that constructs and renders the real entrypoints;
|
|
125
|
+
it is excluded from the coverage thresholds by design.
|
|
126
|
+
|
|
127
|
+
Current baseline: 300+ tests, ~92% line coverage on the core modules.
|
|
114
128
|
|
|
115
129
|
## Publish
|
|
116
130
|
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# locks.mjs Bounded Acquisition Fix Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make `acquireLock` in `src/core/locks.mjs` fail fast (bounded attempts → throw) instead of spinning forever when the lock can never be acquired, and harden every layer above it (queue, runner, test harness) so a lock failure can never produce a 100%-CPU zombie process again (issue #33).
|
|
6
|
+
|
|
7
|
+
**Architecture:** Four defense layers. (1) `locks.mjs`: classify acquisition failures — EEXIST contention keeps the existing wait-window + force-steal semantics (bounded to 2 steal attempts), all other errors (ENOENT/EACCES/ENOTDIR/EROFS/ENOSPC…) get 3 quick retries with `ensureDir` re-run for self-heal, then throw. (2) `follow-up-queue.mjs` catches any throw and returns `{ok:false, error}` per the module's existing convention. (3) `runner/job-runner.mjs` wraps the post-exit finalize chain in try/catch so `process.exit` always runs. (4) `test/runner.integration.test.mjs` kills every detached runner in teardown before deleting the root.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node 24 ESM (`.mjs`), `node:test` + `node:assert/strict`, sync `node:fs`, tabs indentation, JSDoc types, zero new dependencies.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- **Worktree only:** all file paths below are relative to `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-33-locks-acquirelock-spin` — never touch the main checkout.
|
|
14
|
+
- **Existing semantics must not change:** the 5 existing tests in `test/locks.test.mjs` stay unmodified and green (wait-window contention + stale steal + force-steal-after-window are contract).
|
|
15
|
+
- Every new test carries `{ timeout: 5000 }` so a regression to a spin loop fails fast instead of hanging the suite.
|
|
16
|
+
- fs injection follows the `defaultScreenLogFs` precedent in `src/core/screen-log.mjs` (frozen object of raw `node:fs` functions passed via opts).
|
|
17
|
+
- Stage by explicit file path (`git add <file>`), never `git add -A`.
|
|
18
|
+
- Conventional commits, English messages. Comments in English, tabs for indentation.
|
|
19
|
+
- Test command: `npm test` (= `node --test test/*.test.mjs`). Final gate: `npm run verify`.
|
|
20
|
+
- `appendLine`/`appendDiagnostic` are NOT best-effort (verified: `ensureDir` + `appendFileSync` throw on deleted roots) — any diagnostic call on a possibly-dead root must be wrapped.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
### Task 1: locks.mjs — bounded acquisition, error classification, fs injection
|
|
25
|
+
|
|
26
|
+
**Files:**
|
|
27
|
+
- Modify: `src/core/locks.mjs` (full rewrite of internals, same public API + new opts)
|
|
28
|
+
- Test: `test/locks.test.mjs` (append new tests; do not modify existing 5)
|
|
29
|
+
|
|
30
|
+
**Interfaces:**
|
|
31
|
+
- Consumes: `ensureDir(dir)` from `src/core/atomic.mjs`; `P.viewLockPath(root, viewId, name)`.
|
|
32
|
+
- Produces (other tasks rely on these):
|
|
33
|
+
- `withFileLockSync(lockPath, fn, opts?)` — `opts.fs` (frozen `{existsSync, mkdirSync, readFileSync, rmSync, writeFileSync}`, default `defaultLocksFs`), `opts.staleMs` (default 30000). Throws `Error` with `err.code === "LOCK_TIMEOUT"` and message containing the lockPath when acquisition is impossible.
|
|
34
|
+
- `withViewLockSync(root, viewId, name, fn, opts?)` — passes opts through.
|
|
35
|
+
- `defaultLocksFs` exported (for tests).
|
|
36
|
+
|
|
37
|
+
- [ ] **Step 1: Write the failing tests**
|
|
38
|
+
|
|
39
|
+
Append to `test/locks.test.mjs` (also add `defaultLocksFs` to the import from `../src/core/locks.mjs`, and `writeFileSync` to the existing `node:fs` import — it is already there for the stale-lock tests, verify):
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const LOCK_FS = defaultLocksFs;
|
|
43
|
+
|
|
44
|
+
function eexist() {
|
|
45
|
+
const e = new Error("EEXIST");
|
|
46
|
+
e.code = "EEXIST";
|
|
47
|
+
return e;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("withFileLockSync throws promptly when mkdirSync keeps failing", { timeout: 5000 }, () => {
|
|
51
|
+
const root = freshRoot();
|
|
52
|
+
try {
|
|
53
|
+
const fs = {
|
|
54
|
+
...LOCK_FS,
|
|
55
|
+
mkdirSync: (p, o) => {
|
|
56
|
+
const e = new Error("EACCES");
|
|
57
|
+
e.code = "EACCES";
|
|
58
|
+
throw e;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => withFileLockSync(join(root, "x.lock"), () => "no", { fs }),
|
|
64
|
+
/lock path unusable/,
|
|
65
|
+
);
|
|
66
|
+
assert.ok(Date.now() - started < 2000, "must fail fast, not spin");
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("withFileLockSync throws and cleans up when owner.json writes keep failing", { timeout: 5000 }, () => {
|
|
73
|
+
const root = freshRoot();
|
|
74
|
+
try {
|
|
75
|
+
const fs = {
|
|
76
|
+
...LOCK_FS,
|
|
77
|
+
writeFileSync: (file, data, opts) => {
|
|
78
|
+
if (String(file).endsWith("owner.json")) {
|
|
79
|
+
const e = new Error("ENOSPC");
|
|
80
|
+
e.code = "ENOSPC";
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
return LOCK_FS.writeFileSync(file, data, opts);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => withFileLockSync(join(root, "w.lock"), () => "no", { fs }),
|
|
88
|
+
/lock path unusable/,
|
|
89
|
+
);
|
|
90
|
+
assert.equal(existsSync(join(root, "w.lock")), false, "half-created lock must be cleaned up");
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("withFileLockSync throws after bounded steal attempts when rmSync keeps failing", { timeout: 5000 }, () => {
|
|
97
|
+
const root = freshRoot();
|
|
98
|
+
try {
|
|
99
|
+
const lockPath = join(root, "s.lock");
|
|
100
|
+
mkdirSync(lockPath);
|
|
101
|
+
writeFileSync(join(lockPath, "owner.json"), JSON.stringify({ pid: 1, at: Date.now() - 60_000 }), "utf8"); // stale
|
|
102
|
+
const fs = {
|
|
103
|
+
...LOCK_FS,
|
|
104
|
+
rmSync: (p, o) => {
|
|
105
|
+
const e = new Error("EPERM");
|
|
106
|
+
e.code = "EPERM";
|
|
107
|
+
throw e;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const started = Date.now();
|
|
111
|
+
assert.throws(
|
|
112
|
+
() => withFileLockSync(lockPath, () => "no", { fs, staleMs: 50 }),
|
|
113
|
+
/stale lock could not be stolen/,
|
|
114
|
+
);
|
|
115
|
+
assert.ok(Date.now() - started < 2000);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(root, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("withFileLockSync waits through transient contention and acquires", { timeout: 5000 }, () => {
|
|
122
|
+
const root = freshRoot();
|
|
123
|
+
try {
|
|
124
|
+
const lockPath = join(root, "c.lock");
|
|
125
|
+
mkdirSync(lockPath);
|
|
126
|
+
writeFileSync(join(lockPath, "owner.json"), JSON.stringify({ pid: 1, at: Date.now() }), "utf8"); // fresh holder
|
|
127
|
+
let eexistLeft = 3;
|
|
128
|
+
const fs = {
|
|
129
|
+
...LOCK_FS,
|
|
130
|
+
mkdirSync: (p, o) => {
|
|
131
|
+
if (p === lockPath && eexistLeft-- > 0) throw eexist();
|
|
132
|
+
if (p === lockPath) LOCK_FS.rmSync(lockPath, { recursive: true, force: true }); // holder releases
|
|
133
|
+
return LOCK_FS.mkdirSync(p, o);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const started = Date.now();
|
|
137
|
+
const result = withFileLockSync(lockPath, () => "won", { fs, staleMs: 30_000 });
|
|
138
|
+
assert.equal(result, "won");
|
|
139
|
+
assert.ok(Date.now() - started >= 40, "should have slept through contention ticks");
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(root, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("withFileLockSync self-heals a parent dir deleted mid-acquisition", { timeout: 5000 }, () => {
|
|
146
|
+
const root = freshRoot();
|
|
147
|
+
try {
|
|
148
|
+
const lockPath = join(root, "del", "parent", "p.lock");
|
|
149
|
+
let failedOnce = false;
|
|
150
|
+
const fs = {
|
|
151
|
+
...LOCK_FS,
|
|
152
|
+
mkdirSync: (p, o) => {
|
|
153
|
+
if (p === lockPath && !failedOnce) {
|
|
154
|
+
failedOnce = true; // simulate parent deleted between ensureDir and mkdir
|
|
155
|
+
const e = new Error("ENOENT");
|
|
156
|
+
e.code = "ENOENT";
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
return LOCK_FS.mkdirSync(p, o);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const result = withFileLockSync(lockPath, () => "healed", { fs });
|
|
163
|
+
assert.equal(result, "healed");
|
|
164
|
+
assert.equal(existsSync(lockPath), false);
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("lock timeout errors carry LOCK_TIMEOUT code and the lock path", { timeout: 5000 }, () => {
|
|
171
|
+
const root = freshRoot();
|
|
172
|
+
try {
|
|
173
|
+
const fs = {
|
|
174
|
+
...LOCK_FS,
|
|
175
|
+
mkdirSync: (p, o) => {
|
|
176
|
+
const e = new Error("EROFS");
|
|
177
|
+
e.code = "EROFS";
|
|
178
|
+
throw e;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const lockPath = join(root, "code.lock");
|
|
182
|
+
assert.throws(
|
|
183
|
+
() => withFileLockSync(lockPath, () => "no", { fs }),
|
|
184
|
+
(err) => err.code === "LOCK_TIMEOUT" && String(err.message).includes(lockPath),
|
|
185
|
+
);
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(root, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
193
|
+
|
|
194
|
+
Run: `npm test -- --test-name-pattern "withFileLockSync (throws|waits|self-heals)|lock timeout"`
|
|
195
|
+
Expected: the spin-path tests (`mkdirSync keeps failing`, `self-heals`) FAIL via the 5000ms timeout (current code spins forever); the rmSync-steal test fails by timeout too; `owner.json writes` may fail by timeout. RED confirmed.
|
|
196
|
+
|
|
197
|
+
- [ ] **Step 3: Implement the new locks.mjs**
|
|
198
|
+
|
|
199
|
+
Replace `src/core/locks.mjs` with:
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
/**
|
|
203
|
+
* Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
|
|
204
|
+
* Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
|
|
205
|
+
*
|
|
206
|
+
* Failure model (issue #33): acquisition failures are classified.
|
|
207
|
+
* - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
|
|
208
|
+
* force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
|
|
209
|
+
* wait/steal contract (see test/locks.test.mjs).
|
|
210
|
+
* - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
|
|
211
|
+
* MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
|
|
212
|
+
* deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
|
|
213
|
+
*/
|
|
214
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
215
|
+
import * as path from "node:path";
|
|
216
|
+
import { ensureDir } from "./atomic.mjs";
|
|
217
|
+
import * as P from "./paths.mjs";
|
|
218
|
+
|
|
219
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
220
|
+
/** Minimum contention window before a fresh lock can be force-stolen. */
|
|
221
|
+
const MIN_WINDOW_MS = 250;
|
|
222
|
+
const WAIT_TICK_MS = 20;
|
|
223
|
+
/** Max stale-lock steal attempts before giving up. */
|
|
224
|
+
const MAX_STEAL_ATTEMPTS = 2;
|
|
225
|
+
/** Max quick retries for environmental failures before giving up. */
|
|
226
|
+
const MAX_ENV_ATTEMPTS = 3;
|
|
227
|
+
|
|
228
|
+
export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @template T
|
|
232
|
+
* @param {string} lockPath
|
|
233
|
+
* @param {() => T} fn
|
|
234
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
235
|
+
* @returns {T}
|
|
236
|
+
*/
|
|
237
|
+
export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
238
|
+
const fs = opts.fs ?? defaultLocksFs;
|
|
239
|
+
acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
|
|
240
|
+
let result;
|
|
241
|
+
try {
|
|
242
|
+
result = fn();
|
|
243
|
+
} finally {
|
|
244
|
+
releaseLock(lockPath, fs);
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @template T
|
|
251
|
+
* @param {string} root
|
|
252
|
+
* @param {string} viewId
|
|
253
|
+
* @param {string} name
|
|
254
|
+
* @param {() => T} fn
|
|
255
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
256
|
+
* @returns {T}
|
|
257
|
+
*/
|
|
258
|
+
export function withViewLockSync(root, viewId, name, fn, opts = {}) {
|
|
259
|
+
return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
263
|
+
function acquireLock(lockPath, staleMs, fs) {
|
|
264
|
+
const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
|
|
265
|
+
let steals = 0;
|
|
266
|
+
let envAttempts = 0;
|
|
267
|
+
for (;;) {
|
|
268
|
+
let created = false;
|
|
269
|
+
try {
|
|
270
|
+
// Re-run every attempt: a parent deleted mid-acquisition self-heals here.
|
|
271
|
+
ensureDir(path.dirname(lockPath));
|
|
272
|
+
fs.mkdirSync(lockPath);
|
|
273
|
+
created = true;
|
|
274
|
+
fs.writeFileSync(
|
|
275
|
+
path.join(lockPath, "owner.json"),
|
|
276
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
277
|
+
"utf8",
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err && err.code === "EEXIST") {
|
|
282
|
+
const expired = Date.now() >= deadline;
|
|
283
|
+
if (isLockStale(lockPath, staleMs, fs) || expired) {
|
|
284
|
+
if (steals >= MAX_STEAL_ATTEMPTS) {
|
|
285
|
+
throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
|
|
286
|
+
}
|
|
287
|
+
steals += 1;
|
|
288
|
+
releaseLock(lockPath, fs);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
sleep(WAIT_TICK_MS);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Environmental failure: bounded quick retries, then fail fast.
|
|
295
|
+
if (created) releaseLock(lockPath, fs);
|
|
296
|
+
envAttempts += 1;
|
|
297
|
+
if (envAttempts >= MAX_ENV_ATTEMPTS) {
|
|
298
|
+
const reason = (err && (err.code || err.message)) || "unknown error";
|
|
299
|
+
throw lockError(lockPath, `lock path unusable (${reason})`);
|
|
300
|
+
}
|
|
301
|
+
sleep(WAIT_TICK_MS);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {string} lockPath @param {string} reason */
|
|
307
|
+
function lockError(lockPath, reason) {
|
|
308
|
+
const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
|
|
309
|
+
err.code = "LOCK_TIMEOUT";
|
|
310
|
+
return err;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** @param {number} ms */
|
|
314
|
+
function sleep(ms) {
|
|
315
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
319
|
+
function isLockStale(lockPath, staleMs, fs) {
|
|
320
|
+
try {
|
|
321
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
322
|
+
const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
323
|
+
const owner = JSON.parse(raw);
|
|
324
|
+
return Date.now() - Number(owner.at ?? 0) > staleMs;
|
|
325
|
+
} catch {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** @param {string} lockPath @param {typeof defaultLocksFs} fs */
|
|
331
|
+
function releaseLock(lockPath, fs) {
|
|
332
|
+
try {
|
|
333
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
334
|
+
} catch {
|
|
335
|
+
/* best effort */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 4: Run tests to verify green**
|
|
341
|
+
|
|
342
|
+
Run: `npm test -- --test-name-pattern "withFileLockSync|withViewLockSync|lock timeout"`
|
|
343
|
+
Expected: ALL PASS — the 5 existing tests unmodified plus the 6 new ones.
|
|
344
|
+
|
|
345
|
+
- [ ] **Step 5: Commit**
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
git add src/core/locks.mjs test/locks.test.mjs
|
|
349
|
+
git commit -m "fix: bound lock acquisition with error classification and fs injection (issue #33)"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
### Task 2: follow-up-queue.mjs — catch-all {ok:false} translation
|
|
355
|
+
|
|
356
|
+
**Files:**
|
|
357
|
+
- Modify: `src/core/follow-up-queue.mjs` (route all 5 lock sites through one guarded helper)
|
|
358
|
+
- Test: `test/follow-up-queue.test.mjs` (append)
|
|
359
|
+
|
|
360
|
+
**Interfaces:**
|
|
361
|
+
- Consumes: Task 1's `withViewLockSync(root, viewId, name, fn, opts?)` (throws `LOCK_TIMEOUT` on failure).
|
|
362
|
+
- Produces: unchanged public signatures; NEW behavior — every mutating queue op (`enqueueFollowUp`, `claimNextFollowUp`, `completeFollowUp`, `failFollowUp`, `releaseFollowUp`, `removeLastFollowUp`, `clearQueuedFollowUps`) returns `{ok:false, error}` instead of throwing when the lock (or any fs op inside fn) fails. `service.mjs` and `runner/job-runner.mjs` consume the `{ok}` convention unchanged.
|
|
363
|
+
|
|
364
|
+
- [ ] **Step 1: Write the failing test**
|
|
365
|
+
|
|
366
|
+
Append to `test/follow-up-queue.test.mjs` (add `writeFileSync` to the `node:fs` import):
|
|
367
|
+
|
|
368
|
+
```js
|
|
369
|
+
test("queue ops return {ok:false} when the lock path is unusable", { timeout: 5000 }, () => {
|
|
370
|
+
const root = freshRoot();
|
|
371
|
+
try {
|
|
372
|
+
const fileRoot = join(root, "notadir");
|
|
373
|
+
writeFileSync(fileRoot, "x", "utf8"); // ensureDir under a file path -> ENOTDIR, unrecoverable
|
|
374
|
+
const enq = enqueueFollowUp(fileRoot, "v1", "hello");
|
|
375
|
+
assert.equal(enq.ok, false);
|
|
376
|
+
assert.match(String(enq.error), /lock unavailable/);
|
|
377
|
+
const claim = claimNextFollowUp(fileRoot, "v1");
|
|
378
|
+
assert.equal(claim.ok, false);
|
|
379
|
+
assert.match(String(claim.error), /lock unavailable/);
|
|
380
|
+
const removed = removeLastFollowUp(fileRoot, "v1");
|
|
381
|
+
assert.equal(removed.ok, false);
|
|
382
|
+
} finally {
|
|
383
|
+
rmSync(root, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
- [ ] **Step 2: Run to verify it fails**
|
|
389
|
+
|
|
390
|
+
Run: `npm test -- --test-name-pattern "lock path is unusable"`
|
|
391
|
+
Expected: FAIL — current code propagates the raw ENOTDIR throw (uncaught error fails the test).
|
|
392
|
+
|
|
393
|
+
- [ ] **Step 3: Implement**
|
|
394
|
+
|
|
395
|
+
In `src/core/follow-up-queue.mjs`: add a guarded wrapper after the imports and route the 5 `withViewLockSync(...)` call sites (`enqueueFollowUp`, `claimNextFollowUp`, `removeLastFollowUp`, `clearQueuedFollowUps`, and the private `updateItem`) through it — replace each `return withViewLockSync(root, viewId, "queue", () => { ... })` with `return lockedQueueOp(root, viewId, () => { ... })` (body unchanged):
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
import { withViewLockSync } from "./locks.mjs";
|
|
399
|
+
// ...existing imports...
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Run a queue mutation under the view lock, translating any failure (lock
|
|
403
|
+
* unavailable, fs errors inside the mutation) into {ok:false} so callers on
|
|
404
|
+
* the {ok} convention never see a throw (issue #33).
|
|
405
|
+
* @template T
|
|
406
|
+
* @param {string} root
|
|
407
|
+
* @param {string} viewId
|
|
408
|
+
* @param {() => T} fn
|
|
409
|
+
* @returns {T | { ok: false, error: string }}
|
|
410
|
+
*/
|
|
411
|
+
function lockedQueueOp(root, viewId, fn) {
|
|
412
|
+
try {
|
|
413
|
+
return withViewLockSync(root, viewId, "queue", fn);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return { ok: false, error: `follow-up queue lock unavailable: ${err instanceof Error ? err.message : String(err)}` };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- [ ] **Step 4: Run the full queue suite**
|
|
421
|
+
|
|
422
|
+
Run: `npm test -- --test-name-pattern "follow-up|queue"`
|
|
423
|
+
Expected: ALL PASS (existing FIFO/durability tests + the new one).
|
|
424
|
+
|
|
425
|
+
- [ ] **Step 5: Commit**
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
git add src/core/follow-up-queue.mjs test/follow-up-queue.test.mjs
|
|
429
|
+
git commit -m "fix: translate follow-up queue lock failures into {ok:false} results (issue #33)"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
### Task 3: job-runner.mjs — finalize chain can never skip process.exit
|
|
435
|
+
|
|
436
|
+
**Files:**
|
|
437
|
+
- Modify: `runner/job-runner.mjs` (the `worker.on("close")` `.finally` block only)
|
|
438
|
+
|
|
439
|
+
**Interfaces:**
|
|
440
|
+
- Consumes: Task 2's `{ok:false}` queue results (`claimNextFollowUp` no longer throws on lock failure — the existing `if (!claimed.ok || !claimed.item) return;` already handles it).
|
|
441
|
+
- Produces: no signature changes. Behavioral guarantee: `finalizeSteeringIfNeeded` and `drainQueuedFollowUp` failures are logged best-effort and `process.exit` always runs.
|
|
442
|
+
|
|
443
|
+
**Note:** this task has no practical unit test (module runs `main()` on import and the guarded code is inside a child-process event chain). It is covered by Task 4's integration tests + the fix is structurally simple. Red-green is not applicable; code-review verification is the gate (spec test-plan item 7).
|
|
444
|
+
|
|
445
|
+
- [ ] **Step 1: Implement the guards**
|
|
446
|
+
|
|
447
|
+
In `runner/job-runner.mjs`, replace the `.finally(() => { ... })` block:
|
|
448
|
+
|
|
449
|
+
```js
|
|
450
|
+
.finally(() => {
|
|
451
|
+
// The finalize chain must never prevent process.exit: a lock/fs failure
|
|
452
|
+
// here used to pin the runner as a 100% CPU zombie (issue #33).
|
|
453
|
+
try {
|
|
454
|
+
finalizeSteeringIfNeeded(config, status, evidence);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
tryAppendDiagnostic(config, "finalize_steering_failed", err);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
drainQueuedFollowUp(config, status);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
tryAppendDiagnostic(config, "follow_up_drain_failed", err);
|
|
462
|
+
}
|
|
463
|
+
process.exit(stoppedByUser ? 0 : (code ?? 0));
|
|
464
|
+
});
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
And add this helper next to `drainQueuedFollowUp` (appendDiagnostic itself throws on a deleted root — verified `appendLine` is not best-effort):
|
|
468
|
+
|
|
469
|
+
```js
|
|
470
|
+
/** @param {import("../src/core/types.mjs").RunConfig} config @param {string} code @param {unknown} err */
|
|
471
|
+
function tryAppendDiagnostic(config, code, err) {
|
|
472
|
+
try {
|
|
473
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
474
|
+
source: "runner",
|
|
475
|
+
runId: config.runId,
|
|
476
|
+
level: "error",
|
|
477
|
+
code,
|
|
478
|
+
message: "Finalize step failed",
|
|
479
|
+
details: { error: err instanceof Error ? err.message : String(err) },
|
|
480
|
+
});
|
|
481
|
+
} catch {
|
|
482
|
+
/* root may be deleted — nothing to persist, exit anyway */
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
- [ ] **Step 2: Sanity-check syntax + typecheck**
|
|
488
|
+
|
|
489
|
+
Run: `node --check runner/job-runner.mjs && npm run typecheck`
|
|
490
|
+
Expected: both clean.
|
|
491
|
+
|
|
492
|
+
- [ ] **Step 3: Commit**
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
git add runner/job-runner.mjs
|
|
496
|
+
git commit -m "fix: guard job-runner finalize chain so process.exit always runs (issue #33)"
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
### Task 4: integration tests — kill detached runners in teardown
|
|
502
|
+
|
|
503
|
+
**Files:**
|
|
504
|
+
- Modify: `test/runner.integration.test.mjs`
|
|
505
|
+
|
|
506
|
+
**Interfaces:**
|
|
507
|
+
- Consumes: `launchRun(root, config, {runnerScript})` returns `{pid}` (all 7 call sites must capture it — 4 currently discard: the `needs_input`, `dash-prefixed`, `worker exits nonzero`, and `worker error` tests).
|
|
508
|
+
- Produces: `killDetached(pid)` async helper; every test's `finally` kills the runner BEFORE `rmSync(root)`.
|
|
509
|
+
|
|
510
|
+
- [ ] **Step 1: Add the helper (after the existing `sleep` definition)**
|
|
511
|
+
|
|
512
|
+
```js
|
|
513
|
+
/** Kill a detached runner before deleting its root so it can never orphan (issue #33). */
|
|
514
|
+
async function killDetached(pid) {
|
|
515
|
+
if (!pid || pid <= 0) return;
|
|
516
|
+
try {
|
|
517
|
+
process.kill(pid, "SIGTERM");
|
|
518
|
+
} catch {
|
|
519
|
+
return; // already exited
|
|
520
|
+
}
|
|
521
|
+
const deadline = Date.now() + 1000;
|
|
522
|
+
while (Date.now() < deadline) {
|
|
523
|
+
await sleep(50);
|
|
524
|
+
try {
|
|
525
|
+
process.kill(pid, 0);
|
|
526
|
+
} catch {
|
|
527
|
+
return; // exited
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
process.kill(pid, "SIGKILL");
|
|
532
|
+
} catch {
|
|
533
|
+
/* already gone */
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
- [ ] **Step 2: Capture pids at the 4 discarding call sites**
|
|
539
|
+
|
|
540
|
+
Change `launchRun(root, config, { runnerScript: RUNNER });` to `const { pid } = launchRun(root, config, { runnerScript: RUNNER });` in the `needs_input`, `dash-prefixed prompts`, `worker exits nonzero`, and `worker error` tests. The `auto-classifies`, `readPid`, and remaining tests already capture the pid — leave them (rename only if a shadow conflict with `pid` arises; use `runnerPid` as the variable name at ALL sites for uniformity).
|
|
541
|
+
|
|
542
|
+
- [ ] **Step 3: Kill before rmSync in every finally**
|
|
543
|
+
|
|
544
|
+
In each test's `finally`, add `await killDetached(runnerPid);` as the FIRST statement, before `rmSync(root, ...)`. Example (first test):
|
|
545
|
+
|
|
546
|
+
```js
|
|
547
|
+
} finally {
|
|
548
|
+
delete process.env.FAKE_PI_MODE;
|
|
549
|
+
// ...
|
|
550
|
+
await killDetached(runnerPid);
|
|
551
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
- [ ] **Step 4: Run the integration suite and check for leftovers**
|
|
556
|
+
|
|
557
|
+
Run: `npm test -- --test-name-pattern "runner" && (ps -ef | grep '[j]ob-runner.mjs' || echo "no leftover runners")`
|
|
558
|
+
Expected: all runner tests PASS and `no leftover runners` prints.
|
|
559
|
+
|
|
560
|
+
- [ ] **Step 5: Commit**
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
git add test/runner.integration.test.mjs
|
|
564
|
+
git commit -m "test: kill detached job-runners in integration test teardown (issue #33)"
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
### Task 5: full verify + field-repro regression check
|
|
570
|
+
|
|
571
|
+
**Files:**
|
|
572
|
+
- Create: none (verification only; fixups allowed if verification exposes issues)
|
|
573
|
+
|
|
574
|
+
- [ ] **Step 1: Full gate**
|
|
575
|
+
|
|
576
|
+
Run: `npm run verify`
|
|
577
|
+
Expected: typecheck + tests + coverage thresholds + pack:dry all green. If coverage thresholds fail on new branches, extend the Task 1 tests (not the thresholds).
|
|
578
|
+
|
|
579
|
+
- [ ] **Step 2: Field-repro regression check (the /sys read-only scenario from the issue)**
|
|
580
|
+
|
|
581
|
+
Run:
|
|
582
|
+
```bash
|
|
583
|
+
node -e '
|
|
584
|
+
import("/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-33-locks-acquirelock-spin/src/core/locks.mjs").then(({ withFileLockSync }) => {
|
|
585
|
+
const t0 = Date.now();
|
|
586
|
+
try {
|
|
587
|
+
withFileLockSync("/sys/bus/pi-agent-board-repro.lock", () => {});
|
|
588
|
+
console.log("UNEXPECTED: acquired");
|
|
589
|
+
} catch (err) {
|
|
590
|
+
console.log("OK: threw in", Date.now() - t0, "ms:", err.message);
|
|
591
|
+
}
|
|
592
|
+
});'
|
|
593
|
+
```
|
|
594
|
+
Expected: `OK: threw in < 1000 ms: file lock unavailable: /sys/bus/... (lock path unusable (EROFS))` — the pre-fix behavior was an infinite spin.
|
|
595
|
+
|
|
596
|
+
- [ ] **Step 3: No zombies after the suite**
|
|
597
|
+
|
|
598
|
+
Run: `ps -ef | grep -E '[j]ob-runner|[p]ty-runner.*agentview' || echo clean`
|
|
599
|
+
Expected: `clean` (or only the user's real agent-board runners under ~/.pi/agent/agent-board).
|
|
600
|
+
|
|
601
|
+
- [ ] **Step 4: Commit any fixups (only if Steps 1-3 required changes)**
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
git add <changed files>
|
|
605
|
+
git commit -m "fix: address verify findings for issue #33"
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## Self-Review
|
|
611
|
+
|
|
612
|
+
- **Spec coverage:** D1/D3/D4/D5/D8 → Task 1; D4b (re-ensureDir self-heal) → Task 1 implementation + self-heal test; D2 (wait/steal contract) → Task 1 keeps existing tests unmodified; D6 → Task 2; D7 → Task 3; D9 → Task 4; spec test items 1-3 → Task 1 tests, item 4/4b → Task 1 contention/self-heal tests, item 5 → Task 1 Step 4, item 6 → Task 2, item 7 → Task 3 (CR gate, documented), item 8 → Task 4 Step 4. Acceptance items map to Task 5. No gaps.
|
|
613
|
+
- **Placeholder scan:** all steps contain concrete code / commands / expected output. No TBDs.
|
|
614
|
+
- **Type consistency:** `defaultLocksFs` name used in Task 1 tests + implementation; `LOCK_TIMEOUT` code asserted in Task 1 and produced by `lockError`; `lockedQueueOp` defined in Task 2 and used at all 5 sites; `killDetached` defined and used in Task 4; `runnerPid` naming uniform in Task 4.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Plan: fix pty-runner integration test flaky timeout (issue #34)
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Make `pty-runner creates host socket, broadcasts output, forwards input, finalizes` deterministic on CI by not depending on pre-connect socket output. No product code change.
|
|
6
|
+
|
|
7
|
+
## Tasks
|
|
8
|
+
|
|
9
|
+
1. **Edit test** — `test/pty-runner.integration.test.mjs`
|
|
10
|
+
- Replace `await waitFor(() => messages.find((m) => m.type === "output" && m.data.includes("fake pi ready")));`
|
|
11
|
+
with a screen-log read wait:
|
|
12
|
+
```js
|
|
13
|
+
await waitFor(() => {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync(P.screenLogPath(root, "v1"), "utf8").includes("fake pi ready");
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
- Keep all socket-based assertions (echo:hello, resize, exit) unchanged.
|
|
22
|
+
2. **Verify** — run the pty-runner integration test repeatedly:
|
|
23
|
+
- `node --test test/pty-runner.integration.test.mjs` × 6, expect 3/3 pass each run.
|
|
24
|
+
- Forced late-connect scenario (output before connect) passes.
|
|
25
|
+
- Full suite `npm test` — 313/313 pass.
|
|
26
|
+
3. **Commit** — conventional commit: `fix: make pty-runner integration test timing-independent (issue #34)`
|
|
27
|
+
4. **PR** — push `issue-34-pty-runner-test-flaky`, open PR against main, tag `zima:needs-review`, await CR, converge, merge.
|
|
28
|
+
|
|
29
|
+
## Verification
|
|
30
|
+
|
|
31
|
+
- Deterministic under the previously-failing timing (output emitted before client connects).
|
|
32
|
+
- No regression: full test suite green.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Spec: 修复 locks.mjs acquireLock 无眠死循环(issue #33)
|
|
2
|
+
|
|
3
|
+
> Draft 状态:待用户确认设计后进 worktree 实现(github-issue-driven 步 4 暂停点)。
|
|
4
|
+
|
|
5
|
+
## 问题
|
|
6
|
+
|
|
7
|
+
`src/core/locks.mjs` `acquireLock` 在锁持续不可得时(根目录被删 / 只读 / 锁状态损坏),30s 等待窗过期后退化为零睡眠忙等循环:100% 单核 CPU、事件循环冻死、定时器全灭、进程永远不退出。现网两个 job-runner 僵尸进程分别空转 10.5 天 / 4.4 天(#33 现场证据)。
|
|
8
|
+
|
|
9
|
+
## 根因(两处叠加)
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
while (true) {
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(lockPath);
|
|
15
|
+
writeFileSync(path.join(lockPath, "owner.json"), ...);
|
|
16
|
+
return;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
// 缺陷 1:睡眠只在初始窗口内生效,窗口一过永不睡眠
|
|
19
|
+
if (!isLockStale(lockPath, staleMs) && Date.now() - started < Math.max(250, staleMs)) {
|
|
20
|
+
Atomics.wait(...20ms);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
// 缺陷 2:releaseLock 静默吞错,循环无条件继续 → 无眠紧循环
|
|
24
|
+
releaseLock(lockPath);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**触发机制(现场还原)**:teardown 的 `rmSync(root, {recursive})` 与 runner 收尾链在时间上系统性重叠(runner 快速收尾链 ~10-50ms 到达锁 vs 测试 waitFor 轮询 ~50ms + 断言后才 rmSync ~50-150ms)。目录树遍历删掉 `ensureDir` 刚验证过的父目录后,`mkdirSync` 从此永远 ENOENT(**ensureDir 只在循环外跑一次,循环内永不重建父目录**)→ 30s 窗口后零睡眠死循环。另两类等价失败:owner.json 写失败(半成品锁被误判 stale → 删了重建无限循环)、锁目录删不掉(rmSync 失败被吞)。
|
|
30
|
+
|
|
31
|
+
## 设计决策
|
|
32
|
+
|
|
33
|
+
### 决策表
|
|
34
|
+
|
|
35
|
+
| # | 决策点 | 选择 | 理由 |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| D1 | 强夺失败后的行为 | **有界尝试后抛错**(`Error: lock timeout: <path>`) | 锁不可得属环境故障,忙等无意义;抛错让调用层决定降级 |
|
|
38
|
+
| D2 | 强夺(窗口后偷锁)语义 | **保留**:窗口过期 → releaseLock → 立即重试一次 | 现有测试「fresh lock 等窗口后强夺」固化此语义,改动会破坏契约 |
|
|
39
|
+
| D3 | 重试上限 | 等待窗内无限重试(带睡眠,窗口 = `max(250, staleMs)`,保留现有 floor);窗口后**最多 2 次强夺**(含 owner.json 写失败路径),仍失败即抛 | 覆盖 rm 失败 / mkdir 仍失败 / 写失败三类;有界即无死循环 |
|
|
40
|
+
| D4 | 睡眠策略 | 保留 `Atomics.wait(20ms)`;循环内任何 continue 前必有睡眠或已抛错 | 反证 D1 的失败模式,杜绝任何无眠路径 |
|
|
41
|
+
| D4b | **循环内自愈**:每次 catch 后重跑 `ensureDir(dirname)`(ensureDir 自身失败计为一次失败尝试) | 现场最高频竞态(teardown rmSync 删掉父目录)从「等窗口后抛错」升级为「瞬时自愈、正常拿锁退出」;有界性不变 |
|
|
42
|
+
| D5 | fs 注入 | 仿 `screen-log.mjs` `defaultScreenLogFs` 先例,加 `locksFs` 参数(默认 node:fs) | 现有测试无注入,注入后才能确定性复现「mkdir 永败」等场景 |
|
|
43
|
+
| D6 | 队列层错误传播 | follow-up-queue.mjs 5 个入口 try/catch **catch-all**(含 fn 内 writeFollowUpQueue 的 fs 错误,非仅锁错误)→ `{ok:false, error}` | 保持 {ok} 返回值约定,service.mjs 无需改动;已确认 follow-up-queue.test.mjs 无 throw 断言,catch-all 安全 |
|
|
44
|
+
| D7 | job-runner 兜底 | `.finally` 链里 `finalizeSteeringIfNeeded` + `drainQueuedFollowUp` 各自 try/catch | 锁层抛错永远不会阻止 `process.exit`——僵尸进程防线最后一道 |
|
|
45
|
+
| D8 | 默认 staleMs | 不变(30s) | 现有测试与调用方依赖 |
|
|
46
|
+
| D9 | 测试 harness 清理 | 全部 7 处 launchRun 都捕获 pid(现有 4 处丢弃,含出过僵尸的 dash 测试)→ finally 里 TERM → 短等待 → KILL → 再 rmSync(root) | 现场两个僵尸的直接源头是 teardown 只删目录不杀 detached runner;这层保证测试不再产出孤儿(launch.test.mjs 的 detached fake-pi 已确认自然退出,无需处理) |
|
|
47
|
+
|
|
48
|
+
### 数据流(修复后)
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
claimNextFollowUp(root, viewId)
|
|
52
|
+
→ withViewLockSync(root, viewId, "queue", fn)
|
|
53
|
+
→ acquireLock: [wait loop w/ sleep] → 窗口过期 → steal attempt ×2 → 失败 → throw
|
|
54
|
+
→ catch → { ok: false, error: "follow-up queue lock unavailable: ..." }
|
|
55
|
+
→ job-runner drainQueuedFollowUp: claimNextFollowUp 返回 {ok:false} → 直接 return(不进 launch)
|
|
56
|
+
→ .finally → process.exit 必达
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 组件契约
|
|
60
|
+
|
|
61
|
+
- `withFileLockSync` / `withViewLockSync`:成功返回 fn 结果;**新行为**——锁超时抛 `Error`(message 含 lockPath 与耗时)。
|
|
62
|
+
- follow-up-queue 5 个导出(enqueue/claim/complete/release/remove/clear):任何锁失败 → `{ok:false, error}`,不再抛出。
|
|
63
|
+
- service.mjs:零改动(已按 {ok} 消费)。
|
|
64
|
+
- job-runner.mjs:收尾链不因锁失败挂起。
|
|
65
|
+
|
|
66
|
+
### 降级行为
|
|
67
|
+
|
|
68
|
+
- 锁失败时队列操作静默失败并写 diagnostics(job-runner 用 appendDiagnostic;service 层已有该模式),用户可感知但系统不挂。
|
|
69
|
+
- 不引入锁重试队列、不引入跨进程 watchdog——超出本 issue 范围。
|
|
70
|
+
|
|
71
|
+
## 非目标
|
|
72
|
+
|
|
73
|
+
- 不改锁的 mkdir 实现(不换 flock/其他机制)
|
|
74
|
+
- 不处理「锁持有者崩溃残留」之外的竞争语义
|
|
75
|
+
- 不改 30s staleMs 默认值
|
|
76
|
+
- 不引入异步锁
|
|
77
|
+
|
|
78
|
+
## 测试计划(red-green)
|
|
79
|
+
|
|
80
|
+
test/locks.test.mjs 现有 5 测试全绿;新增(全部带 `{ timeout: 5000 }` 防挂):
|
|
81
|
+
|
|
82
|
+
1. **mkdir 永败**(注入 fs):`withFileLockSync(..., { staleMs: 50 })` 在 ~250ms 窗口(`max(250, staleMs)` floor)后抛错,不在 5s 内挂起。
|
|
83
|
+
2. **写 owner.json 永败**(注入 fs):mkdir 成功但 writeFileSync 抛 → 有界强夺后抛错。
|
|
84
|
+
3. **rmSync 永败**(注入 fs):窗口后强夺 rm 失败 → 抛错。
|
|
85
|
+
4. **争用正常恢复**:注入 fs 模拟「前 N 次 mkdir EEXIST、之后成功」→ 等待窗内获取成功(保语义 1)。
|
|
86
|
+
4b. **父目录被删后自愈**(D4b):真实 fs,锁获取前删掉父目录 → ensureDir 在循环内重建 → 正常拿锁(验证现场最高频竞态透明自愈)。
|
|
87
|
+
5. **窗口后强夺仍成功**:复用现有测试 4(不回归)。
|
|
88
|
+
6. follow-up-queue 层:锁失败 → `{ok:false, error}`(用注入 fs 或真实坏路径)。
|
|
89
|
+
7. job-runner 收尾:若可低成本导出/集成测试则覆盖「锁坏时 drainQueuedFollowUp 不挂起、process 退出」;否则以代码评审 + 手动验证为准。
|
|
90
|
+
8. 集成测试 teardown:runner 被杀后进程表里不再残留 job-runner(现有集成测试全绿即可证明 kill 生效)。
|
|
91
|
+
|
|
92
|
+
## 验收
|
|
93
|
+
|
|
94
|
+
- `npm test`(或 `npm run verify`)全绿
|
|
95
|
+
- 35s 复现脚本(/sys 只读路径)修复后应立即抛错而非空转
|
|
96
|
+
- 跑完集成测试后 `ps` 无残留 job-runner
|
|
97
|
+
- 代码评审通过后 PR + Zima CR
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Issue #34 — CI flaky: pty-runner integration test times out waiting for pre-connect output
|
|
2
|
+
|
|
3
|
+
## Root cause
|
|
4
|
+
|
|
5
|
+
`test/pty-runner.integration.test.mjs` (test "pty-runner creates host socket, broadcasts output, forwards input, finalizes") waits for the boot banner `fake pi ready` **over the control socket**. But the socket only carries *live* output: `broadcast()` iterates `clients`, which is empty until a client connects. If the child emits `fake pi ready` before the test's socket connects (CI runners start the child fast), the output is broadcast to zero clients and lost — the 3s `waitFor` times out.
|
|
6
|
+
|
|
7
|
+
History output is intentionally NOT replayed over the socket; the UI attach path replays it from the screen log file (`src/ui/pty-attach.ts` `replayScreenLog`). The test's assumption contradicts the protocol design.
|
|
8
|
+
|
|
9
|
+
### Evidence
|
|
10
|
+
|
|
11
|
+
- CI run 32554867604 (main, #32): `not ok 181 ... error: 'timed out waiting'` at `test/pty-runner.integration.test.mjs:65`, both Node 22 and Node 24.
|
|
12
|
+
- PR branch run 32554619699 failed on a *different* test (`runner.integration.test.mjs:195`, auto-done idle), passed on rerun — separate timing-sensitive spot, out of scope.
|
|
13
|
+
- Reproduced deterministically locally with a forced "output before connect" script: 3/3 timeouts. Same test passes 5/5 under normal timing.
|
|
14
|
+
- Local runs after fix: 6/6 pass; late-connect scenario: 3/3 pass; full suite: 313/313 pass.
|
|
15
|
+
|
|
16
|
+
## Fix design
|
|
17
|
+
|
|
18
|
+
Only the test changes; no product code change (socket protocol behavior is by design).
|
|
19
|
+
|
|
20
|
+
| Step | File | Change |
|
|
21
|
+
|------|------|--------|
|
|
22
|
+
| 1 | `test/pty-runner.integration.test.mjs` | Replace the socket wait for `fake pi ready` with a screen-log read wait (`P.screenLogPath(root, "v1")` contains `fake pi ready`) — mirrors UI attach replay semantics |
|
|
23
|
+
|
|
24
|
+
Assertions that remain on the socket (post-connect realtime events, timing-safe):
|
|
25
|
+
- `echo:hello` output (live broadcast + input forwarding)
|
|
26
|
+
- resize → `readHost().cols === 100`
|
|
27
|
+
- `exit` → `endedAt` set, state `exited`
|
|
28
|
+
|
|
29
|
+
Screen-log assertions (file-based, timing-safe):
|
|
30
|
+
- boot banner `fake pi ready` present (already asserted at test end via `assert.match`)
|
|
31
|
+
|
|
32
|
+
## Non-goals
|
|
33
|
+
|
|
34
|
+
- No change to `runner/pty-runner.mjs` socket protocol.
|
|
35
|
+
- No change to `runner.integration.test.mjs` flaky spot (tracked separately if it recurs).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhuxixi/pi-agent-board",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -53,8 +53,9 @@
|
|
|
53
53
|
"postinstall": "node scripts/patch-vulns.mjs",
|
|
54
54
|
"typecheck": "tsc --noEmit",
|
|
55
55
|
"test": "node --test test/*.test.mjs",
|
|
56
|
+
"test:coverage": "c8 node --test test/*.test.mjs",
|
|
56
57
|
"pack:dry": "npm pack --dry-run",
|
|
57
|
-
"verify": "npm run typecheck && npm test && npm run pack:dry"
|
|
58
|
+
"verify": "npm run typecheck && npm test && npm run test:coverage && npm run pack:dry"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
61
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"@earendil-works/pi-coding-agent": "0.79.8",
|
|
73
74
|
"@earendil-works/pi-tui": "0.79.8",
|
|
74
75
|
"@types/node": "^25.9.1",
|
|
76
|
+
"c8": "^12.0.0",
|
|
75
77
|
"typescript": "^5.9.3"
|
|
76
78
|
},
|
|
77
79
|
"dependencies": {
|
package/runner/job-runner.mjs
CHANGED
|
@@ -228,8 +228,18 @@ function main() {
|
|
|
228
228
|
})
|
|
229
229
|
.catch(() => {})
|
|
230
230
|
.finally(() => {
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
// The finalize chain must never prevent process.exit: a lock/fs failure
|
|
232
|
+
// here used to pin the runner as a 100% CPU zombie (issue #33).
|
|
233
|
+
try {
|
|
234
|
+
finalizeSteeringIfNeeded(config, status, evidence);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
tryAppendDiagnostic(config, "finalize_steering_failed", err);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
drainQueuedFollowUp(config, status);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
tryAppendDiagnostic(config, "follow_up_drain_failed", err);
|
|
242
|
+
}
|
|
233
243
|
process.exit(stoppedByUser ? 0 : (code ?? 0));
|
|
234
244
|
});
|
|
235
245
|
});
|
|
@@ -283,6 +293,22 @@ function drainQueuedFollowUp(config, status) {
|
|
|
283
293
|
}
|
|
284
294
|
}
|
|
285
295
|
|
|
296
|
+
/** @param {import("../src/core/types.mjs").RunConfig} config @param {string} code @param {unknown} err */
|
|
297
|
+
function tryAppendDiagnostic(config, code, err) {
|
|
298
|
+
try {
|
|
299
|
+
appendDiagnostic(config.root, config.viewId, {
|
|
300
|
+
source: "runner",
|
|
301
|
+
runId: config.runId,
|
|
302
|
+
level: "error",
|
|
303
|
+
code,
|
|
304
|
+
message: "Finalize step failed",
|
|
305
|
+
details: { error: err instanceof Error ? err.message : String(err) },
|
|
306
|
+
});
|
|
307
|
+
} catch {
|
|
308
|
+
/* root may be deleted — nothing to persist, exit anyway */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
286
312
|
/** @param {import("../src/core/types.mjs").FollowUpItem} item */
|
|
287
313
|
function runKindForFollowUp(item) {
|
|
288
314
|
switch (item.kind) {
|
|
@@ -5,6 +5,24 @@ import { truncate } from "./heuristics.mjs";
|
|
|
5
5
|
import { withViewLockSync } from "./locks.mjs";
|
|
6
6
|
import * as P from "./paths.mjs";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Run a queue mutation under the view lock, translating any failure (lock
|
|
10
|
+
* unavailable, fs errors inside the mutation) into {ok:false} so callers on
|
|
11
|
+
* the {ok} convention never see a throw (issue #33).
|
|
12
|
+
* @template T
|
|
13
|
+
* @param {string} root
|
|
14
|
+
* @param {string} viewId
|
|
15
|
+
* @param {() => T} fn
|
|
16
|
+
* @returns {T | { ok: false, error: string }}
|
|
17
|
+
*/
|
|
18
|
+
function lockedQueueOp(root, viewId, fn) {
|
|
19
|
+
try {
|
|
20
|
+
return withViewLockSync(root, viewId, "queue", fn);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return { ok: false, error: `follow-up queue lock unavailable: ${err instanceof Error ? err.message : String(err)}` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
/** @param {string} viewId @param {number} [now] @returns {import("./types.mjs").FollowUpQueue} */
|
|
9
27
|
export function emptyFollowUpQueue(viewId, now = Date.now()) {
|
|
10
28
|
return { version: 1, viewId, nextSeq: 1, updatedAt: now, items: [] };
|
|
@@ -41,7 +59,7 @@ export function summarizeFollowUpQueue(queue) {
|
|
|
41
59
|
export function enqueueFollowUp(root, viewId, text, opts = {}) {
|
|
42
60
|
const clean = String(text || "").trim();
|
|
43
61
|
if (!clean) return { ok: false, error: "Empty follow-up" };
|
|
44
|
-
return
|
|
62
|
+
return lockedQueueOp(root, viewId, () => {
|
|
45
63
|
const queue = readFollowUpQueue(root, viewId);
|
|
46
64
|
const now = Date.now();
|
|
47
65
|
const item = {
|
|
@@ -70,7 +88,7 @@ export function enqueueFollowUp(root, viewId, text, opts = {}) {
|
|
|
70
88
|
|
|
71
89
|
/** @param {string} root @param {string} viewId @param {{ runId?: string|null }} [opts] */
|
|
72
90
|
export function claimNextFollowUp(root, viewId, opts = {}) {
|
|
73
|
-
return
|
|
91
|
+
return lockedQueueOp(root, viewId, () => {
|
|
74
92
|
const queue = readFollowUpQueue(root, viewId);
|
|
75
93
|
const item = queue.items.filter((i) => i.status === "queued").sort((a, b) => a.seq - b.seq)[0];
|
|
76
94
|
if (!item) return { ok: false, error: "No queued follow-up" };
|
|
@@ -114,7 +132,7 @@ export function releaseFollowUp(root, viewId, itemId) {
|
|
|
114
132
|
|
|
115
133
|
/** @param {string} root @param {string} viewId */
|
|
116
134
|
export function removeLastFollowUp(root, viewId) {
|
|
117
|
-
return
|
|
135
|
+
return lockedQueueOp(root, viewId, () => {
|
|
118
136
|
const queue = readFollowUpQueue(root, viewId);
|
|
119
137
|
const queued = queue.items.filter((i) => i.status === "queued").sort((a, b) => b.seq - a.seq);
|
|
120
138
|
const last = queued[0];
|
|
@@ -128,7 +146,7 @@ export function removeLastFollowUp(root, viewId) {
|
|
|
128
146
|
|
|
129
147
|
/** @param {string} root @param {string} viewId */
|
|
130
148
|
export function clearQueuedFollowUps(root, viewId) {
|
|
131
|
-
return
|
|
149
|
+
return lockedQueueOp(root, viewId, () => {
|
|
132
150
|
const queue = readFollowUpQueue(root, viewId);
|
|
133
151
|
let cancelled = 0;
|
|
134
152
|
for (const item of queue.items) {
|
|
@@ -145,7 +163,7 @@ export function clearQueuedFollowUps(root, viewId) {
|
|
|
145
163
|
|
|
146
164
|
/** @param {string} root @param {string} viewId @param {string} itemId @param {(item: import("./types.mjs").FollowUpItem) => void} mutate */
|
|
147
165
|
function updateItem(root, viewId, itemId, mutate) {
|
|
148
|
-
return
|
|
166
|
+
return lockedQueueOp(root, viewId, () => {
|
|
149
167
|
const queue = readFollowUpQueue(root, viewId);
|
|
150
168
|
const item = queue.items.find((i) => i.id === itemId);
|
|
151
169
|
if (!item) return { ok: false, error: "Unknown follow-up" };
|
package/src/core/locks.mjs
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
|
|
3
3
|
* Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
|
|
4
|
+
*
|
|
5
|
+
* Failure model (issue #33): acquisition failures are classified.
|
|
6
|
+
* - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
|
|
7
|
+
* force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
|
|
8
|
+
* wait/steal contract (see test/locks.test.mjs).
|
|
9
|
+
* - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
|
|
10
|
+
* MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
|
|
11
|
+
* deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
|
|
4
12
|
*/
|
|
5
13
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
14
|
import * as path from "node:path";
|
|
@@ -8,22 +16,33 @@ import { ensureDir } from "./atomic.mjs";
|
|
|
8
16
|
import * as P from "./paths.mjs";
|
|
9
17
|
|
|
10
18
|
const DEFAULT_STALE_MS = 30_000;
|
|
19
|
+
/** Minimum contention window before a fresh lock can be force-stolen. */
|
|
20
|
+
const MIN_WINDOW_MS = 250;
|
|
21
|
+
const WAIT_TICK_MS = 20;
|
|
22
|
+
/** Max stale-lock steal attempts before giving up. */
|
|
23
|
+
const MAX_STEAL_ATTEMPTS = 2;
|
|
24
|
+
/** Max quick retries for environmental failures before giving up. */
|
|
25
|
+
const MAX_ENV_ATTEMPTS = 3;
|
|
26
|
+
|
|
27
|
+
export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
|
|
11
28
|
|
|
12
29
|
/**
|
|
13
30
|
* @template T
|
|
14
31
|
* @param {string} lockPath
|
|
15
32
|
* @param {() => T} fn
|
|
16
|
-
* @param {{ staleMs?: number }} [opts]
|
|
33
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
17
34
|
* @returns {T}
|
|
18
35
|
*/
|
|
19
36
|
export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
20
|
-
const
|
|
21
|
-
acquireLock(lockPath, staleMs);
|
|
37
|
+
const fs = opts.fs ?? defaultLocksFs;
|
|
38
|
+
acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
|
|
39
|
+
let result;
|
|
22
40
|
try {
|
|
23
|
-
|
|
41
|
+
result = fn();
|
|
24
42
|
} finally {
|
|
25
|
-
releaseLock(lockPath);
|
|
43
|
+
releaseLock(lockPath, fs);
|
|
26
44
|
}
|
|
45
|
+
return result;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
/**
|
|
@@ -32,37 +51,74 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
|
32
51
|
* @param {string} viewId
|
|
33
52
|
* @param {string} name
|
|
34
53
|
* @param {() => T} fn
|
|
35
|
-
* @param {{ staleMs?: number }} [opts]
|
|
54
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
36
55
|
* @returns {T}
|
|
37
56
|
*/
|
|
38
57
|
export function withViewLockSync(root, viewId, name, fn, opts = {}) {
|
|
39
58
|
return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
/** @param {string} lockPath @param {number} staleMs */
|
|
43
|
-
function acquireLock(lockPath, staleMs) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
62
|
+
function acquireLock(lockPath, staleMs, fs) {
|
|
63
|
+
const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
|
|
64
|
+
let steals = 0;
|
|
65
|
+
let envAttempts = 0;
|
|
66
|
+
for (;;) {
|
|
67
|
+
let created = false;
|
|
47
68
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
// Re-run every attempt: a parent deleted mid-acquisition self-heals here.
|
|
70
|
+
ensureDir(path.dirname(lockPath));
|
|
71
|
+
fs.mkdirSync(lockPath);
|
|
72
|
+
created = true;
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
path.join(lockPath, "owner.json"),
|
|
75
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
76
|
+
"utf8",
|
|
77
|
+
);
|
|
50
78
|
return;
|
|
51
79
|
} catch (err) {
|
|
52
|
-
if (
|
|
53
|
-
|
|
80
|
+
if (err && err.code === "EEXIST") {
|
|
81
|
+
const expired = Date.now() >= deadline;
|
|
82
|
+
if (isLockStale(lockPath, staleMs, fs) || expired) {
|
|
83
|
+
if (steals >= MAX_STEAL_ATTEMPTS) {
|
|
84
|
+
throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
|
|
85
|
+
}
|
|
86
|
+
steals += 1;
|
|
87
|
+
releaseLock(lockPath, fs);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
sleep(WAIT_TICK_MS);
|
|
54
91
|
continue;
|
|
55
92
|
}
|
|
56
|
-
|
|
93
|
+
// Environmental failure: bounded quick retries, then fail fast.
|
|
94
|
+
if (created) releaseLock(lockPath, fs);
|
|
95
|
+
envAttempts += 1;
|
|
96
|
+
if (envAttempts >= MAX_ENV_ATTEMPTS) {
|
|
97
|
+
const reason = (err && (err.code || err.message)) || "unknown error";
|
|
98
|
+
throw lockError(lockPath, `lock path unusable (${reason})`);
|
|
99
|
+
}
|
|
100
|
+
sleep(WAIT_TICK_MS);
|
|
57
101
|
}
|
|
58
102
|
}
|
|
59
103
|
}
|
|
60
104
|
|
|
61
|
-
/** @param {string} lockPath @param {
|
|
62
|
-
function
|
|
105
|
+
/** @param {string} lockPath @param {string} reason */
|
|
106
|
+
function lockError(lockPath, reason) {
|
|
107
|
+
const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
|
|
108
|
+
err.code = "LOCK_TIMEOUT";
|
|
109
|
+
return err;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {number} ms */
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
118
|
+
function isLockStale(lockPath, staleMs, fs) {
|
|
63
119
|
try {
|
|
64
|
-
if (!existsSync(lockPath)) return false;
|
|
65
|
-
const raw = readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
120
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
121
|
+
const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
66
122
|
const owner = JSON.parse(raw);
|
|
67
123
|
return Date.now() - Number(owner.at ?? 0) > staleMs;
|
|
68
124
|
} catch {
|
|
@@ -70,10 +126,10 @@ function isLockStale(lockPath, staleMs) {
|
|
|
70
126
|
}
|
|
71
127
|
}
|
|
72
128
|
|
|
73
|
-
/** @param {string} lockPath */
|
|
74
|
-
function releaseLock(lockPath) {
|
|
129
|
+
/** @param {string} lockPath @param {typeof defaultLocksFs} fs */
|
|
130
|
+
function releaseLock(lockPath, fs) {
|
|
75
131
|
try {
|
|
76
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
132
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
77
133
|
} catch {
|
|
78
134
|
/* best effort */
|
|
79
135
|
}
|