@gajae-code/coding-agent 0.15.5 → 0.15.6

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