@gajae-code/coding-agent 0.18.12 → 0.19.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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -13,6 +13,7 @@ import type {
13
13
  import {
14
14
  exactRestore,
15
15
  exactUnlinkDirect,
16
+ exactUnlinkOpenFile,
16
17
  openRecoveryFsRoot,
17
18
  renameDirectoryNoReplacePathAsync,
18
19
  renameNoReplacePathAsync,
@@ -38,6 +39,7 @@ export interface FileLockOptions {
38
39
  }
39
40
 
40
41
  export type FileLockAcquireReason = "acquire_timeout" | "orphan_transition";
42
+ export type FileLockPublicationFailureCode = "access_denied" | "sharing_violation";
41
43
 
42
44
  /**
43
45
  * Why the acquire path could not retire a lock directory whose owner it had already
@@ -68,6 +70,9 @@ export class FileLockAcquireError extends Error {
68
70
  readonly reason: FileLockAcquireReason = "acquire_timeout",
69
71
  readonly orphanPath?: string,
70
72
  readonly removalFailure?: FileLockStaleRemovalFailure,
73
+ readonly publicationFailureCode?: FileLockPublicationFailureCode,
74
+ /** True only when exhaustion proved a same-host owner process is live. */
75
+ readonly holderIsLive = false,
71
76
  ) {
72
77
  const detail =
73
78
  reason === "orphan_transition" && orphanPath ? `blocked by retained removal transition ${orphanPath}` : holder;
@@ -79,17 +84,46 @@ export class FileLockAcquireError extends Error {
79
84
  const cleanupDetail = removalFailure?.manualCleanupCommand
80
85
  ? `; manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ${removalFailure.manualCleanupCommand}`
81
86
  : "";
87
+ const publicationDetail = publicationFailureCode
88
+ ? `; last lock publication failed with ${publicationFailureCode}`
89
+ : "";
90
+ const contentionGuidance =
91
+ publicationFailureCode === "access_denied" && !holderIsLive
92
+ ? ""
93
+ : "; a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying";
82
94
  super(
83
- `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail} (${lockPath}); ` +
84
- `a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying${cleanupDetail}`,
95
+ `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail}${publicationDetail} (${lockPath})${contentionGuidance}${cleanupDetail}`,
85
96
  );
86
97
  this.code = reason;
87
98
  this.name = "FileLockAcquireError";
88
99
  }
89
100
  }
90
101
 
102
+ export class FileLockStagingCleanupError extends Error {
103
+ readonly code: string | undefined;
104
+
105
+ constructor(
106
+ readonly lockPath: string,
107
+ readonly stagingPath: string,
108
+ cause: unknown,
109
+ ) {
110
+ const code =
111
+ typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
112
+ ? cause.code
113
+ : undefined;
114
+ const detail = cause instanceof Error ? cause.message : String(cause);
115
+ super(`Failed to clean staged file-lock publication at ${stagingPath}${code ? ` (${code})` : ""}: ${detail}`);
116
+ this.code = code;
117
+ this.name = "FileLockStagingCleanupError";
118
+ }
119
+ }
120
+
91
121
  export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcquireError {
92
- return error instanceof FileLockAcquireError && error.code === "acquire_timeout";
122
+ return (
123
+ error instanceof FileLockAcquireError &&
124
+ error.code === "acquire_timeout" &&
125
+ (error.publicationFailureCode !== "access_denied" || error.holderIsLive)
126
+ );
93
127
  }
94
128
 
95
129
  const DEFAULT_OPTIONS: Required<
@@ -103,14 +137,29 @@ const DEFAULT_OPTIONS: Required<
103
137
  retryDelayMs: 100,
104
138
  };
105
139
 
140
+ type FileLockStagingFileIdentity = { dev: string; ino: string };
141
+ type FileLockStagingRootIdentity = {
142
+ rootDev: string;
143
+ rootIno: string;
144
+ treeSnapshot?: NativeDirectoryTreeSnapshot;
145
+ infoFileIdentity?: FileLockStagingFileIdentity;
146
+ };
147
+ type PendingFileLockStagingCleanup = {
148
+ lockPath: string;
149
+ identity?: FileLockStagingRootIdentity;
150
+ };
151
+
152
+ const pendingStagingCleanups = new Map<string, PendingFileLockStagingCleanup>();
153
+
106
154
  /**
107
- * Windows can transiently deny a no-replace publication while another handle still
108
- * has the staged or destination path open without delete sharing. The native result
109
- * is explicitly pre-mutation in this case, so retrying the same source name cannot
110
- * publish twice or clean up a committed namespace change.
155
+ * Windows can transiently deny a no-replace publication while another process holds
156
+ * the staged or destination path open. Retry only validated pre-mutation receipts;
157
+ * persistent access-denied failures remain fatal rather than being reported as contention.
111
158
  */
112
- const PUBLICATION_CONTENTION_RETRY_ATTEMPTS = 3;
113
- const PUBLICATION_CONTENTION_RETRY_DELAY_MS = 10;
159
+ const PUBLICATION_TRANSIENT_RETRY_ATTEMPTS = 3;
160
+ const PUBLICATION_TRANSIENT_RETRY_DELAY_MS = 10;
161
+ /** Allow one caller backoff for transient Windows denial without multiplying a persistent ACL failure by the full lock wait. */
162
+ const MAX_ACCESS_DENIED_PUBLICATION_ACQUISITION_ATTEMPTS = 2;
114
163
 
115
164
  /** Grace period after which an unpublished removal transition becomes reclaimable as orphan. */
116
165
  const REMOVAL_TRANSITION_GRACE_MS = 10_000;
@@ -131,6 +180,7 @@ const PROCESS_START_TIME_FORMAT = "utc-v1";
131
180
  type LocalLockState = {
132
181
  owner: FileLockOwnerToken;
133
182
  status: "held" | "release_pending" | "releasing";
183
+ releasePath?: string;
134
184
  releasePromise?: Promise<void>;
135
185
  };
136
186
 
