botmux 3.6.0 → 3.7.0

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 (177) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/riff-backend.d.ts +7 -6
  3. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/riff-backend.js +8 -7
  5. package/dist/adapters/backend/riff-backend.js.map +1 -1
  6. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  7. package/dist/adapters/backend/sandbox.js +5 -2
  8. package/dist/adapters/backend/sandbox.js.map +1 -1
  9. package/dist/adapters/cli/codex.d.ts.map +1 -1
  10. package/dist/adapters/cli/codex.js +15 -6
  11. package/dist/adapters/cli/codex.js.map +1 -1
  12. package/dist/adapters/cli/fs-policy.d.ts +62 -6
  13. package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
  14. package/dist/adapters/cli/fs-policy.js +70 -7
  15. package/dist/adapters/cli/fs-policy.js.map +1 -1
  16. package/dist/bot-registry.d.ts +13 -0
  17. package/dist/bot-registry.d.ts.map +1 -1
  18. package/dist/bot-registry.js +15 -7
  19. package/dist/bot-registry.js.map +1 -1
  20. package/dist/cli/daemon-lifecycle-env.d.ts +1 -1
  21. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
  22. package/dist/cli/daemon-lifecycle-env.js +12 -0
  23. package/dist/cli/daemon-lifecycle-env.js.map +1 -1
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +23 -7
  26. package/dist/cli.js.map +1 -1
  27. package/dist/core/command-handler.d.ts +7 -5
  28. package/dist/core/command-handler.d.ts.map +1 -1
  29. package/dist/core/command-handler.js +37 -21
  30. package/dist/core/command-handler.js.map +1 -1
  31. package/dist/core/cost-calculator.d.ts +3 -0
  32. package/dist/core/cost-calculator.d.ts.map +1 -1
  33. package/dist/core/cost-calculator.js.map +1 -1
  34. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  35. package/dist/core/dashboard-ipc-server.js +12 -9
  36. package/dist/core/dashboard-ipc-server.js.map +1 -1
  37. package/dist/core/dashboard-rows.d.ts.map +1 -1
  38. package/dist/core/dashboard-rows.js +1 -0
  39. package/dist/core/dashboard-rows.js.map +1 -1
  40. package/dist/core/initial-user-turn.d.ts +14 -0
  41. package/dist/core/initial-user-turn.d.ts.map +1 -0
  42. package/dist/core/initial-user-turn.js +65 -0
  43. package/dist/core/initial-user-turn.js.map +1 -0
  44. package/dist/core/maintenance.d.ts.map +1 -1
  45. package/dist/core/maintenance.js +6 -0
  46. package/dist/core/maintenance.js.map +1 -1
  47. package/dist/core/per-bot-env.d.ts.map +1 -1
  48. package/dist/core/per-bot-env.js +10 -0
  49. package/dist/core/per-bot-env.js.map +1 -1
  50. package/dist/core/plugins/pm2.d.ts.map +1 -1
  51. package/dist/core/plugins/pm2.js +4 -0
  52. package/dist/core/plugins/pm2.js.map +1 -1
  53. package/dist/core/session-discovery.d.ts +23 -0
  54. package/dist/core/session-discovery.d.ts.map +1 -1
  55. package/dist/core/session-discovery.js +162 -82
  56. package/dist/core/session-discovery.js.map +1 -1
  57. package/dist/core/session-manager.d.ts.map +1 -1
  58. package/dist/core/session-manager.js +23 -4
  59. package/dist/core/session-manager.js.map +1 -1
  60. package/dist/core/worker-pool.d.ts +2 -0
  61. package/dist/core/worker-pool.d.ts.map +1 -1
  62. package/dist/core/worker-pool.js +27 -4
  63. package/dist/core/worker-pool.js.map +1 -1
  64. package/dist/daemon.d.ts +18 -0
  65. package/dist/daemon.d.ts.map +1 -1
  66. package/dist/daemon.js +205 -39
  67. package/dist/daemon.js.map +1 -1
  68. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  69. package/dist/dashboard/bot-onboarding.js +29 -5
  70. package/dist/dashboard/bot-onboarding.js.map +1 -1
  71. package/dist/dashboard/bot-payload.js +3 -3
  72. package/dist/dashboard/bot-payload.js.map +1 -1
  73. package/dist/dashboard/web/bot-defaults-page.js +5 -5
  74. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  75. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  76. package/dist/dashboard/web/bot-onboarding.js +1 -1
  77. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  78. package/dist/dashboard/web/i18n.js +18 -18
  79. package/dist/dashboard/web/i18n.js.map +1 -1
  80. package/dist/dashboard-web/app.js +1 -1
  81. package/dist/dashboard-web/chunks/{bot-defaults-page-VQMPUJ4Y.js → bot-defaults-page-UKA7K4JV.js} +2 -2
  82. package/dist/dashboard-web/chunks/{chunk-NP4JUBGS.js → chunk-3QAUJXAS.js} +1 -1
  83. package/dist/dashboard-web/chunks/{chunk-JMRGZQDB.js → chunk-FLGXZJTD.js} +1 -1
  84. package/dist/dashboard-web/chunks/chunk-FNPGOZMX.js +1 -0
  85. package/dist/dashboard-web/chunks/{chunk-SNKATZWK.js → chunk-KQB42ML5.js} +4 -4
  86. package/dist/dashboard-web/chunks/{connectors-page-DOY4J3ZL.js → connectors-page-A77YAB5Y.js} +1 -1
  87. package/dist/dashboard-web/chunks/{groups-page-74TS2DIH.js → groups-page-RUZNVXQB.js} +1 -1
  88. package/dist/dashboard-web/chunks/{insights-page-AYCO65JC.js → insights-page-RF7U7RTA.js} +1 -1
  89. package/dist/dashboard-web/chunks/{monitor-room-YKYLTSAN.js → monitor-room-7IVIS75W.js} +1 -1
  90. package/dist/dashboard-web/chunks/{monitoring-page-QBIUNRBR.js → monitoring-page-5SSJRQZF.js} +1 -1
  91. package/dist/dashboard-web/chunks/{office-page-4PH2A26P.js → office-page-EOND3L5D.js} +1 -1
  92. package/dist/dashboard-web/chunks/{overview-page-H73D5UT4.js → overview-page-NISBVBBJ.js} +1 -1
  93. package/dist/dashboard-web/chunks/{roles-page-U4BTOQ7F.js → roles-page-BUMKD7Y4.js} +1 -1
  94. package/dist/dashboard-web/chunks/{schedules-page-RHRHX7R7.js → schedules-page-AVOOKLUH.js} +1 -1
  95. package/dist/dashboard-web/chunks/{sessions-page-VSC6AM5G.js → sessions-page-6HLSEAB7.js} +2 -2
  96. package/dist/dashboard-web/chunks/{settings-page-LF3RKM5P.js → settings-page-MJDZHQ4X.js} +1 -1
  97. package/dist/dashboard-web/chunks/{skills-page-SYOIGGXH.js → skills-page-5MW54PCC.js} +1 -1
  98. package/dist/dashboard-web/chunks/{team-federation-page-RMBLASTW.js → team-federation-page-2SNIMVXI.js} +1 -1
  99. package/dist/dashboard-web/chunks/{v3-page-XJXKKJRN.js → v3-page-W6SBXXQC.js} +1 -1
  100. package/dist/dashboard-web/chunks/{whiteboards-page-K3YVSTHJ.js → whiteboards-page-STC54KTP.js} +1 -1
  101. package/dist/dashboard-web/style.css +5 -0
  102. package/dist/dashboard.js +2 -2
  103. package/dist/dashboard.js.map +1 -1
  104. package/dist/desktop/main/dashboard-compat.js +1 -1
  105. package/dist/i18n/en.js +6 -6
  106. package/dist/i18n/en.js.map +1 -1
  107. package/dist/i18n/zh.js +6 -6
  108. package/dist/i18n/zh.js.map +1 -1
  109. package/dist/im/lark/card-builder.d.ts.map +1 -1
  110. package/dist/im/lark/card-builder.js +5 -4
  111. package/dist/im/lark/card-builder.js.map +1 -1
  112. package/dist/im/lark/card-handler.d.ts.map +1 -1
  113. package/dist/im/lark/card-handler.js +40 -7
  114. package/dist/im/lark/card-handler.js.map +1 -1
  115. package/dist/im/lark/client.d.ts.map +1 -1
  116. package/dist/im/lark/client.js +82 -1
  117. package/dist/im/lark/client.js.map +1 -1
  118. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  119. package/dist/im/lark/event-dispatcher.js +46 -12
  120. package/dist/im/lark/event-dispatcher.js.map +1 -1
  121. package/dist/im/lark/relay-target-routing.d.ts.map +1 -1
  122. package/dist/im/lark/relay-target-routing.js +12 -11
  123. package/dist/im/lark/relay-target-routing.js.map +1 -1
  124. package/dist/im/lark/reply-mode-command.d.ts.map +1 -1
  125. package/dist/im/lark/reply-mode-command.js +4 -3
  126. package/dist/im/lark/reply-mode-command.js.map +1 -1
  127. package/dist/index-daemon.js +6 -1
  128. package/dist/index-daemon.js.map +1 -1
  129. package/dist/mir-runner.js +1 -1
  130. package/dist/mir-runner.js.map +1 -1
  131. package/dist/services/bot-config-store.d.ts +1 -1
  132. package/dist/services/bot-config-store.d.ts.map +1 -1
  133. package/dist/services/bot-config-store.js +1 -1
  134. package/dist/services/bot-config-store.js.map +1 -1
  135. package/dist/services/card-prefs-store.js +8 -8
  136. package/dist/services/card-prefs-store.js.map +1 -1
  137. package/dist/services/chat-reply-mode-store.d.ts.map +1 -1
  138. package/dist/services/chat-reply-mode-store.js +16 -11
  139. package/dist/services/chat-reply-mode-store.js.map +1 -1
  140. package/dist/services/insight/types.d.ts +3 -1
  141. package/dist/services/insight/types.d.ts.map +1 -1
  142. package/dist/services/insight/types.js.map +1 -1
  143. package/dist/services/project-scanner.d.ts +4 -4
  144. package/dist/services/project-scanner.d.ts.map +1 -1
  145. package/dist/services/project-scanner.js +13 -5
  146. package/dist/services/project-scanner.js.map +1 -1
  147. package/dist/services/transcript-resolver.d.ts +4 -0
  148. package/dist/services/transcript-resolver.d.ts.map +1 -1
  149. package/dist/services/transcript-resolver.js +53 -5
  150. package/dist/services/transcript-resolver.js.map +1 -1
  151. package/dist/services/usage-ledger.d.ts.map +1 -1
  152. package/dist/services/usage-ledger.js +1 -0
  153. package/dist/services/usage-ledger.js.map +1 -1
  154. package/dist/setup/bot-config-editor.d.ts +37 -1
  155. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  156. package/dist/setup/bot-config-editor.js +61 -3
  157. package/dist/setup/bot-config-editor.js.map +1 -1
  158. package/dist/setup/setup-args.js +1 -1
  159. package/dist/setup/setup-args.js.map +1 -1
  160. package/dist/types.d.ts +20 -0
  161. package/dist/types.d.ts.map +1 -1
  162. package/dist/utils/allowed-users-apply.d.ts.map +1 -1
  163. package/dist/utils/allowed-users-apply.js +3 -2
  164. package/dist/utils/allowed-users-apply.js.map +1 -1
  165. package/dist/utils/child-env.d.ts +31 -2
  166. package/dist/utils/child-env.d.ts.map +1 -1
  167. package/dist/utils/child-env.js +61 -3
  168. package/dist/utils/child-env.js.map +1 -1
  169. package/dist/utils/file-lock.d.ts +10 -0
  170. package/dist/utils/file-lock.d.ts.map +1 -1
  171. package/dist/utils/file-lock.js +295 -53
  172. package/dist/utils/file-lock.js.map +1 -1
  173. package/dist/worker.js +52 -3
  174. package/dist/worker.js.map +1 -1
  175. package/dist/workflows/v3/run-envelope.d.ts +24 -24
  176. package/package.json +1 -1
  177. package/dist/dashboard-web/chunks/chunk-P327LXOT.js +0 -1
