@gajae-code/coding-agent 0.15.5 → 0.16.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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -1,8 +1,10 @@
1
1
  import * as crypto from "node:crypto";
2
- import type { Stats } from "node:fs";
2
+ import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import { hasFsCode, isEnoent } from "@gajae-code/utils/fs-error";
5
+ import type { NativeDirectoryTreeResult, NativeExactUnlinkResult, NativeNoReplaceResult } from "@gajae-code/natives";
6
+ import { renameDirectoryNoReplacePathAsync, renameNoReplacePathAsync } from "@gajae-code/natives";
7
+ import { isEnoent } from "@gajae-code/utils/fs-error";
6
8
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
7
9
 
8
10
  export interface FileLockOptions {
@@ -25,10 +27,33 @@ const DEFAULT_OPTIONS: Required<
25
27
  retryDelayMs: 100,
26
28
  };
27
29
 
30
+ /** Release retries cover transient Windows/Dropbox handle denial without extending the lock indefinitely. */
31
+ export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 5;
32
+ export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 10;
33
+ const PROCESS_START_TIME_FORMAT = "utc-v1";
34
+
35
+ type LocalLockState = {
36
+ owner: FileLockOwnerToken;
37
+ status: "held" | "release_pending" | "releasing";
38
+ releasePromise?: Promise<void>;
39
+ };
40
+
41
+ /**
42
+ * Process-local ownership is deliberately separate from PID liveness. A PID only says
43
+ * that a process exists; this table says which exact acquisition generation this process
44
+ * created, so a nested contender cannot steal a lock from a still-running holder.
45
+ */
46
+ const localLockStates = new Map<string, LocalLockState>();
47
+
28
48
  type LockInfo = FileLockOwnerToken;
29
49
 
30
50
  export const FileLockTestHooks: {
31
51
  afterParentMkdir?: (lockPath: string) => void | Promise<void>;
52
+ nativePublicationBindings?: () => {
53
+ renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
54
+ renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
55
+ };
56
+ nativeQuarantineBindings?: () => NativeFileLockBindings;
32
57
  } = {};
33
58
 
