@cruxy/cli 1.11.4 → 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.
- package/dist/errors/constructors.js +64 -0
- package/dist/errors/types.js +12 -0
- package/dist/memory/remember-tool.js +5 -1
- package/dist/memory/service.js +73 -36
- package/dist/memory/store.js +78 -32
- package/dist/memory/trust.js +25 -18
- package/dist/usage/store.js +21 -3
- package/dist/utils/store-file.js +51 -0
- package/dist/utils/store-lock.js +324 -0
- package/package.json +1 -1
|
@@ -1247,6 +1247,70 @@ export function memoryInvalid(detail) {
|
|
|
1247
1247
|
],
|
|
1248
1248
|
});
|
|
1249
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* A memory write was refused because the scope's existing store could not be
|
|
1252
|
+
* loaded, so the write would have replaced everything it held with the one
|
|
1253
|
+
* new note and reported success (cli#327). The same shape as
|
|
1254
|
+
* {@link credentialsUnprotected}: nothing is written, the file is named, and
|
|
1255
|
+
* the next step depends on WHICH state the file is in —
|
|
1256
|
+
*
|
|
1257
|
+
* - `unreadable`: the bytes could not be read at all (permissions, a
|
|
1258
|
+
* directory at the path). Fix the path; the content is presumably intact.
|
|
1259
|
+
* - `invalid`: the bytes were read but are not a memory document — a
|
|
1260
|
+
* half-written file (a crash or a concurrent writer mid-write) or a hand
|
|
1261
|
+
* edit that broke the JSON or the shape. Repair or clear it.
|
|
1262
|
+
*
|
|
1263
|
+
* An ABSENT file is not this error: it is the one state where starting empty
|
|
1264
|
+
* is correct, and conflating it with these two is what caused the wipe.
|
|
1265
|
+
*/
|
|
1266
|
+
export function memoryStoreUnloadable(scope, state, path, detail, underlying) {
|
|
1267
|
+
const title = state === "unreadable"
|
|
1268
|
+
? `refusing to write memory over a store that could not be read: ${path}`
|
|
1269
|
+
: `refusing to write memory over a store that is not a valid memory file: ${path}`;
|
|
1270
|
+
const nextSteps = state === "unreadable"
|
|
1271
|
+
? [
|
|
1272
|
+
"nothing was written — the existing notes are presumably intact, but they could not be read",
|
|
1273
|
+
`make ${path} a regular file readable and writable by you, then retry`,
|
|
1274
|
+
]
|
|
1275
|
+
: [
|
|
1276
|
+
"nothing was written — saving would have replaced every note the file holds",
|
|
1277
|
+
`inspect ${path}: a half-written or hand-edited file; restore it from a backup or your editor, then retry`,
|
|
1278
|
+
`or start the scope empty with \`cruxy memory clear --scope ${scope}\` (this discards whatever the file held)`,
|
|
1279
|
+
];
|
|
1280
|
+
return new CruxyError({
|
|
1281
|
+
code: ErrorCode.MemoryStoreUnloadable,
|
|
1282
|
+
title,
|
|
1283
|
+
cause: detail,
|
|
1284
|
+
nextSteps,
|
|
1285
|
+
underlying,
|
|
1286
|
+
meta: { path, scope, state },
|
|
1287
|
+
});
|
|
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
|
+
}
|
|
1250
1314
|
// ── usage telemetry (exit 2) — C.22 ───────────────────────────────────────────
|
|
1251
1315
|
/**
|
|
1252
1316
|
* The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
|
package/dist/errors/types.js
CHANGED
|
@@ -172,6 +172,16 @@ export const ErrorCode = {
|
|
|
172
172
|
/** A memory write was refused because the content matched a secret shape —
|
|
173
173
|
* secrets are never persisted (defense in depth over C.17). */
|
|
174
174
|
MemorySecret: "CRUXY_E_MEMORY_SECRET",
|
|
175
|
+
/** A memory write was refused because the scope's existing store could not
|
|
176
|
+
* be loaded — unreadable, or readable but not a valid memory document (a
|
|
177
|
+
* torn or hand-broken file). Writing would replace everything it held with
|
|
178
|
+
* the one new note and report success (cli#327). Nothing is written. */
|
|
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",
|
|
175
185
|
// usage telemetry (exit 2) — C.22
|
|
176
186
|
/** The local usage store is corrupt/unreadable — the read is SKIPPED and this
|
|
177
187
|
* is surfaced; never fatal to a run (usage display is best-effort). */
|
|
@@ -388,6 +398,8 @@ const EXIT_CODES = {
|
|
|
388
398
|
[ErrorCode.MemoryInvalid]: 14,
|
|
389
399
|
[ErrorCode.MemoryUntrusted]: 14,
|
|
390
400
|
[ErrorCode.MemorySecret]: 14,
|
|
401
|
+
[ErrorCode.MemoryStoreUnloadable]: 14,
|
|
402
|
+
[ErrorCode.StoreLocked]: 14,
|
|
391
403
|
// Usage telemetry (C.22). A corrupt store is a usage/data problem the user can
|
|
392
404
|
// fix (delete the file); it shares the usage exit code and is never fatal to a
|
|
393
405
|
// run — the aggregation just skips it.
|
|
@@ -58,7 +58,11 @@ export const rememberTool = {
|
|
|
58
58
|
// Fail loud to the model: a refused secret / invalid note is reported with
|
|
59
59
|
// its stable code so the model knows it was NOT stored (never a silent no-op).
|
|
60
60
|
if (CruxyError.is(err)) {
|
|
61
|
-
|
|
61
|
+
// The next steps ride along so the model can tell the user what to do
|
|
62
|
+
// (cli#327: "refusing to write over a store that could not be read"
|
|
63
|
+
// is only actionable with the file and the fix named).
|
|
64
|
+
const steps = err.nextSteps.length > 0 ? ` — ${err.nextSteps.join("; ")}` : "";
|
|
65
|
+
return { ok: false, error: `${err.code}: ${err.title}${steps}` };
|
|
62
66
|
}
|
|
63
67
|
return { ok: false, error: err.message };
|
|
64
68
|
}
|
package/dist/memory/service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { memoryInvalid, memorySecretRefused } from "../errors/index.js";
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,15 +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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
});
|
|
143
159
|
}
|
|
144
160
|
return removed;
|
|
145
161
|
}
|
|
@@ -150,9 +166,30 @@ export class MemoryService {
|
|
|
150
166
|
* become recallable.
|
|
151
167
|
*/
|
|
152
168
|
trustProject() {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
// Trust is a fingerprint over the entry SET. Recording it for the empty
|
|
170
|
+
// set an unloadable file reads as would trust nothing and, once the file
|
|
171
|
+
// is repaired, mismatch its real content — so this refuses like a write.
|
|
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
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Load a scope for a path that will WRITE the whole set back (cli#327).
|
|
180
|
+
* `absent` and `loaded` are fine; `unreadable` and `invalid` are refused
|
|
181
|
+
* with `CRUXY_E_MEMORY_STORE_UNLOADABLE`, because the "existing" set is
|
|
182
|
+
* empty only because the load failed, and saving it back would replace every
|
|
183
|
+
* note the file holds with whatever this call adds — while reporting success.
|
|
184
|
+
* A read-only path (`recall`, `status`) keeps using `loadScope` directly and
|
|
185
|
+
* surfaces the same condition as an error row instead.
|
|
186
|
+
*/
|
|
187
|
+
loadForWrite(file, scope) {
|
|
188
|
+
const load = loadScope(file, scope);
|
|
189
|
+
if (load.file.kind === "unreadable" || load.file.kind === "invalid") {
|
|
190
|
+
throw memoryStoreUnloadable(scope, load.file.kind, file, load.file.message, load.file.underlying);
|
|
191
|
+
}
|
|
192
|
+
return load;
|
|
156
193
|
}
|
|
157
194
|
recordProjectTrust(entries) {
|
|
158
195
|
this.trust.record({
|
package/dist/memory/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync
|
|
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";
|
|
@@ -13,46 +14,60 @@ export function defaultMemorySources(cwd) {
|
|
|
13
14
|
};
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* Read and validate one scope's file.
|
|
17
|
-
* (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Read and validate one scope's file. Each entry is (1) schema-validated and
|
|
18
|
+
* (2) scanned for secret content; failures are excluded and collected. The
|
|
19
|
+
* returned `entries` all carry the requested `scope` (the on-disk `scope` field
|
|
20
|
+
* is normalized to it, so a mislabeled entry can't cross scopes).
|
|
21
|
+
*
|
|
22
|
+
* THE FILE-LEVEL STATE IS REPORTED SEPARATELY (`file`, cli#327), because three
|
|
23
|
+
* different situations used to collapse into "empty, carry on":
|
|
24
|
+
*
|
|
25
|
+
* - a MISSING file (ENOENT) is `absent` — the one state where starting from
|
|
26
|
+
* an empty set is right;
|
|
27
|
+
* - any OTHER read failure (EACCES, EISDIR, ...) is `unreadable` — something
|
|
28
|
+
* is there and we could not see it;
|
|
29
|
+
* - bytes that are not a memory document (not JSON, or the wrong shape) are
|
|
30
|
+
* `invalid` — something was there and it is damaged, or half-written.
|
|
31
|
+
*
|
|
32
|
+
* Readers (recall, `cruxy memory list`) still get `entries: []` plus an error
|
|
33
|
+
* row for the last two, so nothing that only READS changes behaviour. Writers
|
|
34
|
+
* must branch on `file.kind`: see {@link saveScope}'s contract and
|
|
35
|
+
* `MemoryService.remember`.
|
|
21
36
|
*/
|
|
22
37
|
export function loadScope(file, scope) {
|
|
23
38
|
let raw;
|
|
24
39
|
try {
|
|
25
40
|
raw = readFileSync(file, "utf8");
|
|
26
41
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === "ENOENT") {
|
|
44
|
+
// Missing → nothing recalled from this scope, and a write may create it.
|
|
45
|
+
return { entries: [], errors: [], file: { kind: "absent" } };
|
|
46
|
+
}
|
|
47
|
+
// Present but unreadable. NOT absent: a writer that treated it as absent
|
|
48
|
+
// would replace whatever is there.
|
|
49
|
+
const message = `unreadable: ${err.message}`;
|
|
50
|
+
return {
|
|
51
|
+
entries: [],
|
|
52
|
+
errors: [{ scope, reason: "unreadable", id: "(file)", message }],
|
|
53
|
+
file: { kind: "unreadable", message, underlying: err },
|
|
54
|
+
};
|
|
30
55
|
}
|
|
31
56
|
let parsedFile;
|
|
32
57
|
try {
|
|
33
58
|
parsedFile = JSON.parse(raw);
|
|
34
59
|
}
|
|
35
|
-
catch {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Read fine, but not JSON: a half-written file (a crash or another
|
|
62
|
+
// writer mid-`writeFileSync`) or a hand edit. Either way it HELD something.
|
|
63
|
+
const message = raw.length === 0
|
|
64
|
+
? "not valid JSON (the file is empty — a truncated or half-written store)"
|
|
65
|
+
: "not valid JSON (a half-written or hand-edited store)";
|
|
66
|
+
return invalidFile(scope, message, err);
|
|
42
67
|
}
|
|
43
68
|
const fileResult = MemoryFileSchema.safeParse(parsedFile);
|
|
44
69
|
if (!fileResult.success) {
|
|
45
|
-
return {
|
|
46
|
-
entries: [],
|
|
47
|
-
errors: [
|
|
48
|
-
{
|
|
49
|
-
scope,
|
|
50
|
-
reason: "invalid",
|
|
51
|
-
id: "(file)",
|
|
52
|
-
message: `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`,
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
};
|
|
70
|
+
return invalidFile(scope, `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`);
|
|
56
71
|
}
|
|
57
72
|
const entries = [];
|
|
58
73
|
const errors = [];
|
|
@@ -86,15 +101,46 @@ export function loadScope(file, scope) {
|
|
|
86
101
|
// move an entry across scopes).
|
|
87
102
|
entries.push({ ...parsed.data, scope });
|
|
88
103
|
});
|
|
89
|
-
return { entries, errors };
|
|
104
|
+
return { entries, errors, file: { kind: "loaded" } };
|
|
105
|
+
}
|
|
106
|
+
function invalidFile(scope, message, underlying) {
|
|
107
|
+
const file = { kind: "invalid", message, underlying };
|
|
108
|
+
return {
|
|
109
|
+
entries: [],
|
|
110
|
+
errors: [{ scope, reason: "invalid", id: "(file)", message }],
|
|
111
|
+
file,
|
|
112
|
+
};
|
|
90
113
|
}
|
|
91
114
|
/**
|
|
92
|
-
* Persist a scope's entries,
|
|
93
|
-
* needed. The user scope is written `0600` (it is personal, cross-project data)
|
|
94
|
-
*
|
|
115
|
+
* Persist a scope's entries, replacing the file. Creates the memory dir if
|
|
116
|
+
* needed. The user scope is written `0600` (it is personal, cross-project data).
|
|
117
|
+
* The project scope KEEPS THE MODE THE FILE ALREADY HAD, and takes the process
|
|
118
|
+
* umask only when creating it: a replace through rename is a new inode, and
|
|
119
|
+
* letting it land at whatever umask says would be a permission change the user
|
|
120
|
+
* never asked for, arriving through a write path — the same reason `api.env`'s
|
|
121
|
+
* mode has to survive a `sed`.
|
|
122
|
+
*
|
|
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
|
|
126
|
+
* fills in after, so anything reading in between — recall in a second cruxy,
|
|
127
|
+
* `cruxy memory list`, or another writer's load — saw an empty or half-written
|
|
128
|
+
* file. `rename(2)` swaps the whole document in one step: a reader sees the
|
|
129
|
+
* old bytes or the new ones, never a prefix. It does NOT serialize two writers
|
|
130
|
+
* (that is #304, the read-modify-write race); it only guarantees that what
|
|
131
|
+
* either of them reads is a complete document.
|
|
132
|
+
*
|
|
133
|
+
* CONTRACT FOR CALLERS: this overwrites whatever is at `file`. It must only be
|
|
134
|
+
* called with a set derived from a load whose `file.kind` was `loaded` or
|
|
135
|
+
* `absent` — never `unreadable` or `invalid`, where the caller's "existing"
|
|
136
|
+
* set is empty only because the load failed. `MemoryService` enforces that;
|
|
137
|
+
* `clear` is the deliberate exception (it is the documented recovery).
|
|
95
138
|
*/
|
|
96
139
|
export function saveScope(file, entries, scope) {
|
|
97
140
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
98
141
|
const body = JSON.stringify({ version: MEMORY_FILE_VERSION, entries }, null, 2);
|
|
99
|
-
|
|
142
|
+
// The mode the replacement must carry: explicit 0600 for the user scope;
|
|
143
|
+
// for the project scope, whatever the existing file has (absent → umask).
|
|
144
|
+
const mode = scope === "user" ? 0o600 : existingMode(file);
|
|
145
|
+
replaceStoreFile(file, body, mode);
|
|
100
146
|
}
|
package/dist/memory/trust.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
|
61
|
-
if (cache)
|
|
62
|
-
return cache;
|
|
62
|
+
const readFresh = () => {
|
|
63
63
|
try {
|
|
64
64
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
}
|
package/dist/usage/store.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync
|
|
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
|
-
|
|
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
|
+
}
|