@@ -24,7 +24,7 @@
24
24
  * don't need it and the equality check would re-open the stale-break race.)
25
25
  */
26
26
  import { closeSync, constants, fstatSync, linkSync, lstatSync, openSync, promises as fsp, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
27
- import { createHash } from 'node:crypto';
27
+ import { createHash, randomUUID } from 'node:crypto';
28
28
  import { basename, dirname, join } from 'node:path';
29
29
  import { readLinuxBootIdentity, readProcessStartIdentity } from '../core/session-marker.js';
30
30
  import { logger } from './logger.js';
@@ -42,6 +42,11 @@ const MIN_INVALID_HOLDER_STALE_AGE_MS = 1_000;
42
42
  const STALE_CLAIM_PREFIX = '.botmux-stale-claim-';
43
43
  const STALE_CLAIM_OWNER_SUFFIX = '.owner-';
44
44
  const STALE_CLAIM_OWNER_WIDTH = 12;
45
+ const STALE_CLAIM_OWNER_CANDIDATE_SUFFIX = '.candidate-';
46
+ let fileLockTestHooks;
47
+ export function __testOnly_setFileLockHooks(hooks) {
48
+ fileLockTestHooks = hooks;
49
+ }
45
50
  let selfProcStartResolved = false;
46
51
  let selfProcStart;
47
52
  let selfBootIdResolved = false;
@@ -157,24 +162,28 @@ async function releaseOwnedLock(lockPath, owned) {
157
162
  const current = await fsp.lstat(lockPath);
158
163
  if (current.isFile() && !current.isSymbolicLink() && sameInode(current, owned)) {
159
164
  await fsp.unlink(lockPath);
165
+ return true;
160
166
  }
161
167
  }
162
168
  catch (error) {
163
169
  if (error.code !== 'ENOENT')
164
170
  throw error;
165
171
  }
172
+ return false;
166
173
  }
