@volter/world-core 3.0.154 → 3.0.156

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.
Files changed (107) hide show
  1. package/dist/generated/pack-facts.json +140 -0
  2. package/dist/inject.cjs +150 -86
  3. package/dist/src/actions.d.ts +15 -0
  4. package/dist/src/actions.js +82 -21
  5. package/dist/src/ancestry.d.ts +5 -4
  6. package/dist/src/ancestry.js +53 -28
  7. package/dist/src/changeset.js +1 -1
  8. package/dist/src/clickhouse/compression.d.ts +28 -0
  9. package/dist/src/clickhouse/compression.js +281 -0
  10. package/dist/src/clickhouse/index.d.ts +1 -0
  11. package/dist/src/clickhouse/index.js +1 -0
  12. package/dist/src/client-bundle.js +15 -15
  13. package/dist/src/derived-core.d.ts +12 -1
  14. package/dist/src/derived-core.js +145 -11
  15. package/dist/src/derived.js +3 -0
  16. package/dist/src/fork.js +1 -1
  17. package/dist/src/graphql-wire.d.ts +5 -0
  18. package/dist/src/graphql-wire.js +6 -6
  19. package/dist/src/grpc-wire.js +2 -1
  20. package/dist/src/h2.js +63 -10
  21. package/dist/src/head.d.ts +3 -0
  22. package/dist/src/head.js +9 -0
  23. package/dist/src/history.d.ts +1 -1
  24. package/dist/src/history.js +7 -7
  25. package/dist/src/index.d.ts +4 -1
  26. package/dist/src/index.js +5 -1
  27. package/dist/src/log.d.ts +23 -3
  28. package/dist/src/log.js +160 -37
  29. package/dist/src/managed-original-inputs.d.ts +101 -0
  30. package/dist/src/managed-original-inputs.js +125 -0
  31. package/dist/src/mongodb/client.d.ts +8 -0
  32. package/dist/src/mongodb/client.js +27 -0
  33. package/dist/src/mongodb/index.d.ts +2 -0
  34. package/dist/src/mongodb/index.js +5 -0
  35. package/dist/src/mongodb/stream.d.ts +74 -0
  36. package/dist/src/mongodb/stream.js +206 -0
  37. package/dist/src/observe.js +1 -1
  38. package/dist/src/pack-fetch.d.ts +2 -1
  39. package/dist/src/pack-fetch.js +21 -9
  40. package/dist/src/packRegistry.d.ts +10 -14
  41. package/dist/src/packRegistry.js +2 -13
  42. package/dist/src/protocol.d.ts +13 -0
  43. package/dist/src/protocol.js +13 -0
  44. package/dist/src/redis/engine.d.ts +10 -4
  45. package/dist/src/redis/engine.js +103 -32
  46. package/dist/src/redis/member-storage.js +20 -8
  47. package/dist/src/redis/service-times.d.ts +25 -0
  48. package/dist/src/redis/service-times.js +87 -0
  49. package/dist/src/redis/stream.d.ts +2 -1
  50. package/dist/src/redis/stream.js +109 -20
  51. package/dist/src/runtime-state.d.ts +11 -0
  52. package/dist/src/runtime-state.js +13 -0
  53. package/dist/src/runtime.d.ts +4 -2
  54. package/dist/src/runtime.js +5 -2
  55. package/dist/src/serve-http.d.ts +2 -0
  56. package/dist/src/serve-http.js +21 -4
  57. package/dist/src/serve.d.ts +10 -0
  58. package/dist/src/serve.js +43 -3
  59. package/dist/src/storage.d.ts +2 -1
  60. package/dist/src/storage.js +6 -3
  61. package/dist/src/world-boot.d.ts +1 -0
  62. package/dist/src/world-boot.js +6 -0
  63. package/dist/src/world-clock.js +6 -2
  64. package/dist/src/world-store.d.ts +108 -7
  65. package/dist/src/world-store.js +883 -103
  66. package/dist/src/xml.d.ts +30 -0
  67. package/dist/src/xml.js +74 -0
  68. package/generated/pack-facts.json +140 -0
  69. package/inject.cjs +150 -86
  70. package/package.json +20 -1
  71. package/src/actions.ts +78 -21
  72. package/src/ancestry.ts +46 -24
  73. package/src/changeset.ts +1 -1
  74. package/src/clickhouse/compression.ts +172 -0
  75. package/src/clickhouse/index.ts +2 -0
  76. package/src/client-bundle.ts +13 -14
  77. package/src/derived-core.ts +142 -11
  78. package/src/derived.ts +3 -0
  79. package/src/fork.ts +1 -1
  80. package/src/graphql-wire.ts +9 -6
  81. package/src/grpc-wire.ts +2 -1
  82. package/src/h2.ts +46 -5
  83. package/src/head.ts +9 -0
  84. package/src/history.ts +7 -7
  85. package/src/index.ts +6 -1
  86. package/src/log.ts +147 -32
  87. package/src/managed-original-inputs.ts +163 -0
  88. package/src/mongodb/client.ts +27 -0
  89. package/src/mongodb/index.ts +5 -0
  90. package/src/mongodb/stream.ts +213 -0
  91. package/src/observe.ts +1 -1
  92. package/src/pack-fetch.ts +23 -11
  93. package/src/packRegistry.ts +3 -15
  94. package/src/protocol.ts +13 -0
  95. package/src/redis/engine.ts +100 -57
  96. package/src/redis/member-storage.ts +17 -9
  97. package/src/redis/service-times.ts +81 -0
  98. package/src/redis/stream.ts +66 -14
  99. package/src/runtime-state.ts +14 -0
  100. package/src/runtime.ts +6 -1
  101. package/src/serve-http.ts +23 -4
  102. package/src/serve.ts +45 -3
  103. package/src/storage.ts +5 -3
  104. package/src/world-boot.ts +6 -0
  105. package/src/world-clock.ts +6 -2
  106. package/src/world-store.ts +704 -91
  107. package/src/xml.ts +71 -0
@@ -30,6 +30,8 @@ import {
30
30
  existsSync,
31
31
  fsyncSync,
32
32
  fstatSync,
33
+ linkSync,
34
+ ftruncateSync,
33
35
  lstatSync,
34
36
  mkdirSync,
35
37
  openSync,
@@ -42,8 +44,11 @@ import {
42
44
  statSync,
43
45
  unlinkSync,
44
46
  writeFileSync,
47
+ writeSync,
45
48
  } from 'node:fs';
49
+ import type { Dirent, Stats } from 'node:fs';
46
50
  import { hostname } from 'node:os';
51
+ import { randomUUID } from 'node:crypto';
47
52
  import { refuseReadOnlyWrite } from './request-scope.ts';
48
53
  import { basename, dirname, join, resolve } from 'node:path';
49
54
 
@@ -52,6 +57,18 @@ import { basename, dirname, join, resolve } from 'node:path';
52
57
  * drive the append-dedupe index's cache-validity check in storage.ts. */
53
58
  export type WorldStat = { size: number; mtimeMs: number; isDirectory: boolean; isSymbolicLink?: boolean };
54
59
 
60
+ /** Diagnostic context only; it does not change lock acquisition or stale-owner policy. */
61
+ export type WorldLockOptions = { retainLiveOwner?: boolean; operation?: string; caller?: string };
62
+
63
+ /** Capture at the outermost lock helper, then carry the same caller through nested helpers. */
64
+ export function storageLockScope(options: WorldLockOptions = {}): WorldLockOptions & { operation: string; caller: string } {
65
+ const frame = options.caller ? undefined : new Error().stack?.split('\n')[3];
66
+ const location = frame?.slice(frame.lastIndexOf('(') + 1).trim().replace(/^at\s+/, '');
67
+ const site = location?.match(/^(.*):(\d+):\d+\)?$/);
68
+ const caller = options.caller ?? (site ? `${site[1]!.replace(/^file:\/\//, '')}:${site[2]}` : 'unknown:0');
69
+ return { ...options, caller, operation: options.operation ?? caller };
70
+ }
71
+
55
72
  /**
56
73
  * The synchronous persistence seam. Every path is an ABSOLUTE key the caller already
57
74
  * computed (via `worldPaths`/`worldStateRoot`); the store treats it as an opaque key,
@@ -92,12 +109,31 @@ export interface WorldStore {
92
109
  readRange?(path: string, start: number): string | null;
93
110
  /** Resolve filesystem aliases for durable ownership; absent on opaque-key stores. */
