@opengsd/gsd-core 1.5.0 → 1.6.0-rc.1
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/bin/install.js +108 -34
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +677 -2
- package/gsd-core/bin/lib/adr-parser.cjs +24 -17
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config-loader.cjs +92 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +29 -0
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/verify.cjs +29 -13
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +4 -6
- package/package.json +3 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared cross-process mutual-exclusion lock primitive — #1459 finding 4 + #1462 finding 1.
|
|
4
|
+
*
|
|
5
|
+
* A SINGLE hardened lockfile protocol shared by BOTH capability-lifecycle (the `.gsd/capabilities/.lock`
|
|
6
|
+
* mutation lock) and capability-consent (the consent-store `.consent.lock`). Before this extraction the
|
|
7
|
+
* two locks had DIFFERENT, divergent steal policies: the lifecycle lock was hardened (#1462 — pid +
|
|
8
|
+
* process-start-time identity + hard deadman, never steals a verified-live same-host holder), while the
|
|
9
|
+
* consent lock used a naive mtime-only 60 s steal that would STEAL A LIVE WRITER (a slow/paused holder
|
|
10
|
+
* past 60 s is reclaimed; the original writer then resumes and overwrites — a lost update). Sharing one
|
|
11
|
+
* primitive makes the consent lock as safe as the lifecycle lock (single source of truth — mirrors the
|
|
12
|
+
* shared-validator / shared bounded-reader lessons).
|
|
13
|
+
*
|
|
14
|
+
* STEAL PROTOCOL (never deadlocks AND never steals a verified-live SAME-host holder). The age is bound
|
|
15
|
+
* to the BODY instance the acquirer acts on — `age = now - body.ts` for a JSON body (a fresh replacement
|
|
16
|
+
* body carries a fresh ts), falling back to `now - mtime` for a legacy/no-`ts` body — and the
|
|
17
|
+
* (dev, ino, ts) identity is re-confirmed immediately before the atomic rename-steal:
|
|
18
|
+
* - age <= LOCK_STALE_MS → FRESH: never stolen (genuinely held → blocked).
|
|
19
|
+
* - age > LOCK_STALE_MS:
|
|
20
|
+
* · SAME host: VERIFIED-LIVE (pid alive AND recorded startTime present AND observed startTime ===
|
|
21
|
+
* recorded) → NEVER steal (even past the deadman). NOT verified-live (dead pid, start-time
|
|
22
|
+
* MISMATCH = pid-reuse, or start-time unobtainable) → STEAL (fast local recovery).
|
|
23
|
+
* · DIFFERENT host / no parseable pid (legacy/oversized/garbage body) → liveness unverifiable →
|
|
24
|
+
* steal ONLY after age > LOCK_DEADMAN_MS (the deadman fallback).
|
|
25
|
+
*
|
|
26
|
+
* The lockfile body is UNTRUSTED: it is read via the shared fd-based bounded reader
|
|
27
|
+
* (ledgerMod.readSmallRegularFile) so a FIFO/device/oversized body cannot block or read unbounded.
|
|
28
|
+
*
|
|
29
|
+
* Test seam: _setLockProbes / _resetLockProbes inject deterministic isPidAlive / getProcessStartTime so
|
|
30
|
+
* the start-time liveness branches are exercised without depending on real OS pids beyond the current
|
|
31
|
+
* process. capability-lifecycle re-exports these so its existing #1462 lock tests keep driving them.
|
|
32
|
+
*
|
|
33
|
+
* Imports: node:fs, node:path, node:os, node:crypto, and the ledger's shared bounded readSmallRegularFile
|
|
34
|
+
* + execTool (for the rare start-time shell-out on win32/macOS).
|
|
35
|
+
*/
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
40
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
41
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
42
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
43
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
44
|
+
const ledgerMod = require('./capability-ledger.cjs');
|
|
45
|
+
const { execTool } = require('./shell-command-projection.cjs');
|
|
46
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Constants
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/**
|
|
51
|
+
* A lock older than this is a CANDIDATE for stealing (the holder may have crashed). A same-host
|
|
52
|
+
* lock past this age whose recorded pid is DEAD is stolen immediately (fast local recovery).
|
|
53
|
+
*/
|
|
54
|
+
const LOCK_STALE_MS = 60_000;
|
|
55
|
+
/**
|
|
56
|
+
* HARD deadman timeout. A lock older than this is stolen REGARDLESS of pid liveness or host. This is
|
|
57
|
+
* the only thing that can break a permanent deadlock caused by:
|
|
58
|
+
* - PID REUSE: a crashed holder's pid reused by an unrelated long-lived process makes isPidAlive
|
|
59
|
+
* return true forever, so the dead-pid fast-recovery branch never fires.
|
|
60
|
+
* - CROSS-HOST (NFS): a remote holder's pid is meaningless to local process.kill(pid,0), so liveness
|
|
61
|
+
* cannot be judged at all — only the deadman can reclaim such a lock.
|
|
62
|
+
* Much larger than LOCK_STALE_MS so a genuinely slow-but-live SAME-host holder is given a wide grace
|
|
63
|
+
* window (it is protected by the same-host liveness check until then); 10 minutes is far longer than
|
|
64
|
+
* any real sub-second capability fs critical section.
|
|
65
|
+
*/
|
|
66
|
+
const LOCK_DEADMAN_MS = 600_000;
|
|
67
|
+
/**
|
|
68
|
+
* The lockfile body is UNTRUSTED content. A well-formed lock body is a tiny JSON object. The body is
|
|
69
|
+
* read via the shared fd-based bounded reader (open → fstat → require a REGULAR file → enforce this
|
|
70
|
+
* size cap → read exactly size). A non-regular/oversized body is treated as UNPARSEABLE → routed to the
|
|
71
|
+
* deadman policy (cannot verify liveness → steal only after the deadman). 64 KiB is orders of magnitude
|
|
72
|
+
* larger than any legitimate lock body.
|
|
73
|
+
*/
|
|
74
|
+
const LOCK_MAX_BODY_BYTES = 64 * 1024;
|
|
75
|
+
/**
|
|
76
|
+
* DEFAULT bounded steal/retry attempts so a pathological never-acquirable lock cannot recurse forever.
|
|
77
|
+
* A caller may raise it (the consent store passes a larger budget — two genuinely-racing same-machine
|
|
78
|
+
* consent writers must SERIALIZE, not fail, before the lock-acquire-failure throw kicks in #1459
|
|
79
|
+
* finding 3). The lifecycle's sub-second critical section is happy with the small default.
|
|
80
|
+
*/
|
|
81
|
+
const LOCK_MAX_ATTEMPTS = 8;
|
|
82
|
+
const LOCK_RETRY_BACKOFF_MS = 25;
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Tokens + backoff
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
let _lockSeq = 0;
|
|
87
|
+
/**
|
|
88
|
+
* A per-acquire unique token so release is owner-safe (never deletes a successor's lock). The FIRST
|
|
89
|
+
* `-`-delimited segment is the holder PID — acquireLock parses it back out to check liveness before
|
|
90
|
+
* stealing a stale lock.
|
|
91
|
+
*/
|
|
92
|
+
function newLockToken() {
|
|
93
|
+
return `${process.pid}-${Date.now()}-${++_lockSeq}`;
|
|
94
|
+
}
|
|
95
|
+
let _lockSleepBuf = null;
|
|
96
|
+
function lockBackoff() {
|
|
97
|
+
// Small jittered backoff between steal attempts (yields the thread via Atomics.wait).
|
|
98
|
+
if (_lockSleepBuf === null)
|
|
99
|
+
_lockSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
100
|
+
const jitter = Math.floor(Math.random() * LOCK_RETRY_BACKOFF_MS);
|
|
101
|
+
Atomics.wait(_lockSleepBuf, 0, 0, LOCK_RETRY_BACKOFF_MS + jitter);
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Body parse / age / host
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Parse the holder PID from a legacy plain-token lockfile body (the first `-`-delimited segment).
|
|
108
|
+
* Returns null when the body has no numeric leading segment (e.g. JSON content, or legacy no-pid).
|
|
109
|
+
*/
|
|
110
|
+
function lockHolderPid(body) {
|
|
111
|
+
const seg = body.split('-')[0];
|
|
112
|
+
if (!/^\d+$/.test(seg))
|
|
113
|
+
return null;
|
|
114
|
+
const pid = Number(seg);
|
|
115
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Parse a lockfile body into { pid, hostname, startTime, ts }. The new format is JSON
|
|
119
|
+
* `{ token, pid, hostname, startTime, ts }`; a legacy body is a plain `pid-ts-seq` token (or
|
|
120
|
+
* non-numeric junk). Never throws — unparseable content yields all-null.
|
|
121
|
+
*
|
|
122
|
+
* `ts` is the body's OWN recorded timestamp. The age decision is bound to `now - ts` (a FRESH
|
|
123
|
+
* replacement body carries a FRESH ts → small age → not stolen), NOT to the file `mtime`. A legacy/
|
|
124
|
+
* no-`ts` body yields ts:null and the caller falls back to the file `mtime` age.
|
|
125
|
+
*/
|
|
126
|
+
function parseLockBody(body) {
|
|
127
|
+
const trimmed = body.trim();
|
|
128
|
+
if (trimmed.startsWith('{')) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(trimmed);
|
|
131
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
132
|
+
const p = parsed;
|
|
133
|
+
const pidVal = p['pid'];
|
|
134
|
+
const pid = typeof pidVal === 'number' && Number.isInteger(pidVal) && pidVal > 0 ? pidVal : null;
|
|
135
|
+
const hostVal = p['hostname'];
|
|
136
|
+
const hostname = typeof hostVal === 'string' && hostVal ? hostVal : null;
|
|
137
|
+
const stVal = p['startTime'];
|
|
138
|
+
const startTime = typeof stVal === 'string' && stVal ? stVal : null;
|
|
139
|
+
const tsVal = p['ts'];
|
|
140
|
+
const ts = typeof tsVal === 'number' && Number.isFinite(tsVal) ? tsVal : null;
|
|
141
|
+
return { pid, hostname, startTime, ts };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* fall through to legacy parse */ }
|
|
145
|
+
}
|
|
146
|
+
// Legacy plain-token body: hostname/startTime/ts were never recorded → null.
|
|
147
|
+
return { pid: lockHolderPid(trimmed), hostname: null, startTime: null, ts: null };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Derive the lock AGE (ms) from the body's own `ts` when trustworthy, else fall back to the file
|
|
151
|
+
* `mtime`. A `ts` is distrusted when it is in the FUTURE (planted body / clock-skewed writer): a
|
|
152
|
+
* trusted future `ts` would keep age <= LOCK_STALE_MS forever → permanent block. A future `mtime` is
|
|
153
|
+
* likewise distrusted past a half-stale-window jitter tolerance → MAX_SAFE_INTEGER so the lock routes
|
|
154
|
+
* into the normal steal decision tree (verified-live holders are still protected there).
|
|
155
|
+
*/
|
|
156
|
+
function lockAgeMs(ts, mtimeMs) {
|
|
157
|
+
if (ts !== null) {
|
|
158
|
+
const age = Date.now() - ts;
|
|
159
|
+
if (age >= 0 && age <= Number.MAX_SAFE_INTEGER)
|
|
160
|
+
return age;
|
|
161
|
+
}
|
|
162
|
+
const mtimeAge = Date.now() - mtimeMs;
|
|
163
|
+
if (mtimeAge >= 0)
|
|
164
|
+
return mtimeAge;
|
|
165
|
+
return mtimeAge >= -(LOCK_STALE_MS / 2) ? 0 : Number.MAX_SAFE_INTEGER;
|
|
166
|
+
}
|
|
167
|
+
/** Is the parsed lock from THIS host? A null (legacy) hostname is treated as same-host. */
|
|
168
|
+
function isSameHost(parsed) {
|
|
169
|
+
return parsed.hostname === null || parsed.hostname === node_os_1.default.hostname();
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Process start-time (the pid-reuse discriminator)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Best-effort process start-time for `pid`, as an OPAQUE platform-specific string used ONLY for
|
|
176
|
+
* equality comparison (never parsed as a date). The pair (pid, startTime) uniquely identifies a
|
|
177
|
+
* process instance: even if a crashed holder's pid is REUSED, the new process's start-time differs.
|
|
178
|
+
* Returns null on ANY error / unobtainable value (liveness cannot be VERIFIED → steal-eligible past
|
|
179
|
+
* the deadman). The shell-outs only run on the rare STEAL-decision path, never the happy path.
|
|
180
|
+
*/
|
|
181
|
+
function getProcessStartTime(pid) {
|
|
182
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
if (process.platform === 'linux') {
|
|
186
|
+
const stat = node_fs_1.default.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
187
|
+
const rparen = stat.lastIndexOf(')');
|
|
188
|
+
if (rparen === -1)
|
|
189
|
+
return null;
|
|
190
|
+
const rest = stat.slice(rparen + 1).trim().split(/\s+/);
|
|
191
|
+
const starttime = rest[19]; // overall field 22 → index 19 after comm.
|
|
192
|
+
return typeof starttime === 'string' && /^\d+$/.test(starttime) ? starttime : null;
|
|
193
|
+
}
|
|
194
|
+
if (process.platform === 'win32') {
|
|
195
|
+
const res = execTool('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.Ticks`], { timeout: 5_000 });
|
|
196
|
+
if (res.exitCode !== 0 || res.error)
|
|
197
|
+
return null;
|
|
198
|
+
const out = res.stdout.trim();
|
|
199
|
+
return /^\d+$/.test(out) ? out : null;
|
|
200
|
+
}
|
|
201
|
+
const res = execTool('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 5_000 });
|
|
202
|
+
if (res.exitCode !== 0 || res.error)
|
|
203
|
+
return null;
|
|
204
|
+
const out = res.stdout.trim();
|
|
205
|
+
return out ? out : null;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** THIS process's start-time, captured ONCE at module load so we never re-shell on every lock write. */
|
|
212
|
+
const _selfStartTime = getProcessStartTime(process.pid);
|
|
213
|
+
/** Serialize the lockfile body: JSON carrying the owner token, pid, hostname, cached start-time, ts. */
|
|
214
|
+
function lockFileBody(token) {
|
|
215
|
+
return JSON.stringify({ token, pid: process.pid, hostname: node_os_1.default.hostname(), startTime: _selfStartTime, ts: Date.now() });
|
|
216
|
+
}
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Liveness probes (test seam)
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
/** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */
|
|
221
|
+
function _realIsPidAlive(pid) {
|
|
222
|
+
try {
|
|
223
|
+
process.kill(pid, 0);
|
|
224
|
+
return true; // signalable → alive
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
// EPERM means the process exists but we cannot signal it (still ALIVE). ESRCH means it's gone.
|
|
228
|
+
return err.code === 'EPERM';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Test seams: the steal-decision path goes through these indirections so unit tests can mock liveness +
|
|
233
|
+
* process start-time DETERMINISTICALLY. The defaults are the real implementations.
|
|
234
|
+
*/
|
|
235
|
+
const _lockProbes = { isPidAlive: _realIsPidAlive, getProcessStartTime };
|
|
236
|
+
function isPidAlive(pid) {
|
|
237
|
+
return _lockProbes.isPidAlive(pid);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Is the recorded SAME-host holder VERIFIED-LIVE? True ONLY when ALL hold: the pid signals alive AND
|
|
241
|
+
* the lock recorded a non-null start-time AND the pid's CURRENT observed start-time matches that
|
|
242
|
+
* recorded value. Any failure — dead pid, no recorded start-time, unobtainable current start-time, or a
|
|
243
|
+
* MISMATCH (= pid-reuse) — means NOT verified-live, so the holder may be stolen. This defeats pid-reuse
|
|
244
|
+
* WITHOUT ever stealing a genuinely-live holder.
|
|
245
|
+
*/
|
|
246
|
+
function holderVerifiedLive(parsed) {
|
|
247
|
+
if (parsed.pid === null)
|
|
248
|
+
return false;
|
|
249
|
+
if (!isPidAlive(parsed.pid))
|
|
250
|
+
return false;
|
|
251
|
+
if (parsed.startTime === null)
|
|
252
|
+
return false;
|
|
253
|
+
const observed = _lockProbes.getProcessStartTime(parsed.pid);
|
|
254
|
+
if (observed === null)
|
|
255
|
+
return false;
|
|
256
|
+
return observed === parsed.startTime;
|
|
257
|
+
}
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Bounded body read + identity recheck
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* Parse the lockfile body via the SHARED fd-based bounded reader. The body is untrusted: a FIFO/device/
|
|
263
|
+
* oversized/garbage body returns all-null (routed to the deadman policy). Never throws.
|
|
264
|
+
*/
|
|
265
|
+
function readParsedLockBounded(lockPath) {
|
|
266
|
+
const allNull = { pid: null, hostname: null, startTime: null, ts: null };
|
|
267
|
+
try {
|
|
268
|
+
const body = ledgerMod.readSmallRegularFile(lockPath, LOCK_MAX_BODY_BYTES);
|
|
269
|
+
if (body === null)
|
|
270
|
+
return allNull; // vanished/missing — cannot verify anything.
|
|
271
|
+
return parseLockBody(body);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return allNull; // non-regular / oversized / unreadable untrusted body → unparseable.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The per-body IDENTITY used to confirm, immediately before the atomic rename-steal, that the lock the
|
|
279
|
+
* acquirer decided to steal is STILL the same body instance. Binds (dev, ino) from a fresh stat AND the
|
|
280
|
+
* body's own `ts` (when JSON). A null on any field means we could not read it → caller treats it as
|
|
281
|
+
* "changed" and retries rather than stealing. Never throws.
|
|
282
|
+
*/
|
|
283
|
+
function lockIdentity(lockPath) {
|
|
284
|
+
let dev = null;
|
|
285
|
+
let ino = null;
|
|
286
|
+
try {
|
|
287
|
+
const st = node_fs_1.default.statSync(lockPath);
|
|
288
|
+
dev = typeof st.dev === 'number' ? st.dev : null;
|
|
289
|
+
ino = typeof st.ino === 'number' ? st.ino : null;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return { dev: null, ino: null, ts: null }; // vanished/unstatable — treat as changed.
|
|
293
|
+
}
|
|
294
|
+
const ts = readParsedLockBounded(lockPath).ts;
|
|
295
|
+
return { dev, ino, ts };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Two lock identities refer to the SAME body instance only when dev AND ino match AND the `ts` is
|
|
299
|
+
* unchanged. A null dev/ino on EITHER side is a CHANGE (fail-safe: do not steal). If the DECISION body
|
|
300
|
+
* had a non-null JSON `ts`, the recheck body MUST carry the SAME non-null `ts` (a disappearing ts is a
|
|
301
|
+
* CHANGE → do not steal, retry).
|
|
302
|
+
*/
|
|
303
|
+
function sameLockInstance(a, b) {
|
|
304
|
+
if (a.dev === null || a.ino === null || b.dev === null || b.ino === null)
|
|
305
|
+
return false;
|
|
306
|
+
if (a.dev !== b.dev || a.ino !== b.ino)
|
|
307
|
+
return false;
|
|
308
|
+
if (a.ts !== null && a.ts !== b.ts)
|
|
309
|
+
return false;
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
/** Extract the owner token from a lockfile body (JSON `token` field), or null if not JSON/absent. */
|
|
313
|
+
function lockBodyToken(body) {
|
|
314
|
+
const trimmed = body.trim();
|
|
315
|
+
if (!trimmed.startsWith('{'))
|
|
316
|
+
return null;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(trimmed);
|
|
319
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
320
|
+
const t = parsed['token'];
|
|
321
|
+
return typeof t === 'string' ? t : null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch { /* not JSON */ }
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Acquire / release
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* Acquire an exclusive lock at `lockPath` (a single lockfile created with O_EXCL), stamping a JSON body
|
|
332
|
+
* that records a unique owner token, our PID, our HOSTNAME, our process START-TIME, and a timestamp.
|
|
333
|
+
* The containing directory is mkdir'd (recursive, best-effort). Returns a LockHandle on success, or
|
|
334
|
+
* null if another LIVE operation holds it / the attempt budget is exhausted.
|
|
335
|
+
*
|
|
336
|
+
* `opts.maxAttempts` raises the bounded steal/retry budget (default LOCK_MAX_ATTEMPTS) so a caller with
|
|
337
|
+
* legitimately-contended writers (the consent store) can SERIALIZE rather than fail under brief
|
|
338
|
+
* contention. The budget is always bounded — no unbounded recursion.
|
|
339
|
+
*
|
|
340
|
+
* `opts.waitForFresh` (consent store) changes the BLOCKED-held disposition: when a held lock is NOT
|
|
341
|
+
* steal-eligible (fresh under the stale window, a verified-live same-host holder, or an unverifiable
|
|
342
|
+
* holder under the deadman), the DEFAULT (lifecycle) returns null IMMEDIATELY (fail-fast — the caller
|
|
343
|
+
* does not retry). With waitForFresh the acquirer instead BACKS OFF AND RETRIES (within the bounded
|
|
344
|
+
* budget) so two genuinely-racing same-machine writers SERIALIZE — the loser waits for the holder to
|
|
345
|
+
* release its sub-ms critical section and then wins the O_EXCL create. It still returns null once the
|
|
346
|
+
* budget is exhausted (then #1459 finding 3 turns that into a throw rather than an unlocked write). This
|
|
347
|
+
* NEVER steals a non-steal-eligible holder — it only WAITS for it; the steal protocol is unchanged.
|
|
348
|
+
*
|
|
349
|
+
* The steal itself is atomic (rename-then-recreate, so only ONE racing process can rename the inode),
|
|
350
|
+
* and the whole thing is a BOUNDED iterative loop.
|
|
351
|
+
*/
|
|
352
|
+
function acquireLock(lockPath, opts) {
|
|
353
|
+
try {
|
|
354
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(lockPath), { recursive: true });
|
|
355
|
+
}
|
|
356
|
+
catch { /* best-effort */ }
|
|
357
|
+
const maxAttempts = (opts && Number.isInteger(opts.maxAttempts) && opts.maxAttempts > 0)
|
|
358
|
+
? opts.maxAttempts
|
|
359
|
+
: LOCK_MAX_ATTEMPTS;
|
|
360
|
+
const waitForFresh = !!(opts && opts.waitForFresh);
|
|
361
|
+
// A held lock that is NOT steal-eligible: fail-fast (return null) by default, or BACK OFF + RETRY
|
|
362
|
+
// (continue) when waitForFresh and a retry budget remains — so a contended consent writer serializes.
|
|
363
|
+
const blocked = (attempt) => {
|
|
364
|
+
if (waitForFresh && attempt + 1 < maxAttempts) {
|
|
365
|
+
lockBackoff();
|
|
366
|
+
return 'retry';
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
};
|
|
370
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
371
|
+
const token = newLockToken();
|
|
372
|
+
try {
|
|
373
|
+
const fd = node_fs_1.default.openSync(lockPath, 'wx'); // exclusive create — fails if held
|
|
374
|
+
// Once the exclusive create SUCCEEDS, a writeSync/closeSync failure must NOT leave the empty
|
|
375
|
+
// lockfile behind — an orphan body self-blocks every later acquirer until the deadman. On any
|
|
376
|
+
// write/close error, best-effort unlink the file we just created and bail. fs.writeFileSync(fd, …)
|
|
377
|
+
// flushes the WHOLE buffer (no short-write) unlike a bare fs.writeSync.
|
|
378
|
+
try {
|
|
379
|
+
node_fs_1.default.writeFileSync(fd, lockFileBody(token));
|
|
380
|
+
}
|
|
381
|
+
catch (writeErr) {
|
|
382
|
+
try {
|
|
383
|
+
node_fs_1.default.closeSync(fd);
|
|
384
|
+
}
|
|
385
|
+
catch { /* best-effort */ }
|
|
386
|
+
try {
|
|
387
|
+
node_fs_1.default.unlinkSync(lockPath);
|
|
388
|
+
}
|
|
389
|
+
catch { /* best-effort — no orphan */ }
|
|
390
|
+
throw writeErr;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
node_fs_1.default.closeSync(fd);
|
|
394
|
+
}
|
|
395
|
+
catch (closeErr) {
|
|
396
|
+
try {
|
|
397
|
+
node_fs_1.default.unlinkSync(lockPath);
|
|
398
|
+
}
|
|
399
|
+
catch { /* best-effort — no orphan */ }
|
|
400
|
+
throw closeErr;
|
|
401
|
+
}
|
|
402
|
+
// Capture the lock inode's (dev, ino) so releaseLock can confirm, immediately before rmSync, that
|
|
403
|
+
// the path still holds OUR inode. Best-effort: a null dev/ino just falls back to the token check.
|
|
404
|
+
let dev = null;
|
|
405
|
+
let ino = null;
|
|
406
|
+
try {
|
|
407
|
+
const lst = node_fs_1.default.statSync(lockPath);
|
|
408
|
+
dev = typeof lst.dev === 'number' ? lst.dev : null;
|
|
409
|
+
ino = typeof lst.ino === 'number' ? lst.ino : null;
|
|
410
|
+
}
|
|
411
|
+
catch { /* best-effort — release falls back to the token check alone */ }
|
|
412
|
+
return { path: lockPath, token, dev, ino };
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
// EEXIST → held (fall through to the steal decision). Any other error here is the create failing
|
|
416
|
+
// for a real reason OR a write/close failure we already cleaned up → bail out.
|
|
417
|
+
if (err.code !== 'EEXIST')
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
// Held — decide whether to steal.
|
|
421
|
+
let st;
|
|
422
|
+
try {
|
|
423
|
+
st = node_fs_1.default.statSync(lockPath);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
continue; // lock vanished between open and stat — retry the create immediately.
|
|
427
|
+
}
|
|
428
|
+
// Bind the age decision to the SAME body instance we act on. Parse the (bounded) body ONCE; derive
|
|
429
|
+
// age from the body's own `ts` for a JSON body so a FRESH replacement (fresh ts) is seen as fresh
|
|
430
|
+
// even if the file `mtime` is stale-old. A legacy/garbage/no-`ts` body — and a FUTURE/implausible
|
|
431
|
+
// `ts` — falls back to the file `mtime` age so a planted/clock-skewed future ts can never deadlock.
|
|
432
|
+
const parsed = readParsedLockBounded(lockPath);
|
|
433
|
+
const age = lockAgeMs(parsed.ts, st.mtimeMs);
|
|
434
|
+
if (age <= LOCK_STALE_MS) { // genuinely held (fresh) — blocked.
|
|
435
|
+
const b = blocked(attempt);
|
|
436
|
+
if (b === 'retry')
|
|
437
|
+
continue;
|
|
438
|
+
return b;
|
|
439
|
+
}
|
|
440
|
+
const decisionIdentity = {
|
|
441
|
+
dev: typeof st.dev === 'number' ? st.dev : null,
|
|
442
|
+
ino: typeof st.ino === 'number' ? st.ino : null,
|
|
443
|
+
ts: parsed.ts,
|
|
444
|
+
};
|
|
445
|
+
if (isSameHost(parsed) && parsed.pid !== null) {
|
|
446
|
+
// SAME host with a parseable pid → we CAN verify liveness via the (pid, start-time) pair. A
|
|
447
|
+
// VERIFIED-LIVE holder is NEVER stolen — even past the deadman. Otherwise → steal.
|
|
448
|
+
if (holderVerifiedLive(parsed)) { // provably-live same-host holder — blocked.
|
|
449
|
+
const b = blocked(attempt);
|
|
450
|
+
if (b === 'retry')
|
|
451
|
+
continue;
|
|
452
|
+
return b;
|
|
453
|
+
}
|
|
454
|
+
// else fall through to the atomic steal.
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
// DIFFERENT host, or no parseable pid → liveness cannot be verified locally. Only the deadman can
|
|
458
|
+
// reclaim it; under the deadman, leave it (blocked).
|
|
459
|
+
if (age <= LOCK_DEADMAN_MS) {
|
|
460
|
+
const b = blocked(attempt);
|
|
461
|
+
if (b === 'retry')
|
|
462
|
+
continue;
|
|
463
|
+
return b;
|
|
464
|
+
}
|
|
465
|
+
// else (age > deadman) → fall through to the atomic steal.
|
|
466
|
+
}
|
|
467
|
+
// Re-stat + re-read the body IMMEDIATELY before the rename and confirm it is the SAME instance
|
|
468
|
+
// (dev/ino unchanged AND, for a JSON body, ts unchanged). If a racer stole+recreated a FRESH lock
|
|
469
|
+
// between our decision and now, the identity differs → do NOT steal; RETRY the bounded loop.
|
|
470
|
+
if (!sameLockInstance(decisionIdentity, lockIdentity(lockPath))) {
|
|
471
|
+
if (attempt + 1 < maxAttempts)
|
|
472
|
+
lockBackoff();
|
|
473
|
+
continue; // the body changed under us — re-evaluate from scratch rather than steal a replacement.
|
|
474
|
+
}
|
|
475
|
+
// Steal atomically (only one racer can rename the inode).
|
|
476
|
+
const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}-${node_crypto_1.default.randomBytes(4).toString('hex')}`;
|
|
477
|
+
try {
|
|
478
|
+
node_fs_1.default.renameSync(lockPath, stolen);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
} // another process won the steal
|
|
483
|
+
try {
|
|
484
|
+
node_fs_1.default.rmSync(stolen, { force: true });
|
|
485
|
+
}
|
|
486
|
+
catch { /* best-effort */ }
|
|
487
|
+
if (attempt + 1 < maxAttempts)
|
|
488
|
+
lockBackoff();
|
|
489
|
+
}
|
|
490
|
+
return null; // attempt budget exhausted (pathological contention) — never throws/recurses.
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Release a lock only if it still carries our owner token (PRIMARY discriminator) — and, as a best-
|
|
494
|
+
* effort SECONDARY check, if its inode still matches the (dev, ino) we captured at acquire, so the
|
|
495
|
+
* common path never deletes a lock that was stale-stolen out from under us.
|
|
496
|
+
*
|
|
497
|
+
* The TOKEN re-check is the load-bearing protection: a real successor wrote a DIFFERENT token, so we
|
|
498
|
+
* read a non-matching token and refuse to delete on every filesystem. The dev/ino recheck is best-
|
|
499
|
+
* effort secondary hardening (may be defeated by inode reuse on some filesystems). The body is read via
|
|
500
|
+
* the bounded reader so a FIFO/oversized body at the path is never read or deleted by us.
|
|
501
|
+
*/
|
|
502
|
+
function releaseLock(handle) {
|
|
503
|
+
if (!handle)
|
|
504
|
+
return;
|
|
505
|
+
try {
|
|
506
|
+
let body;
|
|
507
|
+
try {
|
|
508
|
+
body = ledgerMod.readSmallRegularFile(handle.path, LOCK_MAX_BODY_BYTES);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return; // non-regular / oversized / unreadable → not ours; do not read or delete.
|
|
512
|
+
}
|
|
513
|
+
if (body === null)
|
|
514
|
+
return; // gone / missing — nothing of ours to release.
|
|
515
|
+
// The body is JSON `{ token, … }`; release only if the recorded token is still OURS. A legacy
|
|
516
|
+
// plain-token body (whole body === token) is also honored.
|
|
517
|
+
if (lockBodyToken(body) !== handle.token && body !== handle.token)
|
|
518
|
+
return; // not our token (PRIMARY).
|
|
519
|
+
if (handle.dev !== null && handle.ino !== null) {
|
|
520
|
+
let cur;
|
|
521
|
+
try {
|
|
522
|
+
cur = node_fs_1.default.statSync(handle.path);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return; // vanished/unstatable between read and rmSync → nothing of ours to release.
|
|
526
|
+
}
|
|
527
|
+
if (cur.dev !== handle.dev || cur.ino !== handle.ino)
|
|
528
|
+
return; // successor inode — not ours.
|
|
529
|
+
}
|
|
530
|
+
node_fs_1.default.rmSync(handle.path, { force: true });
|
|
531
|
+
}
|
|
532
|
+
catch { /* already gone / stale-stolen / unreadable — nothing of ours to release */ }
|
|
533
|
+
}
|
|
534
|
+
module.exports = {
|
|
535
|
+
acquireLock,
|
|
536
|
+
releaseLock,
|
|
537
|
+
getProcessStartTime,
|
|
538
|
+
LOCK_STALE_MS,
|
|
539
|
+
LOCK_DEADMAN_MS,
|
|
540
|
+
LOCK_MAX_BODY_BYTES,
|
|
541
|
+
// Test seams (shared by capability-lifecycle's #1462 lock tests via re-export): inject deterministic
|
|
542
|
+
// isPidAlive / getProcessStartTime so the start-time liveness branches are exercised without real pids.
|
|
543
|
+
_setLockProbes(probes) {
|
|
544
|
+
if (typeof probes.isPidAlive === 'function')
|
|
545
|
+
_lockProbes.isPidAlive = probes.isPidAlive;
|
|
546
|
+
if (typeof probes.getProcessStartTime === 'function')
|
|
547
|
+
_lockProbes.getProcessStartTime = probes.getProcessStartTime;
|
|
548
|
+
},
|
|
549
|
+
_resetLockProbes() {
|
|
550
|
+
_lockProbes.isPidAlive = _realIsPidAlive;
|
|
551
|
+
_lockProbes.getProcessStartTime = getProcessStartTime;
|
|
552
|
+
},
|
|
553
|
+
};
|