34
59
  /**
@@ -51,7 +76,11 @@ export function processStartTime(pid: number): string | null {
51
76
  }
52
77
  }
53
78
  try {
54
- const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], { stdout: "pipe", stderr: "ignore" });
79
+ const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
80
+ stdout: "pipe",
81
+ stderr: "ignore",
82
+ env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" },
83
+ });
55
84
  if (result.exitCode !== 0) return null;
56
85
  const startTime = new TextDecoder().decode(result.stdout).trim();
57
86
  return startTime || null;
@@ -79,35 +108,215 @@ function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, s
79
108
 
80
109
  function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
81
110
  if (ownerLiveness(owner.pid) !== "alive") return false;
82
- if (!owner.start_time) return true;
111
+ if (!owner.start_time || owner.start_time === "unknown") return true;
83
112
  const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
84
- return currentStartTime === null || currentStartTime === owner.start_time;
113
+ if (currentStartTime === null || currentStartTime === owner.start_time) return true;
114
+ // A start-time mismatch proves PID reuse only for records that identify the
115
+ // canonical UTC encoding. Legacy records did not identify their timestamp format,
116
+ // so a locale/timezone change can make a live holder look different and must never
117
+ // authorize its removal.
118
+ return owner.start_time_format !== PROCESS_START_TIME_FORMAT;
85
119
  }
86
120
 
87
- function lockInfo(ownerHostId?: string): LockInfo {
121
+ function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
88
122
  return {
89
123
  pid: process.pid,
90
124
  start_time: currentProcessStartTime(),
125
+ start_time_format: PROCESS_START_TIME_FORMAT,
91
126
  timestamp: Date.now(),
127
+ owner_token: ownerToken,
92
128
  ...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
93
129
  };
94
130
  }
95
131
 
96
132
  function writeLockInfo(lockPath: string, info: LockInfo): Promise<LockInfo> {
97
- return Bun.write(`${lockPath}/info`, JSON.stringify(info)).then(() => info);
133
+ // Owner metadata must stay readable by its own process under a restrictive
134
+ // umask: release re-reads this record to authorize removal, and an info file
135
+ // born mode 000 under umask 0777 would wedge the lock at first release.
136
+ return Bun.write(`${lockPath}/info`, JSON.stringify(info), { mode: 0o600 })
137
+ .then(() => fs.chmod(`${lockPath}/info`, 0o600))
138
+ .then(() => info);
139
+ }
140
+
141
+ type LockInfoFileState = {
142
+ dev: bigint;
143
+ ino: bigint;
144
+ mode: bigint;
145
+ size: bigint;
146
+ mtimeNs: bigint;
147
+ ctimeNs: bigint;
148
+ nlink: bigint;
149
+ };
150
+
151
+ type LockInfoPathState = {
152
+ root: {
153
+ dev: bigint;
154
+ ino: bigint;
155
+ mode: bigint;
156
+ };
157
+ file: LockInfoFileState;
158
+ };
159
+
160
+ function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
161
+ if (stats.isSymbolicLink() || !stats.isFile()) return null;
162
+ return {
163
+ dev: stats.dev,
164
+ ino: stats.ino,
165
+ mode: stats.mode,
166
+ size: stats.size,
167
+ mtimeNs: stats.mtimeNs,
168
+ ctimeNs: stats.ctimeNs,
169
+ nlink: stats.nlink,
170
+ };
171
+ }
172
+
173
+ function sameLockInfoFileState(left: LockInfoFileState, right: LockInfoFileState): boolean {
174
+ return (
175
+ left.dev === right.dev &&
176
+ left.ino === right.ino &&
177
+ left.mode === right.mode &&
178
+ left.size === right.size &&
179
+ left.mtimeNs === right.mtimeNs &&
180
+ left.ctimeNs === right.ctimeNs &&
181
+ left.nlink === right.nlink
182
+ );
183
+ }
184
+
185
+ async function lockInfoPathState(lockPath: string): Promise<LockInfoPathState | null> {
186
+ let root: BigIntStats;
187
+ try {
188
+ root = await fs.lstat(lockPath, { bigint: true });
189
+ } catch (error) {
190
+ if (isEnoent(error)) return null;
191
+ throw error;
192
+ }
193
+ if (root.isSymbolicLink() || !root.isDirectory()) return null;
194
+
195
+ let info: BigIntStats;
196
+ try {
197
+ info = await fs.lstat(path.join(lockPath, "info"), { bigint: true });
198
+ } catch (error) {
199
+ if (isEnoent(error)) return null;
200
+ throw error;
201
+ }
202
+ const file = lockInfoFileState(info);
203
+ if (!file) return null;
204
+ return {
205
+ root: { dev: root.dev, ino: root.ino, mode: root.mode },
206
+ file,
207
+ };
208
+ }
209
+
210
+ function sameLockInfoPathState(left: LockInfoPathState, right: LockInfoPathState): boolean {
211
+ return (
212
+ left.root.dev === right.root.dev &&
213
+ left.root.ino === right.root.ino &&
214
+ left.root.mode === right.root.mode &&
215
+ sameLockInfoFileState(left.file, right.file)
216
+ );
217
+ }
218
+
219
+ function fileLockDirIdentityFromPathState(state: LockInfoPathState, bytes: string): GenericFileLockDirIdentity {
220
+ return {
221
+ rootDev: String(state.root.dev),
222
+ rootIno: String(state.root.ino),
223
+ infoDev: String(state.file.dev),
224
+ infoIno: String(state.file.ino),
225
+ infoNlink: String(state.file.nlink),
226
+ infoSize: String(state.file.size),
227
+ infoMtimeNs: String(state.file.mtimeNs),
228
+ infoCtimeNs: String(state.file.ctimeNs),
229
+ infoSha256: crypto.createHash("sha256").update(bytes).digest("hex"),
230
+ };
231
+ }
232
+
233
+ /** Capture the exact root/info identity that a later stale verdict may authorize. */
234
+ async function captureFileLockDirIdentity(lockDir: string): Promise<GenericFileLockDirIdentity | null> {
235
+ const observation = await readLockInfoObservation(lockDir);
236
+ return observation ? fileLockDirIdentityFromPathState(observation.state, observation.bytes) : null;
237
+ }
238
+
239
+ /** Resolve parent aliases without following the mutable lock-dir final component. */
240
+ async function canonicalLockPathPreservingFinal(lockPath: string): Promise<string> {
241
+ const parent = path.dirname(lockPath);
242
+ try {
243
+ return path.join(await fs.realpath(parent), path.basename(lockPath));
244
+ } catch (error) {
245
+ if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
246
+ return lockPath;
247
+ }
248
+ }
249
+
250
+ function normalizeLockKey(lockPath: string): string {
251
+ return path.normalize(lockPath);
252
+ }
253
+
254
+ const LOCK_INFO_OPEN_FLAGS =
255
+ fs.constants.O_RDONLY |
256
+ (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
257
+
258
+ /**
259
+ * Read metadata from the exact regular file that was validated, never through a
260
+ * pathname after the descriptor is opened. The no-follow flag prevents final
261
+ * component symlinks on POSIX; lstat/fstat/path revalidation supplies the same
262
+ * rejection on Windows, where O_NOFOLLOW is unavailable.
263
+ */
264
+ type LockInfoObservation = { bytes: string; state: LockInfoPathState };
265
+
266
+ async function readLockInfoObservation(lockPath: string): Promise<LockInfoObservation | null> {
267
+ const infoPath = path.join(lockPath, "info");
268
+ const initial = await lockInfoPathState(lockPath);
269
+ if (!initial) return null;
270
+
271
+ let handle: fs.FileHandle | undefined;
272
+ try {
273
+ handle = await fs.open(infoPath, LOCK_INFO_OPEN_FLAGS);
274
+ const opened = lockInfoFileState(await handle.stat({ bigint: true }));
275
+ const beforeRead = await lockInfoPathState(lockPath);
276
+ if (
277
+ !opened ||
278
+ !beforeRead ||
279
+ !sameLockInfoFileState(initial.file, opened) ||
280
+ !sameLockInfoPathState(initial, beforeRead)
281
+ )
282
+ return null;
283
+
284
+ const bytes = await handle.readFile();
285
+ const afterRead = lockInfoFileState(await handle.stat({ bigint: true }));
286
+ const afterPath = await lockInfoPathState(lockPath);
287
+ if (
288
+ !afterRead ||
289
+ !afterPath ||
290
+ !sameLockInfoFileState(initial.file, afterRead) ||
291
+ !sameLockInfoPathState(initial, afterPath)
292
+ )
293
+ return null;
294
+ return { bytes: bytes.toString("utf8"), state: afterPath };
295
+ } catch (error) {
296
+ if (isEnoent(error)) return null;
297
+ throw error;
298
+ } finally {
299
+ await handle?.close().catch(() => undefined);
300
+ }
301
+ }
302
+
303
+ async function readLockInfoBytes(lockPath: string): Promise<string | null> {
304
+ return (await readLockInfoObservation(lockPath))?.bytes ?? null;
98
305
  }
99
306
 
100
307
  async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
101
308
  let parsed: unknown;
102
309
  try {
103
- parsed = JSON.parse(await fs.readFile(`${lockPath}/info`, "utf-8"));
310
+ const bytes = await readLockInfoBytes(lockPath);
311
+ if (bytes === null) return null;
312
+ parsed = JSON.parse(bytes);
104
313
  } catch (error) {
105
314
  if (isEnoent(error) || error instanceof SyntaxError) return null;
106
315
  throw error;
107
316
  }
108
317
 
109
318
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
110
- const { pid, start_time, timestamp, owner_host_id } = parsed as Partial<LockInfo>;
319
+ const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
111
320
  if (
112
321
  typeof pid !== "number" ||
113
322
  !Number.isInteger(pid) ||
@@ -115,43 +324,282 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
115
324
  typeof timestamp !== "number" ||
116
325
  !Number.isFinite(timestamp) ||
117
326
  (start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
118
- (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id))
327
+ (start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
328
+ (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
329
+ (owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
119
330
  )
120
331
  return null;
121
- return { pid, start_time, timestamp, owner_host_id };
332
+ return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
333
+ }
334
+
335
+ function parseLockInfoBytes(bytes: string): LockInfo | null {
336
+ let parsed: unknown;
337
+ try {
338
+ parsed = JSON.parse(bytes);
339
+ } catch {
340
+ return null;
341
+ }
342
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
343
+ const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
344
+ if (
345
+ typeof pid !== "number" ||
346
+ !Number.isInteger(pid) ||
347
+ pid <= 0 ||
348
+ typeof timestamp !== "number" ||
349
+ !Number.isFinite(timestamp) ||
350
+ (start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
351
+ (start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
352
+ (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
353
+ (owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
354
+ )
355
+ return null;
356
+ return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
357
+ }
358
+
359
+ /** @internal */
360
+ export interface FileLockGcObservation {
361
+ info: FileLockOwnerToken;
362
+ bytes: string;
363
+ identity: GenericFileLockDirIdentity;
364
+ }
365
+
366
+ /** Capture owner bytes and the identity proving those exact bytes came from this tree. */
367
+ export async function readFileLockObservationForGc(lockDir: string): Promise<FileLockGcObservation | null> {
368
+ const observation = await readLockInfoObservation(lockDir);
369
+ if (!observation) return null;
370
+ const info = parseLockInfoBytes(observation.bytes);
371
+ if (!info) return null;
372
+ const identity = fileLockDirIdentityFromPathState(observation.state, observation.bytes);
373
+ fileLockDirIdentities.set(info, identity);
374
+ return { info, bytes: observation.bytes, identity };
122
375
  }
123
376
 
124
377
  /** @internal */
125
378
  export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOwnerToken | null> {
126
- return await readLockInfo(lockDir);
379
+ return (await readFileLockObservationForGc(lockDir))?.info ?? null;
127
380
  }
128
381
 
129
382
  /** Owner identity stamped into a `<file>.lock/info` record. */
130
383
  export interface FileLockOwnerToken {
131
384
  pid: number;
132
385
  start_time?: string;
386
+ /** Encoding marker for the canonical UTC process-start identity. */
387
+ start_time_format?: string;
133
388
  owner_host_id?: string;
389
+ /** Unique acquisition generation, present on locks created by this runtime. */
390
+ owner_token?: string;
134
391
  timestamp: number;
135
392
  }
136
393
 
394
+ /**
395
+ * Identity captured before a stale/liveness verdict. Kept out of the owner JSON: it is
396
+ * authorization evidence for the in-memory call that produced the verdict, not metadata
397
+ * another process may copy into a new lock generation.
398
+ */
399
+ const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
400
+
137
401
  function getLockPath(filePath: string): string {
138
402
  return `${filePath}.lock`;
139
403
  }
140
404
 
141
- /** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
142
- export type FileLockGcRemoval = "removed" | "owner_changed" | "missing";
405
+ async function ensureLockParent(directory: string): Promise<void> {
406
+ const missing: string[] = [];
407
+ let current = path.resolve(directory);
408
+ for (;;) {
409
+ try {
410
+ await fs.lstat(current);
411
+ break;
412
+ } catch (error) {
413
+ if (!isEnoent(error)) throw error;
414
+ missing.push(current);
415
+ const parent = path.dirname(current);
416
+ if (parent === current) throw error;
417
+ current = parent;
418
+ }
419
+ }
420
+ for (const created of missing.reverse()) {
421
+ try {
422
+ await fs.mkdir(created, { mode: 0o700 });
423
+ await fs.chmod(created, 0o700);
424
+ } catch (error) {
425
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
426
+ }
427
+ }
428
+ }
143
429
 
144
- interface LockDirStatToken {
145
- dev: number;
146
- ino: number;
147
- mtimeMs: number;
148
- ctimeMs: number;
430
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
431
+ return (
432
+ typeof value === "object" &&
433
+ value !== null &&
434
+ !Array.isArray(value) &&
435
+ Object.getPrototypeOf(value) === Object.prototype
436
+ );
149
437
  }
150
438
 
439
+ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceResult {
440
+ if (!isPlainRecord(value)) return false;
441
+ const expectedKeys = [
442
+ "ok",
443
+ "code",
444
+ "mutationState",
445
+ "durabilityState",
446
+ "reason",
447
+ "primitive",
448
+ "phase",
449
+ "diagnostic",
450
+ ];
451
+ if (Object.keys(value).some(key => !expectedKeys.includes(key))) return false;
452
+ if (
453
+ typeof value.ok !== "boolean" ||
454
+ (value.code !== undefined && (typeof value.code !== "string" || !/^[a-z0-9_]{1,64}$/.test(value.code))) ||
455
+ !(["not_committed", "committed", "unknown"] as const).includes(value.mutationState as never) ||
456
+ !(["not_attempted", "proven", "not_provable"] as const).includes(value.durabilityState as never) ||
457
+ !(
458
+ [
459
+ "none",
460
+ "destination_exists",
461
+ "atomic_unavailable",
462
+ "cross_device",
463
+ "permission_denied",
464
+ "io_failure",
465
+ "invalid_request",
466
+ "interrupted",
467
+ "identity_violation",
468
+ "durability_not_provable",
469
+ "unknown",
470
+ ] as const
471
+ ).includes(value.reason as never) ||
472
+ !(
473
+ [
474
+ "renameat2_noreplace",
475
+ "linkat_noreplace",
476
+ "mkdirat_renameat_noreplace",
477
+ "renameatx_np_excl",
478
+ "windows_rename_noreplace",
479
+ "unsupported",
480
+ "unknown",
481
+ ] as const
482
+ ).includes(value.primitive as never) ||
483
+ !(
484
+ [
485
+ "preflight",
486
+ "file_sync",
487
+ "rename",
488
+ "source_unlink",
489
+ "source_parent_sync",
490
+ "destination_parent_sync",
491
+ "terminal_identity",
492
+ "complete",
493
+ "unknown",
494
+ ] as const
495
+ ).includes(value.phase as never)
496
+ )
497
+ return false;
498
+ if (!isPlainRecord(value.diagnostic)) return false;
499
+ if (
500
+ Object.keys(value.diagnostic).some(
501
+ key => !["schemaVersion", "collectionState", "osCode", "syncFailures"].includes(key),
502
+ ) ||
503
+ value.diagnostic.schemaVersion !== 1 ||
504
+ !(["complete", "partial", "unavailable"] as const).includes(value.diagnostic.collectionState as never) ||
505
+ (value.diagnostic.osCode !== undefined &&
506
+ (typeof value.diagnostic.osCode !== "number" || !Number.isInteger(value.diagnostic.osCode))) ||
507
+ (value.diagnostic.syncFailures !== undefined && !Array.isArray(value.diagnostic.syncFailures))
508
+ )
509
+ return false;
510
+ if (value.ok)
511
+ return (
512
+ value.mutationState === "committed" &&
513
+ value.reason === "none" &&
514
+ value.phase === "complete" &&
515
+ (value.durabilityState === "not_attempted" || value.durabilityState === "proven")
516
+ );
517
+ if (value.mutationState === "not_committed") return value.durabilityState === "not_attempted";
518
+ return value.mutationState === "unknown" && value.durabilityState === "not_provable";
519
+ }
520
+
521
+ /**
522
+ * Only a complete native envelope proving that no namespace mutation happened
523
+ * may authorize the directory fallback. A legacy or malformed result is
524
+ * treated as an unknown publication outcome and never followed by another
525
+ * mutating primitive.
526
+ */
527
+ function isPreMutationUnsupportedRenameResult(value: unknown): value is NativeNoReplaceResult {
528
+ if (!isValidNativeNoReplaceResult(value)) return false;
529
+ if (
530
+ value.ok !== false ||
531
+ (value.code !== "invalid_request" && value.code !== "atomic_unavailable") ||
532
+ value.mutationState !== "not_committed" ||
533
+ value.durabilityState !== "not_attempted" ||
534
+ value.reason !== value.code ||
535
+ (value.primitive !== "renameat2_noreplace" && value.primitive !== "renameatx_np_excl") ||
536
+ (value.phase !== "preflight" && value.phase !== "rename")
537
+ )
538
+ return false;
539
+ return true;
540
+ }
541
+
542
+ async function localLockKey(lockPath: string): Promise<string> {
543
+ try {
544
+ return normalizeLockKey(await fs.realpath(lockPath));
545
+ } catch (error) {
546
+ if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
547
+ }
548
+ const parent = path.dirname(lockPath);
549
+ let canonicalParent: string;
550
+ try {
551
+ canonicalParent = await fs.realpath(parent);
552
+ } catch (error) {
553
+ if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
554
+ canonicalParent = path.resolve(parent);
555
+ }
556
+ const key = path.join(canonicalParent, path.basename(lockPath));
557
+ return normalizeLockKey(key);
558
+ }
559
+
560
+ function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
561
+ if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
562
+ return false;
563
+ if (ownerLiveness(owner.pid) !== "alive") return false;
564
+ const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
565
+ return currentStartTime !== null && currentStartTime !== owner.start_time;
566
+ }
567
+
568
+ /** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
569
+ export type FileLockGcRemoval = "removed" | "owner_changed" | "missing" | "cleanup_failed";
570
+
151
571
  type LockStaleSnapshot =
152
572
  | { stale: false }
153
- | { stale: true; owner: FileLockOwnerToken }
154
- | { stale: true; owner: null; stat: LockDirStatToken };
573
+ | { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity };
574
+
575
+ /** Identity evidence carried by the generic stale verdict into a later removal. */
576
+ export interface GenericFileLockDirIdentity {
577
+ rootDev: string;
578
+ rootIno: string;
579
+ infoDev: string;
580
+ infoIno: string;
581
+ infoNlink: string;
582
+ infoSize: string;
583
+ infoMtimeNs: string;
584
+ infoCtimeNs: string;
585
+ infoSha256: string;
586
+ }
587
+
588
+ function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right: GenericFileLockDirIdentity): boolean {
589
+ return (
590
+ left.rootDev === right.rootDev &&
591
+ left.rootIno === right.rootIno &&
592
+ left.infoDev === right.infoDev &&
593
+ left.infoIno === right.infoIno &&
594
+ left.infoNlink === right.infoNlink &&
595
+ left.infoSize === right.infoSize &&
596
+ left.infoMtimeNs === right.infoMtimeNs &&
597
+ left.infoCtimeNs === right.infoCtimeNs &&
598
+ left.infoSha256 === right.infoSha256
599
+ );
600
+ }
601
+
602
+ export type GenericFileLockDirStaleVerdict = { stale: false } | { stale: true; identity: GenericFileLockDirIdentity };
155
603
 
156
604
  /**
157
605
  * @internal
@@ -173,19 +621,109 @@ type LockStaleSnapshot =
173
621
  export async function removeFileLockDirForGc(
174
622
  lockDir: string,
175
623
  expected: FileLockOwnerToken,
624
+ preVerdictIdentity?: GenericFileLockDirIdentity,
176
625
  ): Promise<FileLockGcRemoval> {
177
- const current = await readLockInfo(lockDir);
178
- if (!current) return "missing";
626
+ // A generic release/quarantine call is only authorized by evidence captured
627
+ // before its stale verdict. Capturing the current pathname here would let a
628
+ // fresh successor inherit an old owner's release authority.
629
+ const expectedIdentity = preVerdictIdentity ?? fileLockDirIdentities.get(expected);
630
+ let onDiskBytes: string | null;
631
+ try {
632
+ onDiskBytes = await readLockInfoBytes(lockDir);
633
+ } catch (error) {
634
+ if (isEnoent(error)) return "missing";
635
+ throw error;
636
+ }
637
+ const current = onDiskBytes === null ? null : parseLockInfoBytes(onDiskBytes);
638
+ if (!current || onDiskBytes === null) return "missing";
639
+ if (!expectedIdentity) return "owner_changed";
179
640
  if (
180
641
  current.pid !== expected.pid ||
181
642
  (expected.start_time !== undefined && current.start_time !== expected.start_time) ||
182
643
  current.owner_host_id !== expected.owner_host_id ||
644
+ (expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
183
645
  current.timestamp !== expected.timestamp
184
646
  ) {
185
647
  return "owner_changed";
186
648
  }
187
- await fs.rm(lockDir, { recursive: true, force: true });
188
- return "removed";
649
+ // The token comparison above authorizes the content that was judged, not the
650
+ // pathname. When the caller carried pre-verdict root/info identity, require
651
+ // the post-verdict native snapshot to match that same object before removal;
652
+ // a clone or successor can therefore never inherit the stale authorization.
653
+ // The canonical native path may refuse a symlinked parent ("reparse_point");
654
+ // canonicalize first so the identity-bound capture sees the real directory,
655
+ // mirroring how localLockKey canonicalizes the lock pathname.
656
+ let nativeCapturePath = lockDir;
657
+ try {
658
+ nativeCapturePath = await canonicalLockPathPreservingFinal(lockDir);
659
+ } catch (error) {
660
+ if (!isEnoent(error) && !isTransientReleaseError(error)) return "cleanup_failed";
661
+ }
662
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
663
+ if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
664
+ if (!captured.ok || !captured.snapshot) return "owner_changed";
665
+ const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
666
+ if (!infoEntry?.sha256) return "owner_changed";
667
+ const judgedDigest = crypto.createHash("sha256").update(onDiskBytes).digest("hex");
668
+ if (infoEntry.sha256 !== judgedDigest) return "owner_changed";
669
+ if (
670
+ !captured.snapshot.rootDev ||
671
+ captured.snapshot.rootDev !== expectedIdentity.rootDev ||
672
+ captured.snapshot.rootIno !== expectedIdentity.rootIno ||
673
+ infoEntry.dev !== expectedIdentity.infoDev ||
674
+ infoEntry.ino !== expectedIdentity.infoIno ||
675
+ infoEntry.nlink !== expectedIdentity.infoNlink ||
676
+ infoEntry.size !== expectedIdentity.infoSize ||
677
+ infoEntry.mtimeNs !== expectedIdentity.infoMtimeNs ||
678
+ infoEntry.ctimeNs !== expectedIdentity.infoCtimeNs ||
679
+ infoEntry.sha256 !== expectedIdentity.infoSha256
680
+ )
681
+ return "owner_changed";
682
+ let removed: NativeExactUnlinkResult;
683
+ try {
684
+ removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
685
+ } catch (error) {
686
+ // Keep the #2478 transient retry contract: sharing denials surface with
687
+ // their transient code so callers retry, everything else is a refusal.
688
+ if (isTransientReleaseError(error)) throw error;
689
+ return "cleanup_failed";
690
+ }
691
+ if (removed.ok) return "removed";
692
+ // The canonical name may already be detached: the security-critical phase is
693
+ // done once the verified tree is durably scrubbed and parked under the
694
+ // no-replace quarantine name with no successor retained. Finish that replay
695
+ // deterministically by deleting the retained quarantine — the same completion
696
+ // contract gc-runtime applies to its own exact removals. Any other outcome —
697
+ // including a retained successor or placeholder — leaves the judged object (or
698
+ // its replacement) in place and reports the removal as refused.
699
+ const detachedPath = removed.detachedPath;
700
+ const verifiedDetach =
701
+ detachedPath !== undefined &&
702
+ path.resolve(detachedPath) !== path.resolve(lockDir) &&
703
+ removed.retainedSuccessorPath === undefined &&
704
+ removed.retainedPlaceholderPath === undefined &&
705
+ removed.retainedUnknownPath === undefined;
706
+ if (verifiedDetach && detachedPath !== undefined) {
707
+ // The security-critical phase is done: the verified tree was detached from
708
+ // the canonical name and durably parked under the no-replace quarantine
709
+ // name with no successor retained. Finish that replay deterministically by
710
+ // deleting the retained quarantine — the same completion contract gc-runtime
711
+ // applies to its own exact removals — then report the release as done.
712
+ try {
713
+ await fs.rm(detachedPath, { recursive: true, force: true });
714
+ } catch {
715
+ // The canonical pathname is free either way; a retained quarantine is
716
+ // recoverable debris, not a live lock.
717
+ }
718
+ return "removed";
719
+ }
720
+ if (removed.code === "not_found") return "removed";
721
+ if (removed.code === "identity_mismatch") return "owner_changed";
722
+ const refusal: NodeJS.ErrnoException = new Error(
723
+ `Failed to remove file lock tree: ${removed.code ?? "unknown"}.`,
724
+ ) as NodeJS.ErrnoException;
725
+ refusal.code = "EACCES";
726
+ throw refusal;
189
727
  }
190
728
 
191
729
  type OwnerLiveness = "alive" | "dead" | "unknown";
@@ -204,26 +742,22 @@ function ownerLiveness(pid: number): OwnerLiveness {
204
742
  }
205
743
  }
206
744
 
207
- function statToken(stats: Stats): LockDirStatToken {
208
- return {
209
- dev: stats.dev,
210
- ino: stats.ino,
211
- mtimeMs: stats.mtimeMs,
212
- ctimeMs: stats.ctimeMs,
213
- };
214
- }
215
-
216
- function sameStatToken(a: LockDirStatToken, b: LockDirStatToken): boolean {
217
- return a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
218
- }
219
-
220
745
  async function staleLockSnapshot(
221
746
  lockPath: string,
222
- staleMs: number,
747
+ _staleMs: number,
223
748
  ownerHostId?: string,
224
749
  previousOwnerHostIds: readonly string[] = [],
225
750
  startTimeCache?: Map<string, string | null>,
226
751
  ): Promise<LockStaleSnapshot> {
752
+ // Capture the root and info inode BEFORE asking whether the owner is stale. A later
753
+ // snapshot alone would let a copied successor inherit the stale verdict's authority.
754
+ let judgedIdentity: GenericFileLockDirIdentity | null = null;
755
+ try {
756
+ judgedIdentity = await captureFileLockDirIdentity(lockPath);
757
+ } catch (error) {
758
+ if (isTransientReleaseError(error)) return { stale: false };
759
+ throw error;
760
+ }
227
761
  let info: LockInfo | null;
228
762
  try {
229
763
  info = await readLockInfo(lockPath);
@@ -231,19 +765,15 @@ async function staleLockSnapshot(
231
765
  // Windows can transiently deny reads of a just-created lock metadata file
232
766
  // while another contender is publishing it. Treat that as active
233
767
  // contention and retry rather than failing the caller or reaping by path.
234
- if (hasFsCode(error, "EPERM")) return { stale: false };
768
+ if (isTransientReleaseError(error)) return { stale: false };
235
769
  throw error;
236
770
  }
237
- if (!info && ownerHostId !== undefined) return { stale: false };
238
771
  if (!info) {
239
- try {
240
- const stats = await fs.stat(lockPath);
241
- if (Date.now() - stats.mtimeMs <= staleMs) return { stale: false };
242
- return { stale: true, owner: null, stat: statToken(stats) };
243
- } catch (err) {
244
- if (isEnoent(err)) return { stale: false };
245
- throw err;
246
- }
772
+ // A directory without a valid owner record is either a contender between
773
+ // native mkdirat ownership and metadata publication, or malformed/foreign
774
+ // state. Neither case carries enough identity evidence for stale removal;
775
+ // elapsed mtime must never make this empty namespace reclaimable.
776
+ return { stale: false };
247
777
  }
248
778
 
249
779
  // A host-qualified lock may only be reclaimed after proving that its owner is
@@ -255,33 +785,44 @@ async function staleLockSnapshot(
255
785
  !previousOwnerHostIds.includes(info.owner_host_id ?? "")
256
786
  )
257
787
  return { stale: false };
788
+ if (ownerHostId === undefined && info.owner_host_id !== undefined) return { stale: false };
789
+ if (ownerIncarnationChanged(info, startTimeCache)) {
790
+ if (!judgedIdentity) return { stale: false };
791
+ let currentIdentity: GenericFileLockDirIdentity | null;
792
+ try {
793
+ currentIdentity = await captureFileLockDirIdentity(lockPath);
794
+ } catch (error) {
795
+ if (isTransientReleaseError(error)) return { stale: false };
796
+ throw error;
797
+ }
798
+ if (!currentIdentity || !sameGenericFileLockDirIdentity(judgedIdentity, currentIdentity)) return { stale: false };
799
+ fileLockDirIdentities.set(info, judgedIdentity);
800
+ return { stale: true, owner: info, identity: judgedIdentity };
801
+ }
258
802
  // Never reap a live owner by elapsed time: a long legitimate critical section must
259
- // not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner
260
- // liveness is indeterminate do we fall back to the staleMs elapsed-time heuristic.
803
+ // not have its lock stolen (#652). Reclaim only when the OS proves the owner is dead;
804
+ // indeterminate liveness remains protected regardless of elapsed time.
261
805
  if (ownerIsAlive(info, startTimeCache)) return { stale: false };
262
- if (ownerLiveness(info.pid) === "dead" || Date.now() - info.timestamp > staleMs) {
263
- return { stale: true, owner: info };
806
+ const liveness = ownerLiveness(info.pid);
807
+ if (liveness === "dead") {
808
+ if (!judgedIdentity) return { stale: false };
809
+ let currentIdentity: GenericFileLockDirIdentity | null;
810
+ try {
811
+ currentIdentity = await captureFileLockDirIdentity(lockPath);
812
+ } catch (error) {
813
+ if (isTransientReleaseError(error)) return { stale: false };
814
+ throw error;
815
+ }
816
+ if (!currentIdentity || !sameGenericFileLockDirIdentity(judgedIdentity, currentIdentity)) return { stale: false };
817
+ fileLockDirIdentities.set(info, judgedIdentity);
818
+ return { stale: true, owner: info, identity: judgedIdentity };
264
819
  }
265
820
  return { stale: false };
266
821
  }
267
822
 
268
823
  async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
269
824
  if (!snapshot.stale) return false;
270
- if (snapshot.owner) {
271
- return (await removeFileLockDirForGc(lockPath, snapshot.owner)) === "removed";
272
- }
273
-
274
- const currentInfo = await readLockInfo(lockPath);
275
- if (currentInfo) return false;
276
- try {
277
- const currentStats = await fs.stat(lockPath);
278
- if (!sameStatToken(statToken(currentStats), snapshot.stat)) return false;
279
- await fs.rm(lockPath, { recursive: true, force: true });
280
- return true;
281
- } catch (err) {
282
- if (isEnoent(err)) return false;
283
- throw err;
284
- }
825
+ return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
285
826
  }
286
827
 
287
828
  /**
@@ -306,58 +847,286 @@ export async function genericFileLockDirIsStale(
306
847
  staleMs: number,
307
848
  ownerHostId?: string,
308
849
  ): Promise<boolean> {
309
- return (await staleLockSnapshot(lockDir, staleMs, ownerHostId)).stale;
850
+ return (await genericFileLockDirStaleVerdict(lockDir, staleMs, ownerHostId)).stale;
851
+ }
852
+
853
+ /**
854
+ * Render a generic stale verdict together with the root and owner-file identity that
855
+ * verdict actually observed. A caller that removes the directory must require a later
856
+ * native snapshot to carry this same identity; a snapshot taken only after a clone was
857
+ * installed is not authority for the stale verdict.
858
+ */
859
+ export async function genericFileLockDirStaleVerdict(
860
+ lockDir: string,
861
+ staleMs: number,
862
+ ownerHostId?: string,
863
+ ): Promise<GenericFileLockDirStaleVerdict> {
864
+ const verdict = await staleLockSnapshot(lockDir, staleMs, ownerHostId);
865
+ if (!verdict.stale) return { stale: false };
866
+ return {
867
+ stale: true,
868
+ identity: verdict.identity,
869
+ };
310
870
  }
311
871
 
312
872
  async function tryAcquireLock(
313
873
  lockPath: string,
314
874
  ownerHostId?: string,
875
+ ownerToken = crypto.randomUUID(),
315
876
  onAcquired?: () => void,
316
877
  ): Promise<LockInfo | null> {
317
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
878
+ await ensureLockParent(path.dirname(lockPath));
318
879
  const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
319
880
  if (afterParentMkdir) await afterParentMkdir(lockPath);
320
- if (ownerHostId === undefined) {
881
+ const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
882
+ const owner = lockInfo(ownerHostId, ownerToken);
883
+ try {
884
+ await fs.mkdir(pendingPath, { mode: 0o700 });
885
+ await fs.chmod(pendingPath, 0o700);
886
+ await writeLockInfo(pendingPath, owner);
887
+ // A plain POSIX rename replaces an existing empty directory. The legacy
888
+ // directory lock is a real holder, so publication must use the native
889
+ // no-replace primitive rather than treating an empty destination as free.
890
+ // The native primitive also rejects symlinked/reparse parents. Resolve the
891
+ // already-created staging entry and its parent before publication so aliases
892
+ // retain the same lock identity as the ordinary path.
893
+ const canonicalParent = await fs.realpath(path.dirname(lockPath));
894
+ const destinationPath = path.join(canonicalParent, path.basename(lockPath));
895
+ // Resolve only the stable parent: resolving the mutable staging final
896
+ // component would follow an attacker-replaced symlink before native no-follow
897
+ // validation gets a chance to reject it.
898
+ const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
899
+ const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
900
+ renameNoReplacePathAsync,
901
+ renameDirectoryNoReplacePathAsync,
902
+ };
903
+ const published = await publication.renameNoReplacePathAsync(canonicalPendingPath, destinationPath);
904
+ let publishedSuccessfully = published.ok;
905
+ if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
906
+ const fallback = await publication.renameDirectoryNoReplacePathAsync(canonicalPendingPath, destinationPath);
907
+ if (fallback.ok) {
908
+ publishedSuccessfully = true;
909
+ } else if (fallback.reason === "destination_exists") {
910
+ return null;
911
+ } else {
912
+ const failure = new Error(
913
+ `Failed to publish file lock: ${fallback.code ?? fallback.reason ?? "unknown"}.`,
914
+ ) as NodeJS.ErrnoException;
915
+ if (fallback.code) failure.code = fallback.code;
916
+ throw failure;
917
+ }
918
+ }
919
+ if (!publishedSuccessfully) {
920
+ if (published.reason === "destination_exists") return null;
921
+ const failure = new Error(
922
+ `Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
923
+ ) as NodeJS.ErrnoException;
924
+ if (published.code) failure.code = published.code;
925
+ throw failure;
926
+ }
927
+ // Published above, so an onAcquired failure must propagate instead of
928
+ // retrying an acquisition that already owns the lock.
929
+ onAcquired?.();
930
+ return owner;
931
+ } finally {
932
+ await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
933
+ }
934
+ }
935
+
936
+ function isTransientReleaseError(error: unknown): boolean {
937
+ const code = (error as NodeJS.ErrnoException).code;
938
+ return (
939
+ code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
940
+ );
941
+ }
942
+
943
+ function throwTransientNativeResult(code: string): never {
944
+ throw Object.assign(new Error(`Native lock operation is transiently unavailable: ${code}.`), { code });
945
+ }
946
+
947
+ type NativeFileLockBindings = {
948
+ snapshotDirectoryTree(lockPath: string): NativeDirectoryTreeResult;
949
+ exactRemoveDirectoryTree(
950
+ lockPath: string,
951
+ snapshot: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
952
+ ): NativeExactUnlinkResult;
953
+ };
954
+
955
+ let nativeFileLockBindingCache: NativeFileLockBindings | undefined;
956
+
957
+ function nativeFileLockBindings(): NativeFileLockBindings {
958
+ if (FileLockTestHooks.nativeQuarantineBindings) return FileLockTestHooks.nativeQuarantineBindings();
959
+ if (nativeFileLockBindingCache) return nativeFileLockBindingCache;
960
+ try {
961
+ nativeFileLockBindingCache = require("@gajae-code/natives") as NativeFileLockBindings;
962
+ return nativeFileLockBindingCache;
963
+ } catch (error) {
964
+ throw Object.assign(new Error("Native identity-bound lock quarantine is unavailable."), { cause: error });
965
+ }
966
+ }
967
+
968
+ async function quarantineReleasedLock(
969
+ lockPath: string,
970
+ owner: FileLockOwnerToken,
971
+ preVerdictIdentity?: GenericFileLockDirIdentity,
972
+ ): Promise<boolean> {
973
+ const expectedIdentity = preVerdictIdentity ?? fileLockDirIdentities.get(owner);
974
+ // Quarantine is only a release fallback after earlier transient failures. It must not
975
+ // make a fresh post-failure snapshot authoritative for an object this release never
976
+ // judged; without pre-verdict evidence, refuse and let the caller surface the retry
977
+ // failure instead of risking a successor lock.
978
+ if (!expectedIdentity) return false;
979
+ let captured: NativeDirectoryTreeResult;
980
+ const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
981
+ try {
982
+ captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
983
+ if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
984
+ } catch (error) {
985
+ if (isTransientReleaseError(error)) return false;
986
+ throw error;
987
+ }
988
+ if (!captured.ok || !captured.snapshot) return false;
989
+ const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
990
+ if (!infoEntry?.sha256) return false;
991
+ if (
992
+ captured.snapshot.rootDev !== expectedIdentity.rootDev ||
993
+ captured.snapshot.rootIno !== expectedIdentity.rootIno ||
994
+ infoEntry.dev !== expectedIdentity.infoDev ||
995
+ infoEntry.ino !== expectedIdentity.infoIno ||
996
+ infoEntry.nlink !== expectedIdentity.infoNlink ||
997
+ infoEntry.size !== expectedIdentity.infoSize ||
998
+ infoEntry.mtimeNs !== expectedIdentity.infoMtimeNs ||
999
+ infoEntry.ctimeNs !== expectedIdentity.infoCtimeNs ||
1000
+ infoEntry.sha256 !== expectedIdentity.infoSha256
1001
+ )
1002
+ return false;
1003
+ // Bind the owner generation to the snapshot before exact removal. A successor
1004
+ // installed after this snapshot is rejected by the native identity check instead of
1005
+ // being moved into quarantine by a pathname-only rename.
1006
+ const expectedDigest = crypto.createHash("sha256").update(JSON.stringify(owner)).digest("hex");
1007
+ if (infoEntry.sha256 !== expectedDigest) return false;
1008
+ let removed: NativeExactUnlinkResult;
1009
+ try {
1010
+ removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
1011
+ } catch (error) {
1012
+ if (isTransientReleaseError(error)) return false;
1013
+ throw error;
1014
+ }
1015
+ if (removed.ok || removed.code === "not_found") return true;
1016
+ if (
1017
+ removed.detachedPath !== undefined &&
1018
+ path.resolve(removed.detachedPath) !== path.resolve(lockPath) &&
1019
+ removed.retainedSuccessorPath === undefined &&
1020
+ removed.retainedPlaceholderPath === undefined &&
1021
+ removed.retainedUnknownPath === undefined
1022
+ ) {
321
1023
  try {
322
- await fs.mkdir(lockPath);
323
- onAcquired?.();
324
- return await writeLockInfo(lockPath, lockInfo());
1024
+ await fs.lstat(nativeCapturePath);
1025
+ return false;
325
1026
  } catch (error) {
326
- if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
1027
+ if (isEnoent(error)) {
1028
+ nativeFileLockBindings().exactRemoveDirectoryTree(removed.detachedPath, captured.snapshot);
1029
+ return true;
1030
+ }
327
1031
  throw error;
328
1032
  }
329
1033
  }
1034
+ return false;
1035
+ }
330
1036
 
331
- const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
332
- const owner = lockInfo(ownerHostId);
1037
+ async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
1038
+ let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
333
1039
  try {
334
- await fs.mkdir(pendingPath);
335
- await writeLockInfo(pendingPath, owner);
1040
+ preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
1041
+ if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
1042
+ } catch (error) {
1043
+ if (!isTransientReleaseError(error)) throw error;
1044
+ }
1045
+ let lastTransientError: unknown;
1046
+ for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
336
1047
  try {
337
- await fs.rename(pendingPath, lockPath);
338
- onAcquired?.();
339
- return owner;
340
- } catch (error) {
341
- const code = (error as NodeJS.ErrnoException).code;
342
- if (code === "EEXIST" || code === "ENOTEMPTY") return null;
343
- if (code === "EPERM") {
344
- try {
345
- await fs.stat(lockPath);
346
- return null;
347
- } catch (statError) {
348
- if (!isEnoent(statError)) throw statError;
349
- }
1048
+ const outcome = await removeFileLockDirForGc(lockPath, owner, preVerdictIdentity);
1049
+ if (outcome === "removed" || outcome === "missing") {
1050
+ if (outcome === "missing") throw new Error("Failed to release file lock: missing.");
1051
+ return;
350
1052
  }
351
- throw error;
1053
+ throw new Error(`Failed to release file lock: ${outcome}.`);
1054
+ } catch (error) {
1055
+ if (!isTransientReleaseError(error)) throw error;
1056
+ lastTransientError = error;
1057
+ if (attempt + 1 < FILE_LOCK_RELEASE_RETRY_ATTEMPTS) await Bun.sleep(FILE_LOCK_RELEASE_RETRY_DELAY_MS);
352
1058
  }
1059
+ }
1060
+ if (await quarantineReleasedLock(lockPath, owner, preVerdictIdentity)) return;
1061
+ throw lastTransientError ?? new Error("Failed to release file lock: transient removal failure.");
1062
+ }
1063
+
1064
+ async function retryPendingLocalRelease(lockPath: string, knownKey?: string): Promise<void> {
1065
+ const key = knownKey ?? (await localLockKey(lockPath));
1066
+ const state = localLockStates.get(key);
1067
+ if (!state || state.status === "held") return;
1068
+ if (state.releasePromise) {
1069
+ await state.releasePromise.catch(() => undefined);
1070
+ return;
1071
+ }
1072
+ state.status = "releasing";
1073
+ const releasePromise = releaseOwnedLock(lockPath, state.owner);
1074
+ state.releasePromise = releasePromise;
1075
+ try {
1076
+ await releasePromise;
1077
+ if (localLockStates.get(key) === state) localLockStates.delete(key);
1078
+ } catch (error) {
1079
+ state.status = "release_pending";
1080
+ throw error;
353
1081
  } finally {
354
- await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
1082
+ if (state.releasePromise === releasePromise) state.releasePromise = undefined;
355
1083
  }
356
1084
  }
357
1085
 
358
- async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
359
- const outcome = await removeFileLockDirForGc(lockPath, owner);
360
- if (outcome !== "removed") throw new Error(`Failed to release file lock: ${outcome}.`);
1086
+ async function pendingLocalReleaseKey(lockPath: string, localKey: string): Promise<string | undefined> {
1087
+ const direct = localLockStates.get(localKey);
1088
+ if (direct) return direct.status === "held" ? undefined : localKey;
1089
+ let info: LockInfo | null;
1090
+ try {
1091
+ info = await readLockInfo(lockPath);
1092
+ } catch (error) {
1093
+ if (isTransientReleaseError(error)) return undefined;
1094
+ throw error;
1095
+ }
1096
+ const ownerToken = info?.owner_token;
1097
+ if (!ownerToken) return undefined;
1098
+ for (const [key, state] of localLockStates) {
1099
+ if (key !== localKey && state.status !== "held" && state.owner.owner_token === ownerToken) return key;
1100
+ }
1101
+ return undefined;
1102
+ }
1103
+
1104
+ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey?: string): Promise<void> {
1105
+ const key = knownKey ?? (await localLockKey(lockPath));
1106
+ const state = localLockStates.get(key);
1107
+ if (!state || state.owner.owner_token !== owner.owner_token) {
1108
+ throw new Error("Failed to release file lock: local owner generation is unknown.");
1109
+ }
1110
+ if (state.status === "release_pending") {
1111
+ await retryPendingLocalRelease(lockPath, key);
1112
+ return;
1113
+ }
1114
+ if (state.releasePromise) {
1115
+ await state.releasePromise;
1116
+ return;
1117
+ }
1118
+ state.status = "releasing";
1119
+ const releasePromise = releaseOwnedLock(lockPath, owner);
1120
+ state.releasePromise = releasePromise;
1121
+ try {
1122
+ await releasePromise;
1123
+ if (localLockStates.get(key) === state) localLockStates.delete(key);
1124
+ } catch (error) {
1125
+ state.status = "release_pending";
1126
+ throw error;
1127
+ } finally {
1128
+ if (state.releasePromise === releasePromise) state.releasePromise = undefined;
1129
+ }
361
1130
  }
362
1131
  /**
363
1132
  * Bounded, actionable description of who holds `lockPath` at exhaustion time.
@@ -405,16 +1174,38 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
405
1174
  }
406
1175
 
407
1176
  async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
1177
+ const requestedFilePath: unknown = filePath;
1178
+ if (typeof requestedFilePath !== "string" || requestedFilePath.length === 0 || !path.isAbsolute(requestedFilePath))
1179
+ throw new TypeError("filePath must be a non-empty absolute path");
1180
+ if (requestedFilePath.includes("\0")) throw new TypeError("filePath must not contain NUL bytes");
408
1181
  if (options.ownerHostId !== undefined && !options.ownerHostId) throw new Error("ownerHostId must be non-empty");
409
1182
  if (options.previousOwnerHostIds?.some(hostId => !hostId))
410
1183
  throw new Error("previousOwnerHostIds must contain only non-empty identities");
411
1184
  const opts = { ...DEFAULT_OPTIONS, ...options };
1185
+ if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
412
1186
  const lockPath = getLockPath(filePath);
1187
+ await ensureLockParent(path.dirname(lockPath));
1188
+ const ownerToken = crypto.randomUUID();
413
1189
  const contentionStartTimes = new Map<string, string | null>();
414
1190
  for (let attempt = 0; attempt < opts.retries; attempt++) {
415
1191
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
416
- const owner = await tryAcquireLock(lockPath, opts.ownerHostId, opts.onAcquired);
417
- if (owner) return () => releaseLock(lockPath, owner);
1192
+ const localKey = await localLockKey(lockPath);
1193
+ const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
1194
+ if (owner) {
1195
+ localLockStates.set(localKey, { owner, status: "held" });
1196
+ return () => releaseLock(lockPath, owner, localKey);
1197
+ }
1198
+ const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
1199
+ const localState = localLockStates.get(pendingKey ?? localKey);
1200
+ if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
1201
+ try {
1202
+ await retryPendingLocalRelease(lockPath, pendingKey);
1203
+ continue;
1204
+ } catch {
1205
+ // Keep contending below. A failed local retry is not authority to steal a
1206
+ // lock; the owner generation remains fenced until release succeeds.
1207
+ }
1208
+ }
418
1209
  const stale = await staleLockSnapshot(
419
1210
  lockPath,
420
1211
  opts.staleMs,