94
111
  canonicalPath?(path: string): string;
112
+ /** `read`, for a small file every operation needs and few change (a branch's pointer, the World's clock): the text
113
+ * kept from the last read while the path's own facts stand, so an unchanged file costs one check and no read.
114
+ * Optional: a store without it is read. */
115
+ readKept?(path: string): string | null;
116
+ /** The facts of the generation kept beside a tree's files in `dirPath` (TREE_FILES): one string that differs after
117
+ * any write of one of them through a store, by this process or another; `null` where the directory has none yet,
118
+ * which says nothing. Optional: a store without it is asked for each file's own facts. */
119
+ generation?(dirPath: string): string | null;
120
+ /** The generation in `dirPath` now, when exactly one write of a tree's file through a store has moved it since
121
+ * `before` (a generation this store answered for it); `null` when it cannot say so. A writer that read `before` just
122
+ * ahead of its own write, holding the directory's history lock, learns that its write was the only one (log.ts
123
+ * appendToBranchLog). Optional, beside `generation`. */
124
+ generationAfterOwnWrite?(dirPath: string, before: string): string | null;
95
125
  /** Run `fn` holding an exclusive lock on `lockPath`, releasing it afterwards. The fs
96
126
  * backend uses the historical cross-process reclaimable file lock; an in-memory,
97
127
  * single-instance backend is a synchronous pass-through. */
98
- withLock<T>(lockPath: string, fn: () => T, options?: { retainLiveOwner?: boolean }): T;
128
+ withLock<T>(lockPath: string, fn: () => T, options?: WorldLockOptions): T;
99
129
  }
100
130
 
131
+ /** The files a service's tree is folded from, by name in its state directory (log.ts: the parent log, the branch log,
132
+ * the branch's pointer), and the generation a store keeps beside them. */
133
+ export const TREE_FILES = { parent: 'events.jsonl', branch: 'actions.jsonl', pointer: 'branch.json' } as const;
134
+ export const TREE_GENERATION = 'tree.generation';
135
+ const TREE_FILE_NAMES: ReadonlySet<string> = new Set(Object.values(TREE_FILES));
136
+
101
137
  // ── FsWorldStore — the DEFAULT, byte-identical to the historical node:fs behavior ────
102
138
 
103
139
  let fsAtomicSequence = 0;
@@ -105,17 +141,359 @@ let fsAtomicSequence = 0;
105
141
  /** How long a lock may live before an acquirer treats it as abandoned. Above the 10s
106
142
  * wait timeout so a live-but-slow holder is never reclaimed out from under itself. */
107
143
  const LOCK_STALE_MS = 60_000;
144
+ /**
145
+ * How long an EMPTY lock file is a taker's, between its exclusive create and its holder's record (S2 R13). A taker
146
+ * writes the record in the call after the create (in a tab's file step, in the same request), and one whose record is
147
+ * not written gives the lock up and takes it again (withLock), so no live holder holds a lock with an empty record past
148
+ * that moment. An empty file older than this is a taker's that ended between the two calls (a process killed there,
149
+ * or a state directory copied while one was there), and it is no one's: before, it was waited on until its file was
150
+ * 60 s old, and a lock taken to keep its live owner (the ancestry coordinator) waited on it for ever. Twenty e946aa4c
151
+ * dry-run1: a 0-byte branch-ancestry.lock, the Redis twin's every write waiting 10 s on it inside its history lock,
152
+ * and Twenty's Redis client's 5 s connect timing out (FindAllViews 500, the walk's step 7).
153
+ */
154
+ // Five seconds: in a tab without the kernel's file step the create and the record are two requests of the kernel,
155
+ // and Twenty's Redis-twin handoffs have exceeded 1 s; and under the 10 s a
156
+ // waiter waits before it gives up, so a waiter reclaims such a file rather than timing out on it.
157
+ const EMPTY_RECORD_GRACE_MS = 5_000;
158
+ /** How many takes whose record could not be written are made before the failure is the caller's. */
159
+ const RECORD_ATTEMPTS = 3;
160
+
161
+ type LockHolder = {
162
+ pid: number; hostname: string; at: string; token?: string;
163
+ operation?: string; caller?: string;
164
+ waitingFor?: { path: string; operation: string; since: number };
165
+ };
166
+
167
+ /** The locks this process holds now, by path, and the token each was recorded with (withLock). */
168
+ const ownLocks = new Map<string, string>();
108
169
 
109
- type LockHolder = { pid: number; hostname: string; at: string };
170
+ type LockReading = {
171
+ /** The take's descriptor; null where a kernel's file step took it (fileSteps) and kept none. */
172
+ path: string; fd: number | null; holder: LockHolder;
173
+ /** The bytes of the record the descriptor last wrote: a shorter one is padded to it (recordLockReading). */
174
+ recordBytes?: number;
175
+ nestedWaits: Map<string, { path: string; operation: string; waitedMs: number }>;
176
+ };
177
+ const lockReadings: LockReading[] = [];
178
+
179
+ /**
180
+ * A KERNEL'S FILE STEPS (S2 R17d), where this process runs in a tab: browser-substrate's KernelVirtualFS offers them
181
+ * on the run's tree (`process[Symbol.for('tabnode.run.vfs')]`, under `Symbol.for('browser-substrate.kernel-file-steps')`),
182
+ * each one request of the kernel's worker that makes, by its own calls in the same order, what this store made as
183
+ * requests of their own: a lock's take (an exclusive create, its holder's record, a close), its release (the record
184
+ * read, its token compared, the unlink: SYS_unlink's, with the parent's permission and sticky-bit checks the path
185
+ * unlink it replaces did not make) and a tree file's append between its generation's two bytes. A write of a
186
+ * twin took three locks and an append, about thirty requests; these make it seven. Each step answers undefined where
187
+ * it is not taken (a path the tab keeps outside the kernel, parts too large for one request), and this store then
188
+ * makes its own calls. Absent on a host's filesystem, which is asked as before.
189
+ */
190
+ type KernelFileSteps = {
191
+ createExclusive(path: string, data: string, mode: number): boolean | undefined;
192
+ changeHolding(path: string, needle: string, replacement: string | undefined): { changed: true } | { changed: false; observed: string | null } | undefined;
193
+ appendBetween(markerPath: string, marker: string, path: string, data: string): boolean;
194
+ };
195
+ function fileSteps(): KernelFileSteps | undefined {
196
+ const tree = (process as unknown as Record<symbol, unknown>)[Symbol.for('tabnode.run.vfs')] as Record<symbol, unknown> | null | undefined;
197
+ const offered = tree?.[Symbol.for('browser-substrate.kernel-file-steps')];
198
+ return offered && typeof offered === 'object' ? offered as KernelFileSteps : undefined;
199
+ }
200
+ /** The bytes a holder's record holds for its token, as JSON.stringify writes them: a key and a string, no space. */
201
+ const tokenNeedle = (token: string): string => `"token":${JSON.stringify(token)}`;
202
+
203
+ /** A two-call take whose path did not hold its own record after it: another reclaimed it between the calls. */
204
+ const RECLAIMED_BEFORE_RECORD = 'VOLTER_LOCK_RECLAIMED';
205
+ /** A take whose record could not be written whole: nothing of it is held. */
206
+ const RECORD_NOT_WRITTEN = 'VOLTER_LOCK_UNRECORDED';
207
+ /** Where link(2) is not an exclusive create the store can use: the take falls back to two calls. */
208
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV', 'EMLINK', 'EINVAL']);
209
+
210
+ /**
211
+ * A FILE MADE EXCLUSIVELY WITH ITS RECORD ALREADY IN IT (S2 R13): a lock, or a lock's reclaim record, is never seen
212
+ * empty while its taker lives, so an empty one is always a dead taker's and is reclaimed (lockIsStale, recoveryIsStale).
213
+ * - In a tab: the kernel's file step makes it and writes the record in one request (fileSteps, S2 R17d).
214
+ * - On a host: the record is written whole to a private file of the same directory, which link(2) puts at the path,
215
+ * an exclusive create that fails EEXIST; the private name is then removed. The lock is opened for its descriptor.
216
+ * Never in a tab: tabnode's link copies over the path (no EEXIST), so it is no exclusive create there.
217
+ * - Neither (a tab whose kernel offers no step for the path, a host filesystem without link): two calls, the
218
+ * exclusive create and the record, and the path read back: held only if it holds this take's token. A record that
219
+ * is not written leaves the file removed only where it is still empty or this take's (read before it is removed).
220
+ * EEXIST is thrown as the create's; RECLAIMED_BEFORE_RECORD and RECORD_NOT_WRITTEN say a take not held.
221
+ */
222
+ function createRecorded(path: string, text: string, token: string, operation: string | undefined): { fd: number | null; bytes: number } {
223
+ const bytes = Buffer.byteLength(text);
224
+ const steps = fileSteps();
225
+ const stepped = steps?.createExclusive(path, text, 0o666);
226
+ if (stepped === true) return { fd: null, bytes };
227
+ if (stepped === false) {
228
+ // made in that one request and its record short. Removed only while the path still holds that short record (a
229
+ // prefix of this take's own text) or nothing: a stall past the grace may have let another reclaim it and take it
230
+ lockRecordLine('publish-failed', path, { operation });
231
+ let held: string | undefined;
232
+ try { held = readFileSync(path, 'utf8'); } catch { held = undefined; }
233
+ if (held !== undefined && (held === '' || text.startsWith(held))) { try { unlinkSync(path); } catch { /* gone */ } }
234
+ throw Object.assign(new Error(`${path}: its record was not written whole`), { code: RECORD_NOT_WRITTEN });
235
+ }
236
+ const inTab = (process as unknown as Record<symbol, unknown>)[Symbol.for('tabnode.run.vfs')] !== undefined;
237
+ if (!inTab) {
238
+ const temporary = `${path}.take.${process.pid}.${randomUUID()}`;
239
+ let linked = false;
240
+ try {
241
+ writeFileSync(temporary, text, { flag: 'wx' });
242
+ try { linkSync(temporary, path); linked = true; }
243
+ catch (error) {
244
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') sweepTakeFiles(path);
245
+ if (!LINK_UNSUPPORTED.has((error as NodeJS.ErrnoException).code ?? '')) throw error;
246
+ }
247
+ } finally { try { unlinkSync(temporary); } catch { /* not made, or gone */ } }
248
+ if (linked) {
249
+ let fd: number | null = null;
250
+ try { fd = openSync(path, 'r+'); } catch { fd = null; /* held; its record is then not rewritten */ }
251
+ return { fd, bytes };
252
+ }
253
+ }
254
+ const fd = openSync(path, 'wx');
255
+ try { writeFileSync(fd, text); }
256
+ catch (error) {
257
+ lockRecordLine('publish-failed', path, { fd, operation }, error);
258
+ try { closeSync(fd); } catch { /* not held */ }
259
+ let held = '';
260
+ try { held = readFileSync(path, 'utf8'); } catch { held = 'gone'; }
261
+ if (held === '' || held.includes(tokenNeedle(token))) { try { unlinkSync(path); } catch { /* gone */ } }
262
+ throw Object.assign(new Error(`${path}: its record could not be written`), { code: RECORD_NOT_WRITTEN, cause: error });
263
+ }
264
+ if (readLockHolder(path)?.token !== token) {
265
+ lockRecordLine('take-reclaimed-before-record', path, { fd, operation });
266
+ try { closeSync(fd); } catch { /* not held */ }
267
+ throw Object.assign(new Error(`${path}: reclaimed before its record`), { code: RECLAIMED_BEFORE_RECORD });
268
+ }
269
+ return { fd, bytes };
270
+ }
110
271
 
