@evomap/evolver-core 2.0.0-beta.19 → 2.0.0-beta.22

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 (160) hide show
  1. package/assets/gep/genes.jsonl +5 -5
  2. package/dist/algo/bans.d.ts +10 -1
  3. package/dist/algo/bans.js +56 -6
  4. package/dist/algo/candidateAssembly.d.ts +25 -2
  5. package/dist/algo/candidateAssembly.js +143 -20
  6. package/dist/algo/capabilityCandidates.js +10 -0
  7. package/dist/algo/cycleEngine.d.ts +40 -5
  8. package/dist/algo/cycleEngine.js +277 -37
  9. package/dist/algo/evolutionEvent.d.ts +26 -0
  10. package/dist/algo/evolutionEvent.js +32 -0
  11. package/dist/algo/exploration.d.ts +7 -0
  12. package/dist/algo/exploration.js +16 -3
  13. package/dist/algo/geneHealth.d.ts +34 -1
  14. package/dist/algo/geneHealth.js +42 -0
  15. package/dist/algo/geneIntake.d.ts +46 -1
  16. package/dist/algo/geneIntake.js +121 -10
  17. package/dist/algo/geneSelection.d.ts +115 -6
  18. package/dist/algo/geneSelection.js +459 -32
  19. package/dist/algo/index.d.ts +4 -1
  20. package/dist/algo/index.js +4 -1
  21. package/dist/algo/kautoProjection.d.ts +41 -0
  22. package/dist/algo/kautoProjection.js +95 -0
  23. package/dist/algo/kautoValidator.d.ts +68 -0
  24. package/dist/algo/kautoValidator.js +256 -0
  25. package/dist/algo/orchestrator.d.ts +14 -1
  26. package/dist/algo/orchestrator.js +9 -2
  27. package/dist/algo/solidify.d.ts +11 -2
  28. package/dist/algo/solidify.js +37 -7
  29. package/dist/algo/ucb1.d.ts +53 -0
  30. package/dist/algo/ucb1.js +156 -0
  31. package/dist/assetrepair/hubRejection.d.ts +12 -0
  32. package/dist/assetrepair/hubRejection.js +109 -0
  33. package/dist/assetrepair/index.d.ts +2 -0
  34. package/dist/assetrepair/index.js +2 -0
  35. package/dist/assetrepair/repair.d.ts +33 -0
  36. package/dist/assetrepair/repair.js +155 -0
  37. package/dist/assetstore/assetSidecarRecords.js +5 -1
  38. package/dist/assetstore/assetStoreHealth.d.ts +2 -3
  39. package/dist/assetstore/assetStoreHealth.js +4 -10
  40. package/dist/assetstore/foreignJsonlSource.d.ts +48 -0
  41. package/dist/assetstore/foreignJsonlSource.js +150 -0
  42. package/dist/assetstore/index.d.ts +3 -0
  43. package/dist/assetstore/index.js +3 -0
  44. package/dist/assetstore/learningHistory.js +3 -3
  45. package/dist/assetstore/localAssetStoreSnapshot.d.ts +51 -0
  46. package/dist/assetstore/localAssetStoreSnapshot.js +329 -0
  47. package/dist/assetstore/localJsonl.d.ts +8 -0
  48. package/dist/assetstore/localJsonl.js +128 -3
  49. package/dist/assetstore/provenance.d.ts +38 -3
  50. package/dist/assetstore/provenance.js +103 -45
  51. package/dist/assetstore/provider.d.ts +4 -0
  52. package/dist/assetstore/reviewFilter.d.ts +19 -1
  53. package/dist/assetstore/reviewFilter.js +36 -0
  54. package/dist/assetstore/unionReadStore.d.ts +25 -0
  55. package/dist/assetstore/unionReadStore.js +119 -0
  56. package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
  57. package/dist/benchmark/antiGeneBenchmark.js +4 -3
  58. package/dist/benchmark/antiGeneRollout.d.ts +2 -0
  59. package/dist/benchmark/antiGeneRollout.js +4 -3
  60. package/dist/benchmark/index.d.ts +2 -1
  61. package/dist/benchmark/index.js +2 -1
  62. package/dist/benchmark/selectionFlatAbstention.d.ts +152 -0
  63. package/dist/benchmark/selectionFlatAbstention.js +481 -0
  64. package/dist/bootstrap/envFingerprint.d.ts +9 -0
  65. package/dist/bootstrap/envFingerprint.js +5 -0
  66. package/dist/bootstrap/index.d.ts +2 -1
  67. package/dist/bootstrap/index.js +2 -1
  68. package/dist/bootstrap/lifecycleBootstrap.d.ts +111 -0
  69. package/dist/bootstrap/lifecycleBootstrap.js +433 -0
  70. package/dist/bootstrap/v1EnvCompat.d.ts +3 -0
  71. package/dist/bootstrap/v1EnvCompat.js +48 -1
  72. package/dist/events/eventArchive.d.ts +2 -0
  73. package/dist/events/eventArchive.js +13 -3
  74. package/dist/events/eventSchema.d.ts +7 -7
  75. package/dist/events/eventStore.d.ts +2 -0
  76. package/dist/events/eventStore.js +5 -1
  77. package/dist/events/ingest.d.ts +2 -1
  78. package/dist/events/ingest.js +6 -0
  79. package/dist/exec/autoExec.d.ts +30 -3
  80. package/dist/exec/autoExec.js +294 -29
  81. package/dist/exec/autonomousCycle.d.ts +7 -0
  82. package/dist/exec/autonomousCycle.js +2 -0
  83. package/dist/exec/claudeBridge.d.ts +28 -6
  84. package/dist/exec/claudeBridge.js +411 -258
  85. package/dist/exec/executionBinding.d.ts +414 -0
  86. package/dist/exec/executionBinding.js +588 -0
  87. package/dist/exec/index.d.ts +1 -0
  88. package/dist/exec/index.js +1 -0
  89. package/dist/exec/proofOfWork.d.ts +1 -1
  90. package/dist/exec/proofOfWork.js +2 -2
  91. package/dist/exec/runnerRegistry.d.ts +5 -0
  92. package/dist/exec/runnerRegistry.js +54 -14
  93. package/dist/hub/capability.d.ts +40 -1
  94. package/dist/hub/conversationDistiller.d.ts +19 -0
  95. package/dist/hub/conversationDistiller.js +115 -37
  96. package/dist/hub/fake.d.ts +1 -0
  97. package/dist/hub/fake.js +1 -0
  98. package/dist/hub/index.d.ts +1 -0
  99. package/dist/hub/index.js +1 -0
  100. package/dist/hub/recipeCompose.d.ts +27 -0
  101. package/dist/hub/recipeCompose.js +90 -0
  102. package/dist/hub/sanitize.js +119 -5
  103. package/dist/index.d.ts +2 -0
  104. package/dist/index.js +2 -0
  105. package/dist/mailbox/ipcServer.d.ts +2 -0
  106. package/dist/mailbox/ipcServer.js +20 -0
  107. package/dist/mailbox/store.d.ts +52 -2
  108. package/dist/mailbox/store.js +546 -81
  109. package/dist/modelCompatibility.d.ts +164 -0
  110. package/dist/modelCompatibility.js +309 -0
  111. package/dist/observers/valueDigestObserver.d.ts +9 -0
  112. package/dist/observers/valueDigestObserver.js +35 -2
  113. package/dist/ops/cleanup.js +1 -1
  114. package/dist/ops/evolutionGraphProjection.d.ts +20 -0
  115. package/dist/ops/evolutionGraphProjection.js +315 -0
  116. package/dist/ops/index.d.ts +2 -1
  117. package/dist/ops/index.js +2 -1
  118. package/dist/ops/selfUpdate.d.ts +8 -0
  119. package/dist/ops/selfUpdate.js +24 -8
  120. package/dist/ops/valueOutreach.d.ts +3 -1
  121. package/dist/ops/valueOutreach.js +5 -1
  122. package/dist/personality/schema.d.ts +18 -18
  123. package/dist/schema/evolutionGraph.d.ts +784 -0
  124. package/dist/schema/evolutionGraph.js +187 -0
  125. package/dist/schema/index.d.ts +1 -0
  126. package/dist/schema/index.js +1 -0
  127. package/dist/schema/proofOfWork.d.ts +125 -6
  128. package/dist/schema/proofOfWork.js +102 -4
  129. package/dist/shadow/shadowHub.js +1 -0
  130. package/dist/signals/expand.d.ts +15 -1
  131. package/dist/signals/expand.js +148 -0
  132. package/dist/signals/index.d.ts +3 -1
  133. package/dist/signals/index.js +3 -1
  134. package/dist/signals/scopeVocabulary.d.ts +75 -0
  135. package/dist/signals/scopeVocabulary.js +91 -0
  136. package/dist/signals/taskDomain.d.ts +22 -0
  137. package/dist/signals/taskDomain.js +43 -0
  138. package/dist/trace/learningTrace.d.ts +7 -0
  139. package/dist/trace/learningTrace.js +22 -0
  140. package/dist/trace/trajectory.d.ts +8 -0
  141. package/dist/trace/trajectory.js +14 -2
  142. package/dist/util/fileLock.d.ts +92 -5
  143. package/dist/util/fileLock.js +809 -70
  144. package/dist/util/index.d.ts +2 -1
  145. package/dist/util/index.js +1 -1
  146. package/dist/verify/index.d.ts +2 -1
  147. package/dist/verify/index.js +1 -1
  148. package/dist/verify/sandboxRunner.d.ts +2 -0
  149. package/dist/verify/sandboxRunner.js +154 -10
  150. package/dist/verify/sandboxedValidation.d.ts +7 -2
  151. package/dist/verify/sandboxedValidation.js +102 -24
  152. package/dist/verify/validation.d.ts +24 -3
  153. package/dist/verify/validation.js +141 -24
  154. package/dist/wire/geneHints.d.ts +88 -16
  155. package/dist/wire/geneHints.js +124 -15
  156. package/dist/wire/index.d.ts +8 -3
  157. package/dist/wire/index.js +2 -2
  158. package/dist/wire/schemaGate.d.ts +21 -0
  159. package/dist/wire/schemaGate.js +119 -10
  160. package/package.json +5 -4