167
174
  function releaseOwnedLockSync(lockPath, owned) {
168
175
  try {
169
176
  const current = lstatSync(lockPath);
170
177
  if (current.isFile() && !current.isSymbolicLink() && sameInode(current, owned)) {
171
178
  unlinkSync(lockPath);
179
+ return true;
172
180
  }
173
181
  }
174
182
  catch (error) {
175
183
  if (error.code !== 'ENOENT')
176
184
  throw error;
177
185
  }
186
+ return false;
178
187
  }
179
188
  function staleClaimPathFor(lockPath, observed) {
180
189
  // link(2) changes ctime, so the generation deliberately uses only identity
@@ -206,17 +215,87 @@ function parseStaleClaimOwnerEpoch(claimPath, name) {
206
215
  const epoch = Number(raw);
207
216
  return Number.isSafeInteger(epoch) ? epoch : undefined;
208
217
  }
218
+ function staleClaimOwnerCandidatePath(ownerPath) {
219
+ return `${ownerPath}${STALE_CLAIM_OWNER_CANDIDATE_SUFFIX}${process.pid}-${randomUUID()}`;
220
+ }
221
+ function isStaleClaimOwnerCandidate(claimPath, name) {
222
+ const prefix = `${basename(claimPath)}${STALE_CLAIM_OWNER_SUFFIX}`;
223
+ if (!name.startsWith(prefix))
224
+ return false;
225
+ const suffix = name.slice(prefix.length);
226
+ return new RegExp(`^\\d{${STALE_CLAIM_OWNER_WIDTH}}\\${STALE_CLAIM_OWNER_CANDIDATE_SUFFIX}` +
227
+ '\\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$').test(suffix);
228
+ }
229
+ function pinnedHolderMetadataState(before, after) {
230
+ if (!before.isFile() || !after.isFile() || !sameInode(before, after))
231
+ return 'untrusted';
232
+ const sizeChanged = before.size !== after.size;
233
+ const mtimeChanged = before.mtimeMs !== after.mtimeMs;
234
+ const ctimeChanged = before.ctimeMs !== after.ctimeMs;
235
+ const linkCountChanged = before.nlink !== after.nlink;
236
+ // Compatibility with an older publisher that exposed the O_EXCL inode
237
+ // before writing its identity. Never trust either snapshot, but let the
238
+ // caller retry once the payload has become stable.
239
+ if (before.size === 0 && (sizeChanged || mtimeChanged || ctimeChanged)) {
240
+ return 'transient';
241
+ }
242
+ // Content-relevant metadata changing on a non-empty identity is not a
243
+ // normal owner lifecycle transition. A size or mtime change fails closed
244
+ // here. Note we cannot detect a same-size rewrite that also restores mtime
245
+ // purely from metadata: ctime often does not advance within it at ms
246
+ // resolution (e.g. ext4), so ctime is not a reliable content-tamper signal.
247
+ // The legitimate owner protocol never rewrites an epoch in place (O_EXCL
248
+ // once, then a new epoch pathname), so this residual window is defense in
249
+ // depth rather than a live hazard.
250
+ if (sizeChanged || mtimeChanged)
251
+ return 'untrusted';
252
+ // link(2)/unlink(2) legitimately change ctime and nlink without touching
253
+ // bytes. This means the pinned inode lost a publication/cleanup race; do not
254
+ // use the observation, and do not surface it as a business error.
255
+ if (linkCountChanged)
256
+ return 'transient';
257
+ if (ctimeChanged)
258
+ return 'untrusted';
259
+ if (before.nlink < 1 || after.nlink < 1)
260
+ return 'transient';
261
+ return 'stable';
262
+ }
263
+ function pinnedHolderIntegrityError() {
264
+ return new Error('file-lock stale-claim owner changed while reading');
265
+ }
209
266
  async function readPinnedHolder(path) {
210
267
  const handle = await fsp.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
211
268
  try {
212
269
  const before = await handle.stat();
270
+ if (fileLockTestHooks?.afterPinnedHolderFirstStat) {
271
+ await fileLockTestHooks.afterPinnedHolderFirstStat(path);
272
+ }
213
273
  const raw = await handle.readFile('utf8');
214
274
  const after = await handle.stat();
215
- if (!before.isFile() || !sameInode(before, after) || before.size !== after.size ||
216
- before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
217
- throw new Error('file-lock stale-claim owner changed while reading');
275
+ const pinnedState = pinnedHolderMetadataState(before, after);
276
+ if (pinnedState === 'transient')
277
+ return undefined;
278
+ if (pinnedState === 'untrusted')
279
+ throw pinnedHolderIntegrityError();
280
+ let current;
281
+ try {
282
+ current = await fsp.lstat(path);
218
283
  }
219
- return { holder: parseLockHolder(raw), ageMs: Date.now() - after.mtimeMs, stats: after };
284
+ catch (error) {
285
+ if (error.code === 'ENOENT')
286
+ return undefined;
287
+ throw error;
288
+ }
289
+ if (!current.isFile() || current.isSymbolicLink())
290
+ throw pinnedHolderIntegrityError();
291
+ if (!sameInode(after, current))
292
+ return undefined;
293
+ const pathnameState = pinnedHolderMetadataState(after, current);
294
+ if (pathnameState === 'transient')
295
+ return undefined;
296
+ if (pathnameState === 'untrusted')
297
+ throw pinnedHolderIntegrityError();
298
+ return { holder: parseLockHolder(raw), ageMs: Date.now() - current.mtimeMs, stats: current };
220
299
  }
221
300
  finally {
222
301
  await handle.close();
@@ -226,18 +305,76 @@ function readPinnedHolderSync(path) {
226
305
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
227
306
  try {
228
307
  const before = fstatSync(fd);
308
+ fileLockTestHooks?.afterPinnedHolderFirstStatSync?.(path);
229
309
  const raw = readFileSync(fd, 'utf8');
230
310
  const after = fstatSync(fd);
231
- if (!before.isFile() || !sameInode(before, after) || before.size !== after.size ||
232
- before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
233
- throw new Error('file-lock stale-claim owner changed while reading');
311
+ const pinnedState = pinnedHolderMetadataState(before, after);
312
+ if (pinnedState === 'transient')
313
+ return undefined;
314
+ if (pinnedState === 'untrusted')
315
+ throw pinnedHolderIntegrityError();
316
+ let current;
317
+ try {
318
+ current = lstatSync(path);
319
+ }
320
+ catch (error) {
321
+ if (error.code === 'ENOENT')
322
+ return undefined;
323
+ throw error;
234
324
  }
235
- return { holder: parseLockHolder(raw), ageMs: Date.now() - after.mtimeMs, stats: after };
325
+ if (!current.isFile() || current.isSymbolicLink())
326
+ throw pinnedHolderIntegrityError();
327
+ if (!sameInode(after, current))
328
+ return undefined;
329
+ const pathnameState = pinnedHolderMetadataState(after, current);
330
+ if (pathnameState === 'transient')
331
+ return undefined;
332
+ if (pathnameState === 'untrusted')
333
+ throw pinnedHolderIntegrityError();
334
+ return { holder: parseLockHolder(raw), ageMs: Date.now() - current.mtimeMs, stats: current };
236
335
  }
237
336
  finally {
238
337
  closeSync(fd);
239
338
  }
240
339
  }
340
+ async function unlinkUnchangedPinnedPath(path, pinned) {
341
+ let current;
342
+ try {
343
+ current = await fsp.lstat(path);
344
+ }
345
+ catch (error) {
346
+ if (error.code === 'ENOENT')
347
+ return false;
348
+ throw error;
349
+ }
350
+ if (!current.isFile() || current.isSymbolicLink())
351
+ throw pinnedHolderIntegrityError();
352
+ if (!sameInode(current, pinned))
353
+ return false;
354
+ if (pinnedHolderMetadataState(pinned, current) !== 'stable')
355
+ return false;
356
+ await fsp.unlink(path);
357
+ return true;
358
+ }
359
+ function unlinkUnchangedPinnedPathSync(path, pinned) {
360
+ let current;
361
+ try {
362
+ current = lstatSync(path);
363
+ }
364
+ catch (error) {
365
+ if (error.code === 'ENOENT')
366
+ return false;
367
+ throw error;
368
+ }
369
+ if (!current.isFile() || current.isSymbolicLink())
370
+ throw pinnedHolderIntegrityError();
371
+ if (!sameInode(current, pinned))
372
+ return false;
373
+ if (pinnedHolderMetadataState(pinned, current) !== 'stable')
374
+ return false;
375
+ unlinkSync(path);
376
+ return true;
377
+ }
241
378
  async function tryAcquireStaleClaimOwnership(claimPath, claimWasCreated, minStaleAgeMs) {
242
379
  let claim;
243
380
  try {
@@ -275,6 +412,8 @@ async function tryAcquireStaleClaimOwnership(claimPath, claimWasCreated, minStal
275
412
  return undefined;
276
413
  throw error;
277
414
  }
415
+ if (!observedOwner)
416
+ return undefined;
278
417
  const staleAge = observedOwner.holder
279
418
  ? minStaleAgeMs
280
419
  : Math.max(minStaleAgeMs, MIN_INVALID_HOLDER_STALE_AGE_MS);
@@ -288,9 +427,10 @@ async function tryAcquireStaleClaimOwnership(claimPath, claimWasCreated, minStal
288
427
  }
289
428
  }
290
429
  const ownerPath = staleClaimOwnerPath(claimPath, nextEpoch);
430
+ const candidatePath = staleClaimOwnerCandidatePath(ownerPath);
291
431
  let handle;
292
432
  try {
293
- handle = await fsp.open(ownerPath, 'wx');
433
+ handle = await fsp.open(candidatePath, 'wx');
294
434
  }
295
435
  catch (error) {
296
436
  if (error.code === 'EEXIST' ||
@@ -298,31 +438,58 @@ async function tryAcquireStaleClaimOwnership(claimPath, claimWasCreated, minStal
298
438
  return undefined;
299
439
  throw error;
300
440
  }
301
- try {
302
- // As with the public lock, publish the identity without an async gap.
303
- writeFileSync(handle.fd, currentLockHolderPayload());
304
- const owner = await handle.stat();
305
- const currentClaim = await fsp.lstat(claimPath);
306
- if (!owner.isFile() || !sameInode(currentClaim, claim)) {
441
+ let owned;
442
+ const abandonCandidate = async () => {
443
+ try {
307
444
  await handle.close();
445
+ }
446
+ catch { /* tolerate */ }
447
+ if (owned) {
448
+ try {
449
+ await releaseOwnedLock(ownerPath, owned);
450
+ }
451
+ catch { /* tolerate */ }
452
+ try {
453
+ await releaseOwnedLock(candidatePath, owned);
454
+ }
455
+ catch { /* tolerate */ }
456
+ }
457
+ else {
308
458
  try {
309
- await releaseOwnedLock(ownerPath, owner);
459
+ await fsp.unlink(candidatePath);
310
460
  }
311
461
  catch { /* tolerate */ }
462
+ }
463
+ };
464
+ try {
465
+ writeFileSync(handle.fd, currentLockHolderPayload());
466
+ owned = await handle.stat();
467
+ if (!owned.isFile())
468
+ throw new Error('file-lock stale-claim owner candidate is not a regular file');
469
+ const currentClaimBeforePublish = await fsp.lstat(claimPath);
470
+ if (!sameInode(currentClaimBeforePublish, claim)) {
471
+ await abandonCandidate();
312
472
  return undefined;
313
473
  }
314
- return { ownerPath, handle };
315
- }
316
- catch (error) {
317
- try {
318
- await handle.close();
474
+ if (fileLockTestHooks?.beforeStaleClaimOwnerPublish) {
475
+ await fileLockTestHooks.beforeStaleClaimOwnerPublish(ownerPath);
319
476
  }
320
- catch { /* tolerate */ }
321
- try {
322
- await fsp.unlink(ownerPath);
477
+ // link(2) is an atomic no-overwrite publication: readers can observe
478
+ // either no epoch pathname or the complete identity, never an empty file.
479
+ await fsp.link(candidatePath, ownerPath);
480
+ const publishedOwner = await fsp.lstat(ownerPath);
481
+ const currentClaim = await fsp.lstat(claimPath);
482
+ if (!publishedOwner.isFile() || publishedOwner.isSymbolicLink() ||
483
+ !sameInode(publishedOwner, owned) || !sameInode(currentClaim, claim)) {
484
+ await abandonCandidate();
485
+ return undefined;
323
486
  }
324
- catch { /* tolerate */ }
325
- if (error.code === 'ENOENT')
487
+ return { ownerPath, candidatePath, owned, handle };
488
+ }
489
+ catch (error) {
490
+ await abandonCandidate();
491
+ if (error.code === 'EEXIST' ||
492
+ error.code === 'ENOENT')
326
493
  return undefined;
327
494
  throw error;
328
495
  }
@@ -362,6 +529,8 @@ function tryAcquireStaleClaimOwnershipSync(claimPath, claimWasCreated, minStaleA
362
529
  return undefined;
363
530
  throw error;
364
531
  }
532
+ if (!observedOwner)
533
+ return undefined;
365
534
  const staleAge = observedOwner.holder
366
535
  ? minStaleAgeMs
367
536
  : Math.max(minStaleAgeMs, MIN_INVALID_HOLDER_STALE_AGE_MS);
@@ -375,9 +544,10 @@ function tryAcquireStaleClaimOwnershipSync(claimPath, claimWasCreated, minStaleA
375
544
  }
376
545
  }
377
546
  const ownerPath = staleClaimOwnerPath(claimPath, nextEpoch);
547
+ const candidatePath = staleClaimOwnerCandidatePath(ownerPath);
378
548
  let fd;
379
549
  try {
380
- fd = openSync(ownerPath, 'wx');
550
+ fd = openSync(candidatePath, 'wx');
381
551
  }
382
552
  catch (error) {
383
553
  if (error.code === 'EEXIST' ||
@@ -385,30 +555,54 @@ function tryAcquireStaleClaimOwnershipSync(claimPath, claimWasCreated, minStaleA
385
555
  return undefined;
386
556
  throw error;
387
557
  }
388
- try {
389
- writeFileSync(fd, currentLockHolderPayload());
390
- const owner = fstatSync(fd);
391
- const currentClaim = lstatSync(claimPath);
392
- if (!owner.isFile() || !sameInode(currentClaim, claim)) {
558
+ let owned;
559
+ const abandonCandidate = () => {
560
+ try {
393
561
  closeSync(fd);
562
+ }
563
+ catch { /* tolerate */ }
564
+ if (owned) {
394
565
  try {
395
- releaseOwnedLockSync(ownerPath, owner);
566
+ releaseOwnedLockSync(ownerPath, owned);
396
567
  }
397
568
  catch { /* tolerate */ }
569
+ try {
570
+ releaseOwnedLockSync(candidatePath, owned);
571
+ }
572
+ catch { /* tolerate */ }
573
+ }
574
+ else {
575
+ try {
576
+ unlinkSync(candidatePath);
577
+ }
578
+ catch { /* tolerate */ }
579
+ }
580
+ };
581
+ try {
582
+ writeFileSync(fd, currentLockHolderPayload());
583
+ owned = fstatSync(fd);
584
+ if (!owned.isFile())
585
+ throw new Error('file-lock stale-claim owner candidate is not a regular file');
586
+ const currentClaimBeforePublish = lstatSync(claimPath);
587
+ if (!sameInode(currentClaimBeforePublish, claim)) {
588
+ abandonCandidate();
398
589
  return undefined;
399
590
  }
400
- return { ownerPath, fd };
591
+ fileLockTestHooks?.beforeStaleClaimOwnerPublishSync?.(ownerPath);
592
+ linkSync(candidatePath, ownerPath);
593
+ const publishedOwner = lstatSync(ownerPath);
594
+ const currentClaim = lstatSync(claimPath);
595
+ if (!publishedOwner.isFile() || publishedOwner.isSymbolicLink() ||
596
+ !sameInode(publishedOwner, owned) || !sameInode(currentClaim, claim)) {
597
+ abandonCandidate();
598
+ return undefined;
599
+ }
600
+ return { ownerPath, candidatePath, owned, fd };
401
601
  }
402
602
  catch (error) {
403
- try {
404
- closeSync(fd);
405
- }
406
- catch { /* tolerate */ }
407
- try {
408
- unlinkSync(ownerPath);
409
- }
410
- catch { /* tolerate */ }
411
- if (error.code === 'ENOENT')
603
+ abandonCandidate();
604
+ if (error.code === 'EEXIST' ||
605
+ error.code === 'ENOENT')
412
606
  return undefined;
413
607
  throw error;
414
608
  }
@@ -419,15 +613,20 @@ async function cleanStaleClaimOwnership(claimPath, ownership) {
419
613
  }
420
614
  catch { /* tolerate */ }
421
615
  try {
422
- await fsp.unlink(ownership.ownerPath);
616
+ await releaseOwnedLock(ownership.ownerPath, ownership.owned);
617
+ }
618
+ catch { /* tolerate */ }
619
+ try {
620
+ await releaseOwnedLock(ownership.candidatePath, ownership.owned);
423
621
  }
424
622
  catch { /* tolerate */ }
425
623
  // Older dead epochs are no longer security-relevant after the claim itself
426
624
  // is gone. Clean them best-effort; a loser that was already in flight may
427
- // leave another harmless orphan owner file behind.
625
+ // leave another harmless orphan owner/candidate file behind.
428
626
  try {
429
627
  const names = await fsp.readdir(dirname(claimPath));
430
- await Promise.all(names.filter(name => parseStaleClaimOwnerEpoch(claimPath, name) !== undefined).map(async (name) => {
628
+ await Promise.all(names.filter(name => parseStaleClaimOwnerEpoch(claimPath, name) !== undefined ||
629
+ isStaleClaimOwnerCandidate(claimPath, name)).map(async (name) => {
431
630
  try {
432
631
  await fsp.unlink(join(dirname(claimPath), name));
433
632
  }
@@ -442,12 +641,17 @@ function cleanStaleClaimOwnershipSync(claimPath, ownership) {
442
641
  }
443
642
  catch { /* tolerate */ }
444
643
  try {
445
- unlinkSync(ownership.ownerPath);
644
+ releaseOwnedLockSync(ownership.ownerPath, ownership.owned);
645
+ }
646
+ catch { /* tolerate */ }
647
+ try {
648
+ releaseOwnedLockSync(ownership.candidatePath, ownership.owned);
446
649
  }
447
650
  catch { /* tolerate */ }
448
651
  try {
449
652
  for (const name of readdirSync(dirname(claimPath))) {
450
- if (parseStaleClaimOwnerEpoch(claimPath, name) === undefined)
653
+ if (parseStaleClaimOwnerEpoch(claimPath, name) === undefined &&
654
+ !isStaleClaimOwnerCandidate(claimPath, name))
451
655
  continue;
452
656
  try {
453
657
  unlinkSync(join(dirname(claimPath), name));
@@ -474,12 +678,21 @@ async function resumeStaleBreak(lockPath, claimPath, observed, claimWasCreated,
474
678
  return false;
475
679
  }
476
680
  let current;
681
+ let publicPathMissing = false;
477
682
  try {
478
683
  current = await readPinnedHolder(lockPath);
479
684
  }
480
685
  catch (error) {
481
686
  if (error.code !== 'ENOENT')
482
687
  throw error;
688
+ publicPathMissing = true;
689
+ }
690
+ if (!current && !publicPathMissing) {
691
+ // The pinned inode or its pathname changed during validation. Withdraw
692
+ // this generation and retry from a fresh public-path observation.
693
+ await releaseOwnedLock(claimPath, claim);
694
+ claimRemoved = true;
695
+ return false;
483
696
  }
484
697
  if (current && sameInode(current.stats, observed)) {
485
698
  const staleAge = current.holder
@@ -494,7 +707,16 @@ async function resumeStaleBreak(lockPath, claimPath, observed, claimWasCreated,
494
707
  claimRemoved = true;
495
708
  return false;
496
709
  }
497
- await fsp.unlink(lockPath);
710
+ if (fileLockTestHooks?.beforeStalePublicLockUnlink) {
711
+ await fileLockTestHooks.beforeStalePublicLockUnlink(lockPath);
712
+ }
713
+ if (!(await unlinkUnchangedPinnedPath(lockPath, current.stats))) {
714
+ // A late holder publication or pathname replacement won the race.
715
+ // The pinned bytes are no longer authority to remove the public name.
716
+ await releaseOwnedLock(claimPath, claim);
717
+ claimRemoved = true;
718
+ return false;
719
+ }
498
720
  logger.warn(`[file-lock] broke stale lock at ${lockPath} ` +
499
721
  `(${current.holder ? `dead/reused pid ${current.holder.pid}` : 'empty/invalid holder'}, age ${current.ageMs}ms)`);
500
722
  }
@@ -520,7 +742,11 @@ async function resumeStaleBreak(lockPath, claimPath, observed, claimWasCreated,
520
742
  // unlink. Withdraw its epoch so the same process does not look like a
521
743
  // permanently-live crashed breaker after a transient I/O error.
522
744
  try {
523
- await fsp.unlink(ownership.ownerPath);
745
+ await releaseOwnedLock(ownership.ownerPath, ownership.owned);
746
+ }
747
+ catch { /* tolerate */ }
748
+ try {
749
+ await releaseOwnedLock(ownership.candidatePath, ownership.owned);
524
750
  }
525
751
  catch { /* tolerate */ }
526
752
  }
@@ -539,12 +765,19 @@ function resumeStaleBreakSync(lockPath, claimPath, observed, claimWasCreated, mi
539
765
  return false;
540
766
  }
541
767
  let current;
768
+ let publicPathMissing = false;
542
769
  try {
543
770
  current = readPinnedHolderSync(lockPath);
544
771
  }
545
772
  catch (error) {
546
773
  if (error.code !== 'ENOENT')
547
774
  throw error;
775
+ publicPathMissing = true;
776
+ }
777
+ if (!current && !publicPathMissing) {
778
+ releaseOwnedLockSync(claimPath, claim);
779
+ claimRemoved = true;
780
+ return false;
548
781
  }
549
782
  if (current && sameInode(current.stats, observed)) {
550
783
  const staleAge = current.holder
@@ -557,7 +790,12 @@ function resumeStaleBreakSync(lockPath, claimPath, observed, claimWasCreated, mi
557
790
  claimRemoved = true;
558
791
  return false;
559
792
  }
560
- unlinkSync(lockPath);
793
+ fileLockTestHooks?.beforeStalePublicLockUnlinkSync?.(lockPath);
794
+ if (!unlinkUnchangedPinnedPathSync(lockPath, current.stats)) {
795
+ releaseOwnedLockSync(claimPath, claim);
796
+ claimRemoved = true;
797
+ return false;
798
+ }
561
799
  logger.warn(`[file-lock] broke stale lock at ${lockPath} ` +
562
800
  `(${current.holder ? `dead/reused pid ${current.holder.pid}` : 'empty/invalid holder'}, age ${current.ageMs}ms)`);
563
801
  }
@@ -574,7 +812,11 @@ function resumeStaleBreakSync(lockPath, claimPath, observed, claimWasCreated, mi
574
812
  }
575
813
  catch { /* tolerate */ }
576
814
  try {
577
- unlinkSync(ownership.ownerPath);
815
+ releaseOwnedLockSync(ownership.ownerPath, ownership.owned);
816
+ }
817
+ catch { /* tolerate */ }
818
+ try {
819
+ releaseOwnedLockSync(ownership.candidatePath, ownership.owned);
578
820
  }
579
821
  catch { /* tolerate */ }
580
822
  }