111
- function readLockHolder(lockPath: string): LockHolder | null {
272
+ /**
273
+ * A take's private file a process left at a crash between its link and its removal (`<lock>.take.<pid>.<uuid>`):
274
+ * removed where older than the grace, once a process per lock, when its take meets the lock held. It holds nothing.
275
+ */
276
+ const sweptTakes = new Set<string>();
277
+ function sweepTakeFiles(path: string): void {
278
+ if (sweptTakes.has(path)) return;
279
+ sweptTakes.add(path);
280
+ const prefix = `${basename(path)}.take.`;
112
281
  try {
113
- return JSON.parse(readFileSync(lockPath, 'utf8')) as LockHolder;
114
- } catch {
282
+ for (const name of readdirSync(dirname(path))) {
283
+ if (!name.startsWith(prefix)) continue;
284
+ const at = join(dirname(path), name);
285
+ try { if (Date.now() - statSync(at).mtimeMs > EMPTY_RECORD_GRACE_MS) unlinkSync(at); } catch { /* gone, or another's to remove */ }
286
+ }
287
+ } catch { /* an instrument of tidiness: nothing waits on it */ }
288
+ }
289
+
290
+ /** A file createRecorded made, let go: its descriptor closed, and the path removed only while it holds the token. */
291
+ function releaseRecorded(path: string, token: string, fd: number | null): void {
292
+ if (fd !== null) { try { closeSync(fd); } catch { /* released below */ } }
293
+ try {
294
+ const released = fd === null ? fileSteps()?.changeHolding(path, tokenNeedle(token), undefined) : undefined;
295
+ if (released?.changed) return;
296
+ if (released === undefined && readLockHolder(path)?.token === token) unlinkSync(path);
297
+ } catch (error) { lockRecordLine('release-failed', path, {}, error); }
298
+ }
299
+
300
+ /**
301
+ * A leftover reclaim record (`.recovery`) no reclaimer is behind (S2 R13 review): empty, or recorded by a process of
302
+ * this host that is not alive, and older than EMPTY_RECORD_GRACE_MS. It held every waiter of its lock until each
303
+ * timed out, as an empty lock file did.
304
+ */
305
+ function recoveryIsStale(recoveryPath: string): boolean {
306
+ let file: Stats;
307
+ try { file = statSync(recoveryPath); } catch { return false; }
308
+ if (Date.now() - file.mtimeMs <= EMPTY_RECORD_GRACE_MS) return false;
309
+ if (file.size === 0) return true;
310
+ const holder = readLockHolder(recoveryPath);
311
+ return holder !== null && holder.hostname === ownHostname() && Number.isInteger(holder.pid) && !pidAlive(holder.pid);
312
+ }
313
+
314
+ /**
315
+ * A WORLD'S CARRIED LOCKS, REMOVED AT ITS OPEN (S2 K4). A World booted over its kept state (`up --keep-state`, a
316
+ * tab's restored archive) carries whatever lock files its last life left in its services' state directories. One whose
317
+ * holder record cannot be read is no holder's once its taker has gone, and a restore gives it the restore's file time,
318
+ * so the empty-record grace and the 60 s age ran from the restore: every waiter waited them, or gave up at 10 s (Twenty
319
+ * e946aa4c run 2: anthropic's projection.lock, "holder record unreadable", 10,086 ms, then its credentials 500 and the
320
+ * server 10 s late). A carried `.recovery` that is not empty and does not parse was never ended by R13's rule at all.
321
+ *
322
+ * A file is carried only when all of these hold: it is a world-core lock or its reclaim record (`*.lock`,
323
+ * `*.lock.recovery`) in a service's state directory (`<root>/<service>/<stateDir>/world`, never a yarn.lock or any
324
+ * project file); its text is empty or a holder record's beginning (`{"pid"`) that does not parse; and its file time is
325
+ * before `before`, the start of this boot's claim. A restore sets the time before the claim; a taker that is live now
326
+ * (a CLI's changeset rebase, a branch World keeping this one as its parent's storage, a two-call taker between its
327
+ * create and its record) made its file after it. Each removal is still a reclaimer's: a lock's under its `.recovery`,
328
+ * taken with its record (createRecorded), a held `.recovery` skipped; the file read again, renamed aside, the aside copy
329
+ * read again and linked back where it now holds a record (EEXIST there is the path taken since, left to its holder).
330
+ * A record that parses is kept, under its rules (a dead holder's, our own pid's, the 60 s age, which a restore resets:
331
+ * not this rule's). Answers the paths removed.
332
+ */
333
+ export function removeCarriedLocks(roots: readonly string[], options: { stateDir: string; before: number }): string[] {
334
+ const removed: string[] = [];
335
+ const isHolder = (text: string): boolean => Number.isInteger(lockHolderOf('', text)?.pid);
336
+ const carriedText = (text: string): boolean => {
337
+ const trimmed = text.trim();
338
+ return trimmed === '' || (trimmed.startsWith('{"pid"') && !isHolder(trimmed));
339
+ };
340
+ /** The file's text where it is carried now (small, older than the claim, unreadable as a record); else undefined. */
341
+ const carriedNow = (path: string): string | undefined => {
342
+ try {
343
+ const file = statSync(path);
344
+ if (file.size > 1024 || !(file.mtimeMs < options.before)) return undefined;
345
+ const text = readFileSync(path, 'utf8');
346
+ return carriedText(text) ? text : undefined;
347
+ } catch { return undefined; }
348
+ };
349
+ /** Renamed aside, read again, put back where a taker recorded itself meanwhile; true when it is gone. */
350
+ const setAside = (path: string, text: string): boolean => {
351
+ const aside = `${path}.carried.${process.pid}.${randomUUID()}`;
352
+ try { renameSync(path, aside); }
353
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') lockRecordLine('carried-rename-failed', path, {}, error); return false; }
354
+ let after: string | undefined;
355
+ try { after = readFileSync(aside, 'utf8'); } catch { after = undefined; }
356
+ if (after !== undefined && isHolder(after)) {
357
+ try { linkSync(aside, path); lockRecordLine('carried-restored', path, {}); }
358
+ catch (error) { lockRecordLine((error as NodeJS.ErrnoException).code === 'EEXIST' ? 'carried-retaken' : 'carried-restore-failed', path, {}, error); }
359
+ try { unlinkSync(aside); } catch (error) { lockRecordLine('carried-unlink-failed', aside, {}, error); }
360
+ return false;
361
+ }
362
+ storageLockLine(`[world] carried storage lock renamed aside at open path=${path} bytes=${Buffer.byteLength(text)}`);
363
+ try { unlinkSync(aside); } catch (error) { lockRecordLine('carried-unlink-failed', aside, {}, error); }
364
+ return true;
365
+ };
366
+ const removeLock = (path: string): void => {
367
+ const recoveryPath = `${path}.recovery`, recoveryToken = randomUUID();
368
+ let recovery: { fd: number | null };
369
+ try { recovery = createRecorded(recoveryPath, `${JSON.stringify({ pid: process.pid, hostname: ownHostname(), at: new Date().toISOString(), token: recoveryToken })}\n`, recoveryToken, 'carried lock removal'); }
370
+ catch { return; } // a reclaimer is at work, or the recovery is carried too and goes first (visit)
371
+ try {
372
+ const text = carriedNow(path);
373
+ if (text !== undefined && setAside(path, text)) removed.push(path);
374
+ } finally { releaseRecorded(recoveryPath, recoveryToken, recovery.fd); }
375
+ };
376
+ const visit = (directory: string, depth: number): void => {
377
+ let entries: Dirent[];
378
+ try { entries = readdirSync(directory, { withFileTypes: true }); } catch { return; }
379
+ // A directory's carried reclaim records go first: a lock's removal is made under its `.recovery`.
380
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
381
+ for (const name of files.filter((name) => name.endsWith('.lock.recovery'))) {
382
+ const path = join(directory, name), text = carriedNow(path);
383
+ if (text !== undefined && setAside(path, text)) removed.push(path);
384
+ }
385
+ for (const name of files.filter((name) => name.endsWith('.lock'))) {
386
+ const path = join(directory, name);
387
+ if (carriedNow(path) !== undefined) removeLock(path);
388
+ }
389
+ for (const entry of entries) {
390
+ if (entry.isDirectory() && depth < 8 && entry.name !== 'node_modules' && entry.name !== '.git') visit(join(directory, entry.name), depth + 1);
391
+ }
392
+ };
393
+ // Only the services' state directories (`<root>/<service>/<stateDir>/world`, and `<root>/<stateDir>/world`): never a
394
+ // service's own data beside them (a PostgreSQL cluster, a project tree).
395
+ for (const root of roots) {
396
+ const states = [join(root, options.stateDir, 'world')];
397
+ try { for (const entry of readdirSync(root, { withFileTypes: true })) if (entry.isDirectory()) states.push(join(root, entry.name, options.stateDir, 'world')); }
398
+ catch { continue; }
399
+ for (const state of states) if (existsSync(state)) visit(state, 0);
400
+ }
401
+ return removed;
402
+ }
403
+
404
+ /** This host's name. In a tab (where the kernel's file steps are offered) it is read once per process: every take
405
+ * records it, and each read was a request of the kernel (its UTS); a tab's process renamed while it runs keeps
406
+ * recording, and comparing, the name it started with. On a host it is read live, as before: there a rename
407
+ * (name.local -> name-2.local on macOS) must reach lockIsStale's comparison, or a dead holder recorded under the new
408
+ * name is never reclaimed, and the read costs no request. */
409
+ let hostnameRead: string | undefined;
410
+ const ownHostname = (): string => (fileSteps() ? (hostnameRead ??= hostname()) : hostname());
411
+
412
+ function recordLockReading(reading: LockReading): void {
413
+ try {
414
+ const text = `${JSON.stringify(reading.holder)}\n`;
415
+ // a take with no descriptor rewrites its record by path, and only while the file still holds its token: a file a
416
+ // stale sweep reclaimed and another made is never written over (the descriptor wrote the reclaimed file's own)
417
+ if (reading.fd === null) { fileSteps()?.changeHolding(reading.path, tokenNeedle(reading.holder.token!), text); return; }
418
+ // Padded with spaces to the record it replaces, which JSON allows after a value: a write that ends early leaves the
419
+ // new record's text over the old one's, never a shorter file with the old one's tail (malformed, and a malformed
420
+ // record keeps the 60 s rule, which a coordinator's live-owner lock never reaches).
421
+ const bytes = Buffer.byteLength(text), padded = reading.recordBytes !== undefined && bytes < reading.recordBytes
422
+ ? `${text.slice(0, -1)}${' '.repeat(reading.recordBytes - bytes)}\n` : text;
423
+ writeSync(reading.fd, padded, 0, 'utf8');
424
+ ftruncateSync(reading.fd, Buffer.byteLength(padded));
425
+ reading.recordBytes = Buffer.byteLength(padded);
426
+ } catch (error) {
427
+ lockRecordLine('rewrite-failed', reading.path, { fd: reading.fd, operation: reading.holder.operation }, error);
428
+ }
429
+ }
430
+
431
+ function storageLockLine(line: string): void {
432
+ try { console.warn(`${line.replace(/[\r\n]/g, ' ')} at=${Date.now()}`); } catch { /* an instrument */ }
433
+ }
434
+
435
+ /** Failure-only ownership evidence, on the same channel as the existing hold and reclaim warnings. */
436
+ function lockRecordLine(stage: string, path: string, fields: Record<string, unknown>, error?: unknown): void {
437
+ try {
438
+ const cause = error as { name?: unknown; code?: unknown } | null | undefined;
439
+ console.warn('[world-storage-lock]', JSON.stringify({
440
+ at: Date.now(), stage, path, pid: process.pid, hostname: ownHostname(), ...fields,
441
+ ...(typeof cause?.name === 'string' ? { error: cause.name } : {}),
442
+ ...(typeof cause?.code === 'string' ? { code: cause.code } : {}),
443
+ }));
444
+ } catch { /* an instrument must not change the lock's result */ }
445
+ }
446
+
447
+ /** Name a bookkeeping phase within an existing acquisition; never acquire another lock. */
448
+ export function withStorageLockOperation<T>(path: string, operation: string, fn: () => T): T {
449
+ let reading: LockReading | undefined;
450
+ for (let i = lockReadings.length - 1; i >= 0; i -= 1) {
451
+ if (lockReadings[i].path === path) { reading = lockReadings[i]; break; }
452
+ }
453
+ if (!reading) return fn();
454
+ const previous = reading.holder.operation;
455
+ reading.holder.operation = operation;
456
+ recordLockReading(reading);
457
+ try { return fn(); }
458
+ finally { reading.holder.operation = previous; recordLockReading(reading); }
459
+ }
460
+
461
+ /** A path that is not there: no such entry, or a part of it that is not a directory. */
462
+ const absent = (error: unknown): boolean => { const code = (error as NodeJS.ErrnoException | null)?.code; return code === 'ENOENT' || code === 'ENOTDIR'; };
463
+
464
+ function readLockHolder(lockPath: string, diagnostic?: 'timeout' | 'release'): LockHolder | null {
465
+ let text: string;
466
+ try {
467
+ text = readFileSync(lockPath, 'utf8');
468
+ } catch (error) {
469
+ if (diagnostic) lockRecordLine('read-failed', lockPath, { during: diagnostic }, error);
470
+ return null;
471
+ }
472
+ return lockHolderOf(lockPath, text, diagnostic);
473
+ }
474
+
475
+ /** A record's text as its holder (readLockHolder's parse); `null` text is no file, read as readLockHolder reads one. */
476
+ function lockHolderOf(lockPath: string, text: string | null, diagnostic?: 'timeout' | 'release'): LockHolder | null {
477
+ if (text === null) {
478
+ if (diagnostic) lockRecordLine('read-failed', lockPath, { during: diagnostic, code: 'ENOENT' });
479
+ return null;
480
+ }
481
+ try {
482
+ return JSON.parse(text) as LockHolder;
483
+ } catch (error) {
484
+ if (diagnostic) lockRecordLine('parse-failed', lockPath, { during: diagnostic, bytes: Buffer.byteLength(text) }, error);
115
485
  return null; // missing, empty (mid-write), or malformed
116
486
  }
117
487
  }
118
488
 
489
+ /** The same ownership account reaches a timeout's client as the storage trace. */
490
+ function lockHolderMessage(holder: LockHolder | null): string {
491
+ if (!holder) return ' (holder record unreadable)';
492
+ const since = typeof holder.at === 'string' ? Date.parse(holder.at) : NaN;
493
+ const wait = holder.waitingFor;
494
+ return ` (held by pid ${holder.pid} on ${holder.hostname}${Number.isFinite(since) ? ` for ${Date.now() - since}ms` : ''}; token=${holder.token ?? '?'} operation=${holder.operation ?? 'unknown'} caller=${holder.caller ?? 'unknown'}${wait ? ` waitingFor=${wait.path} waitingOperation=${wait.operation} waitingSince=${wait.since}` : ''})`;
495
+ }
496
+
119
497
  function pidAlive(pid: number): boolean {
120
498
  try {
121
499
  process.kill(pid, 0);
@@ -126,16 +504,28 @@ function pidAlive(pid: number): boolean {
126
504
  }
127
505
  }
128
506
 
129
- /** A lock is stale when its holder is provably gone (same host + dead pid) or it has
130
- * outlived LOCK_STALE_MS. The age falls back to the lockfile's own mtime when the
131
- * `at` record is unreadable — so a writer that crashed between creating the lock and
132
- * recording itself is still eventually reclaimed, while a freshly-created (recent
133
- * mtime) empty lock is left alone, avoiding a race with the live writer. */
507
+ /** A lock is stale when its holder is provably gone (same host + dead pid), when its file is empty past
508
+ * EMPTY_RECORD_GRACE_MS (a taker that ended before recording itself; a live holder never holds an empty record), or
509
+ * when it has outlived LOCK_STALE_MS. The age falls back to the lockfile's own mtime when the `at` record is
510
+ * unreadable, while a freshly-created (recent mtime) empty lock is left alone, avoiding a race with the live writer. */
134
511
  function lockIsStale(lockPath: string, retainLiveOwner = false): boolean {
135
512
  const holder = readLockHolder(lockPath);
136
- if (holder && holder.hostname === hostname() && Number.isInteger(holder.pid) && !pidAlive(holder.pid)) {
513
+ if (holder && holder.hostname === ownHostname() && Number.isInteger(holder.pid) && !pidAlive(holder.pid)) {
137
514
  return true;
138
515
  }
516
+ // A lock recorded under THIS process that this process does not hold now is no holder's: a release that could not
517
+ // remove its file, or an earlier process numbered as this one (a tab numbers its processes the same at each boot,
518
+ // and a World's state outlives them). Waiting on it was waiting on ourselves: Twenty 46's Redis twin, pid 10013,
519
+ // timed out on `actions.jsonl.lock` "held by pid 10013" and exited.
520
+ if (holder && holder.hostname === ownHostname() && holder.pid === process.pid && ownLocks.get(lockPath) !== holder.token) {
521
+ return true;
522
+ }
523
+ // An empty record past its grace names no holder, alive or not (EMPTY_RECORD_GRACE_MS): asked before the live
524
+ // owner's rule, which keeps a lock whose record says who holds it, and an empty one says no one.
525
+ if (!holder) {
526
+ try { const file = statSync(lockPath); if (file.size === 0 && Date.now() - file.mtimeMs > EMPTY_RECORD_GRACE_MS) return true; }
527
+ catch { return false; }
528
+ }
139
529
  if (retainLiveOwner) return false;
140
530
  const recordedAt = holder && typeof holder.at === 'string' ? Date.parse(holder.at) : NaN;
141
531
  let stamp = recordedAt;
@@ -160,14 +550,101 @@ function sleepSync(ms: number): void {
160
550
  * file lock — so the existing twins and the conformance suite are unaffected.
161
551
  */
162
552
  export class FsWorldStore implements WorldStore {
553
+ // ONE CALL OF THE FILE SYSTEM FOR ONE QUESTION. Each of these asked whether the path exists and then asked again
554
+ // for what it wanted; `stat` asked three times. A process on a host pays microseconds for that. A twin in a tab
555
+ // pays a kernel exchange for each, and a tree's stamp is a handful of these on every operation: Twenty's Redis twin
556
+ // spent 54 s of 136 in `existsSync` and `statSync` under `treeStamp`, and its server's requests waited on it.
163
557
  read(path: string): string | null {
164
- if (!existsSync(path)) return null;
165
- return readFileSync(path, 'utf8');
558
+ try { return readFileSync(path, 'utf8'); }
559
+ catch (error) { if (absent(error)) return null; throw error; }
560
+ }
561
+
562
+ /**
563
+ * A small file's text, kept while its own facts stand. THE GUARANTEE: the key is the path's own lstat (device,
564
+ * inode, size, modification and change times); this process's own writes through the store drop what is kept at
565
+ * once; another process's write is seen when any of those facts moves. That is the guarantee a tree's stamp
566
+ * already rests on for its logs (their size and modification time), with the inode and change time added: a
567
+ * replaced file (writeAtomic) is another inode, a file written in place has another time. What it cannot see is a
568
+ * rewrite to the same size on the same or a reused inode within one tick of the file system's clock, with no check
569
+ * between; the logs' stamp has the same limit. A link is read through, and not kept.
570
+ * A text read after a write that came between the lstat and the read is kept under the OLDER key, so the next
571
+ * check finds the facts moved and reads again: what is kept is never newer than its key, and never served past it.
572
+ */
573
+ private readonly kept = new Map<string, { key: string; text: string }>();
574
+ readKept(path: string): string | null {
575
+ // A missing file is asked for without an error being made of it: a World with no parent has no branch.json, and
576
+ // every stamp asks.
577
+ let own: Stats | undefined;
578
+ try { own = lstatSync(path, { throwIfNoEntry: false }); }
579
+ catch (error) { if (!absent(error)) throw error; }
580
+ if (!own) { this.kept.delete(path); return null; }
581
+ if (own.isSymbolicLink()) return this.read(path);
582
+ const key = `${own.dev}:${own.ino}:${own.size}:${own.mtimeMs}:${own.ctimeMs}`;
583
+ const held = this.kept.get(path);
584
+ if (held && held.key === key) return held.text;
585
+ const text = this.read(path);
586
+ if (text === null) { this.kept.delete(path); return null; }
587
+ this.kept.set(path, { key, text });
588
+ return text;
589
+ }
590
+
591
+ /**
592
+ * A TREE'S GENERATION: one file beside a tree's three files, a byte longer after every write of any of them, so a
593
+ * reader that has the tree's stamp asks ONE question to know it still stands (log.ts treeFacts), where it asked
594
+ * three, two of them of files a World with no parent does not have.
595
+ *
596
+ * WHAT MAKES IT SOUND. A writer appends a byte BEFORE its write and another AFTER it; a reader takes the
597
+ * generation's facts BEFORE it reads the files' own. The file only grows, so its size never repeats on one inode,
598
+ * and a replaced or removed file is another inode or none. So for any write W and any reader that kept a stamp S
599
+ * under generation G: if W's first byte is after G was read, the kept G is behind and S is not served; if it is
600
+ * before, then either W itself was before S's own reads (S holds it), or W's second byte is after G was read, and
601
+ * again G is behind. An append is one call the system orders among all writers: each byte is one append of its
602
+ * own, whole or not at all, on a POSIX filesystem and in a tab's one kernel (browser-substrate
603
+ * packages/node/src/kernel-virtual-filesystem.ts appendFileSync, an O_APPEND open and writeAll, which sends a write
604
+ * of at most DATA_BYTES as one SYS_write request the kernel serves whole; or, by a file step, its table's own
605
+ * O_APPEND open and write of the one byte, filesystem.ts appendFileBytes). The proof rests on that; log.ts
606
+ * appendToBranchLog's (`generationAfterOwnWrite`: one write is exactly two bytes) as well.
607
+ *
608
+ * WHERE THAT STOPS. (1) A writer that dies between its write and its second byte, with a reader's whole check
609
+ * between its first byte and its write: that reader keeps the older stamp until the next write of the tree. (2) A
610
+ * writer of these files that does not come through a store of this version (an older package, a copy made over a
611
+ * live directory by another program): the generation does not move. A directory made or removed whole has no
612
+ * generation or another's, which serves nothing kept. (3) A stamp that names files outside its own directory (a
613
+ * World with a parent: its view and its ancestors' logs) is never kept under a generation (log.ts).
614
+ */
615
+ generation(dirPath: string): string | null {
616
+ let own: Stats | undefined;
617
+ try { own = lstatSync(join(dirPath, TREE_GENERATION), { throwIfNoEntry: false }); }
618
+ catch (error) { if (!absent(error)) throw error; }
619
+ if (!own || !own.isFile()) return null;
620
+ return `${own.dev}:${own.ino}:${own.size}:${own.mtimeMs}:${own.ctimeMs}`;
621
+ }
622
+
623
+ /** One write is `moved` twice, a byte each: the same file, those two bytes longer. */
624
+ generationAfterOwnWrite(dirPath: string, before: string): string | null {
625
+ const now = this.generation(dirPath);
626
+ if (now === null) return null;
627
+ const [dev, ino, size] = before.split(':'), [nowDev, nowIno, nowSize] = now.split(':');
628
+ return nowDev === dev && nowIno === ino && Number(nowSize) === Number(size) + 2 ? now : null;
629
+ }
630
+
631
+ /** One more byte on the generation beside `path`, where `path` is one of a tree's files. A directory that is not
632
+ * there has no reader to tell: the write that makes it is followed by the byte that makes its generation. */
633
+ private moved(path: string): void {
634
+ if (!TREE_FILE_NAMES.has(basename(path))) return;
635
+ try { appendFileSync(join(dirname(path), TREE_GENERATION), '.'); }
636
+ catch (error) { if (!absent(error)) throw error; }
637
+ }
638
+
639
+ /** A write of `path`, with the generation beside it moved before and after where it is one of a tree's files. */
640
+ private writing(path: string, write: () => void): void {
641
+ this.moved(path);
642
+ try { write(); } finally { this.moved(path); }
166
643
  }
167
644
 
168
645
  readLines(path: string): string[] {
169
- if (!existsSync(path)) return [];
170
- return readFileSync(path, 'utf8').split('\n');
646
+ try { return readFileSync(path, 'utf8').split('\n'); }
647
+ catch (error) { if (absent(error)) return []; throw error; }
171
648
  }
172
649
 
173
650
  readRange(path: string, start: number): string | null {
@@ -185,40 +662,53 @@ export class FsWorldStore implements WorldStore {
185
662
 
186
663
  append(path: string, data: string): void {
187
664
  refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
665
+ this.kept.delete(path);
666
+ // A tree's file in a tab: the generation's byte, the append and the byte again as one request of the kernel
667
+ // (fileSteps), by its own opens, writes and closes in this order. Not where every append is to be fsynced.
668
+ if (TREE_FILE_NAMES.has(basename(path)) && process.env.VOLTER_DURABLE !== '1'
669
+ && fileSteps()?.appendBetween(join(dirname(path), TREE_GENERATION), '.', path, data)) return;
188
670
  // Historical appendDurable: a completed append syscall survives a process crash;
189
671
  // fsync additionally survives kernel-panic/power-loss when VOLTER_DURABLE=1.
190
- const fd = openSync(path, 'a');
191
- try {
192
- appendFileSync(fd, data);
193
- if (process.env.VOLTER_DURABLE === '1') fsyncSync(fd);
194
- } finally {
195
- closeSync(fd);
196
- }
672
+ this.writing(path, () => {
673
+ const fd = openSync(path, 'a');
674
+ try {
675
+ appendFileSync(fd, data);
676
+ if (process.env.VOLTER_DURABLE === '1') fsyncSync(fd);
677
+ } finally {
678
+ closeSync(fd);
679
+ }
680
+ });
197
681
  }
198
682
 
199
683
  write(path: string, data: string, options: { secret?: boolean } = {}): void {
200
684
  refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
201
- if (!options.secret) {
202
- mkdirSync(dirname(path), { recursive: true });
203
- writeFileSync(path, data);
204
- return;
205
- }
206
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
207
- writeFileSync(path, data, { mode: 0o600 });
208
- chmodSync(path, 0o600);
685
+ this.kept.delete(path);
686
+ this.writing(path, () => {
687
+ if (!options.secret) {
688
+ mkdirSync(dirname(path), { recursive: true });
689
+ writeFileSync(path, data);
690
+ return;
691
+ }
692
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
693
+ writeFileSync(path, data, { mode: 0o600 });
694
+ chmodSync(path, 0o600);
695
+ });
209
696
  }
210
697
 
211
698
  writeAtomic(path: string, data: string, options: { secret?: boolean } = {}): void {
212
699
  refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
700
+ this.kept.delete(path);
213
701
  mkdirSync(dirname(path), { recursive: true });
214
702
  const tmp = `${path}.${process.pid}.${Date.now()}.${fsAtomicSequence++}.tmp`;
215
- try {
216
- writeFileSync(tmp, data, options.secret ? { mode: 0o600 } : undefined);
217
- renameSync(tmp, path);
218
- if (options.secret) chmodSync(path, 0o600);
219
- } finally {
220
- rmSync(tmp, { force: true });
221
- }
703
+ this.writing(path, () => {
704
+ try {
705
+ writeFileSync(tmp, data, options.secret ? { mode: 0o600 } : undefined);
706
+ renameSync(tmp, path);
707
+ if (options.secret) chmodSync(path, 0o600);
708
+ } finally {
709
+ rmSync(tmp, { force: true });
710
+ }
711
+ });
222
712
  }
223
713
 
224
714
  exists(path: string): boolean {
@@ -227,7 +717,8 @@ export class FsWorldStore implements WorldStore {
227
717
 
228
718
  remove(path: string): void {
229
719
  refuseReadOnlyWrite('the World'); // a read-only request writes nothing, whatever path it takes to the store
230
- rmSync(path, { recursive: true, force: true });
720
+ for (const held of this.kept.keys()) if (held === path || held.startsWith(`${path}/`)) this.kept.delete(held);
721
+ this.writing(path, () => rmSync(path, { recursive: true, force: true }));
231
722
  }
232
723
 
233
724
  mkdir(dirPath: string): void {
@@ -235,13 +726,23 @@ export class FsWorldStore implements WorldStore {
235
726
  }
236
727
 
237
728
  list(dirPath: string): string[] {
238
- if (!existsSync(dirPath)) return [];
239
- return readdirSync(dirPath);
729
+ try { return readdirSync(dirPath); }
730
+ catch (error) {
731
+ // No such directory is an empty one, as before; a path that IS there and is not a directory still fails.
732
+ const code = (error as NodeJS.ErrnoException).code;
733
+ if (code === 'ENOENT' || (code === 'ENOTDIR' && !existsSync(dirPath))) return [];
734
+ throw error;
735
+ }
240
736
  }
241
737
 
738
+ /** The real path of each path already resolved: a state directory's does not move under a running process, and
739
+ * every history lock asked for it again. A path that does not exist yet is not kept. */
740
+ private readonly canonical = new Map<string, string>();
242
741
  canonicalPath(path: string): string {
243
742
  const absolute = resolve(path);
244
- try { return realpathSync(absolute); }
743
+ const known = this.canonical.get(absolute);
744
+ if (known !== undefined) return known;
745
+ try { const real = realpathSync(absolute); this.canonical.set(absolute, real); return real; }
245
746
  catch (error) {
246
747
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || dirname(absolute) === absolute) throw error;
247
748
  return join(this.canonicalPath(dirname(absolute)), basename(absolute));
@@ -249,80 +750,192 @@ export class FsWorldStore implements WorldStore {
249
750
  }
250
751
 
251
752
  stat(path: string): WorldStat | null {
252
- if (!existsSync(path)) return null;
253
- const s = statSync(path);
254
- return { size: s.size, mtimeMs: s.mtimeMs, isDirectory: s.isDirectory(), ...(lstatSync(path).isSymbolicLink() ? { isSymbolicLink: true } : {}) };
753
+ // The path itself first: where it is no link, that is the whole answer. A link is followed by a second call,
754
+ // and one that leads nowhere is absent, as it was.
755
+ // Asked without an error being made of a missing path (a tree's parent log is absent in a World with no parent).
756
+ let own: Stats | undefined;
757
+ try { own = lstatSync(path, { throwIfNoEntry: false }); }
758
+ catch (error) { if (!absent(error)) throw error; }
759
+ if (!own) return null;
760
+ if (!own.isSymbolicLink()) return { size: own.size, mtimeMs: own.mtimeMs, isDirectory: own.isDirectory() };
761
+ try { const s = statSync(path); return { size: s.size, mtimeMs: s.mtimeMs, isDirectory: s.isDirectory(), isSymbolicLink: true }; }
762
+ catch (error) { if (absent(error) || (error as NodeJS.ErrnoException).code === 'ELOOP') return null; throw error; }
255
763
  }
256
764
 
257
765
  /** Run `fn` holding an exclusive cross-process file lock. The lockfile records
258
- * `{pid, hostname, at}`; a contender that finds a stale lock (dead pid or age >
766
+ * identity, operation, caller and any nested wait; a contender that finds a stale lock (dead pid or age >
259
767
  * LOCK_STALE_MS) reclaims it by atomically renaming it aside — so a crashed holder
260
768
  * can't wedge the world forever. Reclaim is race-safe: only the process that wins
261
769
  * the rename clears the stale inode, and the exclusive `wx` create still decides the
262
770
  * winner. (Moved verbatim from storage.ts `withFileLock`.) */
263
- withLock<T>(lockPath: string, fn: () => T, options: { retainLiveOwner?: boolean } = {}): T {
264
- mkdirSync(dirname(lockPath), { recursive: true });
771
+ withLock<T>(lockPath: string, fn: () => T, options: WorldLockOptions = {}): T {
265
772
  const started = Date.now();
773
+ const scope = storageLockScope(options);
266
774
  let fd: number | null = null;
267
- while (fd === null) {
268
- try {
269
- fd = openSync(lockPath, 'wx');
270
- } catch (error) {
271
- const code = (error as NodeJS.ErrnoException).code;
272
- if (code !== 'EEXIST') throw error;
273
- if (lockIsStale(lockPath, options.retainLiveOwner)) {
274
- // Serialize reclaimers, then re-read: a contender's earlier stale observation
275
- // must never rename another contender's newly acquired, live lock.
276
- const recoveryPath = `${lockPath}.recovery`;
277
- let recovery: number;
278
- try { recovery = openSync(recoveryPath, 'wx'); }
279
- catch (error) {
280
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
281
- if (Date.now() - started > 10_000) throw new Error(`Lock recovery is active or interrupted: ${recoveryPath}. Inspect its owner before removing the recovery record.`);
282
- sleepSync(25); continue;
775
+ // A kernel's file step takes the lock with its record in one request and keeps no descriptor (fileSteps).
776
+ const steps = fileSteps();
777
+ // The token is the take's own: its release removes the file only while it still holds this token, so a file a
778
+ // stale-lock sweep reclaimed and another process re-created is never removed by the first holder.
779
+ const token = randomUUID();
780
+ const holderNow = (): LockHolder => ({ pid: process.pid, hostname: ownHostname(), at: new Date().toISOString(), token, operation: scope.operation, caller: scope.caller });
781
+ let record: LockHolder | undefined;
782
+ let taken = false;
783
+ let unrecordedTakes = 0;
784
+ let recordBytes: number | undefined;
785
+ let made = false;
786
+ let reported = false;
787
+ let attempts = 0;
788
+ let sleeps = 0;
789
+ let sleepMs = 0;
790
+ let waitedOn: LockHolder | null = null;
791
+ let nestedWait: { path: string; since: number; holders: LockReading[] } | undefined;
792
+ const finishNestedWait = (): void => {
793
+ if (!nestedWait) return;
794
+ const waitedMs = Date.now() - nestedWait.since;
795
+ for (const reading of nestedWait.holders) {
796
+ const key = `${nestedWait.path}\0${scope.operation}`;
797
+ const previous = reading.nestedWaits.get(key);
798
+ reading.nestedWaits.set(key, { path: nestedWait.path, operation: scope.operation, waitedMs: (previous?.waitedMs ?? 0) + waitedMs });
799
+ delete reading.holder.waitingFor;
800
+ recordLockReading(reading);
801
+ }
802
+ nestedWait = undefined;
803
+ };
804
+ const reportWait = (): void => {
805
+ if (reported || !nestedWait || Date.now() - started < 2_000) return;
806
+ reported = true;
807
+ const holder = readLockHolder(nestedWait.path);
808
+ const since = holder && typeof holder.at === 'string' ? Date.parse(holder.at) : NaN;
809
+ storageLockLine(`[world] storage lock still held path=${nestedWait.path} holder=${holder?.pid ?? '?'}@${holder?.hostname ?? '?'} token=${holder?.token ?? '?'} operation=${holder?.operation ?? holder?.caller ?? 'unknown:0'} holderMs=${Number.isFinite(since) ? Date.now() - since : '?'} waiter=${process.pid} waitingOperation=${scope.operation} caller=${scope.caller}`);
810
+ };
811
+ const slept = (path = lockPath): void => {
812
+ if (sleeps === 0) waitedOn = readLockHolder(path);
813
+ if (nestedWait?.path !== path) {
814
+ finishNestedWait();
815
+ nestedWait = { path, since: Date.now(), holders: [...lockReadings] };
816
+ for (const reading of nestedWait.holders) {
817
+ reading.holder.waitingFor = { path, operation: scope.operation, since: nestedWait.since };
818
+ recordLockReading(reading);
819
+ }
820
+ }
821
+ reportWait();
822
+ const sleeping = Date.now();
823
+ sleeps += 1;
824
+ sleepSync(25);
825
+ sleepMs += Date.now() - sleeping;
826
+ };
827
+ try {
828
+ while (!taken) {
829
+ // This runs in the synchronous loop: a blocked holder cannot service a timer.
830
+ reportWait();
831
+ try {
832
+ attempts += 1;
833
+ // The holder is recorded WITH the take (createRecorded): no other process sees this lock without its record.
834
+ record = holderNow();
835
+ const taking = createRecorded(lockPath, `${JSON.stringify(record)}\n`, token, scope.operation);
836
+ fd = taking.fd;
837
+ recordBytes = taking.bytes;
838
+ taken = true;
839
+ } catch (error) {
840
+ const code = (error as NodeJS.ErrnoException).code;
841
+ // The lock's directory is made when it is found missing, once, not before every take: it is there for
842
+ // every take after a state directory's first.
843
+ if (code === 'ENOENT' && !made) { made = true; mkdirSync(dirname(lockPath), { recursive: true }); continue; }
844
+ // A two-call take whose path did not hold its record after it (another reclaimed it between): taken again.
845
+ if (code === RECLAIMED_BEFORE_RECORD) continue;
846
+ // A take whose record could not be written: made again, a few times, before the failure is the caller's.
847
+ if (code === RECORD_NOT_WRITTEN) {
848
+ unrecordedTakes += 1;
849
+ if (unrecordedTakes >= RECORD_ATTEMPTS) throw Object.assign(new Error(`World storage lock ${lockPath}: its holder's record could not be written`), { code: 'EIO', cause: error });
850
+ sleepSync(25 * unrecordedTakes);
851
+ continue;
283
852
  }
284
- try {
285
- writeFileSync(recovery, JSON.stringify({ pid: process.pid, hostname: hostname() }));
286
- if (lockIsStale(lockPath, options.retainLiveOwner)) {
287
- const holder = readLockHolder(lockPath);
288
- const salvage = `${lockPath}.stale.${process.pid}.${Date.now()}`;
289
- try {
290
- renameSync(lockPath, salvage);
291
- console.warn(`[world] reclaiming stale storage lock ${lockPath} (held by pid ${holder?.pid ?? '?'} on ${holder?.hostname ?? '?'})`);
292
- unlinkSync(salvage);
293
- } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
853
+ if (code !== 'EEXIST') throw error;
854
+ if (lockIsStale(lockPath, options.retainLiveOwner)) {
855
+ // Serialize reclaimers, then re-read: a contender's earlier stale observation
856
+ // must never rename another contender's newly acquired, live lock. The reclaim record is made as a lock
857
+ // is, with its record and a token of its own, and released by that token.
858
+ const recoveryPath = `${lockPath}.recovery`;
859
+ const recoveryToken = randomUUID();
860
+ let recovery: { fd: number | null };
861
+ try { recovery = createRecorded(recoveryPath, `${JSON.stringify({ pid: process.pid, hostname: ownHostname(), at: new Date().toISOString(), token: recoveryToken })}\n`, recoveryToken, 'lock recovery'); }
862
+ catch (error) {
863
+ const recoveryCode = (error as NodeJS.ErrnoException).code;
864
+ if (recoveryCode === RECLAIMED_BEFORE_RECORD || recoveryCode === RECORD_NOT_WRITTEN) continue;
865
+ if (recoveryCode !== 'EEXIST') throw error;
866
+ // A reclaim record no reclaimer is behind is reclaimed as an empty lock file is (recoveryIsStale).
867
+ if (recoveryIsStale(recoveryPath)) {
868
+ const aside = `${recoveryPath}.stale.${process.pid}.${randomUUID()}`;
869
+ try { renameSync(recoveryPath, aside); unlinkSync(aside); lockRecordLine('recovery-reclaimed', recoveryPath, {}); }
870
+ catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause; }
871
+ continue;
872
+ }
873
+ if (Date.now() - started > 10_000) throw new Error(`Lock recovery is active or interrupted: ${recoveryPath}${lockHolderMessage(readLockHolder(recoveryPath, 'timeout'))}. Inspect its owner before removing the recovery record.`);
874
+ slept(recoveryPath); continue;
294
875
  }
295
- } finally { closeSync(recovery); unlinkSync(recoveryPath); }
296
- continue;
297
- }
298
- if (Date.now() - started > 10_000) {
299
- const holder = readLockHolder(lockPath);
300
- const since = holder && typeof holder.at === 'string' ? Date.parse(holder.at) : NaN;
301
- throw new Error(`Timed out waiting for world storage lock: ${lockPath}${holder ? ` (held by pid ${holder.pid} on ${holder.hostname}${Number.isFinite(since) ? ` for ${Date.now() - since}ms` : ''})` : ''}`);
876
+ try {
877
+ if (lockIsStale(lockPath, options.retainLiveOwner)) {
878
+ const holder = readLockHolder(lockPath);
879
+ const salvage = `${lockPath}.stale.${process.pid}.${Date.now()}`;
880
+ try {
881
+ renameSync(lockPath, salvage);
882
+ console.warn(`[world] reclaiming stale storage lock ${lockPath} (held by pid ${holder?.pid ?? '?'} on ${holder?.hostname ?? '?'}) (reclaimer pid ${process.pid} on ${ownHostname()})`);
883
+ unlinkSync(salvage);
884
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
885
+ }
886
+ } finally { releaseRecorded(recoveryPath, recoveryToken, recovery.fd); }
887
+ continue;
888
+ }
889
+ if (Date.now() - started > 10_000) {
890
+ const holder = readLockHolder(lockPath, 'timeout');
891
+ throw new Error(`Timed out waiting for world storage lock: ${lockPath}${lockHolderMessage(holder)}`);
892
+ }
893
+ slept();
302
894
  }
303
- sleepSync(25);
895
+ }
896
+ } finally {
897
+ finishNestedWait();
898
+ if (sleeps > 0) {
899
+ const waitedMs = Date.now() - started;
900
+ const first = waitedOn as LockHolder | null;
901
+ storageLockLine(`[world] storage lock wait path=${lockPath} outcome=${taken ? 'acquired' : 'failed'} waiter=${process.pid}@${ownHostname()} operation=${scope.operation} attempts=${attempts} sleeps=${sleeps} requestedSleepMs=${sleeps * 25} sleepMs=${sleepMs} outsideSleepMs=${waitedMs - sleepMs} waitedMs=${waitedMs} firstHolder=${first?.pid ?? '?'}@${first?.hostname ?? '?'} token=${first?.token ?? '?'}`);
304
902
  }
305
903
  }
306
904
 
307
- // Record the holder so a later contender can detect staleness. Best-effort: a write
308
- // failure here doesn't weaken exclusivity, only staleness diagnostics.
309
- try {
310
- writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostname: hostname(), at: new Date().toISOString() } satisfies LockHolder)}\n`);
311
- } catch { /* ignore */ }
905
+ // The holder's record was written with the take, in the loop above: a take whose record could not be written is
906
+ // never held (EMPTY_RECORD_GRACE_MS).
907
+ ownLocks.set(lockPath, token);
312
908
 
313
909
  const acquired = Date.now();
910
+ const reading: LockReading = { path: lockPath, fd, holder: record!, nestedWaits: new Map(), ...(recordBytes !== undefined ? { recordBytes } : {}) };
911
+ lockReadings.push(reading);
314
912
  try {
315
913
  return fn();
316
914
  } finally {
317
915
  // Every World on the machine waits on some of these locks; a long hold stalls them all.
318
916
  const heldMs = Date.now() - acquired;
917
+ if (heldMs > 2_000) storageLockLine(`[world] releasing storage lock path=${lockPath} operation=${scope.operation} heldMs=${heldMs} nestedWaits=${JSON.stringify([...reading.nestedWaits.values()])}`);
319
918
  if (heldMs > 2_000) console.warn(`[world] held storage lock ${lockPath} for ${heldMs}ms (pid ${process.pid})\n${new Error().stack?.split('\n').slice(2, 8).join('\n') ?? ''}`);
320
- const held = fstatSync(fd);
321
- closeSync(fd);
919
+ lockReadings.pop();
920
+ ownLocks.delete(lockPath);
921
+ if (fd !== null) {
922
+ try { closeSync(fd); }
923
+ catch (error) { lockRecordLine('close-failed', lockPath, { fd }, error); throw error; }
924
+ }
925
+ // By the token the take wrote, not by inode: a filesystem may answer a descriptor's stat and the path's with
926
+ // different identities (a tab's, where a committed path is the store's entry), and a lock released by inode
927
+ // was then never removed, left recorded as this live process's (above). A kernel's file step reads the record,
928
+ // compares its token and unlinks in one request; its answer is what the read below would have found.
322
929
  try {
323
- const current = lstatSync(lockPath);
324
- if (held.dev === current.dev && held.ino === current.ino) unlinkSync(lockPath);
325
- } catch { /* already reclaimed by a stale-lock sweep */ }
930
+ const released = fd === null ? steps?.changeHolding(lockPath, tokenNeedle(token), undefined) : undefined;
931
+ if (!released?.changed) {
932
+ const observed = released === undefined ? readLockHolder(lockPath, 'release') : lockHolderOf(lockPath, released.observed, 'release');
933
+ if (observed?.token === token) unlinkSync(lockPath);
934
+ else if (observed) lockRecordLine('release-token-mismatch', lockPath, {
935
+ token, recordedToken: observed.token, recordedPid: observed.pid, recordedHostname: observed.hostname,
936
+ });
937
+ }
938
+ } catch (error) { lockRecordLine('release-failed', lockPath, { fd }, error); }
326
939
  }
327
940
  }
328
941
  }