@cruxy/cli 1.11.5 → 1.11.6

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.
@@ -1286,6 +1286,31 @@ export function memoryStoreUnloadable(scope, state, path, detail, underlying) {
1286
1286
  meta: { path, scope, state },
1287
1287
  });
1288
1288
  }
1289
+ /**
1290
+ * A store write waited {@link STORE_LOCK_WAIT_MS}-ish for the cross-process
1291
+ * lock and a live process still held it (cli#304). Nothing was written. The
1292
+ * holder is named when its stamp could be read; an unreadable stamp (a holder
1293
+ * killed between creating and writing the lock file, microseconds apart) is
1294
+ * the one case the user may need to remove the file by hand — so the path is
1295
+ * in the message.
1296
+ */
1297
+ export function storeLocked(file, lockFile, holder) {
1298
+ const who = holder
1299
+ ? `held by pid ${holder.pid}, started ${holder.startedAt}`
1300
+ : `held by a process whose stamp could not be read (${lockFile})`;
1301
+ return new CruxyError({
1302
+ code: ErrorCode.StoreLocked,
1303
+ title: `another cruxy is writing ${file}`,
1304
+ cause: who,
1305
+ nextSteps: holder
1306
+ ? ["retry in a moment — the other write takes milliseconds"]
1307
+ : [
1308
+ `if no other cruxy is running, remove ${lockFile} and retry`,
1309
+ "retry in a moment otherwise",
1310
+ ],
1311
+ meta: { path: file, lockFile, holderPid: holder?.pid },
1312
+ });
1313
+ }
1289
1314
  // ── usage telemetry (exit 2) — C.22 ───────────────────────────────────────────
1290
1315
  /**
1291
1316
  * The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
@@ -177,6 +177,11 @@ export const ErrorCode = {
177
177
  * torn or hand-broken file). Writing would replace everything it held with
178
178
  * the one new note and report success (cli#327). Nothing is written. */
179
179
  MemoryStoreUnloadable: "CRUXY_E_MEMORY_STORE_UNLOADABLE",
180
+ /** A store write (memory, usage, trust) waited its bounded time for the
181
+ * cross-process store lock and another LIVE cruxy still held it (cli#304).
182
+ * Nothing was written; the holder is named. A stale lock never produces
183
+ * this — a dead holder's lock is taken, not waited on. */
184
+ StoreLocked: "CRUXY_E_STORE_LOCKED",
180
185
  // usage telemetry (exit 2) — C.22
181
186
  /** The local usage store is corrupt/unreadable — the read is SKIPPED and this
182
187
  * is surfaced; never fatal to a run (usage display is best-effort). */
@@ -394,6 +399,7 @@ const EXIT_CODES = {
394
399
  [ErrorCode.MemoryUntrusted]: 14,
395
400
  [ErrorCode.MemorySecret]: 14,
396
401
  [ErrorCode.MemoryStoreUnloadable]: 14,
402
+ [ErrorCode.StoreLocked]: 14,
397
403
  // Usage telemetry (C.22). A corrupt store is a usage/data problem the user can
398
404
  // fix (delete the file); it shares the usage exit code and is never fatal to a
399
405
  // run — the aggregation just skips it.
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { memoryInvalid, memorySecretRefused, memoryStoreUnloadable, } from "../errors/index.js";
4
+ import { withStoreLock } from "../utils/store-lock.js";
4
5
  import { buildRecallBlock } from "./recall.js";
5
6
  import { containsSecret } from "./secrets.js";
6
7
  import { defaultMemorySources, loadScope, saveScope, } from "./store.js";
@@ -78,20 +79,25 @@ export class MemoryService {
78
79
  createdAt: this.now(),
79
80
  };
80
81
  const file = this.sources[scope];
81
- const existing = this.loadForWrite(file, scope).entries;
82
- if (scope === "project") {
83
- // Snapshot trust BEFORE the write — this is the launder guard.
84
- const wasTrustedOrEmpty = existing.length === 0 || this.projectTrusted(existing);
85
- const next = [...existing, entry];
86
- saveScope(file, next, scope);
87
- // Only extend trust when we started clean; never over foreign untrusted
88
- // entries.
89
- if (wasTrustedOrEmpty)
90
- this.recordProjectTrust(next);
91
- }
92
- else {
93
- saveScope(file, [...existing, entry], scope);
94
- }
82
+ // The whole read-modify-write under the cross-process lock (cli#304): a
83
+ // second cruxy's `remember` on the same file waits for this one instead
84
+ // of loading a set this write is about to replace.
85
+ withStoreLock(file, () => {
86
+ const existing = this.loadForWrite(file, scope).entries;
87
+ if (scope === "project") {
88
+ // Snapshot trust BEFORE the write this is the launder guard.
89
+ const wasTrustedOrEmpty = existing.length === 0 || this.projectTrusted(existing);
90
+ const next = [...existing, entry];
91
+ saveScope(file, next, scope);
92
+ // Only extend trust when we started clean; never over foreign untrusted
93
+ // entries.
94
+ if (wasTrustedOrEmpty)
95
+ this.recordProjectTrust(next);
96
+ }
97
+ else {
98
+ saveScope(file, [...existing, entry], scope);
99
+ }
100
+ });
95
101
  return entry;
96
102
  }