@@ -1,12 +1,19 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
- import { basename, dirname, join, resolve } from 'node:path';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, openSync, readSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
4
+ import { basename, dirname, join, resolve, win32 } from 'node:path';
4
5
  export function syncSleep(ms) {
5
6
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
6
7
  }
7
8
  const MALFORMED_LOCK_GRACE_NS = 1000000000n;
9
+ const OBSERVED_PROCESS_IDENTITY_CACHE_NS = 5000000000n;
10
+ const PROCESS_IDENTITY_TIMEOUT_MS = 15_000;
11
+ const HOST_WINDOWS_SYSTEM_ROOT = process.env['SystemRoot']?.trim() || 'C:\\Windows';
8
12
  const ownedLocks = new Map();
9
13
  const malformedLockObservations = new Map();
14
+ const observedProcessIdentities = new Map();
15
+ let currentProcessIdentity;
16
+ let fileLockTestHooks = {};
10
17
  export class LockTimeoutError extends Error {
11
18
  code = 'LOCK_TIMEOUT';
12
19
  constructor(_lockPath) {
@@ -28,14 +35,208 @@ function lockKey(lockPath) {
28
35
  return resolve(lockPath);
29
36
  }
30
37
  function newOwner() {
31
- return { pid: process.pid, token: randomUUID() };
38
+ const processStart = processStartIdentity(process.pid);
39
+ if (processStart === null)
40
+ throw new UnsafeLockPathError('process_identity_unavailable');
41
+ return { pid: process.pid, token: randomUUID(), processStart };
32
42
  }
33
43
  function serializeOwner(owner) {
34
- return `${JSON.stringify({ v: 1, pid: owner.pid, token: owner.token })}\n`;
44
+ return `${JSON.stringify({
45
+ v: 2,
46
+ pid: owner.pid,
47
+ token: owner.token,
48
+ processStart: owner.processStart,
49
+ })}\n`;
50
+ }
51
+ function publicOwnerRecord(owner) {
52
+ return {
53
+ pid: owner.pid,
54
+ token: owner.token,
55
+ processStartIdentity: { ...owner.processStart },
56
+ };
35
57
  }
36
58
  function isErrno(error, code) {
37
59
  return typeof error === 'object' && error !== null && error.code === code;
38
60
  }
61
+ function validPositiveIntegerText(value) {
62
+ return /^[1-9]\d*$/.test(value);
63
+ }
64
+ function validIdentityText(value, maxLength) {
65
+ if (value.length === 0 || value.length > maxLength)
66
+ return false;
67
+ for (let index = 0; index < value.length; index += 1) {
68
+ const code = value.charCodeAt(index);
69
+ if (code <= 0x1f || code === 0x7f)
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+ let cachedLinuxBootId;
75
+ function linuxBootId() {
76
+ if (cachedLinuxBootId !== undefined)
77
+ return cachedLinuxBootId;
78
+ try {
79
+ const value = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim().toLowerCase();
80
+ cachedLinuxBootId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)
81
+ ? value
82
+ : null;
83
+ }
84
+ catch {
85
+ cachedLinuxBootId = null;
86
+ }
87
+ return cachedLinuxBootId;
88
+ }
89
+ function linuxProcessStartTicks(pid) {
90
+ try {
91
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
92
+ const commandEnd = raw.lastIndexOf(')');
93
+ if (commandEnd < 0)
94
+ return null;
95
+ const fields = raw.slice(commandEnd + 1).trim().split(/\s+/);
96
+ const startTicks = fields[19];
97
+ return startTicks !== undefined && validPositiveIntegerText(startTicks) ? startTicks : null;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ function linuxProcessStartIdentity(pid) {
104
+ const first = linuxProcessStartTicks(pid);
105
+ const bootId = linuxBootId();
106
+ const second = linuxProcessStartTicks(pid);
107
+ return first !== null && first === second && bootId !== null
108
+ ? { source: 'linux-proc', bootId, startTicks: first }
109
+ : null;
110
+ }
111
+ function trustedWindowsPowerShell() {
112
+ if (!win32.isAbsolute(HOST_WINDOWS_SYSTEM_ROOT)
113
+ || !validIdentityText(HOST_WINDOWS_SYSTEM_ROOT, 1024))
114
+ return null;
115
+ const executable = win32.join(HOST_WINDOWS_SYSTEM_ROOT, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
116
+ return existsSync(executable) ? executable : null;
117
+ }
118
+ function windowsProcessStartIdentity(pid) {
119
+ const powershell = trustedWindowsPowerShell();
120
+ if (powershell === null)
121
+ return null;
122
+ const script = [
123
+ `$process = Get-Process -Id ${pid} -ErrorAction Stop`,
124
+ '$ticks = $process.StartTime.ToUniversalTime().Ticks',
125
+ '[Console]::Out.Write($ticks.ToString([Globalization.CultureInfo]::InvariantCulture))',
126
+ ].join('; ');
127
+ try {
128
+ const startTimeTicks = execFileSync(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
129
+ encoding: 'utf8',
130
+ stdio: ['ignore', 'pipe', 'ignore'],
131
+ timeout: PROCESS_IDENTITY_TIMEOUT_MS,
132
+ windowsHide: true,
133
+ }).trim();
134
+ return validPositiveIntegerText(startTimeTicks)
135
+ ? { source: 'windows-powershell', startTimeTicks }
136
+ : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ function darwinProcessStartIdentity(pid) {
143
+ try {
144
+ const startTime = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], {
145
+ encoding: 'utf8',
146
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
147
+ stdio: ['ignore', 'pipe', 'ignore'],
148
+ timeout: PROCESS_IDENTITY_TIMEOUT_MS,
149
+ }).trim().replace(/\s+/g, ' ');
150
+ return validIdentityText(startTime, 128)
151
+ ? { source: 'darwin-ps', startTime }
152
+ : null;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ function nativeProcessStartIdentity(pid) {
159
+ if (!Number.isInteger(pid) || pid <= 0)
160
+ return null;
161
+ if (process.platform === 'linux')
162
+ return linuxProcessStartIdentity(pid);
163
+ if (process.platform === 'win32')
164
+ return windowsProcessStartIdentity(pid);
165
+ if (process.platform === 'darwin')
166
+ return darwinProcessStartIdentity(pid);
167
+ return null;
168
+ }
169
+ function processStartIdentity(pid, claimedIdentity) {
170
+ if (pid === process.pid) {
171
+ if (currentProcessIdentity === undefined) {
172
+ const resolved = (fileLockTestHooks.processStartIdentity ?? nativeProcessStartIdentity)(pid);
173
+ if (resolved !== null)
174
+ currentProcessIdentity = resolved;
175
+ return resolved;
176
+ }
177
+ return currentProcessIdentity;
178
+ }
179
+ const now = process.hrtime.bigint();
180
+ const cached = observedProcessIdentities.get(pid);
181
+ if (cached !== undefined
182
+ && claimedIdentity !== undefined
183
+ && sameProcessStartIdentity(cached.claimedIdentity, claimedIdentity)
184
+ && now < cached.expiresAtNs) {
185
+ return cached.identity;
186
+ }
187
+ const identity = (fileLockTestHooks.processStartIdentity ?? nativeProcessStartIdentity)(pid);
188
+ if (claimedIdentity !== undefined) {
189
+ observedProcessIdentities.set(pid, {
190
+ claimedIdentity,
191
+ identity,
192
+ expiresAtNs: now + OBSERVED_PROCESS_IDENTITY_CACHE_NS,
193
+ });
194
+ }
195
+ return identity;
196
+ }
197
+ function sameProcessStartIdentity(left, right) {
198
+ if (left.source !== right.source)
199
+ return false;
200
+ if (left.source === 'linux-proc' && right.source === 'linux-proc') {
201
+ return left.bootId === right.bootId && left.startTicks === right.startTicks;
202
+ }
203
+ if (left.source === 'windows-powershell' && right.source === 'windows-powershell') {
204
+ return left.startTimeTicks === right.startTimeTicks;
205
+ }
206
+ return left.source === 'darwin-ps'
207
+ && right.source === 'darwin-ps'
208
+ && left.startTime === right.startTime;
209
+ }
210
+ /** Fresh native process-start observation. Unlike lock acquisition, this does not use caches. */
211
+ export function readFileLockProcessStartIdentity(pid) {
212
+ if (!Number.isInteger(pid) || pid <= 0)
213
+ return null;
214
+ const identity = (fileLockTestHooks.processStartIdentity ?? nativeProcessStartIdentity)(pid);
215
+ return identity === null ? null : { ...identity };
216
+ }
217
+ export function sameFileLockProcessStartIdentity(left, right) {
218
+ return sameProcessStartIdentity(left, right);
219
+ }
220
+ export function parseFileLockProcessStartIdentity(value) {
221
+ return parseProcessStartIdentity(value) ?? undefined;
222
+ }
223
+ /**
224
+ * Conservative owner classification for crash recovery. PID reuse and unavailable identity
225
+ * remain distinct from a proven-dead PID so callers cannot reclaim ambiguous ownership.
226
+ */
227
+ export function inspectFileLockOwnerProcess(owner) {
228
+ if (!pidAlive(owner.pid))
229
+ return 'dead';
230
+ const current = readFileLockProcessStartIdentity(owner.pid);
231
+ if (current === null)
232
+ return 'unverifiable';
233
+ return sameProcessStartIdentity(owner.processStartIdentity, current) ? 'current' : 'pid_reused';
234
+ }
235
+ export function _setFileLockTestHooksForTest(hooks = {}) {
236
+ fileLockTestHooks = hooks;
237
+ currentProcessIdentity = undefined;
238
+ observedProcessIdentities.clear();
239
+ }
39
240
  function noFollowFlag() {
40
241
  return constants['O_NOFOLLOW'] ?? 0;
41
242
  }
@@ -113,13 +314,12 @@ function readOwnerFileBounded(path) {
113
314
  closeSync(fd);
114
315
  }
115
316
  }
116
- function readOwnerBounded(path) {
117
- return readOwnerFileBounded(path)?.raw ?? null;
118
- }
119
317
  /** True if pid is a live process (cross-platform via signal 0; EPERM = exists but owned by another user). */
120
318
  function pidAlive(pid) {
121
319
  if (!Number.isInteger(pid) || pid <= 0)
122
320
  return false;
321
+ if (fileLockTestHooks.pidAlive)
322
+ return fileLockTestHooks.pidAlive(pid);
123
323
  try {
124
324
  process.kill(pid, 0);
125
325
  return true;
@@ -128,20 +328,57 @@ function pidAlive(pid) {
128
328
  return e.code === 'EPERM';
129
329
  }
130
330
  }
331
+ function parseProcessStartIdentity(value) {
332
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
333
+ return null;
334
+ const record = value;
335
+ if (record['source'] === 'linux-proc') {
336
+ const bootId = record['bootId'];
337
+ const startTicks = record['startTicks'];
338
+ return typeof bootId === 'string'
339
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(bootId)
340
+ && typeof startTicks === 'string'
341
+ && validPositiveIntegerText(startTicks)
342
+ ? { source: 'linux-proc', bootId, startTicks }
343
+ : null;
344
+ }
345
+ if (record['source'] === 'windows-powershell') {
346
+ const startTimeTicks = record['startTimeTicks'];
347
+ return typeof startTimeTicks === 'string' && validPositiveIntegerText(startTimeTicks)
348
+ ? { source: 'windows-powershell', startTimeTicks }
349
+ : null;
350
+ }
351
+ if (record['source'] === 'darwin-ps') {
352
+ const startTime = record['startTime'];
353
+ return typeof startTime === 'string' && validIdentityText(startTime, 128)
354
+ ? { source: 'darwin-ps', startTime }
355
+ : null;
356
+ }
357
+ return null;
358
+ }
131
359
  function parseJsonOwner(raw) {
132
360
  try {
133
361
  const value = JSON.parse(raw);
134
- if (typeof value !== 'object' || value === null)
362
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
135
363
  return null;
136
364
  const record = value;
137
- if (typeof record.pid !== 'number'
138
- || !Number.isInteger(record.pid)
139
- || record.pid <= 0
140
- || typeof record.token !== 'string'
141
- || record.token.length === 0) {
365
+ if (typeof record['pid'] !== 'number'
366
+ || !Number.isInteger(record['pid'])
367
+ || record['pid'] <= 0
368
+ || typeof record['token'] !== 'string'
369
+ || record['token'].length === 0) {
142
370
  return null;
143
371
  }
144
- return { pid: record.pid, token: record.token };
372
+ if (record['v'] === 2) {
373
+ const processStart = parseProcessStartIdentity(record['processStart']);
374
+ return processStart === null
375
+ ? null
376
+ : { pid: record['pid'], token: record['token'], processStart };
377
+ }
378
+ if (record['v'] === 1 || record['v'] === undefined) {
379
+ return { pid: record['pid'], token: record['token'], processStart: null };
380
+ }
381
+ return null;
145
382
  }
146
383
  catch (error) {
147
384
  if (error instanceof SyntaxError)
@@ -154,7 +391,7 @@ function parseLegacyOwner(raw) {
154
391
  if (first === undefined)
155
392
  return null;
156
393
  const pid = Number(first);
157
- return Number.isInteger(pid) && pid > 0 ? { pid, token: null } : null;
394
+ return Number.isInteger(pid) && pid > 0 ? { pid, token: null, processStart: null } : null;
158
395
  }
159
396
  function parseOwner(raw) {
160
397
  const trimmed = raw.trim();
@@ -162,19 +399,22 @@ function parseOwner(raw) {
162
399
  return null;
163
400
  return parseJsonOwner(trimmed) ?? parseLegacyOwner(trimmed);
164
401
  }
165
- /** Snapshot of the lock inode and its owner payload; null if the path disappeared. */
402
+ function readLockSnapshot(lockPath) {
403
+ const read = readOwnerFileBounded(lockPath);
404
+ if (read === null)
405
+ return null;
406
+ return {
407
+ owner: parseOwner(read.raw),
408
+ dev: read.stat.dev,
409
+ ino: read.stat.ino,
410
+ size: read.stat.size,
411
+ mtimeNs: read.stat.mtimeNs,
412
+ };
413
+ }
414
+ /** Snapshot of the lock inode and its owner payload; null if the path disappeared or changed during inspection. */
166
415
  function lockSnapshot(lockPath) {
167
416
  try {
168
- const read = readOwnerFileBounded(lockPath);
169
- if (read === null)
170
- return null;
171
- return {
172
- owner: parseOwner(read.raw),
173
- dev: read.stat.dev,
174
- ino: read.stat.ino,
175
- size: read.stat.size,
176
- mtimeNs: read.stat.mtimeNs,
177
- };
417
+ return readLockSnapshot(lockPath);
178
418
  }
179
419
  catch (error) {
180
420
  // A valid owner can release and another waiter can acquire between lstat/open/fstat. Treat that snapshot as
@@ -198,7 +438,15 @@ function forgetMalformedObservation(lockPath) {
198
438
  function reclaimableSnapshot(lockPath, snapshot) {
199
439
  if (snapshot.owner !== null) {
200
440
  forgetMalformedObservation(lockPath);
201
- return snapshot.owner.pid !== process.pid && !pidAlive(snapshot.owner.pid);
441
+ if (!pidAlive(snapshot.owner.pid)) {
442
+ observedProcessIdentities.delete(snapshot.owner.pid);
443
+ return true;
444
+ }
445
+ if (snapshot.owner.processStart === null)
446
+ return false;
447
+ const liveProcessStart = processStartIdentity(snapshot.owner.pid, snapshot.owner.processStart);
448
+ return liveProcessStart !== null
449
+ && !sameProcessStartIdentity(snapshot.owner.processStart, liveProcessStart);
202
450
  }
203
451
  const key = lockKey(lockPath);
204
452
  const now = process.hrtime.bigint();
@@ -210,7 +458,101 @@ function reclaimableSnapshot(lockPath, snapshot) {
210
458
  return now - observed.firstSeenAtNs >= MALFORMED_LOCK_GRACE_NS;
211
459
  }
212
460
  function sameOwner(parsed, owner) {
213
- return parsed?.pid === owner.pid && parsed.token === owner.token;
461
+ return parsed?.pid === owner.pid
462
+ && parsed.token === owner.token
463
+ && parsed.processStart !== null
464
+ && sameProcessStartIdentity(parsed.processStart, owner.processStart);
465
+ }
466
+ function guardianPath(lockPath) {
467
+ return join(dirname(lockPath), `.${basename(lockPath)}.guardian`);
468
+ }
469
+ function serializedOwnerRecord(owner) {
470
+ return {
471
+ pid: owner.pid,
472
+ token: owner.token,
473
+ processStart: owner.processStart,
474
+ };
475
+ }
476
+ function parseGuardianOwner(value) {
477
+ if (!value || typeof value !== 'object' || Array.isArray(value))
478
+ return null;
479
+ const record = value;
480
+ const keys = Object.keys(record).sort();
481
+ if (keys.length !== 3
482
+ || keys[0] !== 'pid'
483
+ || keys[1] !== 'processStart'
484
+ || keys[2] !== 'token'
485
+ || !Number.isSafeInteger(record['pid'])
486
+ || record['pid'] <= 0
487
+ || typeof record['token'] !== 'string'
488
+ || record['token'].length === 0
489
+ || record['token'].length > 128)
490
+ return null;
491
+ const processStart = parseProcessStartIdentity(record['processStart']);
492
+ return processStart === null
493
+ ? null
494
+ : { pid: record['pid'], token: record['token'], processStart };
495
+ }
496
+ function parseLockGuardian(raw) {
497
+ try {
498
+ const value = JSON.parse(raw);
499
+ if (!value || typeof value !== 'object' || Array.isArray(value))
500
+ return null;
501
+ const record = value;
502
+ const keys = Object.keys(record).sort();
503
+ if (keys.length !== 3 || keys[0] !== 'from' || keys[1] !== 'guardian'
504
+ || keys[2] !== 'v' || record['v'] !== 1)
505
+ return null;
506
+ const from = parseGuardianOwner(record['from']);
507
+ const guardian = parseGuardianOwner(record['guardian']);
508
+ return from && guardian ? { from, guardian } : null;
509
+ }
510
+ catch (error) {
511
+ if (error instanceof SyntaxError)
512
+ return null;
513
+ throw error;
514
+ }
515
+ }
516
+ function serializeLockGuardian(record) {
517
+ return `${JSON.stringify({
518
+ v: 1,
519
+ from: serializedOwnerRecord(record.from),
520
+ guardian: serializedOwnerRecord(record.guardian),
521
+ })}\n`;
522
+ }
523
+ function readLockGuardianSnapshot(lockPath) {
524
+ const read = readOwnerFileBounded(guardianPath(lockPath));
525
+ if (read === null)
526
+ return null;
527
+ return {
528
+ record: parseLockGuardian(read.raw),
529
+ snapshot: {
530
+ owner: null,
531
+ dev: read.stat.dev,
532
+ ino: read.stat.ino,
533
+ size: read.stat.size,
534
+ mtimeNs: read.stat.mtimeNs,
535
+ },
536
+ };
537
+ }
538
+ function guardianReclaimable(record) {
539
+ if (!pidAlive(record.guardian.pid)) {
540
+ observedProcessIdentities.delete(record.guardian.pid);
541
+ return true;
542
+ }
543
+ const current = processStartIdentity(record.guardian.pid, record.guardian.processStart);
544
+ return current !== null && !sameProcessStartIdentity(record.guardian.processStart, current);
545
+ }
546
+ function sameGuardianRecord(left, right) {
547
+ return sameOwner({
548
+ pid: left.from.pid,
549
+ token: left.from.token,
550
+ processStart: left.from.processStart,
551
+ }, right.from) && sameOwner({
552
+ pid: left.guardian.pid,
553
+ token: left.guardian.token,
554
+ processStart: left.guardian.processStart,
555
+ }, right.guardian);
214
556
  }
215
557
  function sidecarPath(lockPath, kind) {
216
558
  return join(dirname(lockPath), `.${basename(lockPath)}.${kind}.${process.pid}.${randomUUID()}`);
@@ -249,7 +591,7 @@ function restoreMovedFile(lockPath, movedPath, moved) {
249
591
  }
250
592
  throw new Error(`文件锁在安全回收期间被替换,已保留候选文件: ${movedPath}`);
251
593
  }
252
- function removeUnchangedLock(lockPath, expected, kind) {
594
+ function removeUnchangedLock(lockPath, expected, kind, onCleanupError) {
253
595
  const movedPath = sidecarPath(lockPath, kind);
254
596
  try {
255
597
  renameSync(lockPath, movedPath);
@@ -265,23 +607,131 @@ function removeUnchangedLock(lockPath, expected, kind) {
265
607
  restoreMovedFile(lockPath, movedPath, moved);
266
608
  return false;
267
609
  }
268
- removeFileIfExists(movedPath);
610
+ try {
611
+ removeFileIfExists(movedPath);
612
+ }
613
+ catch (error) {
614
+ if (onCleanupError === undefined)
615
+ throw error;
616
+ onCleanupError(error);
617
+ }
269
618
  forgetMalformedObservation(lockPath);
270
619
  return true;
271
620
  }
272
- function removeFileIfOwned(lockPath, owner) {
621
+ function removeFileIfOwned(lockPath, owner, onCleanupError) {
273
622
  const snapshot = lockSnapshot(lockPath);
274
623
  if (snapshot === null || !sameOwner(snapshot.owner, owner))
275
624
  return false;
276
- return removeUnchangedLock(lockPath, snapshot, 'released');
625
+ return removeUnchangedLock(lockPath, snapshot, 'released', onCleanupError);
626
+ }
627
+ function publishOwnerFile(lockPath, owner) {
628
+ const temporaryPath = sidecarPath(lockPath, 'publishing');
629
+ let descriptor;
630
+ let published = false;
631
+ let publicationResult = false;
632
+ let failure;
633
+ try {
634
+ descriptor = openSync(temporaryPath, 'wx', 0o600);
635
+ writeFileSync(descriptor, serializeOwner(owner), { encoding: 'utf8' });
636
+ fsyncSync(descriptor);
637
+ closeSync(descriptor);
638
+ descriptor = undefined;
639
+ fileLockTestHooks.beforeOwnerPublish?.(temporaryPath, lockPath);
640
+ try {
641
+ linkSync(temporaryPath, lockPath);
642
+ published = true;
643
+ publicationResult = true;
644
+ }
645
+ catch (error) {
646
+ if (!isErrno(error, 'EEXIST'))
647
+ failure = error;
648
+ }
649
+ }
650
+ catch (error) {
651
+ failure = error;
652
+ }
653
+ finally {
654
+ if (descriptor !== undefined) {
655
+ try {
656
+ closeSync(descriptor);
657
+ }
658
+ catch (error) {
659
+ failure ??= error;
660
+ }
661
+ }
662
+ try {
663
+ unlinkSync(temporaryPath);
664
+ }
665
+ catch (error) {
666
+ // Once linked, the final owner is valid and must be tracked even if the temporary name cannot be removed.
667
+ if (!published && !isErrno(error, 'ENOENT'))
668
+ failure ??= error;
669
+ }
670
+ }
671
+ if (failure !== undefined)
672
+ throw failure;
673
+ return publicationResult;
674
+ }
675
+ function publishGuardianFile(lockPath, record) {
676
+ const finalPath = guardianPath(lockPath);
677
+ const temporaryPath = sidecarPath(finalPath, 'publishing');
678
+ let descriptor;
679
+ let published = false;
680
+ let publicationResult = false;
681
+ let failure;
682
+ try {
683
+ descriptor = openSync(temporaryPath, 'wx', 0o600);
684
+ writeFileSync(descriptor, serializeLockGuardian(record), { encoding: 'utf8' });
685
+ fsyncSync(descriptor);
686
+ closeSync(descriptor);
687
+ descriptor = undefined;
688
+ fileLockTestHooks.beforeGuardianPublish?.(temporaryPath, finalPath);
689
+ try {
690
+ linkSync(temporaryPath, finalPath);
691
+ published = true;
692
+ publicationResult = true;
693
+ }
694
+ catch (error) {
695
+ if (!isErrno(error, 'EEXIST'))
696
+ failure = error;
697
+ }
698
+ }
699
+ catch (error) {
700
+ failure = error;
701
+ }
702
+ finally {
703
+ if (descriptor !== undefined) {
704
+ try {
705
+ closeSync(descriptor);
706
+ }
707
+ catch (error) {
708
+ failure ??= error;
709
+ }
710
+ }
711
+ try {
712
+ unlinkSync(temporaryPath);
713
+ }
714
+ catch (error) {
715
+ if (!published && !isErrno(error, 'ENOENT'))
716
+ failure ??= error;
717
+ }
718
+ }
719
+ if (failure !== undefined)
720
+ throw failure;
721
+ return publicationResult;
277
722
  }
278
723
  function createLockFile(lockPath, owner, trackOwnership) {
279
724
  const guardPath = mutationGuardPath(lockPath);
280
725
  if (trackOwnership && pathExists(guardPath))
281
726
  return false;
727
+ if (trackOwnership && pathExists(guardianPath(lockPath)))
728
+ return false;
729
+ if (pathExists(lockPath))
730
+ return false;
282
731
  for (let permissionAttempt = 0; permissionAttempt < 2; permissionAttempt++) {
283
732
  try {
284
- writeFileSync(lockPath, serializeOwner(owner), { flag: 'wx', mode: 0o600 });
733
+ if (!publishOwnerFile(lockPath, owner))
734
+ return false;
285
735
  if (trackOwnership && pathExists(guardPath)) {
286
736
  removeFileIfOwned(lockPath, owner);
287
737
  return false;
@@ -321,8 +771,39 @@ function tryAcquireMutationGuard(lockPath) {
321
771
  return null;
322
772
  return createLockFile(guardPath, guard, false) ? guard : null;
323
773
  }
324
- function releaseMutationGuard(lockPath, guard) {
325
- removeFileIfOwned(mutationGuardPath(lockPath), guard);
774
+ function releaseMutationGuard(lockPath, guard, onCleanupError) {
775
+ fileLockTestHooks.beforeMutationGuardRelease?.(mutationGuardPath(lockPath), lockPath);
776
+ return removeFileIfOwned(mutationGuardPath(lockPath), guard, onCleanupError);
777
+ }
778
+ function withMutationGuardRelease(lockPath, guard, operation, onCleanupError) {
779
+ let operationCompleted = false;
780
+ let operationResult;
781
+ let operationError;
782
+ try {
783
+ operationResult = operation();
784
+ operationCompleted = true;
785
+ }
786
+ catch (error) {
787
+ operationError = error;
788
+ }
789
+ let releaseError;
790
+ try {
791
+ if (!releaseMutationGuard(lockPath, guard, onCleanupError)) {
792
+ releaseError = new UnsafeLockPathError('path_changed');
793
+ }
794
+ }
795
+ catch (error) {
796
+ releaseError = error;
797
+ }
798
+ if (!operationCompleted) {
799
+ if (releaseError !== undefined) {
800
+ throw new AggregateError([operationError, releaseError], 'file lock mutation failed and mutation-guard release is unconfirmed');
801
+ }
802
+ throw operationError;
803
+ }
804
+ if (releaseError !== undefined)
805
+ throw releaseError;
806
+ return operationResult;
326
807
  }
327
808
  function reclaimStaleLock(lockPath) {
328
809
  const guard = tryAcquireMutationGuard(lockPath);
@@ -338,16 +819,242 @@ function reclaimStaleLock(lockPath) {
338
819
  releaseMutationGuard(lockPath, guard);
339
820
  }
340
821
  }
822
+ function reclaimStaleGuardian(lockPath) {
823
+ const guard = tryAcquireMutationGuard(lockPath);
824
+ if (guard === null)
825
+ return false;
826
+ try {
827
+ const observed = readLockGuardianSnapshot(lockPath);
828
+ if (observed === null)
829
+ return false;
830
+ const receiptPath = guardianPath(lockPath);
831
+ if (observed.record === null) {
832
+ if (!reclaimableSnapshot(receiptPath, observed.snapshot))
833
+ return false;
834
+ fileLockTestHooks.beforeGuardianReclaim?.(receiptPath, lockPath);
835
+ return removeUnchangedLock(receiptPath, observed.snapshot, 'guardian-malformed');
836
+ }
837
+ if (!guardianReclaimable(observed.record))
838
+ return false;
839
+ fileLockTestHooks.beforeGuardianReclaim?.(guardianPath(lockPath), lockPath);
840
+ const current = lockSnapshot(lockPath);
841
+ const locallyRetained = observed.record.from.pid === process.pid
842
+ && ownedLocks.get(lockKey(lockPath)) === undefined;
843
+ if (current !== null
844
+ && sameOwner(current.owner, observed.record.from)
845
+ && (locallyRetained || reclaimableSnapshot(lockPath, current))) {
846
+ removeUnchangedLock(lockPath, current, 'guardian-owner-stale');
847
+ }
848
+ return removeUnchangedLock(receiptPath, observed.snapshot, 'guardian-stale');
849
+ }
850
+ finally {
851
+ releaseMutationGuard(lockPath, guard);
852
+ }
853
+ }
854
+ function samePublicOwner(left, right) {
855
+ return left.pid === right.pid
856
+ && left.token === right.token
857
+ && sameProcessStartIdentity(left.processStartIdentity, right.processStart);
858
+ }
859
+ function observeTransferTarget(pid) {
860
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !pidAlive(pid)) {
861
+ return { reason: 'target_process_dead' };
862
+ }
863
+ const first = readFileLockProcessStartIdentity(pid);
864
+ if (first === null)
865
+ return { reason: 'target_process_unverifiable' };
866
+ if (!pidAlive(pid))
867
+ return { reason: 'target_process_dead' };
868
+ const second = readFileLockProcessStartIdentity(pid);
869
+ if (second === null)
870
+ return { reason: 'target_process_unverifiable' };
871
+ if (!sameProcessStartIdentity(first, second)) {
872
+ return { reason: 'target_process_pid_reused' };
873
+ }
874
+ if (!pidAlive(pid))
875
+ return { reason: 'target_process_dead' };
876
+ return { owner: { pid, token: randomUUID(), processStart: second } };
877
+ }
341
878
  /**
342
- * Cross-process O_EXCL file lock with crashed-owner recovery.
879
+ * Pre-arm an acquired lock with the exact child PID generation while retaining local ownership.
880
+ * A controller can therefore keep mutating its journal, while a hard controller crash cannot make
881
+ * the primary owner reclaimable until the child exits.
882
+ */
883
+ export function attachLockGuardianToProcess(lockPath, expectedOwner, targetPid) {
884
+ const key = lockKey(lockPath);
885
+ const owned = ownedLocks.get(key);
886
+ if (owned === undefined)
887
+ return { attached: false, reason: 'not_owned' };
888
+ if (owned.releasePending || !samePublicOwner(expectedOwner, owned.owner)) {
889
+ return { attached: false, reason: 'ownership_changed' };
890
+ }
891
+ const guard = tryAcquireMutationGuard(lockPath);
892
+ if (guard === null)
893
+ return { attached: false, reason: 'mutation_busy' };
894
+ const guarded = withMutationGuardRelease(lockPath, guard, () => {
895
+ const current = readLockSnapshot(lockPath);
896
+ if (current === null || !sameOwner(current.owner, owned.owner)) {
897
+ return { result: { attached: false, reason: 'ownership_changed' } };
898
+ }
899
+ if (owned.guardian !== undefined || readLockGuardianSnapshot(lockPath) !== null) {
900
+ return { result: { attached: false, reason: 'guardian_exists' } };
901
+ }
902
+ const target = observeTransferTarget(targetPid);
903
+ if ('reason' in target) {
904
+ return { result: { attached: false, reason: target.reason } };
905
+ }
906
+ const record = {
907
+ from: owned.owner,
908
+ guardian: target.owner,
909
+ };
910
+ if (!publishGuardianFile(lockPath, record)) {
911
+ return { result: { attached: false, reason: 'guardian_exists' } };
912
+ }
913
+ const publishedGuardian = readLockGuardianSnapshot(lockPath);
914
+ if (publishedGuardian?.record === null || publishedGuardian === null
915
+ || !sameGuardianRecord(publishedGuardian.record, record)) {
916
+ return { result: { attached: false, reason: 'path_changed' } };
917
+ }
918
+ fileLockTestHooks.afterGuardianPublish?.(guardianPath(lockPath), lockPath);
919
+ const settledGuardian = readLockGuardianSnapshot(lockPath);
920
+ const settled = lockSnapshot(lockPath);
921
+ const guardianUnchanged = settledGuardian !== null
922
+ && sameSnapshot(publishedGuardian.snapshot, settledGuardian.snapshot)
923
+ && settledGuardian.record !== null
924
+ && sameGuardianRecord(settledGuardian.record, record);
925
+ const ownerUnchanged = settled !== null
926
+ && sameSnapshot(current, settled)
927
+ && sameOwner(settled.owner, owned.owner);
928
+ if (!guardianUnchanged || !ownerUnchanged) {
929
+ if (guardianUnchanged) {
930
+ removeUnchangedLock(guardianPath(lockPath), settledGuardian.snapshot, 'guardian-transfer-aborted');
931
+ }
932
+ if (!ownerUnchanged)
933
+ ownedLocks.delete(key);
934
+ return { result: { attached: false, reason: 'path_changed' } };
935
+ }
936
+ forgetMalformedObservation(lockPath);
937
+ return {
938
+ result: {
939
+ attached: true,
940
+ reason: 'attached',
941
+ guardian: publicOwnerRecord(target.owner),
942
+ },
943
+ guardian: record,
944
+ };
945
+ });
946
+ if (guarded.guardian !== undefined) {
947
+ owned.guardian = guarded.guardian;
948
+ }
949
+ return guarded.result;
950
+ }
951
+ /**
952
+ * Drop local ownership only after an already-published guardian has been revalidated exactly.
953
+ * No file mutation is needed: the durable guardian receipt is the crash-safe owner binding.
954
+ */
955
+ export function retainLockGuardianForProcess(lockPath, expectedOwner, expectedGuardian) {
956
+ const key = lockKey(lockPath);
957
+ const owned = ownedLocks.get(key);
958
+ if (owned === undefined)
959
+ return { retained: false, reason: 'not_owned' };
960
+ if (owned.releasePending || !samePublicOwner(expectedOwner, owned.owner)) {
961
+ return { retained: false, reason: 'ownership_changed' };
962
+ }
963
+ if (owned.guardian === undefined)
964
+ return { retained: false, reason: 'guardian_missing' };
965
+ if (!samePublicOwner(expectedGuardian, owned.guardian.guardian)) {
966
+ return { retained: false, reason: 'ownership_changed' };
967
+ }
968
+ const guardian = owned.guardian;
969
+ const guard = tryAcquireMutationGuard(lockPath);
970
+ if (guard === null)
971
+ return { retained: false, reason: 'mutation_busy' };
972
+ const result = withMutationGuardRelease(lockPath, guard, () => {
973
+ const current = readLockSnapshot(lockPath);
974
+ const receipt = readLockGuardianSnapshot(lockPath);
975
+ if (current === null || !sameOwner(current.owner, owned.owner)) {
976
+ return { retained: false, reason: 'ownership_changed' };
977
+ }
978
+ if (receipt?.record === null || receipt === null
979
+ || !sameGuardianRecord(receipt.record, guardian)) {
980
+ return { retained: false, reason: 'path_changed' };
981
+ }
982
+ return { retained: true, reason: 'retained' };
983
+ });
984
+ if (result.retained) {
985
+ ownedLocks.delete(key);
986
+ }
987
+ return result;
988
+ }
989
+ /**
990
+ * Remove an exact attached guardian after the child has definitely exited, retaining the primary
991
+ * owner so rollback or another launch can continue under the same lifecycle lease.
992
+ */
993
+ export function clearLockGuardianForProcess(lockPath, expectedOwner, expectedGuardian) {
994
+ const key = lockKey(lockPath);
995
+ const owned = ownedLocks.get(key);
996
+ if (owned === undefined)
997
+ return { cleared: false, reason: 'not_owned' };
998
+ if (owned.releasePending || !samePublicOwner(expectedOwner, owned.owner)) {
999
+ return { cleared: false, reason: 'ownership_changed' };
1000
+ }
1001
+ if (owned.guardian === undefined
1002
+ || !samePublicOwner(expectedGuardian, owned.guardian.guardian)) {
1003
+ return { cleared: false, reason: 'ownership_changed' };
1004
+ }
1005
+ const guardian = owned.guardian;
1006
+ const guard = tryAcquireMutationGuard(lockPath);
1007
+ if (guard === null)
1008
+ return { cleared: false, reason: 'mutation_busy' };
1009
+ const result = withMutationGuardRelease(lockPath, guard, () => {
1010
+ const current = readLockSnapshot(lockPath);
1011
+ if (current === null || !sameOwner(current.owner, owned.owner)) {
1012
+ return { cleared: false, reason: 'ownership_changed' };
1013
+ }
1014
+ const receipt = readLockGuardianSnapshot(lockPath);
1015
+ if (receipt === null) {
1016
+ return { cleared: true, reason: 'missing' };
1017
+ }
1018
+ if (receipt.record === null || !sameGuardianRecord(receipt.record, guardian)) {
1019
+ return { cleared: false, reason: 'path_changed' };
1020
+ }
1021
+ if (!removeUnchangedLock(guardianPath(lockPath), receipt.snapshot, 'guardian-cleared')) {
1022
+ return { cleared: false, reason: 'path_changed' };
1023
+ }
1024
+ return { cleared: true, reason: 'cleared' };
1025
+ });
1026
+ if (result.cleared) {
1027
+ delete owned.guardian;
1028
+ }
1029
+ return result;
1030
+ }
1031
+ /**
1032
+ * Atomically bind an acquired lock to a still-running child and relinquish local ownership.
1033
+ */
1034
+ export function transferLockOwnershipToProcess(lockPath, expectedOwner, targetPid) {
1035
+ const attached = attachLockGuardianToProcess(lockPath, expectedOwner, targetPid);
1036
+ if (!attached.attached)
1037
+ return { transferred: false, reason: attached.reason };
1038
+ const retained = retainLockGuardianForProcess(lockPath, expectedOwner, attached.guardian);
1039
+ if (!retained.retained)
1040
+ return { transferred: false, reason: retained.reason };
1041
+ return {
1042
+ transferred: true,
1043
+ reason: 'transferred',
1044
+ guardian: attached.guardian,
1045
+ };
1046
+ }
1047
+ /**
1048
+ * Cross-process file lock with crashed-owner recovery.
343
1049
  *
344
1050
  * The lock file records the owner pid and token. If a waiter finds the lock held by a pid that is no longer
345
1051
  * alive (the owner crashed without releaseLock), it reclaims the stale lock instead of spinning
346
1052
  * until timeout — otherwise one crashed process would deadlock every future writer until the file
347
1053
  * is removed by hand. Empty or truncated locks are reclaimed only after the same inode and contents
348
1054
  * remain malformed for a grace period, so a live creator can finish publishing its owner payload.
349
- * Acquisition stays atomic (O_EXCL), and stale reclaim moves the old lock aside under a mutation
350
- * guard, then verifies the inode snapshot before deletion. A live owner's lock (including this
1055
+ * Acquisition publishes a fully synced temporary owner with an atomic no-replace hardlink, and stale
1056
+ * reclaim moves the old lock aside under a mutation guard, then verifies the inode snapshot before
1057
+ * deletion. A live owner's lock (including this
351
1058
  * process's own) is never stolen.
352
1059
  *
353
1060
  * NOTE: still synchronous (blocks the event loop while waiting) by design — it guards short
@@ -363,9 +1070,23 @@ export function acquireLock(lockPath, opts = {}) {
363
1070
  const maxTries = opts.maxTries ?? 300;
364
1071
  const waitMs = opts.waitMs ?? 10;
365
1072
  for (let i = 0; i < maxTries; i++) {
1073
+ const guardian = readLockGuardianSnapshot(lockPath);
1074
+ if (guardian !== null) {
1075
+ const reclaimable = guardian.record === null
1076
+ ? reclaimableSnapshot(guardianPath(lockPath), guardian.snapshot)
1077
+ : guardianReclaimable(guardian.record);
1078
+ if (!reclaimable) {
1079
+ syncSleep(waitMs);
1080
+ continue;
1081
+ }
1082
+ if (!reclaimStaleGuardian(lockPath)) {
1083
+ syncSleep(waitMs);
1084
+ continue;
1085
+ }
1086
+ }
366
1087
  const owner = newOwner();
367
1088
  if (createLockFile(lockPath, owner, true)) {
368
- return;
1089
+ return publicOwnerRecord(owner);
369
1090
  }
370
1091
  const current = lockSnapshot(lockPath);
371
1092
  if (current !== null && reclaimableSnapshot(lockPath, current)) {
@@ -391,46 +1112,64 @@ export function releaseLock(lockPath) {
391
1112
  owned.releasePending = true;
392
1113
  return { released: false, reason };
393
1114
  };
394
- // Release must not use lockOwner(): that helper intentionally folds path_changed into a conservative null
395
- // for acquire retries. Here, an inconclusive read marks a pending release so the next acquire can retry or
396
- // surface LOCK_RELEASE_FAILED instead of silently timing out behind this process's own PID.
397
- let raw;
1115
+ let guard;
398
1116
  try {
399
- raw = readOwnerBounded(lockPath);
1117
+ guard = tryAcquireMutationGuard(lockPath);
400
1118
  }
401
1119
  catch (error) {
402
1120
  return failed(error instanceof UnsafeLockPathError ? error.reason : 'release_failed');
403
1121
  }
404
- if (raw === null) {
405
- ownedLocks.delete(key);
406
- return { released: true, reason: 'missing' };
407
- }
408
- const current = parseOwner(raw);
409
- if (current === null)
410
- return failed('invalid_owner');
411
- if (!sameOwner(current, owned.owner)) {
412
- ownedLocks.delete(key);
413
- return { released: true, reason: 'ownership_changed' };
414
- }
415
- const releasedPath = sidecarPath(lockPath, 'released');
416
- try {
417
- renameSync(lockPath, releasedPath);
418
- }
419
- catch (error) {
420
- if (isErrno(error, 'ENOENT')) {
421
- ownedLocks.delete(key);
422
- return { released: true, reason: 'missing' };
423
- }
1122
+ if (guard === null)
424
1123
  return failed('release_failed');
425
- }
426
- ownedLocks.delete(key);
1124
+ let cleanupFailed = false;
1125
+ let forgetOwnership = false;
1126
+ let result;
427
1127
  try {
428
- removeFileIfExists(releasedPath);
1128
+ result = withMutationGuardRelease(lockPath, guard, () => {
1129
+ // Release must use exact snapshots. Inconclusive reads leave a pending release instead of
1130
+ // silently unlocking a live guarded child.
1131
+ const current = readLockSnapshot(lockPath);
1132
+ if (current === null) {
1133
+ if (owned.guardian !== undefined)
1134
+ return failed('ownership_changed');
1135
+ forgetOwnership = true;
1136
+ return { released: true, reason: 'missing' };
1137
+ }
1138
+ if (current.owner === null)
1139
+ return failed('invalid_owner');
1140
+ if (!sameOwner(current.owner, owned.owner)) {
1141
+ forgetOwnership = true;
1142
+ return { released: true, reason: 'ownership_changed' };
1143
+ }
1144
+ const receipt = readLockGuardianSnapshot(lockPath);
1145
+ if (owned.guardian !== undefined) {
1146
+ if (receipt !== null) {
1147
+ if (receipt.record === null || !sameGuardianRecord(receipt.record, owned.guardian)) {
1148
+ return failed('path_changed');
1149
+ }
1150
+ if (!removeUnchangedLock(guardianPath(lockPath), receipt.snapshot, 'guardian-released', () => { cleanupFailed = true; })) {
1151
+ return failed('path_changed');
1152
+ }
1153
+ }
1154
+ }
1155
+ else if (receipt !== null) {
1156
+ return failed('path_changed');
1157
+ }
1158
+ if (!removeUnchangedLock(lockPath, current, 'released', () => { cleanupFailed = true; })) {
1159
+ return failed('path_changed');
1160
+ }
1161
+ forgetOwnership = true;
1162
+ return { released: true, reason: 'released' };
1163
+ }, () => { cleanupFailed = true; });
429
1164
  }
430
- catch {
431
- return { released: true, reason: 'released_with_cleanup_error' };
1165
+ catch (error) {
1166
+ return failed(error instanceof UnsafeLockPathError ? error.reason : 'release_failed');
432
1167
  }
433
- return { released: true, reason: 'released' };
1168
+ if (result.released && forgetOwnership)
1169
+ ownedLocks.delete(key);
1170
+ return result.released && cleanupFailed
1171
+ ? { released: true, reason: 'released_with_cleanup_error' }
1172
+ : result;
434
1173
  }
435
1174
  export class LockReleaseError extends Error {
436
1175
  reason;