@@ -140,12 +190,30 @@ type LocalLockState = {
140
190
  * created, so a nested contender cannot steal a lock from a still-running holder.
141
191
  */
142
192
  const localLockStates = new Map<string, LocalLockState>();
193
+ const MAX_FILE_LOCK_REMOVAL_RECORD_BYTES = 64 * 1024;
143
194
 
144
195
  type LockInfo = FileLockOwnerToken;
145
196
 
146
197
  export const FileLockTestHooks: {
147
198
  afterParentMkdir?: (lockPath: string) => void | Promise<void>;
199
+ afterStagingInfoWrite?: (stagingPath: string) => void | Promise<void>;
200
+ stagingInfoIdentity?: (
201
+ infoPath: string,
202
+ capture: () => Promise<FileLockStagingFileIdentity>,
203
+ ) => Promise<FileLockStagingFileIdentity>;
204
+ stagingInfoCleanup?: (
205
+ infoPath: string,
206
+ callerFd: number,
207
+ quarantineName: string,
208
+ ) => NativeExactUnlinkResult | Promise<NativeExactUnlinkResult>;
209
+ beforeDetachedLockCleanup?: (detachedPath: string) => boolean | Promise<boolean>;
210
+ afterRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
211
+ afterRemovalTransitionMissing?: (transitionPath: string) => void | Promise<void>;
212
+ afterForeignTransitionMissing?: (transitionPath: string) => void | Promise<void>;
213
+ beforeRemovalRecordUnlink?: (recordPath: string) => boolean | Promise<boolean>;
148
214
  beforeRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
215
+ cleanupPendingStaging?: (stagingPath: string) => Promise<void>;
216
+ stagingRootIdentity?: (stagingPath: string) => FileLockStagingRootIdentity | Promise<FileLockStagingRootIdentity>;
149
217
  nativePublicationBindings?: () => {
150
218
  renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
151
219
  renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
@@ -154,10 +222,353 @@ export const FileLockTestHooks: {
154
222
  processIncarnation: string | null;
155
223
  startTime: string | null;
156
224
  };
225
+ processIncarnation?: (pid: number) => string | null;
157
226
  nativeQuarantineBindings?: () => NativeFileLockBindings;
158
227
  nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
159
228
  } = {};
160
229
 
230
+ type FileLockAcquisitionRetryBudget = {
231
+ remainingDelays: number;
232
+ retryDelayMs: number;
233
+ signal?: AbortSignal;
234
+ };
235
+
236
+ function throwIfFileLockAcquisitionAborted(signal?: AbortSignal): void {
237
+ if (signal?.aborted) throw signal.reason ?? new Error("File lock acquisition aborted");
238
+ }
239
+
240
+ async function sleepFileLockRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
241
+ if (!signal) {
242
+ await Bun.sleep(delayMs);
243
+ return;
244
+ }
245
+ throwIfFileLockAcquisitionAborted(signal);
246
+ const settled = Promise.withResolvers<void>();
247
+ const onAbort = (): void => {
248
+ settled.reject(signal.reason ?? new Error("File lock acquisition aborted"));
249
+ };
250
+ signal.addEventListener("abort", onAbort, { once: true });
251
+ try {
252
+ if (signal.aborted) onAbort();
253
+ await Promise.race([Bun.sleep(Math.max(0, delayMs)), settled.promise]);
254
+ } finally {
255
+ signal.removeEventListener("abort", onAbort);
256
+ }
257
+ }
258
+
259
+ async function captureFileLockStagingRootIdentity(
260
+ stagingPath: string,
261
+ retryBudget: FileLockAcquisitionRetryBudget,
262
+ ): Promise<FileLockStagingRootIdentity> {
263
+ const capture = FileLockTestHooks.stagingRootIdentity;
264
+ if (capture) return await capture(stagingPath);
265
+ for (;;) {
266
+ try {
267
+ const root = await fs.lstat(stagingPath, { bigint: true });
268
+ if (!root.isDirectory() || root.isSymbolicLink())
269
+ throw Object.assign(new Error("Staged file-lock root is not a directory."), { code: "identity_mismatch" });
270
+ return {
271
+ rootDev: canonicalLockDeviceId(root.dev).toString(),
272
+ rootIno: canonicalLockFileId(root.ino).toString(),
273
+ };
274
+ } catch (error) {
275
+ if (!isTransientReleaseError(error) || retryBudget.remainingDelays === 0) throw error;
276
+ retryBudget.remainingDelays--;
277
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
278
+ }
279
+ }
280
+ }
281
+
282
+ async function captureFileLockStagingTreeSnapshot(
283
+ stagingPath: string,
284
+ retryBudget: FileLockAcquisitionRetryBudget,
285
+ ): Promise<NativeDirectoryTreeSnapshot> {
286
+ for (;;) {
287
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(stagingPath);
288
+ if (captured.ok && captured.snapshot) return captured.snapshot;
289
+ const code = captured.code ?? "snapshot_unavailable";
290
+ if (
291
+ code !== "access_denied" &&
292
+ code !== "io_error" &&
293
+ code !== "permission_denied" &&
294
+ code !== "sharing_violation"
295
+ ) {
296
+ throw Object.assign(new Error(`Failed to snapshot staged file lock: ${code}.`), { code });
297
+ }
298
+ if (retryBudget.remainingDelays === 0)
299
+ throw Object.assign(new Error(`Failed to snapshot staged file lock: ${code}.`), { code });
300
+ retryBudget.remainingDelays--;
301
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
302
+ }
303
+ }
304
+
305
+ function bindFileLockStagingTreeSnapshot(
306
+ stagingPath: string,
307
+ identity: FileLockStagingRootIdentity,
308
+ snapshot: NativeDirectoryTreeSnapshot,
309
+ ): FileLockStagingRootIdentity {
310
+ if (snapshot.rootDev !== identity.rootDev || snapshot.rootIno !== identity.rootIno)
311
+ throw Object.assign(new Error(`Staged file-lock root identity changed while snapshotting ${stagingPath}.`), {
312
+ code: "identity_mismatch",
313
+ });
314
+ return { ...identity, treeSnapshot: snapshot };
315
+ }
316
+
317
+ function matchesExpectedFileLockStagingTree(
318
+ snapshot: NativeDirectoryTreeSnapshot,
319
+ identity: FileLockStagingRootIdentity,
320
+ ): boolean {
321
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
322
+ if (
323
+ snapshot.rootDev !== identity.rootDev ||
324
+ snapshot.rootIno !== identity.rootIno ||
325
+ root?.kind !== "directory" ||
326
+ root.dev !== identity.rootDev ||
327
+ root.ino !== identity.rootIno
328
+ )
329
+ return false;
330
+ if (snapshot.entries.length === 1) return true;
331
+ if (snapshot.entries.length !== 2 || !identity.infoFileIdentity) return false;
332
+ const info = snapshot.entries.find(entry => entry.relativePath === "info");
333
+ return (
334
+ info?.kind === "file" &&
335
+ info.dev === identity.infoFileIdentity.dev &&
336
+ info.ino === identity.infoFileIdentity.ino &&
337
+ info.nlink === "1"
338
+ );
339
+ }
340
+
341
+ async function captureFileLockStagingCleanupIdentity(
342
+ stagingPath: string,
343
+ identity: FileLockStagingRootIdentity,
344
+ retryBudget: FileLockAcquisitionRetryBudget,
345
+ ): Promise<FileLockStagingRootIdentity> {
346
+ const snapshot = await captureFileLockStagingTreeSnapshot(stagingPath, retryBudget);
347
+ // A replay can authorize only the empty root or that root plus this attempt's exact info inode.
348
+ if (!matchesExpectedFileLockStagingTree(snapshot, identity))
349
+ throw Object.assign(
350
+ new Error("Staged file-lock contents changed before cleanup authority was captured; refusing removal."),
351
+ { code: "identity_unavailable" },
352
+ );
353
+ return bindFileLockStagingTreeSnapshot(stagingPath, identity, snapshot);
354
+ }
355
+
356
+ async function hasPendingStagingRootIdentity(
357
+ stagingPath: string,
358
+ identity: FileLockStagingRootIdentity,
359
+ ): Promise<boolean> {
360
+ let root: BigIntStats;
361
+ try {
362
+ root = await fs.lstat(stagingPath, { bigint: true });
363
+ } catch (error) {
364
+ if (isEnoent(error)) return false;
365
+ throw error;
366
+ }
367
+ if (
368
+ !root.isDirectory() ||
369
+ root.isSymbolicLink() ||
370
+ canonicalLockDeviceId(root.dev).toString() !== identity.rootDev ||
371
+ canonicalLockFileId(root.ino).toString() !== identity.rootIno
372
+ )
373
+ throw Object.assign(new Error("Staged file-lock root identity changed; refusing removal."), {
374
+ code: "identity_mismatch",
375
+ });
376
+ return true;
377
+ }
378
+
379
+ async function removePendingStaging(stagingPath: string, identity: FileLockStagingRootIdentity): Promise<void> {
380
+ const cleanup = FileLockTestHooks.cleanupPendingStaging;
381
+ if (cleanup) {
382
+ if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
383
+ await cleanup(stagingPath);
384
+ return;
385
+ }
386
+ if (!identity.infoFileIdentity && process.platform !== "win32") {
387
+ if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
388
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(stagingPath);
389
+ const root = captured.snapshot?.entries.find(entry => entry.relativePath === "");
390
+ if (
391
+ !captured.ok ||
392
+ !captured.snapshot ||
393
+ captured.snapshot.entries.length !== 1 ||
394
+ captured.snapshot.rootDev !== identity.rootDev ||
395
+ captured.snapshot.rootIno !== identity.rootIno ||
396
+ root?.kind !== "directory" ||
397
+ root.dev !== identity.rootDev ||
398
+ root.ino !== identity.rootIno
399
+ )
400
+ throw Object.assign(
401
+ new Error("Staged file-lock root is not empty and its info identity is unavailable; refusing removal."),
402
+ { code: captured.code ?? "identity_unavailable" },
403
+ );
404
+ // Unlike recursive removal, rmdir cannot consume an entry that appears after
405
+ // the empty-tree proof; it fails with ENOTEMPTY instead.
406
+ await fs.rmdir(stagingPath);
407
+ return;
408
+ }
409
+ if (process.platform === "win32") {
410
+ if (!identity.treeSnapshot && !(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
411
+ if (!identity.treeSnapshot)
412
+ throw Object.assign(new Error("Staged file-lock tree identity is unavailable; refusing removal."), {
413
+ code: "identity_unavailable",
414
+ });
415
+ // A pathname check followed by fs.rm can erase a replacement installed in
416
+ // between; Windows cleanup must revalidate the captured tree atomically.
417
+ const removal = nativeFileLockBindings().exactRemoveDirectoryTree(stagingPath, identity.treeSnapshot);
418
+ if (!removal.ok && removal.code !== "not_found") {
419
+ const detachedPath = validateDetachedStagingCleanupPath(stagingPath, removal.detachedPath);
420
+ throw Object.assign(new Error(`Failed to remove staged file-lock tree: ${removal.code ?? "unknown"}.`), {
421
+ code: removal.code ?? "cleanup_failed",
422
+ ...(detachedPath === undefined ? {} : { detachedPath }),
423
+ });
424
+ }
425
+ return;
426
+ }
427
+ if (await hasPendingStagingRootIdentity(stagingPath, identity))
428
+ await fs.rm(stagingPath, { recursive: true, force: true });
429
+ }
430
+
431
+ function sameFileLockPath(left: string, right: string): boolean {
432
+ const resolvedLeft = path.resolve(left);
433
+ const resolvedRight = path.resolve(right);
434
+ return process.platform === "win32"
435
+ ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
436
+ : resolvedLeft === resolvedRight;
437
+ }
438
+
439
+ function validateDetachedStagingCleanupPath(stagingPath: string, detachedPath: string | undefined): string | undefined {
440
+ if (detachedPath === undefined) return undefined;
441
+ if (!sameFileLockPath(detachedPath, stagingPath) && !sameFileLockPath(detachedPath, `${stagingPath}.removing`))
442
+ throw Object.assign(new Error("Native staging cleanup returned an unexpected detached path."), {
443
+ code: "identity_mismatch",
444
+ });
445
+ return detachedPath;
446
+ }
447
+
448
+ function detachedStagingCleanupPath(stagingPath: string, error: unknown): string {
449
+ if (typeof error !== "object" || error === null || !("detachedPath" in error)) return stagingPath;
450
+ const detachedPath = (error as { detachedPath?: unknown }).detachedPath;
451
+ try {
452
+ return (
453
+ validateDetachedStagingCleanupPath(stagingPath, typeof detachedPath === "string" ? detachedPath : undefined) ??
454
+ stagingPath
455
+ );
456
+ } catch {
457
+ return stagingPath;
458
+ }
459
+ }
460
+
461
+ function retainPendingStagingCleanup(
462
+ lockPath: string,
463
+ stagingPath: string,
464
+ identity: FileLockStagingRootIdentity | undefined,
465
+ error: unknown,
466
+ ): void {
467
+ pendingStagingCleanups.set(stagingPath, { lockPath, identity });
468
+ logger.debug("Retained file-lock staging cleanup", { stagingPath, error: String(error) });
469
+ }
470
+
471
+ async function abortStagingCleanup(
472
+ lockPath: string,
473
+ stagingPath: string,
474
+ identity: FileLockStagingRootIdentity,
475
+ signal: AbortSignal,
476
+ ): Promise<never> {
477
+ let cleanupPath = stagingPath;
478
+ try {
479
+ await removePendingStaging(cleanupPath, identity);
480
+ pendingStagingCleanups.delete(cleanupPath);
481
+ } catch (error) {
482
+ cleanupPath = detachedStagingCleanupPath(cleanupPath, error);
483
+ if (cleanupPath !== stagingPath) pendingStagingCleanups.delete(stagingPath);
484
+ // The stage is still owned by this acquisition. Preserve an in-process retry
485
+ // until another acquisition can clean it; after process exit the orphan reaper
486
+ // can safely reclaim it once this publisher is no longer live.
487
+ retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
488
+ }
489
+ throw signal.reason ?? new Error("File lock acquisition aborted");
490
+ }
491
+
492
+ async function retryPendingStagingCleanups(
493
+ lockPath: string,
494
+ retryBudget: FileLockAcquisitionRetryBudget,
495
+ ): Promise<void> {
496
+ const lockKey = await localLockKey(lockPath);
497
+ for (const [stagingPath, pending] of pendingStagingCleanups) {
498
+ if ((await localLockKey(pending.lockPath)) !== lockKey) continue;
499
+ if (!pending.identity) {
500
+ try {
501
+ await fs.lstat(stagingPath);
502
+ } catch (error) {
503
+ if (isEnoent(error)) {
504
+ pendingStagingCleanups.delete(stagingPath);
505
+ continue;
506
+ }
507
+ if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
508
+ throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
509
+ }
510
+ const error = Object.assign(new Error("Retained staging cleanup has no captured identity."), {
511
+ code: "identity_unavailable",
512
+ });
513
+ throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
514
+ }
515
+ let identity = pending.identity;
516
+ if (process.platform === "win32" && !identity.treeSnapshot) {
517
+ try {
518
+ if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) {
519
+ pendingStagingCleanups.delete(stagingPath);
520
+ continue;
521
+ }
522
+ identity = await captureFileLockStagingCleanupIdentity(stagingPath, identity, retryBudget);
523
+ pendingStagingCleanups.set(stagingPath, { ...pending, identity });
524
+ } catch (error) {
525
+ if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
526
+ throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
527
+ }
528
+ }
529
+ await cleanupPendingStagingOrThrow(lockPath, stagingPath, identity, retryBudget);
530
+ }
531
+ }
532
+
533
+ async function cleanupPendingStagingOrThrow(
534
+ lockPath: string,
535
+ stagingPath: string,
536
+ identity: FileLockStagingRootIdentity,
537
+ retryBudget: FileLockAcquisitionRetryBudget,
538
+ ): Promise<void> {
539
+ let cleanupPath = stagingPath;
540
+ for (;;) {
541
+ if (retryBudget.signal?.aborted) await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
542
+ try {
543
+ await removePendingStaging(cleanupPath, identity);
544
+ pendingStagingCleanups.delete(cleanupPath);
545
+ return;
546
+ } catch (error) {
547
+ if (retryBudget.signal?.aborted)
548
+ await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
549
+ const detachedPath = detachedStagingCleanupPath(cleanupPath, error);
550
+ if (detachedPath !== cleanupPath) {
551
+ pendingStagingCleanups.delete(cleanupPath);
552
+ cleanupPath = detachedPath;
553
+ }
554
+ if (!isTransientReleaseError(error) || retryBudget.remainingDelays <= 0) {
555
+ retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
556
+ throw new FileLockStagingCleanupError(lockPath, cleanupPath, error);
557
+ }
558
+ // An open child can deny both publication and removal on Windows. Keep the
559
+ // same staging identity and share the outer acquisition delay budget.
560
+ retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
561
+ retryBudget.remainingDelays--;
562
+ try {
563
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
564
+ } catch (abortError) {
565
+ if (!retryBudget.signal?.aborted) throw abortError;
566
+ await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
567
+ }
568
+ }
569
+ }
570
+ }
571
+
161
572
  /**
162
573
  * Returns the OS-provided process start timestamp for PID-reuse detection.
163
574
  * `ps` is available on the supported Unix hosts (macOS and Linux), unlike
@@ -199,7 +610,7 @@ function currentProcessStartTime(): string {
199
610
  return ownProcessStartTime;
200
611
  }
201
612
 
202
- function processIncarnation(pid: number): string | null {
613
+ function nativeProcessIncarnation(pid: number): string | null {
203
614
  try {
204
615
  const incarnation = nativeProcessBindings().Process.fromPid(pid)?.incarnation;
205
616
  return typeof incarnation === "string" && incarnation.length > 0 ? incarnation : null;
@@ -208,8 +619,17 @@ function processIncarnation(pid: number): string | null {
208
619
  }
209
620
  }
210
621
 
622
+ function processIncarnation(pid: number): string | null {
623
+ try {
624
+ const testHook = FileLockTestHooks.processIncarnation;
625
+ return testHook ? testHook(pid) : nativeProcessIncarnation(pid);
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
630
+
211
631
  function currentProcessIncarnation(): string | null {
212
- if (ownProcessIncarnation === undefined) ownProcessIncarnation = processIncarnation(process.pid);
632
+ if (ownProcessIncarnation === undefined) ownProcessIncarnation = nativeProcessIncarnation(process.pid);
213
633
  return ownProcessIncarnation;
214
634
  }
215
635
 
@@ -239,6 +659,17 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
239
659
  return owner.start_time_format !== PROCESS_START_TIME_FORMAT;
240
660
  }
241
661
 
662
+ function ownerIdentityIsProven(owner: FileLockOwnerToken): boolean {
663
+ if (owner.process_incarnation) {
664
+ const currentIncarnation = processIncarnation(owner.pid);
665
+ if (currentIncarnation !== null) return currentIncarnation === owner.process_incarnation;
666
+ }
667
+ if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
668
+ return false;
669
+ const currentStartTime = cachedProcessStartTime(owner);
670
+ return currentStartTime !== null && currentStartTime === owner.start_time;
671
+ }
672
+
242
673
  function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
243
674
  const incarnation = currentProcessIncarnation();
244
675
  return {
@@ -252,13 +683,70 @@ function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo
252
683
  };
253
684
  }
254
685
 
255
- function writeLockInfo(lockPath: string, info: LockInfo): Promise<LockInfo> {
686
+ async function writeLockInfo(
687
+ lockPath: string,
688
+ info: LockInfo,
689
+ retryBudget: FileLockAcquisitionRetryBudget,
690
+ onFileCreated: (identity: FileLockStagingFileIdentity) => void,
691
+ ): Promise<LockInfo> {
256
692
  // Owner metadata must stay readable by its own process under a restrictive
257
693
  // umask: release re-reads this record to authorize removal, and an info file
258
694
  // born mode 000 under umask 0777 would wedge the lock at first release.
259
- return Bun.write(`${lockPath}/info`, JSON.stringify(info), { mode: 0o600 })
260
- .then(() => fs.chmod(`${lockPath}/info`, 0o600))
261
- .then(() => info);
695
+ // Keep the created file's identity through setup so an early cleanup can reject
696
+ // a same-name replacement instead of treating a later tree snapshot as authority.
697
+ const handle = await fs.open(`${lockPath}/info`, "wx", 0o600);
698
+ try {
699
+ let identity: FileLockStagingFileIdentity;
700
+ try {
701
+ for (;;) {
702
+ try {
703
+ const captureIdentity = async (): Promise<FileLockStagingFileIdentity> => {
704
+ const stats = await handle.stat({ bigint: true });
705
+ return {
706
+ dev: canonicalLockDeviceId(stats.dev).toString(),
707
+ ino: canonicalLockFileId(stats.ino).toString(),
708
+ };
709
+ };
710
+ const testCaptureIdentity = FileLockTestHooks.stagingInfoIdentity;
711
+ identity = testCaptureIdentity
712
+ ? await testCaptureIdentity(`${lockPath}/info`, captureIdentity)
713
+ : await captureIdentity();
714
+ break;
715
+ } catch (error) {
716
+ if (!isTransientReleaseError(error) || retryBudget.remainingDelays === 0) throw error;
717
+ retryBudget.remainingDelays--;
718
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
719
+ }
720
+ }
721
+ } catch (error) {
722
+ // Without the handle's inode identity, retaining this empty info entry would
723
+ // make both immediate cleanup and the later orphan reaper fail closed.
724
+ try {
725
+ const infoPath = `${lockPath}/info`;
726
+ const quarantineName = `${path.basename(infoPath)}.cleanup.${crypto.randomUUID()}`;
727
+ const cleanup = await (FileLockTestHooks.stagingInfoCleanup
728
+ ? FileLockTestHooks.stagingInfoCleanup(infoPath, handle.fd, quarantineName)
729
+ : exactUnlinkOpenFile(infoPath, handle.fd, quarantineName));
730
+ if (!cleanup.ok && cleanup.code !== "not_found")
731
+ throw Object.assign(
732
+ new Error(`Identity-bound cleanup of staged file-lock info failed: ${cleanup.code ?? "unknown"}.`),
733
+ { code: cleanup.code ?? "identity_unavailable" },
734
+ );
735
+ } catch (cleanupError) {
736
+ throw new AggregateError(
737
+ [error, cleanupError],
738
+ `Failed to remove staged file-lock info after identity capture failed at ${lockPath}.`,
739
+ );
740
+ }
741
+ throw error;
742
+ }
743
+ onFileCreated(identity);
744
+ await handle.writeFile(JSON.stringify(info));
745
+ await handle.chmod(0o600);
746
+ return info;
747
+ } finally {
748
+ await handle.close();
749
+ }
262
750
  }
263
751
 
264
752
  type LockInfoFileState = {
@@ -580,10 +1068,24 @@ async function removeDetachedLockQuarantineOnDisk(
580
1068
  ) {
581
1069
  return false;
582
1070
  }
1071
+ if ((await FileLockTestHooks.beforeDetachedLockCleanup?.(detachedPath)) === false) return false;
583
1072
  await fs.rm(detachedPath, { recursive: true, force: true });
584
1073
  return true;
585
1074
  }
586
1075
 
1076
+ function markLocalRemovalResumable(transitionPath: string, rootDev: string, rootIno: string): void {
1077
+ const recordPath = removalRecordPath(transitionPath);
1078
+ const local = localRemovalRecords.get(recordPath);
1079
+ if (!local) return;
1080
+ try {
1081
+ const record = JSON.parse(local.bytes) as Partial<RemovalTransitionOwnerRecord>;
1082
+ if (record.rootDev === rootDev && record.rootIno === rootIno)
1083
+ localRemovalRecords.set(recordPath, { ...local, resumable: true });
1084
+ } catch {
1085
+ // A malformed local receipt cannot authorize resuming a detached tree.
1086
+ }
1087
+ }
1088
+
587
1089
  async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
588
1090
  const pending = pendingDetachedLockCleanups.get(owner);
589
1091
  if (!pending) return false;
@@ -602,7 +1104,7 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
602
1104
  // finish the quarantine its own fallback created, so finish on disk instead.
603
1105
  if (process.platform === "win32" && (await isNativeExactRemovalUsable())) {
604
1106
  const removal = nativeFileLockBindings().exactRemoveDirectoryTree(pending.path, pending.snapshot);
605
- if (removal.code === "cleanup_pending") return false;
1107
+ if (removal.code === "cleanup_pending" || (!removal.ok && isTransientReleaseError(removal))) return false;
606
1108
  if (!removal.ok && removal.code !== "not_found")
607
1109
  throw new Error(`Failed to finish detached file lock cleanup: ${removal.code ?? "unknown"}.`);
608
1110
  } else {
@@ -621,7 +1123,22 @@ function getLockPath(filePath: string): string {
621
1123
  }
622
1124
 
623
1125
  function fileLockRemovalTransitionPath(lockPath: string): string {
624
- return `${lockPath}.removing`;
1126
+ return derivedFileLockPath(lockPath, ".removing");
1127
+ }
1128
+
1129
+ /** Keep derived siblings bounded without changing the canonical lock identity. */
1130
+ function derivedFileLockPath(sourcePath: string, suffix: string): string {
1131
+ const name = path.basename(sourcePath);
1132
+ const derived = `${name}${suffix}`;
1133
+ const bounded =
1134
+ Buffer.byteLength(derived, "utf8") <= 255
1135
+ ? derived
1136
+ : `.gjc-lock-${crypto.createHash("sha256").update(name).digest("hex")}${suffix}`;
1137
+ return path.join(path.dirname(sourcePath), bounded);
1138
+ }
1139
+
1140
+ function removalRecordPath(transitionPath: string): string {
1141
+ return derivedFileLockPath(transitionPath, ".owner");
625
1142
  }
626
1143
 
627
1144
  type RemovalTransitionOwnerRecord = {
@@ -630,7 +1147,9 @@ type RemovalTransitionOwnerRecord = {
630
1147
  rootIno: string;
631
1148
  };
632
1149
 
633
- const localRemovalRecords = new Map<string, { bytes: string; resumable: boolean }>();
1150
+ type LocalRemovalRecord = { bytes: string; resumable: boolean; inFlight: boolean };
1151
+
1152
+ const localRemovalRecords = new Map<string, LocalRemovalRecord>();
634
1153
 
635
1154
  function sameRemovalRecordBytes(left: string, right: string): boolean {
636
1155
  try {
@@ -651,19 +1170,50 @@ function sameRemovalRecordBytes(left: string, right: string): boolean {
651
1170
  }
652
1171
 
653
1172
  function isResumableLocalRemoval(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
654
- const local = localRemovalRecords.get(`${transitionPath}.owner`);
655
- return local?.resumable === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
1173
+ const local = localRemovalRecords.get(removalRecordPath(transitionPath));
1174
+ return local?.resumable === true && !local.inFlight && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
1175
+ }
1176
+
1177
+ function isLocalRemovalInFlight(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
1178
+ const local = localRemovalRecords.get(removalRecordPath(transitionPath));
1179
+ return local?.inFlight === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
1180
+ }
1181
+
1182
+ function markLocalRemovalCompleted(transitionPath: string, expectedBytes?: string): void {
1183
+ const recordPath = removalRecordPath(transitionPath);
1184
+ const local = localRemovalRecords.get(recordPath);
1185
+ if (!local?.inFlight || (expectedBytes !== undefined && !sameRemovalRecordBytes(local.bytes, expectedBytes))) return;
1186
+ localRemovalRecords.set(recordPath, { ...local, inFlight: false });
1187
+ }
1188
+
1189
+ async function readBoundedRemovalRecordBytes(recordPath: string): Promise<Buffer | null> {
1190
+ const bytes = Buffer.from(
1191
+ await Bun.file(recordPath)
1192
+ .slice(0, MAX_FILE_LOCK_REMOVAL_RECORD_BYTES + 1)
1193
+ .arrayBuffer(),
1194
+ );
1195
+ return bytes.length > MAX_FILE_LOCK_REMOVAL_RECORD_BYTES ? null : bytes;
656
1196
  }
657
1197
 
658
1198
  type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
659
1199
  type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
660
- type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | null;
1200
+ type FileLockPublicationRetry = { kind: "publication_retry"; code: FileLockPublicationFailureCode };
1201
+ type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | FileLockPublicationRetry | null;
661
1202
 
662
1203
  async function readRemovalTransitionOwnerRecord(transitionPath: string): Promise<RemovalTransitionOwnerRecord | null> {
663
1204
  try {
664
- const stat = await fs.lstat(`${transitionPath}.owner`);
665
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return null;
666
- const record = JSON.parse(await Bun.file(`${transitionPath}.owner`).text()) as RemovalTransitionOwnerRecord;
1205
+ const recordPath = removalRecordPath(transitionPath);
1206
+ const stat = await fs.lstat(recordPath, { bigint: true });
1207
+ if (
1208
+ !stat.isFile() ||
1209
+ stat.isSymbolicLink() ||
1210
+ stat.nlink !== 1n ||
1211
+ stat.size > BigInt(MAX_FILE_LOCK_REMOVAL_RECORD_BYTES)
1212
+ )
1213
+ return null;
1214
+ const bytes = await readBoundedRemovalRecordBytes(recordPath);
1215
+ if (!bytes) return null;
1216
+ const record = JSON.parse(bytes.toString("utf8")) as RemovalTransitionOwnerRecord;
667
1217
  const owner = parseLockInfoBytes(JSON.stringify(record.owner));
668
1218
  return owner && typeof record.rootDev === "string" && typeof record.rootIno === "string"
669
1219
  ? { owner, rootDev: record.rootDev, rootIno: record.rootIno }
@@ -687,30 +1237,60 @@ async function classifyFileLockRemovalTransition(
687
1237
  transition = await fs.lstat(transitionPath, { bigint: true });
688
1238
  if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
689
1239
  } catch (error) {
690
- if (isEnoent(error)) return null;
1240
+ if (isEnoent(error)) {
1241
+ await FileLockTestHooks.afterRemovalTransitionMissing?.(transitionPath);
1242
+ const recordPath = removalRecordPath(transitionPath);
1243
+ const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
1244
+ if (!recordedOwner) {
1245
+ try {
1246
+ await fs.lstat(recordPath);
1247
+ return "active";
1248
+ } catch (recordError) {
1249
+ return isEnoent(recordError) ? null : "active";
1250
+ }
1251
+ }
1252
+ const localRecord = localRemovalRecords.get(recordPath);
1253
+ if (localRecord && sameRemovalRecordBytes(localRecord.bytes, JSON.stringify(recordedOwner))) {
1254
+ if (localRecord.inFlight) return "active";
1255
+ if (!(await clearCompletedLocalRemovalRecord(lockPath, transitionPath, recordedOwner, localRecord)))
1256
+ return "active";
1257
+ return null;
1258
+ }
1259
+ if (lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)) {
1260
+ // Never probe a foreign PID. Absence of both captured names proves
1261
+ // retirement completed, rather than a live remover awaiting detach.
1262
+ return (await clearCompletedForeignRemovalRecord(lockPath, transitionPath, recordedOwner))
1263
+ ? null
1264
+ : "active";
1265
+ }
1266
+ // Another same-host process may have completed native removal but been
1267
+ // unable to unlink its receipt. Prove the captured generation is absent
1268
+ // before treating its still-live publisher PID as an active retirement.
1269
+ if (await removalTransitionIsComplete(lockPath, transitionPath, recordedOwner))
1270
+ return (await removeRemovalRecord(recordPath, JSON.stringify(recordedOwner))) ? null : "active";
1271
+ if (ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown") return "active";
1272
+ return (await removeRemovalRecord(recordPath, JSON.stringify(recordedOwner))) ? null : "active";
1273
+ }
691
1274
  throw error;
692
1275
  }
693
1276
 
694
1277
  // A sibling record survives rename and scrub without mutating the captured tree.
695
1278
  const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
696
1279
  if (recordedOwner) {
697
- if (
698
- recordedOwner.owner.owner_host_id !== undefined &&
699
- lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)
700
- )
701
- return "active";
1280
+ if (lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)) return "active";
702
1281
  if (
703
1282
  canonicalLockDeviceId(transition.dev).toString() !== recordedOwner.rootDev ||
704
1283
  canonicalLockFileId(transition.ino).toString() !== recordedOwner.rootIno
705
1284
  )
706
1285
  return "active";
1286
+ if (isLocalRemovalInFlight(transitionPath, recordedOwner)) return "active";
707
1287
  if (isResumableLocalRemoval(transitionPath, recordedOwner)) return "orphan_transition"; // The remover itself may resume its own interrupted cleanup.
708
1288
  return ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown"
709
1289
  ? "active"
710
1290
  : "orphan_transition";
711
1291
  }
712
1292
  try {
713
- await fs.lstat(`${transitionPath}.owner`);
1293
+ await fs.lstat(removalRecordPath(transitionPath));
714
1294
  return "active";
715
1295
  } catch (error) {
716
1296
  if (!isEnoent(error)) return "active";
@@ -741,6 +1321,10 @@ function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is
741
1321
  return value !== null && "kind" in value && value.kind === "orphan_transition";
742
1322
  }
743
1323
 
1324
+ function isFileLockPublicationRetry(value: FileLockAcquisitionResult): value is FileLockPublicationRetry {
1325
+ return value !== null && "kind" in value && value.kind === "publication_retry";
1326
+ }
1327
+
744
1328
  /** Without an owner, only an aged, completely empty directory is reclaimable. */
745
1329
  function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs: number): boolean {
746
1330
  const root = snapshot.entries[0];
@@ -752,9 +1336,11 @@ function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs
752
1336
  async function removeRemovalRecord(recordPath: string, expectedBytes: string): Promise<boolean> {
753
1337
  try {
754
1338
  const stat = await fs.lstat(recordPath, { bigint: true });
755
- if (!stat.isFile() || stat.isSymbolicLink()) return false;
756
- const bytes = await Bun.file(recordPath).text();
757
- if (!sameRemovalRecordBytes(bytes, expectedBytes)) return false;
1339
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(MAX_FILE_LOCK_REMOVAL_RECORD_BYTES))
1340
+ return false;
1341
+ const bytes = await readBoundedRemovalRecordBytes(recordPath);
1342
+ if (!bytes || !sameRemovalRecordBytes(bytes.toString("utf8"), expectedBytes)) return false;
1343
+ if ((await FileLockTestHooks.beforeRemovalRecordUnlink?.(recordPath)) === false) return false;
758
1344
  return exactUnlinkDirect(recordPath, {
759
1345
  dev: canonicalLockDeviceId(stat.dev),
760
1346
  ino: canonicalLockFileId(stat.ino),
@@ -762,7 +1348,9 @@ async function removeRemovalRecord(recordPath: string, expectedBytes: string): P
762
1348
  mtimeNs: stat.mtimeNs,
763
1349
  nlink: stat.nlink,
764
1350
  sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
765
- quarantineName: `${path.basename(recordPath)}.retired.${crypto.randomUUID()}`,
1351
+ quarantineName: path.basename(
1352
+ derivedFileLockPath(recordPath, `.retired.${crypto.randomBytes(12).toString("base64url")}`),
1353
+ ),
766
1354
  }).ok;
767
1355
  } catch (error) {
768
1356
  if (isEnoent(error)) return true;
@@ -771,43 +1359,145 @@ async function removeRemovalRecord(recordPath: string, expectedBytes: string): P
771
1359
  }
772
1360
 
773
1361
  async function clearRemovalRecord(transitionPath: string): Promise<void> {
774
- const recordPath = `${transitionPath}.owner`;
775
- const bytes = localRemovalRecords.get(recordPath)?.bytes;
776
- if (!bytes) return;
1362
+ const recordPath = removalRecordPath(transitionPath);
1363
+ const local = localRemovalRecords.get(recordPath);
1364
+ if (!local || local.inFlight) return;
1365
+ const bytes = local.bytes;
777
1366
  try {
778
- await fs.lstat(transitionPath);
779
- return;
1367
+ const transition = await fs.lstat(transitionPath, { bigint: true });
1368
+ const recorded = JSON.parse(bytes) as Partial<RemovalTransitionOwnerRecord>;
1369
+ if (
1370
+ transition.isDirectory() &&
1371
+ typeof recorded.rootDev === "string" &&
1372
+ typeof recorded.rootIno === "string" &&
1373
+ canonicalLockDeviceId(transition.dev).toString() === recorded.rootDev &&
1374
+ canonicalLockFileId(transition.ino).toString() === recorded.rootIno
1375
+ )
1376
+ return;
780
1377
  } catch (error) {
781
1378
  if (!isEnoent(error)) throw error;
782
1379
  }
783
1380
  if (await removeRemovalRecord(recordPath, bytes)) localRemovalRecords.delete(recordPath);
784
1381
  }
785
1382
 
1383
+ async function removalTransitionIsComplete(
1384
+ lockPath: string,
1385
+ transitionPath: string,
1386
+ record: RemovalTransitionOwnerRecord,
1387
+ afterTransitionMissing?: (transitionPath: string) => void | Promise<void>,
1388
+ ): Promise<boolean> {
1389
+ try {
1390
+ await fs.lstat(transitionPath);
1391
+ return false;
1392
+ } catch (error) {
1393
+ if (!isEnoent(error)) throw error;
1394
+ }
1395
+ await afterTransitionMissing?.(transitionPath);
1396
+ try {
1397
+ const canonical = await fs.lstat(lockPath, { bigint: true });
1398
+ if (
1399
+ canonicalLockDeviceId(canonical.dev).toString() === record.rootDev &&
1400
+ canonicalLockFileId(canonical.ino).toString() === record.rootIno
1401
+ )
1402
+ return false;
1403
+ } catch (error) {
1404
+ if (!isEnoent(error)) throw error;
1405
+ }
1406
+ try {
1407
+ await fs.lstat(transitionPath);
1408
+ return false;
1409
+ } catch (error) {
1410
+ if (!isEnoent(error)) throw error;
1411
+ }
1412
+ return true;
1413
+ }
1414
+
1415
+ async function clearCompletedLocalRemovalRecord(
1416
+ lockPath: string,
1417
+ transitionPath: string,
1418
+ record: RemovalTransitionOwnerRecord,
1419
+ localRecord: LocalRemovalRecord,
1420
+ ): Promise<boolean> {
1421
+ const recordPath = removalRecordPath(transitionPath);
1422
+ const current = localRemovalRecords.get(recordPath);
1423
+ if (
1424
+ current !== localRecord ||
1425
+ current.inFlight ||
1426
+ !(await removalTransitionIsComplete(lockPath, transitionPath, record))
1427
+ )
1428
+ return false;
1429
+ const latest = localRemovalRecords.get(recordPath);
1430
+ if (
1431
+ !latest ||
1432
+ latest.inFlight ||
1433
+ !sameRemovalRecordBytes(latest.bytes, localRecord.bytes) ||
1434
+ !(await removeRemovalRecord(recordPath, localRecord.bytes))
1435
+ )
1436
+ return false;
1437
+ const afterRemoval = localRemovalRecords.get(recordPath);
1438
+ if (afterRemoval?.inFlight || (afterRemoval && !sameRemovalRecordBytes(afterRemoval.bytes, localRecord.bytes)))
1439
+ return false;
1440
+ if (afterRemoval) localRemovalRecords.delete(recordPath);
1441
+ return true;
1442
+ }
1443
+
1444
+ async function clearCompletedForeignRemovalRecord(
1445
+ lockPath: string,
1446
+ transitionPath: string,
1447
+ record: RemovalTransitionOwnerRecord,
1448
+ ): Promise<boolean> {
1449
+ if (
1450
+ !(await removalTransitionIsComplete(
1451
+ lockPath,
1452
+ transitionPath,
1453
+ record,
1454
+ FileLockTestHooks.afterForeignTransitionMissing,
1455
+ ))
1456
+ )
1457
+ return false;
1458
+ return removeRemovalRecord(removalRecordPath(transitionPath), JSON.stringify(record));
1459
+ }
1460
+
786
1461
  async function publishRemovalRecord(
787
1462
  transitionPath: string,
788
1463
  snapshot: NativeDirectoryTreeSnapshot,
789
1464
  ownerHostId?: string,
790
1465
  resumable = false,
791
1466
  ): Promise<boolean> {
792
- const recordPath = `${transitionPath}.owner`;
1467
+ const recordPath = removalRecordPath(transitionPath);
1468
+ // A pre-detach refusal can leave a completed local receipt whose exact
1469
+ // unlink was temporarily refused. Release itself must retry that cleanup.
1470
+ await clearRemovalRecord(transitionPath);
793
1471
  if (localRemovalRecords.has(recordPath)) return false;
794
1472
  const bytes = JSON.stringify({
795
1473
  owner: lockInfo(ownerHostId, crypto.randomUUID()),
796
1474
  rootDev: snapshot.rootDev,
797
1475
  rootIno: snapshot.rootIno,
798
1476
  });
799
- const staged = `${recordPath}.${process.pid}.${crypto.randomUUID()}`;
1477
+ if (Buffer.byteLength(bytes, "utf8") > MAX_FILE_LOCK_REMOVAL_RECORD_BYTES) return false;
1478
+ const staged = derivedFileLockPath(recordPath, `.${process.pid}.${crypto.randomBytes(12).toString("base64url")}`);
800
1479
  await Bun.write(staged, bytes, { mode: 0o600 });
1480
+ let publishedLocally = false;
801
1481
  try {
802
1482
  await fs.chmod(staged, 0o600);
803
1483
  await FileLockTestHooks.beforeRemovalOwnerPublish?.(transitionPath);
804
1484
  const published = await renameNoReplacePathAsync(staged, recordPath);
805
- if (!published.ok) return false;
806
- localRemovalRecords.set(recordPath, { bytes, resumable });
807
- return true;
808
- } finally {
1485
+ if (published.ok) {
1486
+ localRemovalRecords.set(recordPath, { bytes, resumable, inFlight: true });
1487
+ publishedLocally = true;
1488
+ }
1489
+ } catch (error) {
1490
+ if (publishedLocally) markLocalRemovalCompleted(transitionPath, bytes);
1491
+ await fs.rm(staged, { force: true }).catch(() => undefined);
1492
+ throw error;
1493
+ }
1494
+ try {
809
1495
  await fs.rm(staged, { force: true });
1496
+ } catch (error) {
1497
+ if (publishedLocally) markLocalRemovalCompleted(transitionPath, bytes);
1498
+ throw error;
810
1499
  }
1500
+ return publishedLocally;
811
1501
  }
812
1502
 
813
1503
  /** Publish outside the captured tree, atomically and before native rename. */
@@ -818,24 +1508,32 @@ async function exactRemoveLockTree(
818
1508
  parentIdentity?: { dev: bigint; ino: bigint },
819
1509
  detachOnly?: boolean,
820
1510
  ): Promise<NativeExactUnlinkResult> {
821
- if (process.platform === "win32" || lockPath.endsWith(".removing"))
1511
+ if (lockPath.endsWith(".removing"))
822
1512
  return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
823
1513
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
824
- const recordPath = `${transitionPath}.owner`;
825
- const prior = await readRemovalTransitionOwnerRecord(transitionPath);
826
- if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
827
- // A foreign transition remains untouchable. Once its tree is gone, only
828
- // the remover's final record clear is pending: wait without probing its
829
- // PID or deleting its record, even for an unqualified successor.
1514
+ if (process.platform === "win32") {
830
1515
  try {
831
1516
  await fs.lstat(transitionPath);
1517
+ // Native removal can resume this generation when the canonical name is
1518
+ // already detached. A different generation at `.removing` is left for
1519
+ // acquisition-time adoption; never attach this owner record to it.
1520
+ try {
1521
+ await fs.lstat(lockPath);
1522
+ return { ok: false, code: "quarantine_collision" };
1523
+ } catch (error) {
1524
+ if (!isEnoent(error)) throw error;
1525
+ }
1526
+ return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
832
1527
  } catch (error) {
833
1528
  if (!isEnoent(error)) throw error;
834
- return { ok: false, code: "removal_record_busy" };
835
1529
  }
836
- return { ok: false, code: "identity_mismatch" };
837
1530
  }
838
- if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
1531
+ const recordPath = removalRecordPath(transitionPath);
1532
+ const prior = await readRemovalTransitionOwnerRecord(transitionPath);
1533
+ if (prior && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
1534
+ if (!(await clearCompletedForeignRemovalRecord(lockPath, transitionPath, prior)))
1535
+ return { ok: false, code: "removal_record_busy" };
1536
+ } else if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
839
1537
  try {
840
1538
  await fs.lstat(transitionPath);
841
1539
  return { ok: false, code: "identity_mismatch" };
@@ -851,15 +1549,48 @@ async function exactRemoveLockTree(
851
1549
  // final and strands this live owner's lock for every other process.
852
1550
  if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
853
1551
  return { ok: false, code: "removal_record_busy" };
1552
+ const publishedRecordBytes = localRemovalRecords.get(recordPath)?.bytes;
854
1553
  try {
855
- return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
1554
+ await FileLockTestHooks.afterRemovalOwnerPublish?.(transitionPath);
1555
+ const bindings = nativeFileLockBindings();
1556
+ if (process.platform === "win32" && !detachOnly) {
1557
+ const detached = bindings.exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, true);
1558
+ if (!detached.ok) return detached;
1559
+ if (
1560
+ detached.code !== undefined ||
1561
+ detached.detachedPath !== transitionPath ||
1562
+ Object.keys(detached).some(key => key !== "ok" && key !== "detachedPath")
1563
+ )
1564
+ return { ok: false, code: "identity_mismatch", detachedPath: detached.detachedPath };
1565
+ try {
1566
+ const removeResult = bindings.exactRemoveDirectoryTree(transitionPath, snapshot, parentIdentity);
1567
+ // Handle returned transient failures (not thrown exceptions):
1568
+ // when native pre-open fails with e.g. sharing_violation, the result
1569
+ // is returned without detachedPath. Mark the receipt resumable before
1570
+ // returning so that in-process retry or cleanup can complete the removal.
1571
+ if (!removeResult.ok && isResultTransientFailure(removeResult)) {
1572
+ markLocalRemovalResumable(transitionPath, snapshot.rootDev, snapshot.rootIno);
1573
+ // Transitionpath was successfully detached; include it in the result
1574
+ // so cleanup can continue in-process or by a later invocation.
1575
+ return { ...removeResult, detachedPath: transitionPath };
1576
+ }
1577
+ return removeResult;
1578
+ } catch (error) {
1579
+ // Also handle thrown errors (if implementation changes in future)
1580
+ if (isTransientReleaseError(error))
1581
+ markLocalRemovalResumable(transitionPath, snapshot.rootDev, snapshot.rootIno);
1582
+ throw error;
1583
+ }
1584
+ }
1585
+ return bindings.exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
856
1586
  } finally {
1587
+ markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
857
1588
  await clearRemovalRecord(transitionPath);
858
1589
  }
859
1590
  }
860
1591
 
861
1592
  /**
862
- * Adopt-and-finish a provably orphaned POSIX removal transition.
1593
+ * Adopt-and-finish a provably orphaned removal transition.
863
1594
  *
864
1595
  * The prepublished sibling carries the remover's process generation and the
865
1596
  * captured root identity until cleanup finishes. A dead remover permits replay
@@ -872,7 +1603,7 @@ async function adoptOrphanedFileLockRemovalTransition(
872
1603
  orphanAgeMs: number,
873
1604
  ownerHostId?: string,
874
1605
  previousOwnerHostIds: readonly string[] = [],
875
- ): Promise<boolean> {
1606
+ ): Promise<boolean | "retry"> {
876
1607
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
877
1608
  const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
878
1609
  if (recordedOwner && ownerIsAlive(recordedOwner.owner) && !isResumableLocalRemoval(transitionPath, recordedOwner))
@@ -890,53 +1621,100 @@ async function adoptOrphanedFileLockRemovalTransition(
890
1621
  if (!recordedOwner && !isEmptyRemovalTransition(snapshot, orphanAgeMs)) return false;
891
1622
  if (recordedOwner && (snapshot.rootDev !== recordedOwner.rootDev || snapshot.rootIno !== recordedOwner.rootIno))
892
1623
  return false;
1624
+ const recordPath = removalRecordPath(transitionPath);
893
1625
  // Claim an ownerless generation without replacing any live publisher's record.
894
1626
  // The snapshot binds both the grace verdict and subsequent native removal.
895
- if (!recordedOwner && !(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
1627
+ if (recordedOwner) {
1628
+ const current = localRemovalRecords.get(recordPath);
1629
+ if (current?.inFlight) return false;
1630
+ localRemovalRecords.set(recordPath, {
1631
+ bytes: JSON.stringify(recordedOwner),
1632
+ resumable: true,
1633
+ inFlight: true,
1634
+ });
1635
+ } else if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) {
1636
+ return false;
1637
+ }
1638
+ const removalRecordBytes = localRemovalRecords.get(recordPath)?.bytes;
1639
+ if (!removalRecordBytes) return false;
896
1640
  const finish = async (): Promise<boolean> => {
897
- if (recordedOwner) {
898
- localRemovalRecords.set(`${transitionPath}.owner`, { bytes: JSON.stringify(recordedOwner), resumable: true });
899
- }
1641
+ markLocalRemovalCompleted(transitionPath, removalRecordBytes);
900
1642
  await clearRemovalRecord(transitionPath);
901
1643
  logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
902
1644
  return true;
903
1645
  };
904
- let removal: NativeExactUnlinkResult;
905
1646
  try {
906
- removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
907
- } catch (error) {
908
- if (isTransientReleaseError(error)) return false;
909
- throw error;
910
- }
911
- if (removal.ok === true) {
912
- return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
913
- }
914
- if (
915
- removal.ok === false &&
916
- removal.code === "not_found" &&
917
- Object.keys(removal).every(key => key === "ok" || key === "code")
918
- )
919
- return await finish();
920
- // POSIX cannot bind a namespace unlink to the verified descriptor, so the
921
- // primitive retains the scrubbed tree under its deterministic name and the
922
- // caller finishes the on-disk removal. Finish only the exact tree this call
923
- // scrubbed; a retained successor/placeholder/unknown path is refused so a
924
- // replacement is never deleted.
925
- if (
926
- removal.ok === false &&
927
- removal.code === "cleanup_pending" &&
928
- removal.payloadDurable === true &&
929
- removal.detachedPath !== undefined &&
930
- path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
931
- removal.retainedSuccessorPath === undefined &&
932
- removal.retainedPlaceholderPath === undefined &&
933
- removal.retainedUnknownPath === undefined &&
934
- Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
935
- ) {
936
- const finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
937
- return finished && (await finish());
1647
+ let removal: NativeExactUnlinkResult;
1648
+ try {
1649
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
1650
+ } catch (error) {
1651
+ if (isTransientReleaseError(error)) return "retry";
1652
+ throw error;
1653
+ }
1654
+ if (!removal.ok && isTransientReleaseError(removal)) return "retry";
1655
+ if (removal.ok === true) {
1656
+ return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
1657
+ }
1658
+ if (
1659
+ removal.ok === false &&
1660
+ removal.code === "not_found" &&
1661
+ Object.keys(removal).every(key => key === "ok" || key === "code")
1662
+ )
1663
+ return await finish();
1664
+ // POSIX cannot bind a namespace unlink to the verified descriptor, so the
1665
+ // primitive retains the scrubbed tree under its deterministic name and the
1666
+ // caller finishes the on-disk removal. Finish only the exact tree this call
1667
+ // scrubbed; a retained successor/placeholder/unknown path is refused so a
1668
+ // replacement is never deleted.
1669
+ if (
1670
+ removal.ok === false &&
1671
+ removal.code === "cleanup_pending" &&
1672
+ removal.payloadDurable === true &&
1673
+ removal.detachedPath !== undefined &&
1674
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
1675
+ removal.retainedSuccessorPath === undefined &&
1676
+ removal.retainedPlaceholderPath === undefined &&
1677
+ removal.retainedUnknownPath === undefined &&
1678
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
1679
+ ) {
1680
+ try {
1681
+ const finished = await removeDetachedLockQuarantineOnDisk(
1682
+ transitionPath,
1683
+ snapshot.rootDev,
1684
+ snapshot.rootIno,
1685
+ );
1686
+ return finished && (await finish());
1687
+ } catch (error) {
1688
+ if (isTransientReleaseError(error)) return "retry";
1689
+ throw error;
1690
+ }
1691
+ }
1692
+ return false;
1693
+ } finally {
1694
+ markLocalRemovalCompleted(transitionPath, removalRecordBytes);
938
1695
  }
939
- return false;
1696
+ }
1697
+
1698
+ /**
1699
+ * Whether an abandoned transition's captured tree is safe to adopt: the root is
1700
+ * the transition directory, `info` is the only file, and every other entry is a
1701
+ * directory or an empty file. Any other payload means adoption refuses the
1702
+ * generation, so it must be reported as a block instead of a silent retry.
1703
+ */
1704
+ function abandonedTransitionPayloadIsAdoptable(snapshot: NativeDirectoryTreeSnapshot): boolean {
1705
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
1706
+ const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
1707
+ return (
1708
+ root?.kind === "directory" &&
1709
+ infoEntry?.kind === "file" &&
1710
+ !snapshot.entries.some(
1711
+ entry =>
1712
+ entry.relativePath !== "" &&
1713
+ entry.relativePath !== "info" &&
1714
+ entry.kind !== "directory" &&
1715
+ (entry.kind !== "file" || entry.size !== "0"),
1716
+ )
1717
+ );
940
1718
  }
941
1719
 
942
1720
  async function adoptAbandonedFileLockRemovalTransition(
@@ -960,20 +1738,9 @@ async function adoptAbandonedFileLockRemovalTransition(
960
1738
  }
961
1739
  if (!captured.ok || !captured.snapshot) return false;
962
1740
  const snapshot = captured.snapshot;
963
- const root = snapshot.entries.find(entry => entry.relativePath === "");
1741
+ if (!abandonedTransitionPayloadIsAdoptable(snapshot)) return false;
964
1742
  const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
965
- if (
966
- root?.kind !== "directory" ||
967
- infoEntry?.kind !== "file" ||
968
- snapshot.entries.some(
969
- entry =>
970
- entry.relativePath !== "" &&
971
- entry.relativePath !== "info" &&
972
- entry.kind !== "directory" &&
973
- (entry.kind !== "file" || entry.size !== "0"),
974
- )
975
- )
976
- return false;
1743
+ if (infoEntry?.kind !== "file") return false;
977
1744
  const currentIdentity = await captureFileLockDirIdentity(transitionPath);
978
1745
  if (!currentIdentity || !sameStableFileLockIdentity(currentIdentity, stale.identity)) return false;
979
1746
  if (
@@ -984,38 +1751,45 @@ async function adoptAbandonedFileLockRemovalTransition(
984
1751
  // Legacy info proves the old owner dead, but native scrub destroys that info.
985
1752
  // Commit this remover's sibling identity before allowing any payload mutation.
986
1753
  if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
987
-
988
- let removal: NativeExactUnlinkResult;
1754
+ const publishedRecordBytes = localRemovalRecords.get(removalRecordPath(transitionPath))?.bytes;
1755
+ if (!publishedRecordBytes) return false;
989
1756
  try {
990
- removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
991
- } catch (error) {
992
- if (isTransientReleaseError(error)) return false;
993
- throw error;
994
- }
995
- let finished = removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
996
- if (
997
- removal.ok === false &&
998
- removal.code === "cleanup_pending" &&
999
- removal.payloadDurable === true &&
1000
- removal.detachedPath !== undefined &&
1001
- path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
1002
- removal.retainedSuccessorPath === undefined &&
1003
- removal.retainedPlaceholderPath === undefined &&
1004
- removal.retainedUnknownPath === undefined &&
1005
- Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
1006
- )
1007
- finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
1008
- if (
1009
- removal.ok === false &&
1010
- removal.code === "not_found" &&
1011
- Object.keys(removal).every(key => key === "ok" || key === "code")
1012
- )
1013
- finished = true;
1014
- if (finished) {
1015
- await clearRemovalRecord(transitionPath);
1016
- logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
1757
+ let removal: NativeExactUnlinkResult;
1758
+ try {
1759
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
1760
+ } catch (error) {
1761
+ if (isTransientReleaseError(error)) return false;
1762
+ throw error;
1763
+ }
1764
+ let finished =
1765
+ removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
1766
+ if (
1767
+ removal.ok === false &&
1768
+ removal.code === "cleanup_pending" &&
1769
+ removal.payloadDurable === true &&
1770
+ removal.detachedPath !== undefined &&
1771
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
1772
+ removal.retainedSuccessorPath === undefined &&
1773
+ removal.retainedPlaceholderPath === undefined &&
1774
+ removal.retainedUnknownPath === undefined &&
1775
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
1776
+ )
1777
+ finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
1778
+ if (
1779
+ removal.ok === false &&
1780
+ removal.code === "not_found" &&
1781
+ Object.keys(removal).every(key => key === "ok" || key === "code")
1782
+ )
1783
+ finished = true;
1784
+ if (finished) {
1785
+ markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
1786
+ await clearRemovalRecord(transitionPath);
1787
+ logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
1788
+ }
1789
+ return finished;
1790
+ } finally {
1791
+ markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
1017
1792
  }
1018
- return finished;
1019
1793
  }
1020
1794
 
1021
1795
  function sameFileLockTreeAfterPublication(
@@ -1068,6 +1842,7 @@ async function rollbackPublishedFileLock(
1068
1842
  lockPath: string,
1069
1843
  pendingPath: string,
1070
1844
  stagedSnapshot: NativeDirectoryTreeSnapshot,
1845
+ parentIdentity: { dev: bigint; ino: bigint },
1071
1846
  ): Promise<void> {
1072
1847
  if (process.platform !== "linux") {
1073
1848
  // RecoveryFsRoot is Linux-only. POSIX exact restore retains the parent
@@ -1077,6 +1852,8 @@ async function rollbackPublishedFileLock(
1077
1852
  const rollback = exactRestore(lockPath, pendingPath, {
1078
1853
  dev: BigInt(stagedSnapshot.rootDev),
1079
1854
  ino: BigInt(stagedSnapshot.rootIno),
1855
+ parentDev: parentIdentity.dev,
1856
+ parentIno: parentIdentity.ino,
1080
1857
  size: BigInt(root.size),
1081
1858
  mtimeNs: BigInt(root.mtimeNs),
1082
1859
  directory: true,
@@ -1217,6 +1994,7 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
1217
1994
  "atomic_unavailable",
1218
1995
  "cross_device",
1219
1996
  "permission_denied",
1997
+ "access_denied",
1220
1998
  "io_failure",
1221
1999
  "invalid_request",
1222
2000
  "interrupted",
@@ -1346,18 +2124,44 @@ function isPreMutationPublicationContention(value: unknown): boolean {
1346
2124
  );
1347
2125
  }
1348
2126
 
1349
- async function publishNoReplaceWithContentionRetry(
2127
+ function isPreMutationAccessDeniedPublication(value: unknown): boolean {
2128
+ return (
2129
+ isValidNativeNoReplaceResult(value) &&
2130
+ !value.ok &&
2131
+ value.code === "access_denied" &&
2132
+ value.mutationState === "not_committed" &&
2133
+ value.durabilityState === "not_attempted" &&
2134
+ value.reason === "access_denied" &&
2135
+ value.primitive === "windows_rename_noreplace" &&
2136
+ value.phase === "rename"
2137
+ );
2138
+ }
2139
+
2140
+ function isPreMutationPublicationRetryable(value: unknown): boolean {
2141
+ return isPreMutationPublicationContention(value) || isPreMutationAccessDeniedPublication(value);
2142
+ }
2143
+
2144
+ function preMutationPublicationFailureCode(value: unknown): FileLockPublicationFailureCode | undefined {
2145
+ if (isPreMutationPublicationContention(value)) return "sharing_violation";
2146
+ if (isPreMutationAccessDeniedPublication(value)) return "access_denied";
2147
+ return undefined;
2148
+ }
2149
+
2150
+ async function publishNoReplaceWithTransientRetry(
1350
2151
  publish: (sourcePath: string, destinationPath: string) => Promise<NativeNoReplaceResult>,
1351
2152
  sourcePath: string,
1352
2153
  destinationPath: string,
2154
+ signal?: AbortSignal,
1353
2155
  ): Promise<NativeNoReplaceResult> {
2156
+ throwIfFileLockAcquisitionAborted(signal);
1354
2157
  let result = await publish(sourcePath, destinationPath);
1355
2158
  for (
1356
2159
  let attempt = 1;
1357
- attempt < PUBLICATION_CONTENTION_RETRY_ATTEMPTS && isPreMutationPublicationContention(result);
2160
+ attempt < PUBLICATION_TRANSIENT_RETRY_ATTEMPTS && isPreMutationPublicationRetryable(result);
1358
2161
  attempt++
1359
2162
  ) {
1360
- await Bun.sleep(PUBLICATION_CONTENTION_RETRY_DELAY_MS * attempt);
2163
+ await sleepFileLockRetry(PUBLICATION_TRANSIENT_RETRY_DELAY_MS * attempt, signal);
2164
+ throwIfFileLockAcquisitionAborted(signal);
1361
2165
  result = await publish(sourcePath, destinationPath);
1362
2166
  }
1363
2167
  return result;
@@ -1426,12 +2230,14 @@ export interface GenericFileLockDirIdentity {
1426
2230
  }
1427
2231
 
1428
2232
  function sameStableFileLockIdentity(left: GenericFileLockDirIdentity, right: GenericFileLockDirIdentity): boolean {
2233
+ const birthtimeMatches =
2234
+ left.infoBirthtimeNs === "0" || right.infoBirthtimeNs === "0" || left.infoBirthtimeNs === right.infoBirthtimeNs;
1429
2235
  return (
1430
2236
  left.rootDev === right.rootDev &&
1431
2237
  left.rootIno === right.rootIno &&
1432
2238
  left.infoDev === right.infoDev &&
1433
2239
  left.infoIno === right.infoIno &&
1434
- left.infoBirthtimeNs === right.infoBirthtimeNs
2240
+ birthtimeMatches
1435
2241
  );
1436
2242
  }
1437
2243
 
@@ -1449,6 +2255,25 @@ function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right:
1449
2255
  return sameStableFileLockIdentity(left, right) && sameFileLockContentEvidence(left, right);
1450
2256
  }
1451
2257
 
2258
+ function fileLockDirIdentityFromNativeSnapshot(snapshot: NativeDirectoryTreeSnapshot): GenericFileLockDirIdentity {
2259
+ const info = snapshot.entries.find(entry => entry.relativePath === "info");
2260
+ if (info?.kind !== "file" || !info.sha256) throw new Error("file_lock_snapshot_missing_owner_identity");
2261
+ return {
2262
+ rootDev: snapshot.rootDev,
2263
+ rootIno: snapshot.rootIno,
2264
+ infoDev: info.dev,
2265
+ infoIno: info.ino,
2266
+ infoNlink: info.nlink,
2267
+ infoSize: info.size,
2268
+ infoMtimeNs: info.mtimeNs,
2269
+ infoCtimeNs: info.ctimeNs,
2270
+ // Native snapshots do not expose birthtime; the remaining inode and content
2271
+ // evidence still binds this exact staged generation across publication.
2272
+ infoBirthtimeNs: "0",
2273
+ infoSha256: info.sha256,
2274
+ };
2275
+ }
2276
+
1452
2277
  let nativeExactRemovalUsable: boolean | undefined;
1453
2278
  let nativeExactRemovalProbePromise: Promise<boolean> | undefined;
1454
2279
 
@@ -1542,8 +2367,22 @@ async function removeVerifiedLockDirWithoutNative(
1542
2367
  removal.retainedUnknownPath !== undefined
1543
2368
  )
1544
2369
  return "cleanup_failed";
1545
- if (!(await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno)))
2370
+ let detachedCleanupCompleted: boolean;
2371
+ try {
2372
+ detachedCleanupCompleted = await removeDetachedLockQuarantineOnDisk(
2373
+ removal.detachedPath,
2374
+ expected.rootDev,
2375
+ expected.rootIno,
2376
+ );
2377
+ } catch (error) {
2378
+ markLocalRemovalResumable(removal.detachedPath, expected.rootDev, expected.rootIno);
2379
+ if (!isTransientReleaseError(error)) throw error;
2380
+ return "cleanup_failed";
2381
+ }
2382
+ if (!detachedCleanupCompleted) {
2383
+ markLocalRemovalResumable(removal.detachedPath, expected.rootDev, expected.rootIno);
1546
2384
  return "cleanup_failed";
2385
+ }
1547
2386
  await clearRemovalRecord(removal.detachedPath);
1548
2387
  return "removed";
1549
2388
  }
@@ -1756,7 +2595,20 @@ export async function removeFileLockDirForGc(
1756
2595
  rootIno: captured.snapshot.rootIno,
1757
2596
  snapshot: captured.snapshot,
1758
2597
  });
1759
- return (await finishDetachedLockCleanup(expected)) ? "removed" : "cleanup_failed";
2598
+ let cleanupCompleted = false;
2599
+ try {
2600
+ cleanupCompleted = await finishDetachedLockCleanup(expected);
2601
+ } catch (error) {
2602
+ // Treat an OS sharing denial like a cleanup_pending receipt: both leave
2603
+ // this exact detached generation for a later local retry.
2604
+ if (!isTransientReleaseError(error)) throw error;
2605
+ }
2606
+ if (cleanupCompleted) return "removed";
2607
+ // Stale-owner cleanup has no LocalLockState release handle to drive another
2608
+ // retry. Mark the receipt resumable only after the native attempt returns;
2609
+ // other processes still see its live owner and remain fenced from adoption.
2610
+ markLocalRemovalResumable(detachedPath, captured.snapshot.rootDev, captured.snapshot.rootIno);
2611
+ return "cleanup_failed";
1760
2612
  }
1761
2613
  if (removed.code === "not_found") return "removed";
1762
2614
  if (removed.code === "identity_mismatch") return "owner_changed";
@@ -1840,9 +2692,10 @@ async function staleLockSnapshot(
1840
2692
  throw error;
1841
2693
  }
1842
2694
  if (!info) {
1843
- // An ownerless directory has no generation or liveness proof. Age and
1844
- // emptiness cannot distinguish interrupted cleanup from a live legacy holder
1845
- // or a paused acquisition, so every platform fails closed.
2695
+ // This verdict can authorize cleanup only from a recorded owner generation.
2696
+ // Windows acquisition has a separate age- and identity-bound path for a
2697
+ // completely empty legacy directory; that path never treats it as a dead
2698
+ // owner or claims the existing directory as a new lock.
1846
2699
  return { stale: false };
1847
2700
  }
1848
2701
 
@@ -2030,22 +2883,43 @@ async function tryAcquireLock(
2030
2883
  orphanTransitionAgeMs: number,
2031
2884
  previousOwnerHostIds: readonly string[],
2032
2885
  ownerToken = crypto.randomUUID(),
2033
- onAcquired?: () => void,
2886
+ onAcquired: (() => void) | undefined,
2887
+ retryBudget: FileLockAcquisitionRetryBudget,
2888
+ onPublished?: (owner: LockInfo) => void,
2889
+ onPublicationRolledBack?: (owner: LockInfo) => void,
2890
+ onPublicationFailed?: (owner: LockInfo, identity: GenericFileLockDirIdentity, releasePath: string) => void,
2034
2891
  ): Promise<FileLockAcquisitionResult> {
2035
2892
  await ensureLockParent(path.dirname(lockPath));
2036
2893
  const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
2037
2894
  if (afterParentMkdir) await afterParentMkdir(lockPath);
2038
2895
  const stagingIdentity = currentFileLockStagingIdentity();
2039
- const pendingPath = stagingIdentity
2040
- ? `${lockPath}.pending.${process.pid}.${stagingIdentity}.${crypto.randomBytes(12).toString("base64url")}`
2041
- : `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
2896
+ const pendingPath = derivedFileLockPath(
2897
+ lockPath,
2898
+ stagingIdentity
2899
+ ? `.pending.${process.pid}.${stagingIdentity}.${crypto.randomBytes(12).toString("base64url")}`
2900
+ : `.pending.${process.pid}.${crypto.randomUUID()}`,
2901
+ );
2042
2902
  const owner = lockInfo(ownerHostId, ownerToken);
2043
2903
  let removePending = false;
2904
+ let pendingStagingIdentity: FileLockStagingRootIdentity | undefined;
2905
+ let failedPublicationPath = lockPath;
2044
2906
  try {
2045
2907
  await fs.mkdir(pendingPath, { mode: 0o700 });
2046
2908
  removePending = true;
2909
+ try {
2910
+ pendingStagingIdentity = await captureFileLockStagingRootIdentity(pendingPath, retryBudget);
2911
+ } catch (error) {
2912
+ removePending = false;
2913
+ retainPendingStagingCleanup(lockPath, pendingPath, undefined, error);
2914
+ if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
2915
+ throw new FileLockStagingCleanupError(lockPath, pendingPath, error);
2916
+ }
2047
2917
  await fs.chmod(pendingPath, 0o700);
2048
- await writeLockInfo(pendingPath, owner);
2918
+ await writeLockInfo(pendingPath, owner, retryBudget, infoFileIdentity => {
2919
+ if (!pendingStagingIdentity) throw new Error("Staged file-lock root identity is unavailable.");
2920
+ pendingStagingIdentity = { ...pendingStagingIdentity, infoFileIdentity };
2921
+ });
2922
+ await FileLockTestHooks.afterStagingInfoWrite?.(pendingPath);
2049
2923
  // A plain POSIX rename replaces an existing empty directory. The legacy
2050
2924
  // directory lock is a real holder, so publication must use the native
2051
2925
  // no-replace primitive rather than treating an empty destination as free.
@@ -2054,56 +2928,68 @@ async function tryAcquireLock(
2054
2928
  // retain the same lock identity as the ordinary path.
2055
2929
  const canonicalParent = await fs.realpath(path.dirname(lockPath));
2056
2930
  const destinationPath = path.join(canonicalParent, path.basename(lockPath));
2931
+ failedPublicationPath = destinationPath;
2932
+ const parent = await fs.lstat(canonicalParent, { bigint: true });
2933
+ const parentIdentity = {
2934
+ dev: canonicalLockDeviceId(parent.dev),
2935
+ ino: canonicalLockFileId(parent.ino),
2936
+ };
2057
2937
  // Resolve only the stable parent: resolving the mutable staging final
2058
2938
  // component would follow an attacker-replaced symlink before native no-follow
2059
2939
  // validation gets a chance to reject it.
2060
2940
  const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
2061
2941
  let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
2062
- if (process.platform !== "win32") {
2063
- // POSIX exact tree removal owns this deterministic sibling from detach
2064
- // until cleanup. The outer acquisition loop supplies the existing bounded
2065
- // contention wait while the predecessor retains that namespace.
2066
- let transitionState = await classifyFileLockRemovalTransition(
2942
+ // A predecessor may retain this deterministic sibling while native cleanup
2943
+ // is in progress on either platform. Do not let a successor publish until
2944
+ // the transition is gone or its dead owner has been safely adopted.
2945
+ let transitionState = await classifyFileLockRemovalTransition(
2946
+ destinationPath,
2947
+ orphanTransitionAgeMs,
2948
+ ownerHostId,
2949
+ previousOwnerHostIds,
2950
+ );
2951
+ if (transitionState === "orphan_transition") {
2952
+ // A scrubbed, aged transition is the residue of a removal whose owner died
2953
+ // before its final on-disk cleanup. Adopt and finish it here so the wedged
2954
+ // namespace heals instead of aborting (or re-spinning the whole budget).
2955
+ const adopted = await adoptOrphanedFileLockRemovalTransition(
2067
2956
  destinationPath,
2068
2957
  orphanTransitionAgeMs,
2069
2958
  ownerHostId,
2070
2959
  previousOwnerHostIds,
2071
2960
  );
2072
- if (transitionState === "orphan_transition") {
2073
- // A scrubbed, aged transition is the residue of a removal whose owner died
2074
- // before its final on-disk cleanup. Adopt and finish it here so the wedged
2075
- // namespace heals instead of aborting (or re-spinning the whole budget).
2076
- if (
2077
- !(await adoptOrphanedFileLockRemovalTransition(
2078
- destinationPath,
2079
- orphanTransitionAgeMs,
2080
- ownerHostId,
2081
- previousOwnerHostIds,
2082
- ))
2083
- )
2084
- return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2961
+ if (adopted === "retry") return null;
2962
+ if (!adopted) return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2963
+ transitionState = await classifyFileLockRemovalTransition(
2964
+ destinationPath,
2965
+ orphanTransitionAgeMs,
2966
+ ownerHostId,
2967
+ previousOwnerHostIds,
2968
+ );
2969
+ }
2970
+ if (transitionState === "orphan_transition")
2971
+ return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2972
+ if (transitionState === "abandoned") {
2973
+ if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
2085
2974
  transitionState = await classifyFileLockRemovalTransition(
2086
2975
  destinationPath,
2087
2976
  orphanTransitionAgeMs,
2088
2977
  ownerHostId,
2089
2978
  previousOwnerHostIds,
2090
2979
  );
2980
+ } else {
2981
+ return null;
2091
2982
  }
2092
- if (transitionState === "orphan_transition")
2093
- return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2094
- if (transitionState === "abandoned") {
2095
- if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
2096
- transitionState = await classifyFileLockRemovalTransition(
2097
- destinationPath,
2098
- orphanTransitionAgeMs,
2099
- ownerHostId,
2100
- previousOwnerHostIds,
2101
- );
2102
- } else {
2103
- return null;
2104
- }
2983
+ }
2984
+ if (transitionState !== null) return null;
2985
+ let stagedTree: NativeDirectoryTreeSnapshot;
2986
+ if (process.platform === "win32") {
2987
+ try {
2988
+ stagedTree = await captureFileLockStagingTreeSnapshot(canonicalPendingPath, retryBudget);
2989
+ } catch (error) {
2990
+ throw new FileLockStagingCleanupError(lockPath, pendingPath, error);
2105
2991
  }
2106
- if (transitionState !== null) return null;
2992
+ } else {
2107
2993
  const staged = snapshotDirectoryTree(canonicalPendingPath);
2108
2994
  if (!staged.ok || !staged.snapshot) {
2109
2995
  const failure = new Error(
@@ -2112,29 +2998,40 @@ async function tryAcquireLock(
2112
2998
  if (staged.code) failure.code = staged.code;
2113
2999
  throw failure;
2114
3000
  }
2115
- stagedSnapshot = staged.snapshot;
3001
+ stagedTree = staged.snapshot;
2116
3002
  }
3003
+ stagedSnapshot = stagedTree;
3004
+ if (pendingStagingIdentity)
3005
+ pendingStagingIdentity = bindFileLockStagingTreeSnapshot(
3006
+ canonicalPendingPath,
3007
+ pendingStagingIdentity,
3008
+ stagedTree,
3009
+ );
3010
+ const stagedIdentity = fileLockDirIdentityFromNativeSnapshot(stagedTree);
2117
3011
  const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
2118
3012
  renameNoReplacePathAsync,
2119
3013
  renameDirectoryNoReplacePathAsync,
2120
3014
  };
2121
- const published = await publishNoReplaceWithContentionRetry(
3015
+ const published = await publishNoReplaceWithTransientRetry(
2122
3016
  publication.renameNoReplacePathAsync,
2123
3017
  canonicalPendingPath,
2124
3018
  destinationPath,
3019
+ retryBudget.signal,
2125
3020
  );
2126
3021
  let publishedSuccessfully = isSuccessfulNativePublication(published, "primary");
2127
3022
  if (published.ok && !publishedSuccessfully)
2128
3023
  throw new Error("Failed to publish file lock: invalid primary success receipt.");
2129
3024
  if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
2130
- const fallback = await publishNoReplaceWithContentionRetry(
3025
+ const fallback = await publishNoReplaceWithTransientRetry(
2131
3026
  publication.renameDirectoryNoReplacePathAsync,
2132
3027
  canonicalPendingPath,
2133
3028
  destinationPath,
3029
+ retryBudget.signal,
2134
3030
  );
2135
3031
  const fallbackSuccessfully = isSuccessfulNativePublication(fallback, "directory");
2136
3032
  if (fallback.ok && !fallbackSuccessfully)
2137
3033
  throw new Error("Failed to publish file lock: invalid directory success receipt.");
3034
+ const fallbackFailureCode = preMutationPublicationFailureCode(fallback);
2138
3035
  let verifiedCommittedPublication = false;
2139
3036
  if (isCommittedDirectoryVerificationFailure(fallback)) {
2140
3037
  // The staged name has been consumed. Relinquish pathname cleanup even
@@ -2152,10 +3049,12 @@ async function tryAcquireLock(
2152
3049
  }
2153
3050
  if (fallbackSuccessfully || verifiedCommittedPublication) {
2154
3051
  publishedSuccessfully = true;
2155
- } else if (isPreMutationPublicationContention(fallback)) {
2156
- return null;
2157
3052
  } else if (fallback.reason === "destination_exists") {
2158
3053
  return null;
3054
+ } else if (fallbackFailureCode) {
3055
+ removePending = false;
3056
+ await cleanupPendingStagingOrThrow(lockPath, pendingPath, pendingStagingIdentity, retryBudget);
3057
+ return { kind: "publication_retry", code: fallbackFailureCode };
2159
3058
  } else {
2160
3059
  const failure = new Error(
2161
3060
  `Failed to publish file lock: ${fallback.code ?? fallback.reason ?? "unknown"}.`,
@@ -2165,9 +3064,15 @@ async function tryAcquireLock(
2165
3064
  }
2166
3065
  }
2167
3066
  if (!publishedSuccessfully) {
2168
- if (isPreMutationPublicationContention(published) || published.reason === "destination_exists") {
3067
+ if (published.reason === "destination_exists") {
2169
3068
  return null;
2170
3069
  }
3070
+ const publicationFailureCode = preMutationPublicationFailureCode(published);
3071
+ if (publicationFailureCode) {
3072
+ removePending = false;
3073
+ await cleanupPendingStagingOrThrow(lockPath, pendingPath, pendingStagingIdentity, retryBudget);
3074
+ return { kind: "publication_retry", code: publicationFailureCode };
3075
+ }
2171
3076
  const failure = new Error(
2172
3077
  `Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
2173
3078
  ) as NodeJS.ErrnoException;
@@ -2175,55 +3080,110 @@ async function tryAcquireLock(
2175
3080
  throw failure;
2176
3081
  }
2177
3082
  removePending = false;
2178
- // The transition can appear after the pre-publication check only when the
2179
- // predecessor atomically detaches its lock before this no-replace publish.
2180
- // Roll this exact staged tree back to its UUID path before reporting
2181
- // contention; never delete or rename the predecessor's `.removing` tree.
2182
- if (stagedSnapshot) {
2183
- const transitionState = await classifyFileLockRemovalTransition(
2184
- destinationPath,
2185
- orphanTransitionAgeMs,
2186
- ownerHostId,
2187
- previousOwnerHostIds,
2188
- );
2189
- if (transitionState !== null) {
2190
- const publishedSnapshot = snapshotDirectoryTree(destinationPath);
2191
- if (
2192
- !publishedSnapshot.ok ||
2193
- !publishedSnapshot.snapshot ||
2194
- !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
2195
- ) {
2196
- const failure = new Error(
2197
- `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
2198
- ) as NodeJS.ErrnoException;
2199
- if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
2200
- throw failure;
2201
- }
2202
- await rollbackPublishedFileLock(
2203
- canonicalParent,
3083
+ try {
3084
+ // Preserve exact-generation release authority before post-publication checks
3085
+ // can throw; otherwise a live canonical owner can be left untracked locally.
3086
+ onPublished?.(owner);
3087
+ // The transition can appear after the pre-publication check only when the
3088
+ // predecessor atomically detaches its lock before this no-replace publish.
3089
+ // Roll this exact staged tree back to its UUID path before reporting
3090
+ // contention; never delete or rename the predecessor's `.removing` tree.
3091
+ if (stagedSnapshot) {
3092
+ const transitionState = await classifyFileLockRemovalTransition(
2204
3093
  destinationPath,
2205
- canonicalPendingPath,
2206
- publishedSnapshot.snapshot,
3094
+ orphanTransitionAgeMs,
3095
+ ownerHostId,
3096
+ previousOwnerHostIds,
2207
3097
  );
2208
- removePending = true;
2209
- return transitionState === "orphan_transition"
2210
- ? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
2211
- : null;
3098
+ if (transitionState !== null) {
3099
+ const publishedSnapshot = snapshotDirectoryTree(destinationPath);
3100
+ if (
3101
+ !publishedSnapshot.ok ||
3102
+ !publishedSnapshot.snapshot ||
3103
+ !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
3104
+ ) {
3105
+ const failure = new Error(
3106
+ `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
3107
+ ) as NodeJS.ErrnoException;
3108
+ if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
3109
+ throw failure;
3110
+ }
3111
+ try {
3112
+ await rollbackPublishedFileLock(
3113
+ canonicalParent,
3114
+ destinationPath,
3115
+ canonicalPendingPath,
3116
+ publishedSnapshot.snapshot,
3117
+ parentIdentity,
3118
+ );
3119
+ } catch (error) {
3120
+ // Rollback can commit before a later verification or authority-close
3121
+ // failure. Release the staged generation at the path that actually
3122
+ // retains it, rather than retrying against the now-vacant canonical name.
3123
+ try {
3124
+ const stagedAgain = snapshotDirectoryTree(canonicalPendingPath);
3125
+ if (
3126
+ stagedAgain.ok &&
3127
+ stagedAgain.snapshot &&
3128
+ sameFileLockTreeAfterPublication(publishedSnapshot.snapshot, stagedAgain.snapshot)
3129
+ )
3130
+ failedPublicationPath = canonicalPendingPath;
3131
+ } catch {
3132
+ // Keep the canonical target when the staging identity cannot be proven.
3133
+ }
3134
+ throw error;
3135
+ }
3136
+ removePending = true;
3137
+ onPublicationRolledBack?.(owner);
3138
+ return transitionState === "orphan_transition"
3139
+ ? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
3140
+ : null;
3141
+ }
2212
3142
  }
3143
+ // Published and transition-free above, so an onAcquired failure must
3144
+ // propagate instead of retrying an acquisition that already owns the lock.
3145
+ onAcquired?.();
3146
+ return owner;
3147
+ } catch (error) {
3148
+ onPublicationFailed?.(owner, stagedIdentity, failedPublicationPath);
3149
+ throw error;
2213
3150
  }
2214
- // Published and transition-free above, so an onAcquired failure must
2215
- // propagate instead of retrying an acquisition that already owns the lock.
2216
- onAcquired?.();
2217
- return owner;
2218
3151
  } finally {
2219
- if (removePending) await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
3152
+ if (removePending && pendingStagingIdentity) {
3153
+ try {
3154
+ if (process.platform === "win32" && !pendingStagingIdentity.treeSnapshot)
3155
+ pendingStagingIdentity = await captureFileLockStagingCleanupIdentity(
3156
+ pendingPath,
3157
+ pendingStagingIdentity,
3158
+ retryBudget,
3159
+ );
3160
+ await removePendingStaging(pendingPath, pendingStagingIdentity);
3161
+ pendingStagingCleanups.delete(pendingPath);
3162
+ } catch (cleanupError) {
3163
+ const cleanupPath = detachedStagingCleanupPath(pendingPath, cleanupError);
3164
+ if (cleanupPath !== pendingPath) pendingStagingCleanups.delete(pendingPath);
3165
+ retainPendingStagingCleanup(lockPath, cleanupPath, pendingStagingIdentity, cleanupError);
3166
+ }
3167
+ }
2220
3168
  }
2221
3169
  }
2222
3170
 
3171
+ function isResultTransientFailure(result: { code?: string }): boolean {
3172
+ const code = result.code;
3173
+ return (
3174
+ code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
3175
+ );
3176
+ }
3177
+
2223
3178
  function isTransientReleaseError(error: unknown): boolean {
2224
3179
  const code = (error as NodeJS.ErrnoException).code;
2225
3180
  return (
2226
- code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
3181
+ code === "EBUSY" ||
3182
+ code === "EPERM" ||
3183
+ code === "EACCES" ||
3184
+ code === "ENOTEMPTY" ||
3185
+ code === "access_denied" ||
3186
+ code === "sharing_violation"
2227
3187
  );
2228
3188
  }
2229
3189
 
@@ -2392,7 +3352,7 @@ async function retryPendingLocalRelease(lockPath: string, knownKey?: string): Pr
2392
3352
  return;
2393
3353
  }
2394
3354
  state.status = "releasing";
2395
- const releasePromise = releaseOwnedLock(lockPath, state.owner);
3355
+ const releasePromise = releaseOwnedLock(state.releasePath ?? lockPath, state.owner);
2396
3356
  state.releasePromise = releasePromise;
2397
3357
  try {
2398
3358
  await releasePromise;
@@ -2477,27 +3437,36 @@ function scheduleReleasePendingRetry(lockPath: string, key: string, state: Local
2477
3437
  * Bounded, actionable description of who holds `lockPath` at exhaustion time.
2478
3438
  * Never a stealing authority: purely diagnostic, read once after the last retry.
2479
3439
  */
3440
+ interface FileLockHolderDescription {
3441
+ description: string;
3442
+ isLive: boolean;
3443
+ }
3444
+
2480
3445
  async function lockHolderDescription(
2481
3446
  lockPath: string,
2482
3447
  orphanTransitionAgeMs: number,
2483
3448
  ownerHostId?: string,
2484
3449
  previousOwnerHostIds: readonly string[] = [],
2485
- ): Promise<string> {
3450
+ ): Promise<FileLockHolderDescription> {
2486
3451
  try {
2487
- if (process.platform !== "win32") {
2488
- const transitionState = await classifyFileLockRemovalTransition(
2489
- lockPath,
2490
- orphanTransitionAgeMs,
2491
- ownerHostId,
2492
- previousOwnerHostIds,
2493
- );
2494
- if (transitionState === "orphan_transition")
2495
- return `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`;
2496
- if (transitionState === "abandoned")
2497
- return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
2498
- if (transitionState === "active")
2499
- return `blocked by retained removal transition ${fileLockRemovalTransitionPath(lockPath)}; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed`;
2500
- }
3452
+ const transitionState = await classifyFileLockRemovalTransition(
3453
+ lockPath,
3454
+ orphanTransitionAgeMs,
3455
+ ownerHostId,
3456
+ previousOwnerHostIds,
3457
+ );
3458
+ if (transitionState === "orphan_transition")
3459
+ return { description: `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`, isLive: false };
3460
+ if (transitionState === "abandoned")
3461
+ return {
3462
+ description: `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`,
3463
+ isLive: false,
3464
+ };
3465
+ if (transitionState === "active")
3466
+ return {
3467
+ description: `blocked by retained removal transition ${fileLockRemovalTransitionPath(lockPath)}; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed`,
3468
+ isLive: false,
3469
+ };
2501
3470
  let info = await readLockInfo(lockPath);
2502
3471
  let bytes: string | null = null;
2503
3472
  if (!info) {
@@ -2519,51 +3488,58 @@ async function lockHolderDescription(
2519
3488
  // is missing instead of naming a host it does not have.
2520
3489
  const provenance =
2521
3490
  info.owner_host_id === undefined ? "on an unrecorded host" : `on host ${info.owner_host_id}`;
2522
- return (
2523
- `held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
2524
- ` since ${new Date(info.timestamp).toISOString()}`
2525
- );
3491
+ return {
3492
+ description:
3493
+ `held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
3494
+ ` since ${new Date(info.timestamp).toISOString()}`,
3495
+ isLive: false,
3496
+ };
2526
3497
  }
2527
- // Same-host holder: use the full liveness proof (pid alive AND, when the
2528
- // record carries a start_time, the start-time identity match) so a dead
2529
- // holder whose pid was already reused is not mislabeled "(live)".
2530
- const alive = ownerIsAlive(info);
2531
- const liveness = alive
2532
- ? "live"
2533
- : ownerLiveness(info.pid) === "dead"
2534
- ? "dead but not reaped"
2535
- : "liveness unknown";
2536
- return (
2537
- `held by pid ${info.pid}` +
2538
- // Keep the host on a host-qualified local record: on a shared volume the
2539
- // same pathname is contended from several hosts, so naming the one whose
2540
- // pid space this verdict came from is what makes it actionable.
2541
- (info.owner_host_id === undefined ? "" : " on this host") +
2542
- ` (${liveness})` +
2543
- ` since ${new Date(info.timestamp).toISOString()}`
2544
- );
3498
+ // Same-host holder: label it live only when the PID is alive and its
3499
+ // recorded process identity matches exactly. Conservative uncertainty
3500
+ // still prevents reclamation, but is not proof for operator diagnostics.
3501
+ const pidLiveness = ownerLiveness(info.pid);
3502
+ const alive = pidLiveness === "alive" && ownerIdentityIsProven(info);
3503
+ const liveness = alive ? "live" : pidLiveness === "dead" ? "dead but not reaped" : "liveness unknown";
3504
+ return {
3505
+ description:
3506
+ `held by pid ${info.pid}` +
3507
+ // Keep the host on a host-qualified local record: on a shared volume the
3508
+ // same pathname is contended from several hosts, so naming the one whose
3509
+ // pid space this verdict came from is what makes it actionable.
3510
+ (info.owner_host_id === undefined ? "" : " on this host") +
3511
+ ` (${liveness})` +
3512
+ ` since ${new Date(info.timestamp).toISOString()}`,
3513
+ isLive: alive,
3514
+ };
2545
3515
  }
2546
3516
  try {
2547
3517
  if (bytes !== null)
2548
- return "held by an owner record that never became readable (empty, truncated, or non-JSON info); malformed records carry no liveness proof and are never reclaimed — inspect and remove the directory manually once no publisher remains";
3518
+ return {
3519
+ description:
3520
+ "held by an owner record that never became readable (empty, truncated, or non-JSON info); malformed records carry no liveness proof and are never reclaimed — inspect and remove the directory manually once no publisher remains",
3521
+ isLive: false,
3522
+ };
2549
3523
  await fs.stat(path.join(lockPath, "info"));
2550
- return "held by an owner whose metadata is not yet readable";
3524
+ return { description: "held by an owner whose metadata is not yet readable", isLive: false };
2551
3525
  } catch (error) {
2552
3526
  if (!isEnoent(error)) throw error;
2553
3527
  }
2554
3528
  try {
2555
3529
  if ((await fs.readdir(lockPath)).length === 0) {
2556
- return (
2557
- "held by an ownerless empty lock directory; inspect and remove this exact directory manually only after " +
2558
- `confirming no publisher remains: ${manualLockCleanupCommand(lockPath)}`
2559
- );
3530
+ return {
3531
+ description:
3532
+ "held by an ownerless empty lock directory; inspect and remove this exact directory manually only after " +
3533
+ `confirming no publisher remains: ${manualLockCleanupCommand(lockPath)}`,
3534
+ isLive: false,
3535
+ };
2560
3536
  }
2561
3537
  } catch (error) {
2562
3538
  if (!isEnoent(error)) throw error;
2563
3539
  }
2564
- return "held by an unrecognized owner record";
3540
+ return { description: "held by an unrecognized owner record", isLive: false };
2565
3541
  } catch (error) {
2566
- return `held by an unreadable owner (${(error as Error).message})`;
3542
+ return { description: `held by an unreadable owner (${(error as Error).message})`, isLive: false };
2567
3543
  }
2568
3544
  }
2569
3545
 
@@ -2576,13 +3552,23 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2576
3552
  if (options.previousOwnerHostIds?.some(hostId => !hostId))
2577
3553
  throw new Error("previousOwnerHostIds must contain only non-empty identities");
2578
3554
  const opts = { ...DEFAULT_OPTIONS, ...options };
3555
+ if (opts.retries !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(opts.retries) || opts.retries < 1))
3556
+ throw new RangeError("retries must be a positive safe integer");
2579
3557
  const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
2580
3558
  if (!Number.isFinite(orphanTransitionAgeMs) || orphanTransitionAgeMs < 0)
2581
3559
  throw new RangeError("orphanTransitionAgeMs must be a finite, non-negative number");
2582
3560
 
2583
3561
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
2584
3562
  const lockPath = getLockPath(filePath);
3563
+ // Bound staging cleanup across the outer retry budget: all staging cleanup retries
3564
+ // use a shared delay budget and are subject to the same abort signal as the lock acquisition.
3565
+ const retryBudget: FileLockAcquisitionRetryBudget = {
3566
+ remainingDelays: opts.retries - 1,
3567
+ retryDelayMs: opts.retryDelayMs,
3568
+ signal: opts.signal,
3569
+ };
2585
3570
  await ensureLockParent(path.dirname(lockPath));
3571
+ await retryPendingStagingCleanups(lockPath, retryBudget);
2586
3572
  try {
2587
3573
  await reapOrphanedLockStagingDirs(lockPath);
2588
3574
  } catch (error) {
@@ -2592,12 +3578,36 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2592
3578
  const contentionStartTimes = new Map<string, string | null>();
2593
3579
  let contentionObserved = false;
2594
3580
  let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
2595
- for (let attempt = 0; attempt < opts.retries; attempt++) {
2596
- if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
3581
+ let publicationFailureCode: FileLockPublicationFailureCode | undefined;
3582
+ let accessDeniedPublicationAttempts = 0;
3583
+ let attemptsMade = 0;
3584
+ let attemptBudget = opts.retries;
3585
+ let recoveryExtensions = 0;
3586
+ const extendAttemptBudgetAfterCleanup = (): void => {
3587
+ // Reaping is progress, but it still needs a fresh publication attempt. Bound
3588
+ // extensions so a stream of dead generations cannot make acquisition unbounded.
3589
+ if (recoveryExtensions >= opts.retries) return;
3590
+ recoveryExtensions++;
3591
+ attemptBudget++;
3592
+ };
3593
+ let attempt = 0;
3594
+ for (; attempt < attemptBudget; attempt++) {
3595
+ attemptsMade = attempt + 1;
3596
+ throwIfFileLockAcquisitionAborted(opts.signal);
3597
+ await retryPendingStagingCleanups(lockPath, retryBudget);
2597
3598
  const localKey = await localLockKey(lockPath);
3599
+ let publishedState: LocalLockState | undefined;
2598
3600
  const priorRelease = localLockStates.get(localKey);
2599
- if (priorRelease && priorRelease.status !== "held" && pendingDetachedLockCleanups.has(priorRelease.owner)) {
3601
+ if (
3602
+ priorRelease &&
3603
+ priorRelease.status !== "held" &&
3604
+ (pendingDetachedLockCleanups.has(priorRelease.owner) ||
3605
+ (priorRelease.status === "release_pending" &&
3606
+ priorRelease.releasePath !== undefined &&
3607
+ !priorRelease.releasePromise))
3608
+ ) {
2600
3609
  await retryPendingLocalRelease(lockPath, localKey);
3610
+ extendAttemptBudgetAfterCleanup();
2601
3611
  continue;
2602
3612
  }
2603
3613
  const result = await tryAcquireLock(
@@ -2607,12 +3617,35 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2607
3617
  opts.previousOwnerHostIds ?? [],
2608
3618
  ownerToken,
2609
3619
  opts.onAcquired,
3620
+ retryBudget,
3621
+ owner => {
3622
+ const state: LocalLockState = { owner, status: "held" };
3623
+ publishedState = state;
3624
+ localLockStates.set(localKey, state);
3625
+ },
3626
+ owner => {
3627
+ const state = publishedState;
3628
+ if (!state || state.owner.owner_token !== owner.owner_token) return;
3629
+ if (localLockStates.get(localKey) === state) localLockStates.delete(localKey);
3630
+ if (publishedState === state) publishedState = undefined;
3631
+ },
3632
+ (owner, identity, releasePath) => {
3633
+ const state = publishedState;
3634
+ if (!state || state.owner.owner_token !== owner.owner_token || localLockStates.get(localKey) !== state)
3635
+ return;
3636
+ fileLockDirIdentities.set(owner, identity);
3637
+ state.releasePath = releasePath;
3638
+ state.status = "release_pending";
3639
+ scheduleReleasePendingRetry(lockPath, localKey, state);
3640
+ },
2610
3641
  );
2611
3642
  if (isFileLockOrphanTransition(result)) {
2612
3643
  // Adoption is identity-bound and can be refused by a transient cleanup race.
2613
3644
  // Keep using the caller's bounded contention budget; the same generation is
2614
3645
  // revalidated before every later adoption attempt.
2615
- if (attempt + 1 === opts.retries)
3646
+ publicationFailureCode = undefined;
3647
+ accessDeniedPublicationAttempts = 0;
3648
+ if (attempt + 1 === attemptBudget)
2616
3649
  throw new FileLockAcquireError(
2617
3650
  filePath,
2618
3651
  lockPath,
@@ -2621,11 +3654,23 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2621
3654
  "orphan_transition",
2622
3655
  result.path,
2623
3656
  );
2624
- } else if (result) {
2625
- localLockStates.set(localKey, { owner: result, status: "held" });
2626
- return () => releaseLock(lockPath, result, localKey);
3657
+ } else if (isFileLockPublicationRetry(result)) {
3658
+ publicationFailureCode = result.code;
3659
+ accessDeniedPublicationAttempts = result.code === "access_denied" ? accessDeniedPublicationAttempts + 1 : 0;
3660
+ } else {
3661
+ publicationFailureCode = undefined;
3662
+ accessDeniedPublicationAttempts = 0;
3663
+ if (result) {
3664
+ const state = publishedState ?? { owner: result, status: "held" as const };
3665
+ state.status = "held";
3666
+ localLockStates.set(localKey, state);
3667
+ return () => releaseLock(lockPath, result, localKey);
3668
+ }
2627
3669
  }
2628
- if (!contentionObserved) {
3670
+ throwIfFileLockAcquisitionAborted(opts.signal);
3671
+ if (accessDeniedPublicationAttempts >= MAX_ACCESS_DENIED_PUBLICATION_ACQUISITION_ATTEMPTS) break;
3672
+ const publicationIsProvenContention = isFileLockPublicationRetry(result) && result.code === "sharing_violation";
3673
+ if (!contentionObserved && (!isFileLockPublicationRetry(result) || publicationIsProvenContention)) {
2629
3674
  contentionObserved = true;
2630
3675
  opts.onContended?.();
2631
3676
  }
@@ -2634,6 +3679,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2634
3679
  if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
2635
3680
  try {
2636
3681
  await retryPendingLocalRelease(lockPath, pendingKey);
3682
+ extendAttemptBudgetAfterCleanup();
2637
3683
  continue;
2638
3684
  } catch {
2639
3685
  // Keep contending below. A failed local retry is not authority to steal a
@@ -2650,26 +3696,19 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2650
3696
  const staleRemoval = await removeStaleLockForAcquire(lockPath, stale);
2651
3697
  if (staleRemoval.removed) {
2652
3698
  staleRemovalFailure = undefined;
3699
+ extendAttemptBudgetAfterCleanup();
2653
3700
  continue;
2654
3701
  }
2655
3702
  staleRemovalFailure =
2656
3703
  stale.stale && staleRemoval.failure
2657
3704
  ? { owner: stale.owner, identity: stale.identity, failure: staleRemoval.failure }
2658
3705
  : undefined;
2659
- if (!opts.signal) {
2660
- await Bun.sleep(opts.retryDelayMs);
2661
- continue;
2662
- }
2663
- const { promise, resolve, reject } = Promise.withResolvers<void>();
2664
- const onAbort = (): void => reject(opts.signal?.reason ?? new Error("File lock acquisition aborted"));
2665
- opts.signal.addEventListener("abort", onAbort, { once: true });
2666
- void Bun.sleep(opts.retryDelayMs).then(resolve);
2667
- try {
2668
- await promise;
2669
- } finally {
2670
- opts.signal.removeEventListener("abort", onAbort);
3706
+ if (retryBudget.remainingDelays > 0) {
3707
+ retryBudget.remainingDelays--;
3708
+ await sleepFileLockRetry(opts.retryDelayMs, opts.signal);
2671
3709
  }
2672
3710
  }
3711
+ throwIfFileLockAcquisitionAborted(opts.signal);
2673
3712
  const holder = await lockHolderDescription(
2674
3713
  lockPath,
2675
3714
  orphanTransitionAgeMs,
@@ -2686,14 +3725,73 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2686
3725
  throw new FileLockAcquireError(
2687
3726
  filePath,
2688
3727
  lockPath,
2689
- opts.retries,
2690
- holder,
3728
+ attemptsMade,
3729
+ holder.description,
2691
3730
  transitionState === "orphan_transition" ? "orphan_transition" : "acquire_timeout",
2692
3731
  transitionPath,
2693
3732
  await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
3733
+ publicationFailureCode,
3734
+ holder.isLive,
3735
+ );
3736
+ }
3737
+ /**
3738
+ * Describes the retained removal transition blocking `filePath`, if one exists.
3739
+ *
3740
+ * `acquireFileLock` reports this on exhaustion, but a caller whose own budget
3741
+ * aborts the acquisition first would otherwise never learn that a stale
3742
+ * transition — not ordinary contention — is what blocked it. Such a caller can
3743
+ * ask directly after an abort so the block is surfaced instead of swallowed.
3744
+ */
3745
+ export async function describeRetainedLockTransition(
3746
+ filePath: string,
3747
+ options: FileLockOptions = {},
3748
+ ): Promise<FileLockAcquireError | undefined> {
3749
+ const opts = { ...DEFAULT_OPTIONS, ...options };
3750
+ const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
3751
+ const lockPath = getLockPath(filePath);
3752
+ const previousOwnerHostIds = opts.previousOwnerHostIds ?? [];
3753
+ const transitionState = await classifyFileLockRemovalTransition(
3754
+ lockPath,
3755
+ orphanTransitionAgeMs,
3756
+ opts.ownerHostId,
3757
+ previousOwnerHostIds,
3758
+ );
3759
+ const reportsOrphanTransition = transitionState === "orphan_transition";
3760
+ // An abandoned generation with payload outside the adoption shape is never
3761
+ // reclaimable by this path, so it blocks renewal exactly like a retained orphan
3762
+ // transition. Reporting only `orphan_transition` would leave that case idle.
3763
+ const reportsUnadoptableAbandoned =
3764
+ transitionState === "abandoned" && (await abandonedTransitionIsUnadoptable(lockPath));
3765
+ if (!reportsOrphanTransition && !reportsUnadoptableAbandoned) return undefined;
3766
+ const holder = await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, previousOwnerHostIds);
3767
+ return new FileLockAcquireError(
3768
+ filePath,
3769
+ lockPath,
3770
+ opts.retries,
3771
+ holder.description,
3772
+ "orphan_transition",
3773
+ fileLockRemovalTransitionPath(lockPath),
2694
3774
  );
2695
3775
  }
2696
3776
 
3777
+ /** A proven-dead abandoned generation whose payload adoption refuses on sight. */
3778
+ async function abandonedTransitionIsUnadoptable(lockPath: string): Promise<boolean> {
3779
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
3780
+ if (await readRemovalTransitionOwnerRecord(transitionPath)) return false;
3781
+ if (!(await isNativeExactRemovalUsable())) return false;
3782
+ let captured: NativeDirectoryTreeResult;
3783
+ try {
3784
+ captured = nativeFileLockBindings().snapshotDirectoryTree(transitionPath);
3785
+ } catch (error) {
3786
+ if (isTransientReleaseError(error)) return false;
3787
+ throw error;
3788
+ }
3789
+ if (!captured.ok || !captured.snapshot) return false;
3790
+ // A shape adoption accepts is retried by the ordinary remover; only a refused
3791
+ // payload is a permanent block.
3792
+ return !abandonedTransitionPayloadIsAdoptable(captured.snapshot);
3793
+ }
3794
+
2697
3795
  /**
2698
3796
  * Serializes all contenders, including callers in the same process. Because this
2699
3797
  * API exposes no ownership token, recursive acquisition is indistinguishable
@@ -2726,12 +3824,17 @@ type FileLockStagingOwner = { pid: number; identity?: FileLockStagingIdentity };
2726
3824
 
2727
3825
  const FILE_LOCK_STAGING_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
2728
3826
  const FILE_LOCK_STAGING_NAME_WITH_IDENTITY =
2729
- /^.+\.lock\.pending\.([1-9]\d*)\.([is][A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{16})$/;
2730
- const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(`^.+\\.lock\\.pending\\.([1-9]\\d*)\\.${FILE_LOCK_STAGING_UUID}$`);
2731
-
3827
+ /^(?:.+\.lock|\.gjc-lock-[0-9a-f]{64})\.pending\.([1-9]\d*)\.([is][A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{16})$/;
3828
+ const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(
3829
+ `^(?:.+\\.lock|\\.gjc-lock-[0-9a-f]{64})\\.pending\\.([1-9]\\d*)\\.${FILE_LOCK_STAGING_UUID}$`,
3830
+ );
3831
+ const FILE_LOCK_STAGING_REMOVING_SUFFIX = ".removing";
2732
3832
  function fileLockStagingOwner(name: string): FileLockStagingOwner | null {
2733
- const current = FILE_LOCK_STAGING_NAME_WITH_IDENTITY.exec(name);
2734
- const legacy = current ? null : LEGACY_FILE_LOCK_STAGING_NAME.exec(name);
3833
+ const stagingName = name.endsWith(FILE_LOCK_STAGING_REMOVING_SUFFIX)
3834
+ ? name.slice(0, -FILE_LOCK_STAGING_REMOVING_SUFFIX.length)
3835
+ : name;
3836
+ const current = FILE_LOCK_STAGING_NAME_WITH_IDENTITY.exec(stagingName);
3837
+ const legacy = current ? null : LEGACY_FILE_LOCK_STAGING_NAME.exec(stagingName);
2735
3838
  const match = current ?? legacy;
2736
3839
  if (!match) return null;
2737
3840
  const pid = Number(match[1]);
@@ -2875,6 +3978,28 @@ export async function inspectFileLockStagingDir(
2875
3978
  reason: `file_lock_staging_owner_${status}`,
2876
3979
  };
2877
3980
  if (status !== "dead" || !remove) return result;
3981
+ if (path.basename(canonical).endsWith(FILE_LOCK_STAGING_REMOVING_SUFFIX)) {
3982
+ const removal = await exactRemoveLockTree(canonical, captured.snapshot);
3983
+ if (removal.ok || removal.code === "not_found") return { ...result, removed: true, reason: "removed" };
3984
+ const detachedTreeWasDurablyScrubbed =
3985
+ removal.code === "cleanup_pending" &&
3986
+ removal.payloadDurable === true &&
3987
+ removal.detachedPath !== undefined &&
3988
+ sameFileLockPath(removal.detachedPath, canonical) &&
3989
+ removal.retainedSuccessorPath === undefined &&
3990
+ removal.retainedPlaceholderPath === undefined &&
3991
+ removal.retainedUnknownPath === undefined &&
3992
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key));
3993
+ if (detachedTreeWasDurablyScrubbed) {
3994
+ const removed = await removeDetachedLockQuarantineOnDisk(
3995
+ canonical,
3996
+ captured.snapshot.rootDev,
3997
+ captured.snapshot.rootIno,
3998
+ );
3999
+ return { ...result, removed, reason: removed ? "removed" : "cleanup_pending" };
4000
+ }
4001
+ return { ...result, reason: removal.code ?? "cleanup_failed" };
4002
+ }
2878
4003
  if (observation) {
2879
4004
  const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
2880
4005
  return { ...result, removed: removal === "removed", reason: removal };
@@ -2924,10 +4049,12 @@ export async function reapOrphanedLockStagingDirs(lockPath: string): Promise<{
2924
4049
  const summary: { removed: string[]; retained: FileLockStagingResult[] } = { removed: [], retained: [] };
2925
4050
  const parent = path.dirname(lockPath);
2926
4051
  const prefix = `${path.basename(lockPath)}.pending.`;
4052
+ const boundedPrefix = `.gjc-lock-${crypto.createHash("sha256").update(path.basename(lockPath)).digest("hex")}.pending.`;
2927
4053
  const entries = await fs.readdir(parent);
2928
4054
  let candidates = 0;
2929
4055
  for (const entry of entries) {
2930
- if (!entry.startsWith(prefix) || fileLockStagingOwnerPid(entry) === null) continue;
4056
+ if ((!entry.startsWith(prefix) && !entry.startsWith(boundedPrefix)) || fileLockStagingOwnerPid(entry) === null)
4057
+ continue;
2931
4058
  if (++candidates > 64) break;
2932
4059
  const result = await inspectFileLockStagingDir(path.join(parent, entry), ownerLiveness, true);
2933
4060
  if (result.removed) summary.removed.push(result.path);