97
103
  /** A snapshot of both scopes + project trust, for `cruxy memory list`. */
@@ -113,15 +119,19 @@ export class MemoryService {
113
119
  forget(id) {
114
120
  for (const scope of ["user", "project"]) {
115
121
  const file = this.sources[scope];
116
- const entries = this.loadForWrite(file, scope).entries;
117
- const kept = entries.filter((e) => e.id !== id);
118
- if (kept.length === entries.length)
119
- continue;
120
- const wasTrusted = scope === "project" && this.projectTrusted(entries);
121
- saveScope(file, kept, scope);
122
- if (wasTrusted)
123
- this.recordProjectTrust(kept);
124
- return true;
122
+ const removed = withStoreLock(file, () => {
123
+ const entries = this.loadForWrite(file, scope).entries;
124
+ const kept = entries.filter((e) => e.id !== id);
125
+ if (kept.length === entries.length)
126
+ return false;
127
+ const wasTrusted = scope === "project" && this.projectTrusted(entries);
128
+ saveScope(file, kept, scope);
129
+ if (wasTrusted)
130
+ this.recordProjectTrust(kept);
131
+ return true;
132
+ });
133
+ if (removed)
134
+ return true;
125
135
  }
126
136
  return false;
127
137
  }
@@ -131,19 +141,21 @@ export class MemoryService {
131
141
  let removed = 0;
132
142
  for (const s of scopes) {
133
143
  const file = this.sources[s];
134
- // Deliberately NOT `loadForWrite`: clearing is the documented recovery
135
- // for a store that cannot be loaded (cli#327). Emptying it is the
136
- // user's explicit intent here, so an unloadable file counts as 0
137
- // removed and is replaced with an empty document.
138
- const entries = loadScope(file, s).entries;
139
- removed += entries.length;
140
- saveScope(file, [], s);
141
- // Clearing project memory to empty: re-anchor trust to the empty set so a
142
- // later `remember` starts from a clean, trusted baseline (empty memory is
143
- // never recalled anyway).
144
- if (s === "project" && this.projectTrusted(entries)) {
145
- this.recordProjectTrust([]);
146
- }
144
+ withStoreLock(file, () => {
145
+ // Deliberately NOT `loadForWrite`: clearing is the documented recovery
146
+ // for a store that cannot be loaded (cli#327). Emptying it is the
147
+ // user's explicit intent here, so an unloadable file counts as 0
148
+ // removed and is replaced with an empty document.
149
+ const entries = loadScope(file, s).entries;
150
+ removed += entries.length;
151
+ saveScope(file, [], s);
152
+ // Clearing project memory to empty: re-anchor trust to the empty set
153
+ // so a later `remember` starts from a clean, trusted baseline (empty
154
+ // memory is never recalled anyway).
155
+ if (s === "project" && this.projectTrusted(entries)) {
156
+ this.recordProjectTrust([]);
157
+ }
158
+ });
147
159
  }
148
160
  return removed;
149
161
  }
@@ -157,9 +169,11 @@ export class MemoryService {
157
169
  // Trust is a fingerprint over the entry SET. Recording it for the empty
158
170
  // set an unloadable file reads as would trust nothing and, once the file
159
171
  // is repaired, mismatch its real content — so this refuses like a write.
160
- const entries = this.loadForWrite(this.sources.project, "project").entries;
161
- this.recordProjectTrust(entries);
162
- return entries.length;
172
+ return withStoreLock(this.sources.project, () => {
173
+ const entries = this.loadForWrite(this.sources.project, "project").entries;
174
+ this.recordProjectTrust(entries);
175
+ return entries.length;
176
+ });
163
177
  }
164
178
  /**
165
179
  * Load a scope for a path that will WRITE the whole set back (cli#327).
@@ -1,6 +1,7 @@
1
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/paths.js";
4
+ import { existingMode, replaceStoreFile } from "../utils/store-file.js";
4
5
  import { GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME, } from "../constants.js";
5
6
  import { containsSecret } from "./secrets.js";
6
7
  import { MemoryEntrySchema, MemoryFileSchema, MEMORY_FILE_VERSION, } from "./types.js";
@@ -119,8 +120,9 @@ function invalidFile(scope, message, underlying) {
119
120
  * never asked for, arriving through a write path — the same reason `api.env`'s
120
121
  * mode has to survive a `sed`.
121
122
  *
122
- * WRITTEN TO A TEMP FILE AND RENAMED INTO PLACE (cli#327), the `credentials.ts`
123
- * pattern. A plain `writeFileSync` on an existing path truncates first and
123
+ * WRITTEN TO A TEMP FILE AND RENAMED INTO PLACE (cli#327) through
124
+ * `utils/store-file.ts`, which also checks the cross-process store lock right
125
+ * before the rename (cli#304). A plain `writeFileSync` on an existing path truncates first and
124
126
  * fills in after, so anything reading in between — recall in a second cruxy,
125
127
  * `cruxy memory list`, or another writer's load — saw an empty or half-written
126
128
  * file. `rename(2)` swaps the whole document in one step: a reader sees the
@@ -140,32 +142,5 @@ export function saveScope(file, entries, scope) {
140
142
  // The mode the replacement must carry: explicit 0600 for the user scope;
141
143
  // for the project scope, whatever the existing file has (absent → umask).
142
144
  const mode = scope === "user" ? 0o600 : existingMode(file);
143
- const tmp = `${file}.tmp-${process.pid}`;
144
- try {
145
- writeFileSync(tmp, body, mode !== undefined ? { mode } : undefined);
146
- // `writeFileSync`'s mode is masked by the umask and applies only on create;
147
- // chmod pins the exact bits on the temp BEFORE it becomes the file.
148
- if (mode !== undefined)
149
- chmodSync(tmp, mode);
150
- renameSync(tmp, file); // atomic replace; the mode moves with the inode
151
- }
152
- catch (err) {
153
- try {
154
- rmSync(tmp, { force: true });
155
- }
156
- catch {
157
- // The temp may already be gone; the target file is untouched regardless.
158
- }
159
- throw err;
160
- }
161
- }
162
- /** The permission bits of `file`, or `undefined` when there is nothing there
163
- * to preserve (the create case takes the umask, as before). */
164
- function existingMode(file) {
165
- try {
166
- return statSync(file).mode & 0o777;
167
- }
168
- catch {
169
- return undefined;
170
- }
145
+ replaceStoreFile(file, body, mode);
171
146
  }
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { replaceStoreFile } from "../utils/store-file.js";
4
+ import { withStoreLock } from "../utils/store-lock.js";
3
5
  import path from "node:path";
4
6
  import { globalDir } from "../config/paths.js";
5
7
  import { MEMORY_TRUST_FILE_NAME } from "../constants.js";
@@ -57,20 +59,21 @@ export function isMemoryTrusted(store, root, currentFingerprint) {
57
59
  */
58
60
  export function fileMemoryTrustStore(file = memoryTrustPath()) {
59
61
  let cache = null;
60
- const load = () => {
61
- if (cache)
62
- return cache;
62
+ const readFresh = () => {
63
63
  try {
64
64
  const raw = JSON.parse(readFileSync(file, "utf8"));
65
- cache =
66
- raw && typeof raw === "object"
67
- ? raw
68
- : {};
65
+ return raw && typeof raw === "object"
66
+ ? raw
67
+ : {};
69
68
  }
70
69
  catch {
71
70
  // Missing or corrupt → no trust (fail-closed).
72
- cache = {};
71
+ return {};
73
72
  }
73
+ };
74
+ const load = () => {
75
+ if (!cache)
76
+ cache = readFresh();
74
77
  return cache;
75
78
  };
76
79
  return {
@@ -78,16 +81,20 @@ export function fileMemoryTrustStore(file = memoryTrustPath()) {
78
81
  return load()[path.resolve(root)];
79
82
  },
80
83
  record(trust) {
81
- const store = load();
82
- store[path.resolve(trust.root)] = {
83
- ...trust,
84
- root: path.resolve(trust.root),
85
- };
86
- mkdirSync(path.dirname(file), { recursive: true });
87
- // 0600: trust records name local paths; keep them owner-only.
88
- writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
89
- if (existsSync(file))
84
+ // Under the cross-process lock, from a FRESH read (cli#304): the cached
85
+ // document may predate another cruxy's record for a different root, and
86
+ // writing the cache back would drop that root's trust. Atomic replace,
87
+ // `0600`: trust records name local paths; keep them owner-only.
88
+ withStoreLock(file, () => {
89
+ const store = readFresh();
90
+ store[path.resolve(trust.root)] = {
91
+ ...trust,
92
+ root: path.resolve(trust.root),
93
+ };
94
+ mkdirSync(path.dirname(file), { recursive: true });
95
+ replaceStoreFile(file, JSON.stringify(store, null, 2), 0o600);
90
96
  cache = store;
97
+ });
91
98
  },
92
99
  };
93
100
  }
@@ -1,8 +1,10 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/paths.js";
4
4
  import { USAGE_DIR_NAME, USAGE_FILE_NAME } from "../constants.js";
5
- import { usageRead } from "../errors/index.js";
5
+ import { CruxyError, usageRead } from "../errors/index.js";
6
+ import { existingMode, replaceStoreFile } from "../utils/store-file.js";
7
+ import { withStoreLock } from "../utils/store-lock.js";
6
8
  import { UsageFileSchema, USAGE_FILE_VERSION, } from "./types.js";
7
9
  /**
8
10
  * The usage store (C.22): a single JSON file under `~/.cruxy/usage`, holding a
@@ -62,6 +64,20 @@ export function loadUsage(file = usageStorePath()) {
62
64
  */
63
65
  export function appendRun(record, opts = { retention: 50 }) {
64
66
  const file = opts.file ?? usageStorePath();
67
+ // The whole read-modify-write under the cross-process lock (cli#304), so two
68
+ // runs finishing at once queue instead of one dropping the other's record.
69
+ // `never throws` still holds: a lock held past its deadline by a live cruxy
70
+ // comes back as the coded error for the caller to downgrade.
71
+ try {
72
+ return withStoreLock(file, () => appendRunLocked(record, opts, file));
73
+ }
74
+ catch (err) {
75
+ if (CruxyError.is(err))
76
+ return { error: err };
77
+ throw err;
78
+ }
79
+ }
80
+ function appendRunLocked(record, opts, file) {
65
81
  // A corrupt existing file is NOT overwritten — surface it and skip, rather
66
82
  // than silently destroying whatever the user has (or blindly appending to a
67
83
  // shape we couldn't validate).
@@ -80,7 +96,9 @@ export function appendRun(record, opts = { retention: 50 }) {
80
96
  // the data, just one write later. version/runs are re-stated after the
81
97
  // spread so this build's own values always win.
82
98
  const body = JSON.stringify({ ...loaded.data, version: USAGE_FILE_VERSION, runs: pruned }, null, 2);
83
- writeFileSync(file, body, { mode: 0o600 });
99
+ // Atomic replace through the shared helper; `0600` on create, the file's
100
+ // own bits on a replace (a rename is a new inode; see store-file.ts).
101
+ replaceStoreFile(file, body, existingMode(file) ?? 0o600);
84
102
  return {};
85
103
  }
86
104
  catch (err) {
@@ -0,0 +1,51 @@
1
+ import { chmodSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { assertStoreLockHeld } from "./store-lock.js";
3
+ /**
4
+ * Replace a store document atomically (cli#304): write a temp file beside it,
5
+ * pin the mode, verify the store lock is still ours, rename into place. The
6
+ * one copy of the pattern `memory/store.ts`, `usage/store.ts` and
7
+ * `memory/trust.ts` share; `config/credentials.ts` keeps its own because it
8
+ * also enforces an owner-only ACL on the temp.
9
+ *
10
+ * `rename(2)` swaps a complete document in one step, so a reader — or another
11
+ * writer's load — sees the old bytes or the new ones, never a truncated or
12
+ * spliced file. The lock check sits immediately before the rename because the
13
+ * rename is the commit: if the lock was taken from under us, nothing lands
14
+ * and `withStoreLock` retries the whole read-modify-write from a fresh load.
15
+ *
16
+ * `mode` is chmod'd explicitly: `writeFileSync`'s mode is masked by the umask
17
+ * and applies only on create, and the temp IS a create. A rename is a new
18
+ * inode, so without this a replace would silently change the file's
19
+ * permission bits — a change the user never asked for, arriving through a
20
+ * write path. Callers decide the bits (explicit `0600` for personal data,
21
+ * {@link existingMode} to preserve what a file already has).
22
+ */
23
+ export function replaceStoreFile(file, body, mode) {
24
+ const tmp = `${file}.tmp-${process.pid}`;
25
+ try {
26
+ writeFileSync(tmp, body, mode !== undefined ? { mode } : undefined);
27
+ if (mode !== undefined)
28
+ chmodSync(tmp, mode);
29
+ assertStoreLockHeld(file);
30
+ renameSync(tmp, file);
31
+ }
32
+ catch (err) {
33
+ try {
34
+ rmSync(tmp, { force: true });
35
+ }
36
+ catch {
37
+ // The temp may already be gone; the target file is untouched regardless.
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+ /** The permission bits of `file`, or `undefined` when there is nothing there
43
+ * to preserve (the create case takes the caller's default). */
44
+ export function existingMode(file) {
45
+ try {
46
+ return statSync(file).mode & 0o777;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
@@ -0,0 +1,324 @@
1
+ import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { performance } from "node:perf_hooks";
4
+ import { storeLocked } from "../errors/index.js";
5
+ import { describeOwner, pidAlive, selfStamp, } from "./process-owner.js";
6
+ /**
7
+ * An exclusive, cross-process lock around a store's read-modify-write span
8
+ * (cli#304).
9
+ *
10
+ * WHY A LOCK AND NOT A COMPARE. The memory, usage and trust stores are each
11
+ * one JSON document: load, mutate, replace. The span is synchronous, so
12
+ * nothing in one process can interleave it — but a second cruxy in the same
13
+ * project can, and when it does the later writer's load predates the earlier
14
+ * writer's rename and the earlier record is gone, while both callers were
15
+ * told it was saved. Measured: ~50 % of writes lost under a two-process tight
16
+ * loop, ~span ÷ gap otherwise. A compare-and-swap on the loaded bytes was
17
+ * measured too and still lost a third of them, because both writers pass the
18
+ * compare before either renames. Only mutual exclusion closed it: zero lost
19
+ * across every trial, at 0.5–2 ms per write.
20
+ *
21
+ * THE LOCK IS A FILE created with `wx` (`O_CREAT | O_EXCL`) beside the store,
22
+ * holding this process's {@link ProcessStamp}. Creation is the atomic step;
23
+ * whoever's `open` succeeds holds the lock. A contender spins for at most
24
+ * {@link STORE_LOCK_WAIT_MS} and then refuses with `CRUXY_E_STORE_LOCKED`
25
+ * naming the holder — never silently proceeds.
26
+ *
27
+ * STALENESS IS P1's RULE, NOT A NEW ONE. The stamp is a pid plus the start
28
+ * token `utils/process-owner.ts` reads from the OS for that pid, and
29
+ * {@link describeOwner} decides: a stamp whose process is gone, or whose pid
30
+ * was recycled onto a different start time, is `stale`, and a stale lock is
31
+ * not a holder — it is removed and taken. Nothing is cleaned up for
32
+ * correctness; a crash leaves a stamp that the next writer recognises as dead
33
+ * the moment it reads it. This is the same rule the session owner file uses,
34
+ * chosen in P1 because a lock that outlives its crash is worse than no lock.
35
+ *
36
+ * ONE RESIDUAL, AND THE CHECK THAT COVERS IT. Breaking a stale lock is
37
+ * read-then-unlink, two syscalls; two contenders that both read the same
38
+ * dead stamp can both unlink, and the second unlink can remove the first's
39
+ * freshly created lock. So a holder records its lock file's inode, and
40
+ * {@link assertStoreLockHeld} — called by the store right before its rename —
41
+ * re-stats the lock: a missing or different inode means the lock was taken
42
+ * from under us, and the write is NOT committed. The error is caught here
43
+ * and the whole read-modify-write is retried from a fresh load. Retried, not
44
+ * refused: there is no approval in a store write, the operation is a
45
+ * function of the current state, so a retry produces exactly the right
46
+ * result and a refusal would fail a valid append.
47
+ *
48
+ * A stamp that cannot be parsed (a holder killed between `open` and
49
+ * `write`, microseconds apart) has no pid to judge and is treated as held
50
+ * until the deadline, then refused with the path so the user can remove it.
51
+ * Inventing a second liveness rule (mtime, age) for that case would be worse
52
+ * than the refusal.
53
+ *
54
+ * WIN32 REPORTS CONTENTION AS EPERM, NOT EEXIST. Unlinking a file another
55
+ * process still has open does not remove it there; it becomes delete-pending
56
+ * until the last handle closes, and during that window a create on the same
57
+ * name fails with `EPERM` (ERROR_ACCESS_DENIED), as does opening it to read.
58
+ * A releasing holder and a contender mid-`readStamp` hit exactly that window
59
+ * on every burst. So `EPERM`, `EACCES` and `EBUSY` from the create or the
60
+ * read are the same event as `EEXIST` — someone else is at the lock — and
61
+ * are spun on, not thrown. A real permission problem still surfaces: if the
62
+ * deadline passes without a holder ever having been read, the last such
63
+ * error is thrown as itself rather than dressed up as `CRUXY_E_STORE_LOCKED`.
64
+ * Found by CI: the three contention tests passed on darwin and linux and
65
+ * failed on windows-latest with `EPERM open runs.json.lock`.
66
+ */
67
+ /** Errors a create/read/unlink on the lock file can return while another
68
+ * process is at it (see WIN32 in the module comment). */
69
+ const CONTENTION_CODES = new Set(["EEXIST", "EPERM", "EACCES", "EBUSY"]);
70
+ function isContention(err) {
71
+ return CONTENTION_CODES.has(err.code ?? "");
72
+ }
73
+ /**
74
+ * How long a contender waits for a held lock before refusing.
75
+ *
76
+ * Measured spans are 0.5–3 ms, and 250 ms was the first figure: two orders
77
+ * of magnitude of headroom on an idle machine. On a SATURATED one it is not
78
+ * enough — a holder that is descheduled mid-span holds the lock for as long
79
+ * as the scheduler leaves it off-CPU, and with the full test suite running in
80
+ * parallel the two-process memory test refused writes at 250 ms in one of two
81
+ * runs. The budget bounds a wait, not a span; nothing on the success path
82
+ * costs more with a larger one. Two seconds is still a bounded, visible
83
+ * refusal, and it was not reached under that same load.
84
+ *
85
+ * Do not tighten this back toward the measured span for tidiness: a smaller
86
+ * figure buys nothing on the success path and turns scheduler jitter into a
87
+ * refused write. Only lower it with a new measurement under full-suite load.
88
+ */
89
+ export const STORE_LOCK_WAIT_MS = 2000;
90
+ /** How many times a lost-lock retry is attempted before the error propagates. */
91
+ const MAX_LOST_LOCK_RETRIES = 3;
92
+ const SPIN_MS = 1;
93
+ /** How long a waiter polls without sleeping before it backs off to
94
+ * {@link SPIN_MS} sleeps. A holder in a burst releases and re-creates the lock
95
+ * microseconds apart; a sleeping poller misses that window almost every time,
96
+ * a busy poller catches it. Costs CPU only while contended. */
97
+ const BUSY_POLL_MS = 3;
98
+ /**
99
+ * FAIRNESS. A process writing the same store in a burst releases the lock and
100
+ * re-creates it microseconds later, while a contender polls once per
101
+ * {@link SPIN_MS}; measured, that starves the contender for the whole wait
102
+ * (1–2 refusals per 2000 writes under a two-process tight loop) even though
103
+ * every individual hold is a millisecond. So a process that released a lock
104
+ * within the last poll interval yields one interval before taking it again:
105
+ * an isolated write pays nothing, a burst pays a millisecond per write, and a
106
+ * waiter always gets a window. Polls are jittered so contenders do not
107
+ * phase-lock on the holder's cycle.
108
+ */
109
+ const lastReleased = new Map();
110
+ /**
111
+ * Liveness verdicts by the exact stamp bytes seen on disk. {@link describeOwner}
112
+ * reads the holder's start token from the OS — on darwin that is one `ps`
113
+ * subprocess (~10 ms), on win32 a PowerShell call — so it is asked ONCE per
114
+ * distinct stamp, not once per spin. Later spins on the same stamp re-check
115
+ * only the cheap `pidAlive` signal: a holder that exits mid-wait is noticed
116
+ * within a spin, and a recycled pid cannot fool this because the stamp bytes
117
+ * (pid + token) would differ and be judged afresh.
118
+ */
119
+ const verdicts = new Map();
120
+ /** The lock file beside a store file. */
121
+ export function storeLockPath(file) {
122
+ return `${path.resolve(file)}.lock`;
123
+ }
124
+ /** Thrown by {@link assertStoreLockHeld} when the lock we hold is gone or is
125
+ * someone else's; caught by {@link withStoreLock}, which retries `fn`. */
126
+ export class StoreLockLostError extends Error {
127
+ constructor(file) {
128
+ super(`store lock for ${file} was taken by another process before the write committed`);
129
+ this.name = "StoreLockLostError";
130
+ }
131
+ }
132
+ /** Locks this process currently holds, by resolved store path. */
133
+ const held = new Map();
134
+ /**
135
+ * Run `fn` holding the exclusive lock for `file`. Re-entrant within the
136
+ * process for the same file (a nested call runs inline). Throws
137
+ * `CRUXY_E_STORE_LOCKED` if another live process holds it past the deadline;
138
+ * everything `fn` throws propagates, after the lock is released.
139
+ */
140
+ export function withStoreLock(file, fn) {
141
+ const key = path.resolve(file);
142
+ const nested = held.get(key);
143
+ if (nested) {
144
+ nested.depth++;
145
+ try {
146
+ return fn();
147
+ }
148
+ finally {
149
+ nested.depth--;
150
+ }
151
+ }
152
+ const lock = storeLockPath(key);
153
+ mkdirSync(path.dirname(key), { recursive: true });
154
+ for (let attempt = 0;; attempt++) {
155
+ const ino = acquire(lock, key);
156
+ held.set(key, { ino, depth: 1 });
157
+ try {
158
+ return fn();
159
+ }
160
+ catch (err) {
161
+ if (err instanceof StoreLockLostError &&
162
+ attempt < MAX_LOST_LOCK_RETRIES) {
163
+ continue; // nothing was committed; take the lock again and redo from a fresh load
164
+ }
165
+ throw err;
166
+ }
167
+ finally {
168
+ held.delete(key);
169
+ release(lock, ino);
170
+ }
171
+ }
172
+ }
173
+ /**
174
+ * For a store's write path: verify, right before the rename that commits the
175
+ * document, that the lock this process took for `file` is still the one on
176
+ * disk. No-op when the caller did not lock (a direct `saveScope` in a test,
177
+ * or `clear`'s deliberate recovery path).
178
+ */
179
+ export function assertStoreLockHeld(file) {
180
+ const key = path.resolve(file);
181
+ const h = held.get(key);
182
+ if (!h)
183
+ return;
184
+ let ino;
185
+ try {
186
+ ino = statSync(storeLockPath(key), { bigint: true }).ino;
187
+ }
188
+ catch {
189
+ throw new StoreLockLostError(key);
190
+ }
191
+ if (ino !== h.ino)
192
+ throw new StoreLockLostError(key);
193
+ }
194
+ // ── internals ─────────────────────────────────────────────────────────────────
195
+ function acquire(lock, file) {
196
+ const since = performance.now() - (lastReleased.get(lock) ?? -Infinity);
197
+ if (since < SPIN_MS * 2)
198
+ sleep(SPIN_MS);
199
+ const started = performance.now();
200
+ let deadline = started + STORE_LOCK_WAIT_MS;
201
+ let holder = null;
202
+ /** The last non-EEXIST create error, thrown as itself if nobody was ever
203
+ * seen holding the lock — a real permission problem, not contention. */
204
+ let lastDenied = null;
205
+ for (;;) {
206
+ try {
207
+ writeFileSync(lock, JSON.stringify(selfStamp()), {
208
+ flag: "wx",
209
+ mode: 0o600,
210
+ });
211
+ return statSync(lock, { bigint: true }).ino;
212
+ }
213
+ catch (err) {
214
+ if (!isContention(err))
215
+ throw err;
216
+ if (err.code !== "EEXIST")
217
+ lastDenied = err;
218
+ }
219
+ // "gone": released (or delete-pending) between our open and our read;
220
+ // retry the create, but through the deadline check below — a permanent
221
+ // EPERM must not spin forever.
222
+ const read = readStamp(lock);
223
+ if (read !== "gone" && read !== "unreadable") {
224
+ const { stamp, raw } = read;
225
+ // `self` here is our own pid and token with no entry in `held`: a lock
226
+ // this process created and lost track of (it cannot be a live holder —
227
+ // we are single-threaded and not inside `withStoreLock` for this file).
228
+ // Treated as stale, like any other dead stamp.
229
+ let verdict = verdicts.get(raw);
230
+ if (verdict === undefined) {
231
+ // The OS lookup behind `describeOwner` is not waiting time: it is one
232
+ // `ps` on darwin, and a PowerShell call on win32 that can itself
233
+ // outlast the whole budget. Push the deadline out by what it took.
234
+ const t0 = performance.now();
235
+ verdict = describeOwner(stamp);
236
+ deadline += performance.now() - t0;
237
+ verdicts.set(raw, verdict);
238
+ }
239
+ else if (verdict === "live" && !pidAlive(stamp.pid)) {
240
+ verdict = "stale";
241
+ verdicts.set(raw, verdict);
242
+ }
243
+ if (verdict !== "live") {
244
+ breakLock(lock);
245
+ continue;
246
+ }
247
+ holder = stamp;
248
+ }
249
+ const now = performance.now();
250
+ if (now >= deadline) {
251
+ if (holder === null && lastDenied !== null)
252
+ throw lastDenied;
253
+ throw storeLocked(file, lock, holder);
254
+ }
255
+ if (now - started >= BUSY_POLL_MS)
256
+ sleep(SPIN_MS * (0.5 + Math.random()));
257
+ }
258
+ }
259
+ /** How long {@link release} keeps retrying an unlink that win32 refuses
260
+ * while a contender holds the file open. A lock that a live holder failed to
261
+ * remove is refused by every other process for as long as this one runs, so
262
+ * the retry is worth far more than it costs. */
263
+ const RELEASE_RETRY_MS = 100;
264
+ function release(lock, ino) {
265
+ const until = performance.now() + RELEASE_RETRY_MS;
266
+ for (;;) {
267
+ try {
268
+ // Only remove the lock if it is still ours — never a successor's.
269
+ if (statSync(lock, { bigint: true }).ino === ino)
270
+ unlinkSync(lock);
271
+ break;
272
+ }
273
+ catch (err) {
274
+ // ENOENT: already gone (broken as stale by a contender, or never
275
+ // landed). A contention code: a reader has it open (win32); retry.
276
+ if (!isContention(err) || performance.now() >= until)
277
+ break;
278
+ sleep(SPIN_MS);
279
+ }
280
+ }
281
+ lastReleased.set(lock, performance.now());
282
+ }
283
+ function breakLock(lock) {
284
+ try {
285
+ unlinkSync(lock);
286
+ }
287
+ catch {
288
+ // Someone else broke it first; the next `wx` attempt decides.
289
+ }
290
+ }
291
+ function readStamp(lock) {
292
+ let raw;
293
+ try {
294
+ raw = readFileSync(lock, "utf8");
295
+ }
296
+ catch (err) {
297
+ // ENOENT: released before we read. A contention code: delete-pending on
298
+ // win32, which is the same thing a few microseconds earlier.
299
+ const code = err.code;
300
+ return code === "ENOENT" || isContention(err) ? "gone" : "unreadable";
301
+ }
302
+ try {
303
+ const parsed = JSON.parse(raw);
304
+ if (typeof parsed.pid !== "number" ||
305
+ typeof parsed.token !== "string" ||
306
+ typeof parsed.startedAt !== "string") {
307
+ return "unreadable";
308
+ }
309
+ return {
310
+ stamp: {
311
+ pid: parsed.pid,
312
+ token: parsed.token,
313
+ startedAt: parsed.startedAt,
314
+ },
315
+ raw,
316
+ };
317
+ }
318
+ catch {
319
+ return "unreadable";
320
+ }
321
+ }
322
+ function sleep(ms) {
323
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
324
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.11.5",
3
+ "version": "1.11.6",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {