@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
@@ -7,10 +7,18 @@ import type {
7
7
  NativeDirectoryTreeSnapshot,
8
8
  NativeExactUnlinkResult,
9
9
  } from "@gajae-code/natives";
10
- import { genericFileLockDirIsStale, processStartTime as portableProcessStartTime } from "../config/file-lock";
10
+ import {
11
+ type GenericFileLockDirIdentity,
12
+ genericFileLockDirStaleVerdict,
13
+ processStartTime as portableProcessStartTime,
14
+ readFileLockObservationForGc,
15
+ } from "../config/file-lock";
11
16
  import { loadInstallationHostId, loadLegacyInstallationHostId } from "../config/machine-identity";
12
17
  import { readLinuxProcStartTimeSync } from "./linux-proc";
13
18
 
19
+ /** SHA-256 of the empty payload; the constant identity of every verified-empty receipt. */
20
+ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
21
+
14
22
  /**
15
23
  * The one lock implementation for a coordinator-shared session state file.
16
24
  *
@@ -27,10 +35,34 @@ import { readLinuxProcStartTimeSync } from "./linux-proc";
27
35
  * socket, or device at this path is not a lock this code wrote, and reading or removing
28
36
  * it would follow an attacker-chosen target.
29
37
  */
30
- const LOCK_ACQUIRE_ATTEMPTS = 12_000;
31
38
  const LOCK_ACQUIRE_RETRY_MS = 5;
39
+ const LOCK_ACQUIRE_MAX_RETRY_MS = 100;
40
+ const LOCK_ACQUIRE_TIMEOUT_MS = 2_000;
32
41
  const LOCK_STALE_MS = 30_000;
33
42
 
43
+ interface LockRetryBudget {
44
+ startedAt: number;
45
+ nextDelayMs: number;
46
+ attempts: number;
47
+ }
48
+
49
+ function lockRetryBudget(): LockRetryBudget {
50
+ return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0 };
51
+ }
52
+
53
+ function lockRetryElapsedMs(budget: LockRetryBudget): number {
54
+ return Math.max(0, performance.now() - budget.startedAt);
55
+ }
56
+
57
+ async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
58
+ budget.attempts++;
59
+ const remainingMs = LOCK_ACQUIRE_TIMEOUT_MS - lockRetryElapsedMs(budget);
60
+ if (remainingMs <= 0) return false;
61
+ await Bun.sleep(Math.min(budget.nextDelayMs, remainingMs));
62
+ budget.nextDelayMs = Math.min(LOCK_ACQUIRE_MAX_RETRY_MS, budget.nextDelayMs * 2);
63
+ return lockRetryElapsedMs(budget) < LOCK_ACQUIRE_TIMEOUT_MS;
64
+ }
65
+
34
66
  /**
35
67
  * The claim that serializes PATHNAME TRANSITIONS of `<file>.lock` among current writers.
36
68
  *
@@ -54,15 +86,228 @@ const LOCK_STALE_MS = 30_000;
54
86
  * `locks/mutation.lock` (whose generic directory semantics are unchanged).
55
87
  */
56
88
  const LOCK_TRANSITION_RESOURCE_SUFFIX = ".transition";
89
+ const LINUX_PROC_START_TIME_FORMAT = "linux-proc-v1";
90
+ const PORTABLE_START_TIME_FORMAT = "ps-utc-v1";
91
+ const TRANSIENT_LOCK_ERROR_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY", "sharing_violation"]);
92
+
93
+ interface TransitionDirectoryGeneration {
94
+ dev: bigint;
95
+ ino: bigint;
96
+ mode: bigint;
97
+ nlink: bigint;
98
+ mtimeNs: bigint;
99
+ ctimeNs: bigint;
100
+ }
101
+
102
+ const TRANSITION_DIRECTORY_OPEN_FLAGS =
103
+ fsSync.constants.O_RDONLY | (fsSync.constants.O_DIRECTORY ?? 0) | (fsSync.constants.O_NOFOLLOW ?? 0);
104
+
105
+ async function captureTransitionDirectoryGenerationFromHandle(
106
+ handle: fs.FileHandle,
107
+ ): Promise<TransitionDirectoryGeneration> {
108
+ const stat = await handle.stat({ bigint: true });
109
+ if (!stat.isDirectory()) throw new Error("Transition claim is no longer a directory.");
110
+ return transitionGenerationFromStat(stat as fsSync.BigIntStats);
111
+ }
112
+
113
+ function transitionGenerationFromStat(stat: fsSync.BigIntStats): TransitionDirectoryGeneration {
114
+ return {
115
+ dev: stat.dev,
116
+ ino: stat.ino,
117
+ mode: stat.mode,
118
+ nlink: stat.nlink,
119
+ mtimeNs: stat.mtimeNs,
120
+ ctimeNs: stat.ctimeNs,
121
+ };
122
+ }
123
+
124
+ interface PendingTransitionRelease {
125
+ phase: "setup" | "release";
126
+ token: string;
127
+ generation?: TransitionDirectoryGeneration;
128
+ /** Physical claim pathname used for every native identity operation. */
129
+ nativePath?: string;
130
+ /** A no-follow descriptor captured immediately after mkdir. This remains the
131
+ * authority when setup generation capture itself faults or the pathname is
132
+ * replaced before recovery gets to run. */
133
+ generationHandle?: fs.FileHandle;
134
+ /** Descriptor retained when a rewrite fault defeats immediate repair. */
135
+ repairHandle?: fs.FileHandle;
136
+ repairBytes?: string;
137
+ repairSnapshot?: LockOwnerSnapshot;
138
+ held?: LockOwnerSnapshot;
139
+ releasedOwner?: SessionStateLockOwner;
140
+ recoverable: boolean;
141
+ recovery?: Promise<boolean>;
142
+ }
143
+
144
+ const pendingTransitionReleases = new Map<string, PendingTransitionRelease>();
145
+
146
+ function clearPendingTransitionRelease(key: string, pending?: PendingTransitionRelease): void {
147
+ const current = pendingTransitionReleases.get(key);
148
+ if (pending !== undefined && current !== pending) return;
149
+ pendingTransitionReleases.delete(key);
150
+ const handle = (pending ?? current)?.generationHandle;
151
+ if (handle) {
152
+ (pending ?? current)!.generationHandle = undefined;
153
+ void handle.close().catch(() => undefined);
154
+ }
155
+ const repairHandle = (pending ?? current)?.repairHandle;
156
+ if (repairHandle) {
157
+ (pending ?? current)!.repairHandle = undefined;
158
+ void repairHandle.close().catch(() => undefined);
159
+ }
160
+ }
161
+
162
+ async function repairPendingOwnerRecord(pending: PendingTransitionRelease): Promise<boolean> {
163
+ const handle = pending.repairHandle;
164
+ if (!handle || pending.repairBytes === undefined) return true;
165
+ try {
166
+ await writeOwnerBytes(handle, Buffer.from(pending.repairBytes, "utf8"));
167
+ const stat = await handle.stat({ bigint: true });
168
+ pending.repairSnapshot = ownerSnapshotFrom(stat, Buffer.from(pending.repairBytes, "utf8"));
169
+ await handle.close();
170
+ pending.repairHandle = undefined;
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ async function ensureSessionStateParent(directory: string): Promise<void> {
178
+ const missing: string[] = [];
179
+ let current = path.resolve(directory);
180
+ for (;;) {
181
+ try {
182
+ const existing = await fs.stat(current);
183
+ if (!existing.isDirectory())
184
+ throw Object.assign(new Error(`Session state parent is not a directory: ${current}.`), {
185
+ code: "ENOTDIR",
186
+ path: current,
187
+ });
188
+ break;
189
+ } catch (error) {
190
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
191
+ missing.push(current);
192
+ const parent = path.dirname(current);
193
+ if (parent === current) throw error;
194
+ current = parent;
195
+ }
196
+ }
197
+ for (const created of missing.reverse()) {
198
+ try {
199
+ await fs.mkdir(created, { mode: 0o700 });
200
+ await fs.chmod(created, 0o700);
201
+ } catch (error) {
202
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
203
+ }
204
+ }
205
+ }
206
+
207
+ function isTransientLockError(error: unknown): boolean {
208
+ if (
209
+ error !== null &&
210
+ typeof error === "object" &&
211
+ TRANSIENT_LOCK_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? "")
212
+ )
213
+ return true;
214
+ return error instanceof Error && isTransientLockError(error.cause);
215
+ }
216
+
217
+ function transientNativeResultError(code: string): NodeJS.ErrnoException {
218
+ return Object.assign(new Error(`Native lock operation is transiently unavailable: ${code}.`), { code });
219
+ }
220
+
221
+ async function removeTransitionDir(transitionDir: string): Promise<void> {
222
+ for (let attempt = 0; ; attempt++) {
223
+ try {
224
+ await fs.rmdir(transitionDir);
225
+ return;
226
+ } catch (error) {
227
+ if (!isTransientLockError(error) || attempt >= 4) throw error;
228
+ await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
229
+ }
230
+ }
231
+ }
232
+
233
+ function removeOwnedTransitionClaim(nativePath: string, generation: TransitionDirectoryGeneration): boolean {
234
+ const native = nativeSessionStateLock();
235
+ const captured = native.snapshotDirectoryTree(nativePath);
236
+ if (!captured.ok || !captured.snapshot) return captured.code === "not_found";
237
+ const root = captured.snapshot.entries.find(entry => entry.relativePath === "");
238
+ if (
239
+ !root ||
240
+ root.kind !== "directory" ||
241
+ root.dev !== String(generation.dev) ||
242
+ root.ino !== String(generation.ino) ||
243
+ root.nlink !== String(generation.nlink) ||
244
+ root.mtimeNs !== String(generation.mtimeNs) ||
245
+ root.ctimeNs !== String(generation.ctimeNs)
246
+ )
247
+ return false;
248
+ const removed = native.exactRemoveDirectoryTree(nativePath, captured.snapshot);
249
+ return (
250
+ removed.ok ||
251
+ removed.code === "not_found" ||
252
+ (removed.code === "cleanup_pending" &&
253
+ removed.payloadDurable === true &&
254
+ removed.detachedPath === `${nativePath}.removing` &&
255
+ removed.retainedSuccessorPath === undefined &&
256
+ removed.retainedUnknownPath === undefined &&
257
+ removed.retainedPlaceholderPath === undefined)
258
+ );
259
+ }
260
+
261
+ async function transitionRecoveryKey(transitionDir: string): Promise<string> {
262
+ try {
263
+ return path.normalize(await fs.realpath(transitionDir));
264
+ } catch (error) {
265
+ const code = (error as NodeJS.ErrnoException).code;
266
+ if (code !== "ENOENT" && !isTransientLockError(error)) throw error;
267
+ }
268
+ const parent = path.dirname(transitionDir);
269
+ let canonicalParent: string;
270
+ try {
271
+ canonicalParent = await fs.realpath(parent);
272
+ } catch (error) {
273
+ const code = (error as NodeJS.ErrnoException).code;
274
+ if (code !== "ENOENT" && !isTransientLockError(error)) throw error;
275
+ canonicalParent = path.resolve(parent);
276
+ }
277
+ return path.normalize(path.join(canonicalParent, path.basename(transitionDir)));
278
+ }
279
+
280
+ /** Resolve the owned claim to its physical path while it is still present. */
281
+ async function canonicalOwnedTransitionPath(transitionDir: string): Promise<string> {
282
+ return path.normalize(await fs.realpath(transitionDir));
283
+ }
284
+
285
+ function sameTransitionGeneration(left: TransitionDirectoryGeneration, right: TransitionDirectoryGeneration): boolean {
286
+ return (
287
+ left.dev === right.dev &&
288
+ left.ino === right.ino &&
289
+ left.mode === right.mode &&
290
+ left.nlink === right.nlink &&
291
+ left.mtimeNs === right.mtimeNs &&
292
+ left.ctimeNs === right.ctimeNs
293
+ );
294
+ }
57
295
 
58
296
  interface SessionStateLockOwner {
59
297
  pid: number;
60
298
  start_time: string;
299
+ start_time_format?: string;
61
300
  token: string;
62
301
  owner_host_id?: string;
63
302
  released?: true;
64
303
  }
65
304
 
305
+ type OwnerRewriteFailure = Error & {
306
+ repairSnapshot?: LockOwnerSnapshot;
307
+ repairHandle?: fs.FileHandle;
308
+ repairBytes?: string;
309
+ };
310
+
66
311
  /**
67
312
  * How a `<file>.lock` owner record can be opened safely on this platform.
68
313
  *
@@ -143,6 +388,12 @@ export const SessionStateLockTestHooks: {
143
388
  unqualifiedOwnerIsLocal?: boolean;
144
389
  /** @internal Runs after final live-owner validation and before descriptor rewrite. */
145
390
  afterCurrentOwnerValidation?: (file: string) => void | Promise<void>;
391
+ /** @internal Fault seam immediately before a held owner record rewrite mutates bytes. */
392
+ beforeOwnerRecordRewrite?: (file: string) => void | Promise<void>;
393
+ /** @internal Fault seam immediately before final transition-generation validation. */
394
+ beforeTransitionReleaseLstat?: (transitionDir: string) => void | Promise<void>;
395
+ /** @internal Fault seam immediately before setup generation capture. */
396
+ beforeTransitionSetupLstat?: (transitionDir: string) => void | Promise<void>;
146
397
  /**
147
398
  * @internal Runs after a live owner has been proven and immediately before its
148
399
  * final pathname capture for release. Tests use it to replace the pathname and
@@ -150,17 +401,93 @@ export const SessionStateLockTestHooks: {
150
401
  */
151
402
  beforeCurrentOwnerRelease?: (file: string) => void | Promise<void>;
152
403
  beforeLegacyDirectoryRemoval?: (lockDir: string) => void | Promise<void>;
404
+ /** @internal Last quarantine name minted in the current lock cycle (tests). */
405
+ lastQuarantineName?: string;
406
+ /** @internal Count of quarantine names minted in the current lock cycle (tests). */
407
+ quarantineMints?: number;
408
+ /** @internal Force the next mint to this name so leftover-collision tests can plant it. */
409
+ forcedQuarantineName?: string;
410
+ /** @internal Observes bounded acquisition retries without changing their timing. */
411
+ afterAcquireContention?: (lockFile: string, attempt: number, elapsedMs: number) => void;
153
412
  } = {};
154
413
 
155
414
  /** Raised when the lock could not be acquired; callers map it to their own refusal. */
415
+ export type SessionStateLockUnavailableReason =
416
+ | "acquire_timeout"
417
+ | "legacy_directory_owner_unprovenanced"
418
+ | "lock_initialization_failed"
419
+ | "lock_inspection_failed"
420
+ | "lock_owner_live_or_unverifiable"
421
+ | "lock_owner_record_fresh"
422
+ | "lock_owner_record_unprovenanced"
423
+ | "lock_release_failed"
424
+ | "transition_claim_timeout"
425
+ | "unsafe_lock_path_type";
426
+
427
+ interface SessionStateLockUnavailableDetails {
428
+ lockPath: string;
429
+ reason: SessionStateLockUnavailableReason;
430
+ attempts?: number;
431
+ elapsedMs?: number;
432
+ cause?: unknown;
433
+ }
434
+
156
435
  export class SessionStateLockUnavailableError extends Error {
157
- constructor(cause?: unknown) {
158
- super("Coordinator session state lock is unavailable.");
436
+ readonly lockPath?: string;
437
+ readonly reason?: SessionStateLockUnavailableReason;
438
+ readonly attempts?: number;
439
+ readonly elapsedMs?: number;
440
+
441
+ constructor(causeOrDetails?: unknown | SessionStateLockUnavailableDetails) {
442
+ const details =
443
+ causeOrDetails !== null &&
444
+ typeof causeOrDetails === "object" &&
445
+ "lockPath" in causeOrDetails &&
446
+ "reason" in causeOrDetails
447
+ ? (causeOrDetails as SessionStateLockUnavailableDetails)
448
+ : undefined;
449
+ super(
450
+ details
451
+ ? `Coordinator session state lock is unavailable at ${details.lockPath}: ${details.reason}.`
452
+ : "Coordinator session state lock is unavailable.",
453
+ );
159
454
  this.name = "SessionStateLockUnavailableError";
160
- if (cause !== undefined) this.cause = cause;
455
+ if (details) {
456
+ this.lockPath = details.lockPath;
457
+ this.reason = details.reason;
458
+ this.attempts = details.attempts;
459
+ this.elapsedMs = details.elapsedMs;
460
+ if (details.cause !== undefined) this.cause = details.cause;
461
+ } else if (causeOrDetails !== undefined) {
462
+ this.cause = causeOrDetails;
463
+ }
161
464
  }
162
465
  }
163
466
 
467
+ function lockUnavailable(
468
+ lockPath: string,
469
+ reason: SessionStateLockUnavailableReason,
470
+ budget?: LockRetryBudget,
471
+ cause?: unknown,
472
+ ): SessionStateLockUnavailableError {
473
+ return new SessionStateLockUnavailableError({
474
+ lockPath,
475
+ reason,
476
+ ...(budget ? { attempts: budget.attempts + 1, elapsedMs: Math.round(lockRetryElapsedMs(budget)) } : {}),
477
+ ...(cause === undefined ? {} : { cause }),
478
+ });
479
+ }
480
+
481
+ function lockDiagnostic(
482
+ error: unknown,
483
+ lockPath: string,
484
+ reason: SessionStateLockUnavailableReason,
485
+ budget?: LockRetryBudget,
486
+ ): unknown {
487
+ if (!(error instanceof SessionStateLockUnavailableError) || error.lockPath !== undefined) return error;
488
+ return lockUnavailable(lockPath, reason, budget, error.cause ?? error);
489
+ }
490
+
164
491
  /**
165
492
  * The identity-bound deletion primitives this lock is built on.
166
493
  *
@@ -174,7 +501,7 @@ export class SessionStateLockUnavailableError extends Error {
174
501
  */
175
502
  export type SessionStateLockNativeBindings = Pick<
176
503
  typeof import("@gajae-code/natives"),
177
- "exactRemoveDirectoryTree" | "exactUnlink" | "snapshotDirectoryTree"
504
+ "exactRemoveDirectoryTree" | "exactUnlink" | "exactUnlinkDirect" | "snapshotDirectoryTree"
178
505
  >;
179
506
 
180
507
  /** How the deletion primitives are obtained. Throwing means they are unavailable. */
@@ -225,18 +552,28 @@ function nativeSessionStateLock(): SessionStateLockNativeBindings {
225
552
  * portable `ps` value is used instead of giving up and writing `unknown`, which would
226
553
  * make PID reuse undetectable.
227
554
  */
228
- function ownerStartTime(pid: number): string {
229
- return readLinuxProcStartTimeSync(pid) ?? portableProcessStartTime(pid) ?? "unknown";
555
+ function ownerStartIdentity(pid: number): { start_time: string; start_time_format?: string } {
556
+ const procStartTime = readLinuxProcStartTimeSync(pid);
557
+ if (procStartTime !== null) return { start_time: procStartTime, start_time_format: LINUX_PROC_START_TIME_FORMAT };
558
+ const portableStartTime = portableProcessStartTime(pid);
559
+ if (portableStartTime !== null)
560
+ return { start_time: portableStartTime, start_time_format: PORTABLE_START_TIME_FORMAT };
561
+ return { start_time: "unknown" };
230
562
  }
231
563
 
232
564
  function validLockOwner(value: unknown): value is SessionStateLockOwner {
233
565
  if (!value || typeof value !== "object") return false;
234
566
  const owner = value as Partial<SessionStateLockOwner>;
567
+ const startTimeFormatValid =
568
+ owner.start_time_format === undefined ||
569
+ owner.start_time_format === LINUX_PROC_START_TIME_FORMAT ||
570
+ owner.start_time_format === PORTABLE_START_TIME_FORMAT;
235
571
  return (
236
572
  typeof owner.pid === "number" &&
237
573
  Number.isSafeInteger(owner.pid) &&
238
574
  owner.pid > 0 &&
239
575
  typeof owner.start_time === "string" &&
576
+ startTimeFormatValid &&
240
577
  typeof owner.token === "string" &&
241
578
  owner.token.length > 0 &&
242
579
  (owner.owner_host_id === undefined ||
@@ -246,6 +583,7 @@ function validLockOwner(value: unknown): value is SessionStateLockOwner {
246
583
  }
247
584
 
248
585
  let ownerHostIdPromise: Promise<string> | undefined;
586
+ let ownerHostIdLoader: (() => Promise<string>) | undefined;
249
587
  let legacyOwnerHostIdPromise: Promise<string> | undefined;
250
588
 
251
589
  async function currentOwnerHostId(): Promise<string> {
@@ -254,8 +592,12 @@ async function currentOwnerHostId(): Promise<string> {
254
592
  if (SessionStateLockTestHooks.ownerHostId) {
255
593
  hostId = await SessionStateLockTestHooks.ownerHostId();
256
594
  } else {
257
- const promise =
258
- ownerHostIdPromise ?? (SessionStateLockTestHooks.loadInstallationHostId ?? loadInstallationHostId)();
595
+ const loader = SessionStateLockTestHooks.loadInstallationHostId ?? loadInstallationHostId;
596
+ if (ownerHostIdLoader !== loader) {
597
+ ownerHostIdLoader = loader;
598
+ ownerHostIdPromise = undefined;
599
+ }
600
+ const promise = ownerHostIdPromise ?? loader();
259
601
  ownerHostIdPromise = promise;
260
602
  try {
261
603
  hostId = await promise;
@@ -295,10 +637,17 @@ async function currentLegacyOwnerHostId(): Promise<string> {
295
637
  function sameOwnerIncarnation(owner: SessionStateLockOwner): boolean {
296
638
  if (owner.start_time === "unknown" || owner.start_time.length === 0) return true;
297
639
  const procStartTime = readLinuxProcStartTimeSync(owner.pid);
298
- if (procStartTime !== null && procStartTime === owner.start_time) return true;
299
640
  const psStartTime = portableProcessStartTime(owner.pid);
641
+ if (owner.start_time_format === LINUX_PROC_START_TIME_FORMAT)
642
+ return procStartTime === null || procStartTime === owner.start_time;
643
+ if (owner.start_time_format === PORTABLE_START_TIME_FORMAT)
644
+ return psStartTime === null || psStartTime === owner.start_time;
645
+ if (procStartTime !== null && procStartTime === owner.start_time) return true;
300
646
  if (psStartTime !== null && psStartTime === owner.start_time) return true;
301
- return procStartTime === null && psStartTime === null;
647
+ // Legacy records do not identify the encoding of their ps timestamp. A mismatch
648
+ // may be only a caller timezone/locale change, so an alive unversioned owner is
649
+ // never reclaimable on identity mismatch.
650
+ return true;
302
651
  }
303
652
 
304
653
  /**
@@ -355,6 +704,42 @@ async function lockOwnerIsAlive(value: unknown): Promise<boolean> {
355
704
  return sameOwnerIncarnation(owner);
356
705
  }
357
706
 
707
+ type MalformedOwnerProvenance = "dead_local" | "live_or_unverifiable" | "unprovenanced";
708
+
709
+ /**
710
+ * Recognize only the historical near-owner shape that current writers never emit:
711
+ * every ownership field is valid, but `released: false` makes the record malformed.
712
+ * Host qualification plus a dead-pid proof makes stale reclamation safe; every other
713
+ * malformed payload remains unprovenanced and fail-closed.
714
+ */
715
+ async function malformedOwnerProvenance(value: unknown): Promise<MalformedOwnerProvenance> {
716
+ if (SessionStateLockTestHooks.unqualifiedOwnerIsLocal === true) return "dead_local";
717
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "unprovenanced";
718
+ const owner = value as Partial<Omit<SessionStateLockOwner, "released">> & { released?: unknown };
719
+ if (
720
+ typeof owner.pid !== "number" ||
721
+ !Number.isSafeInteger(owner.pid) ||
722
+ owner.pid <= 0 ||
723
+ typeof owner.start_time !== "string" ||
724
+ owner.start_time.length === 0 ||
725
+ typeof owner.token !== "string" ||
726
+ owner.token.length === 0 ||
727
+ owner.released !== false ||
728
+ (owner.start_time_format !== undefined &&
729
+ owner.start_time_format !== LINUX_PROC_START_TIME_FORMAT &&
730
+ owner.start_time_format !== PORTABLE_START_TIME_FORMAT)
731
+ )
732
+ return "unprovenanced";
733
+ if (owner.owner_host_id === undefined) return "unprovenanced";
734
+ if (
735
+ owner.owner_host_id !== (await currentOwnerHostId()) &&
736
+ owner.owner_host_id !== (await currentLegacyOwnerHostId())
737
+ ) {
738
+ return "unprovenanced";
739
+ }
740
+ return probeOwnerProcess(owner.pid) === "dead" ? "dead_local" : "live_or_unverifiable";
741
+ }
742
+
358
743
  /**
359
744
  * The immutable identity of the EXACT owner bytes one decision was made from.
360
745
  *
@@ -525,6 +910,10 @@ function sameLockOwnerSnapshot(left: LockOwnerSnapshot, right: LockOwnerSnapshot
525
910
  );
526
911
  }
527
912
 
913
+ function sameLockOwnerObject(left: LockOwnerSnapshot, right: LockOwnerSnapshot): boolean {
914
+ return left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
915
+ }
916
+
528
917
  /**
529
918
  * What an identity-bound removal is allowed to conclude.
530
919
  *
@@ -537,8 +926,16 @@ type ExactRemovalOutcome = "removed" | "absent" | "identity_mismatch" | "refused
537
926
  * A single-component quarantine destination, required by the native primitive so that
538
927
  * authority over a detached record survives a crash between detach and unlink.
539
928
  */
540
- function lockQuarantineName(): string {
541
- return `.gjc-delete-session-state-lock-${randomUUID()}.json`;
929
+ function lockQuarantineName(existing?: string): string {
930
+ const name =
931
+ existing ??
932
+ SessionStateLockTestHooks.forcedQuarantineName ??
933
+ `.gjc-delete-session-state-lock-${randomUUID()}.json`;
934
+ if (!existing) {
935
+ SessionStateLockTestHooks.lastQuarantineName = name;
936
+ SessionStateLockTestHooks.quarantineMints = (SessionStateLockTestHooks.quarantineMints ?? 0) + 1;
937
+ }
938
+ return name;
542
939
  }
543
940
 
544
941
  /**
@@ -548,7 +945,11 @@ function lockQuarantineName(): string {
548
945
  * never downgraded to `fs.rm`: not knowing what is at the pathname is precisely the state
549
946
  * in which deleting it destroys a successor's lock.
550
947
  */
551
- function exactUnlinkOwnerRecord(file: string, identity: LockOwnerSnapshot): ExactRemovalOutcome {
948
+ function exactUnlinkOwnerRecord(
949
+ file: string,
950
+ identity: LockOwnerSnapshot,
951
+ quarantineName: string,
952
+ ): ExactRemovalOutcome {
552
953
  let result: NativeExactUnlinkResult;
553
954
  try {
554
955
  result = nativeSessionStateLock().exactUnlink(file, {
@@ -558,7 +959,7 @@ function exactUnlinkOwnerRecord(file: string, identity: LockOwnerSnapshot): Exac
558
959
  size: identity.size,
559
960
  mtimeNs: identity.mtimeNs,
560
961
  sha256: identity.sha256,
561
- quarantineName: lockQuarantineName(),
962
+ quarantineName,
562
963
  });
563
964
  } catch (error) {
564
965
  throw new SessionStateLockUnavailableError(error);
@@ -607,27 +1008,32 @@ function ownerCreateFlags(): number | undefined {
607
1008
  }
608
1009
 
609
1010
  async function newLockOwner(): Promise<SessionStateLockOwner> {
1011
+ const identity = ownerStartIdentity(process.pid);
610
1012
  return {
611
1013
  pid: process.pid,
612
- start_time: ownerStartTime(process.pid),
1014
+ ...identity,
613
1015
  token: randomUUID(),
614
1016
  owner_host_id: await currentOwnerHostId(),
615
1017
  };
616
1018
  }
617
1019
 
618
- async function releasedLockOwner(): Promise<SessionStateLockOwner> {
1020
+ function releasedOwnerForHost(ownerHostId: string, token = randomUUID()): SessionStateLockOwner {
619
1021
  return {
620
1022
  // PID 1 exists in every supported process namespace. Legacy readers that do
621
1023
  // not understand `released` therefore keep this compatibility fence instead
622
1024
  // of deleting it and racing a current writer.
623
1025
  pid: 1,
624
1026
  start_time: "unknown",
625
- token: randomUUID(),
626
- owner_host_id: await currentOwnerHostId(),
1027
+ token,
1028
+ owner_host_id: ownerHostId,
627
1029
  released: true,
628
1030
  };
629
1031
  }
630
1032
 
1033
+ async function releasedLockOwner(): Promise<SessionStateLockOwner> {
1034
+ return releasedOwnerForHost(await currentOwnerHostId());
1035
+ }
1036
+
631
1037
  /**
632
1038
  * The object a descriptor is currently open on.
633
1039
  *
@@ -684,6 +1090,7 @@ async function retractFailedOwnerRecord(
684
1090
  file: string,
685
1091
  handle: fs.FileHandle,
686
1092
  created: OpenOwnerIdentity | null,
1093
+ quarantineName: string,
687
1094
  ): Promise<void> {
688
1095
  if (!created) return;
689
1096
  const stillOpen = await openOwnerIdentity(handle);
@@ -691,7 +1098,7 @@ async function retractFailedOwnerRecord(
691
1098
  if (ownerAccessStrategy() === "windows-validated") await handle.close().catch(() => undefined);
692
1099
  const current = await captureRegularLockOwner(file).catch(() => null);
693
1100
  if (!current || current.dev !== created.dev || current.ino !== created.ino) return;
694
- exactUnlinkOwnerRecord(file, current);
1101
+ exactUnlinkOwnerRecord(file, current, quarantineName);
695
1102
  }
696
1103
 
697
1104
  /**
@@ -712,20 +1119,25 @@ async function retractFailedOwnerRecord(
712
1119
  * @throws `EEXIST` (or `EISDIR`/`EPERM`) unchanged, so callers can tell contention apart
713
1120
  * from failure.
714
1121
  */
715
- async function createOwnerLock(file: string, owner: SessionStateLockOwner): Promise<LockOwnerSnapshot> {
1122
+ async function createOwnerLock(
1123
+ file: string,
1124
+ owner: SessionStateLockOwner,
1125
+ quarantineName: string,
1126
+ ): Promise<LockOwnerSnapshot> {
716
1127
  const flags = ownerCreateFlags();
717
1128
  if (flags === undefined)
718
1129
  throw new SessionStateLockUnavailableError(
719
1130
  new Error("Safe owner record creation is unsupported on this platform."),
720
1131
  );
721
1132
  nativeSessionStateLock();
722
- const handle = await fs.open(file, flags);
1133
+ const handle = await fs.open(file, flags, 0o600);
723
1134
  const bytes = Buffer.from(JSON.stringify(owner), "utf8");
724
1135
  // Taken from the SAME descriptor the exclusive create produced, before anything is
725
1136
  // written through it. `O_EXCL` proves the object did not exist a moment ago, so this
726
1137
  // pair names the record this call brought into existence and nothing else.
727
1138
  let created: OpenOwnerIdentity | null = null;
728
1139
  try {
1140
+ await handle.chmod(0o600);
729
1141
  created = await openOwnerIdentity(handle);
730
1142
  await SessionStateLockTestHooks.ownerRecordWriteFault?.(file);
731
1143
  await handle.writeFile(bytes);
@@ -737,7 +1149,7 @@ async function createOwnerLock(file: string, owner: SessionStateLockOwner): Prom
737
1149
  // Deliberately BEFORE any close: releasing the descriptor first would free the
738
1150
  // created inode for reuse and turn a successor into a match.
739
1151
  try {
740
- await retractFailedOwnerRecord(file, handle, created);
1152
+ await retractFailedOwnerRecord(file, handle, created, quarantineName);
741
1153
  } catch (cleanupError) {
742
1154
  throw new AggregateError(
743
1155
  [error, cleanupError],
@@ -754,6 +1166,18 @@ const POSIX_OWNER_REWRITE_FLAGS = POSIX_NOFOLLOW_AVAILABLE
754
1166
  ? fsSync.constants.O_RDWR | fsSync.constants.O_NONBLOCK | fsSync.constants.O_NOFOLLOW
755
1167
  : undefined;
756
1168
 
1169
+ async function writeOwnerBytes(handle: fs.FileHandle, bytes: Buffer): Promise<void> {
1170
+ await handle.truncate(0);
1171
+ let offset = 0;
1172
+ while (offset < bytes.byteLength) {
1173
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, offset);
1174
+ if (bytesWritten <= 0) throw new Error("Owner record rewrite made no progress.");
1175
+ offset += bytesWritten;
1176
+ }
1177
+ await handle.truncate(bytes.byteLength);
1178
+ await handle.sync();
1179
+ }
1180
+
757
1181
  async function rewriteHeldOwnerRecord(
758
1182
  file: string,
759
1183
  held: LockOwnerSnapshot,
@@ -778,6 +1202,9 @@ async function rewriteHeldOwnerRecord(
778
1202
  } catch (error) {
779
1203
  throw new SessionStateLockUnavailableError(error);
780
1204
  }
1205
+ let mutationStarted = false;
1206
+ let rewriteHookPassed = false;
1207
+ let retainRepairHandle = false;
781
1208
  try {
782
1209
  const opened = await handle.stat({ bigint: true });
783
1210
  if (!opened.isFile()) throw new SessionStateLockUnavailableError(new Error("Owner record is not regular."));
@@ -794,22 +1221,13 @@ async function rewriteHeldOwnerRecord(
794
1221
  throw new SessionStateLockUnavailableError(new Error("Owner record changed before rewrite."));
795
1222
  if (!sameLockOwnerSnapshot(ownerSnapshotFrom(settled, currentBytes), held))
796
1223
  throw new SessionStateLockUnavailableError(new Error("Owner record identity changed before rewrite."));
1224
+ await handle.chmod(0o600);
797
1225
 
798
1226
  const replacementBytes = Buffer.from(JSON.stringify(replacement), "utf8");
799
- await handle.truncate(0);
800
- let offset = 0;
801
- while (offset < replacementBytes.byteLength) {
802
- const { bytesWritten } = await handle.write(
803
- replacementBytes,
804
- offset,
805
- replacementBytes.byteLength - offset,
806
- offset,
807
- );
808
- if (bytesWritten <= 0) throw new Error("Owner record rewrite made no progress.");
809
- offset += bytesWritten;
810
- }
811
- await handle.truncate(replacementBytes.byteLength);
812
- await handle.sync();
1227
+ mutationStarted = true;
1228
+ await SessionStateLockTestHooks.beforeOwnerRecordRewrite?.(file);
1229
+ rewriteHookPassed = true;
1230
+ await writeOwnerBytes(handle, replacementBytes);
813
1231
  const rewritten = await handle.stat({ bigint: true });
814
1232
  const canonical = await captureRegularLockOwner(file);
815
1233
  if (!canonical || canonical.dev !== rewritten.dev || canonical.ino !== rewritten.ino)
@@ -826,16 +1244,58 @@ async function rewriteHeldOwnerRecord(
826
1244
  }
827
1245
  return canonical;
828
1246
  } catch (error) {
1247
+ // Keep the descriptor as the repair authority until the held bytes have
1248
+ // been restored. A truncate/write/fsync fault may leave a malformed or
1249
+ // partially committed record; closing first would force recovery to trust
1250
+ // an unproven pathname and can wedge a successor behind the transition.
1251
+ let repairNeeded = mutationStarted;
1252
+ if (mutationStarted && !rewriteHookPassed) {
1253
+ try {
1254
+ repairNeeded = (await handle.readFile()).toString("utf8") !== held.bytes;
1255
+ } catch {
1256
+ repairNeeded = true;
1257
+ }
1258
+ }
1259
+ if (repairNeeded) {
1260
+ let repaired: LockOwnerSnapshot;
1261
+ try {
1262
+ await writeOwnerBytes(handle, Buffer.from(held.bytes, "utf8"));
1263
+ const repairedStat = await handle.stat({ bigint: true });
1264
+ repaired = ownerSnapshotFrom(repairedStat, Buffer.from(held.bytes, "utf8"));
1265
+ } catch (repairError) {
1266
+ const aggregate = new AggregateError(
1267
+ [error, repairError],
1268
+ "Owner record rewrite failed and its descriptor-bound repair also failed.",
1269
+ );
1270
+ const failure = aggregate as OwnerRewriteFailure;
1271
+ failure.repairHandle = handle;
1272
+ failure.repairBytes = held.bytes;
1273
+ // The finally block must not close this handle: it is now the only
1274
+ // authority capable of repairing the exact inode without trusting its
1275
+ // mutable pathname.
1276
+ retainRepairHandle = true;
1277
+ throw failure;
1278
+ }
1279
+ const wrapped =
1280
+ error instanceof SessionStateLockUnavailableError ? error : new SessionStateLockUnavailableError(error);
1281
+ (wrapped as OwnerRewriteFailure).repairSnapshot = repaired;
1282
+ throw wrapped;
1283
+ }
829
1284
  throw error instanceof SessionStateLockUnavailableError ? error : new SessionStateLockUnavailableError(error);
830
1285
  } finally {
831
- await handle.close().catch(() => undefined);
1286
+ if (!retainRepairHandle) await handle.close().catch(() => undefined);
832
1287
  }
833
1288
  }
834
1289
 
835
- async function acquireOwnerLock(file: string, owner: SessionStateLockOwner): Promise<LockOwnerSnapshot> {
1290
+ async function acquireOwnerLock(
1291
+ file: string,
1292
+ owner: SessionStateLockOwner,
1293
+ quarantineName: string,
1294
+ onRewriteFailure?: (held: LockOwnerSnapshot) => void,
1295
+ ): Promise<LockOwnerSnapshot> {
836
1296
  nativeSessionStateLock();
837
1297
  try {
838
- return await createOwnerLock(file, owner);
1298
+ return await createOwnerLock(file, owner, quarantineName);
839
1299
  } catch (error) {
840
1300
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
841
1301
  const stat = await fs.lstat(file).catch(() => null);
@@ -849,7 +1309,12 @@ async function acquireOwnerLock(file: string, owner: SessionStateLockOwner): Pro
849
1309
  throw error;
850
1310
  }
851
1311
  if (!validLockOwner(observed) || observed.released !== true) throw error;
852
- return await rewriteHeldOwnerRecord(file, current, owner);
1312
+ try {
1313
+ return await rewriteHeldOwnerRecord(file, current, owner);
1314
+ } catch (error) {
1315
+ onRewriteFailure?.(current);
1316
+ throw error;
1317
+ }
853
1318
  }
854
1319
  }
855
1320
 
@@ -910,15 +1375,24 @@ async function releaseOwnerLock(file: string, held: LockOwnerSnapshot): Promise<
910
1375
  * a successor is reported as `identity_mismatch` and survives untouched, and the caller
911
1376
  * simply retries.
912
1377
  */
1378
+ type SessionStateLockReclaimResult =
1379
+ | "absent_or_changed"
1380
+ | "legacy_directory_unprovenanced"
1381
+ | "owner_live_or_unverifiable"
1382
+ | "owner_record_fresh"
1383
+ | "owner_unprovenanced"
1384
+ | "reclaimed";
1385
+
913
1386
  async function reclaimStaleOwnerRecord(
914
1387
  file: string,
915
1388
  hooks: {
916
1389
  afterInspection?: (file: string) => void | Promise<void>;
917
1390
  beforeRemoval?: (file: string) => void | Promise<void>;
918
1391
  },
919
- ): Promise<void> {
1392
+ quarantineName?: string,
1393
+ ): Promise<SessionStateLockReclaimResult> {
920
1394
  const snapshot = await captureRegularLockOwner(file);
921
- if (!snapshot) return;
1395
+ if (!snapshot) return "absent_or_changed";
922
1396
  let owner: unknown;
923
1397
  try {
924
1398
  owner = JSON.parse(snapshot.bytes);
@@ -926,45 +1400,169 @@ async function reclaimStaleOwnerRecord(
926
1400
  owner = null;
927
1401
  }
928
1402
  if (!validLockOwner(owner)) {
929
- if (SessionStateLockTestHooks.unqualifiedOwnerIsLocal !== true) return;
1403
+ const provenance = await malformedOwnerProvenance(owner);
1404
+ if (provenance === "unprovenanced") return "owner_unprovenanced";
1405
+ if (provenance === "live_or_unverifiable") return "owner_live_or_unverifiable";
930
1406
  // The mtime of the very inode the bytes were read from, not a fresh path `stat`.
931
- if (Date.now() - Number(snapshot.mtimeNs / 1_000_000n) < LOCK_STALE_MS) return;
932
- } else if (await lockOwnerIsAlive(owner)) return;
1407
+ if (Date.now() - Number(snapshot.mtimeNs / 1_000_000n) < LOCK_STALE_MS) return "owner_record_fresh";
1408
+ } else if (await lockOwnerIsAlive(owner)) return "owner_live_or_unverifiable";
933
1409
  await hooks.afterInspection?.(file);
934
1410
  const current = await captureRegularLockOwner(file);
935
- if (!current || !sameLockOwnerSnapshot(current, snapshot)) return;
1411
+ if (!current || !sameLockOwnerSnapshot(current, snapshot)) return "absent_or_changed";
936
1412
  await hooks.beforeRemoval?.(file);
937
- const outcome = exactUnlinkOwnerRecord(file, current);
1413
+ const reserved = quarantineName ?? lockQuarantineName();
1414
+ await removeVerifiedEmptyQuarantine(path.dirname(file), reserved);
1415
+ const outcome = exactUnlinkOwnerRecord(file, current, reserved);
938
1416
  // A successor that took the path in the final window keeps it; this call just loses.
939
1417
  if (outcome === "refused")
940
1418
  throw new SessionStateLockUnavailableError(new Error("Stale owner record could not be reclaimed."));
1419
+ return outcome === "removed" || outcome === "absent" ? "reclaimed" : "absent_or_changed";
1420
+ }
1421
+
1422
+ /**
1423
+ * Verified empty leftover at a reserved quarantine name is incomplete debris, not
1424
+ * in-progress cleanup.
1425
+ *
1426
+ * Deletion authority is the native identity-bound direct unlink — never a plain
1427
+ * pathname unlink. A replacement planted at the reserved name between observation
1428
+ * and deletion has a different inode identity and is refused by construction.
1429
+ */
1430
+ export async function removeVerifiedEmptyQuarantine(directory: string, name: string): Promise<void> {
1431
+ // The name must be a single path component on every supported platform: both
1432
+ // separator forms and drive/colon syntax are rejected before path.join, so a
1433
+ // caller-controlled name can never escape the lock directory (Windows included).
1434
+ if (
1435
+ !name.startsWith(".gjc-delete-") ||
1436
+ name.includes("/") ||
1437
+ name.includes("\\") ||
1438
+ name.includes(":") ||
1439
+ name.includes("\0")
1440
+ )
1441
+ return;
1442
+ const target = path.join(directory, name);
1443
+ let parent: fsSync.BigIntStats;
1444
+ try {
1445
+ parent = await fs.stat(path.dirname(target), { bigint: true });
1446
+ } catch (error) {
1447
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1448
+ throw error;
1449
+ }
1450
+ let stat: fsSync.BigIntStats;
1451
+ try {
1452
+ stat = await fs.lstat(target, { bigint: true });
1453
+ } catch (error) {
1454
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1455
+ throw error;
1456
+ }
1457
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== 0n || stat.nlink !== 1n) return;
1458
+ const result = nativeSessionStateLock().exactUnlinkDirect(target, {
1459
+ dev: stat.dev,
1460
+ ino: stat.ino,
1461
+ nlink: stat.nlink,
1462
+ parentDev: parent.dev,
1463
+ parentIno: parent.ino,
1464
+ size: stat.size,
1465
+ mtimeNs: stat.mtimeNs,
1466
+ sha256: EMPTY_FILE_SHA256,
1467
+ quarantineName: `.gjc-delete-cleanup-${randomUUID()}.json`,
1468
+ });
1469
+ if (result.ok) return;
1470
+ const retained = [
1471
+ result.detachedPath,
1472
+ result.retainedSuccessorPath,
1473
+ result.retainedPlaceholderPath,
1474
+ result.retainedUnknownPath,
1475
+ ].filter((value): value is string => typeof value === "string");
1476
+ // not_found is an ordinary concurrent-cleanup race; identity_mismatch without a
1477
+ // retained path means the native restored the verified object to its original
1478
+ // name and a replacement owns the pathname now — both leave nothing hidden.
1479
+ if (retained.length === 0 && (result.code === "not_found" || result.code === "identity_mismatch")) return;
1480
+ // Anything else is fail-closed and observable: the stale reclaim must not proceed
1481
+ // while recovery evidence (a stranded detached object, a retained successor, or an
1482
+ // unrecovered placeholder) would otherwise vanish silently.
1483
+ throw new SessionStateLockUnavailableError(
1484
+ new Error(
1485
+ `Verified empty quarantine cleanup did not complete (${result.code ?? "unknown"})` +
1486
+ (retained.length > 0 ? `; retained: ${retained.join(", ")}` : "") +
1487
+ ".",
1488
+ ),
1489
+ );
941
1490
  }
942
1491
 
943
1492
  async function releaseTransitionClaim(
944
1493
  transitionDir: string,
945
1494
  ownerFile: string,
946
1495
  held: LockOwnerSnapshot,
1496
+ recoveryKey: string,
1497
+ transitionGeneration: TransitionDirectoryGeneration,
1498
+ nativePath: string,
947
1499
  ): Promise<void> {
948
- await SessionStateLockTestHooks.beforeCurrentOwnerRelease?.(ownerFile);
949
- const current = await captureRegularLockOwner(ownerFile);
950
- if (!current || !sameLockOwnerSnapshot(current, held))
951
- throw new SessionStateLockUnavailableError(new Error("Transition owner changed before release."));
952
- await SessionStateLockTestHooks.afterCurrentOwnerValidation?.(ownerFile);
953
- await rewriteHeldOwnerRecord(ownerFile, held, await releasedLockOwner());
954
- await fs.rmdir(transitionDir);
1500
+ let heldOwner: SessionStateLockOwner;
1501
+ try {
1502
+ heldOwner = JSON.parse(held.bytes) as SessionStateLockOwner;
1503
+ } catch (error) {
1504
+ throw new SessionStateLockUnavailableError(error);
1505
+ }
1506
+ const releasedOwner = releasedOwnerForHost(heldOwner.owner_host_id ?? "", randomUUID());
1507
+ // Arm recovery before every fallible release phase. In particular, an initial
1508
+ // lstat/capture or a rewrite that commits bytes and then reports an I/O fault must not
1509
+ // strand the claim without a generation-bound replay record.
1510
+ pendingTransitionReleases.set(recoveryKey, {
1511
+ phase: "release",
1512
+ token: releasedOwner.token,
1513
+ generation: transitionGeneration,
1514
+ nativePath,
1515
+ held,
1516
+ releasedOwner,
1517
+ recoverable: false,
1518
+ });
1519
+ try {
1520
+ await SessionStateLockTestHooks.beforeCurrentOwnerRelease?.(ownerFile);
1521
+ const current = await captureRegularLockOwner(ownerFile);
1522
+ if (!current || !sameLockOwnerSnapshot(current, held))
1523
+ throw new SessionStateLockUnavailableError(new Error("Transition owner changed before release."));
1524
+ await SessionStateLockTestHooks.afterCurrentOwnerValidation?.(ownerFile);
1525
+ await rewriteHeldOwnerRecord(ownerFile, held, releasedOwner);
1526
+ await SessionStateLockTestHooks.beforeTransitionReleaseLstat?.(transitionDir);
1527
+ const currentTransition = await fs.lstat(transitionDir, { bigint: true });
1528
+ if (
1529
+ !currentTransition.isDirectory() ||
1530
+ !sameTransitionGeneration(transitionGenerationFromStat(currentTransition), transitionGeneration)
1531
+ )
1532
+ throw new SessionStateLockUnavailableError(new Error("Transition claim changed before release."));
1533
+ await removeTransitionDir(transitionDir);
1534
+ clearPendingTransitionRelease(recoveryKey);
1535
+ } catch (error) {
1536
+ const currentPending = pendingTransitionReleases.get(recoveryKey);
1537
+ if (currentPending && currentPending.held === held) {
1538
+ const rewriteFailure = error as OwnerRewriteFailure;
1539
+ const repaired = rewriteFailure.repairSnapshot;
1540
+ if (repaired && sameLockOwnerObject(repaired, held)) currentPending.held = repaired;
1541
+ if (rewriteFailure.repairHandle) {
1542
+ currentPending.repairHandle = rewriteFailure.repairHandle;
1543
+ currentPending.repairBytes = rewriteFailure.repairBytes ?? held.bytes;
1544
+ }
1545
+ currentPending.recoverable = true;
1546
+ }
1547
+ throw error;
1548
+ }
955
1549
  }
956
1550
 
957
- async function reclaimStaleTransitionClaim(transitionDir: string): Promise<void> {
1551
+ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<void> {
958
1552
  const stat = await fs.lstat(transitionDir).catch(() => null);
959
1553
  if (!stat) return;
960
1554
  // Regular-file claims belong to the superseded protocol. They retain the old
961
1555
  // exact-identity stale path; released PID-1 tombstones deliberately require
962
1556
  // explicit cleanup before this atomic-directory protocol can take over.
963
1557
  if (stat.isFile()) {
964
- await reclaimStaleOwnerRecord(transitionDir, {
965
- afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
966
- beforeRemoval: SessionStateLockTestHooks.beforeTransitionStaleRemoval,
967
- });
1558
+ await reclaimStaleOwnerRecord(
1559
+ transitionDir,
1560
+ {
1561
+ afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
1562
+ beforeRemoval: SessionStateLockTestHooks.beforeTransitionStaleRemoval,
1563
+ },
1564
+ quarantineName,
1565
+ );
968
1566
  return;
969
1567
  }
970
1568
  if (!stat.isDirectory()) throw new SessionStateLockUnavailableError();
@@ -974,25 +1572,366 @@ async function reclaimStaleTransitionClaim(transitionDir: string): Promise<void>
974
1572
  }
975
1573
 
976
1574
  /** Run one pathname transition under an atomic `mkdir`/`rmdir` claim. */
977
- async function withLockPathTransition<T>(lockFile: string, transition: () => Promise<T>): Promise<T> {
1575
+ /**
1576
+ * Recover a transition claim this process stranded in a prior failed release.
1577
+ *
1578
+ * `releaseTransitionClaim` rewrites the owner record to its released tombstone
1579
+ * before removing the claim directory, so a transiently denied `rmdir` (Windows
1580
+ * sharing denial, EBUSY/EPERM) leaves a RELEASED owner record plus an intact
1581
+ * claim. Without recovery, that claim is a fail-closed wall this same process
1582
+ * then deadlocks on: the on-disk owner is live (this process), so
1583
+ * `reclaimStaleTransitionClaim` refuses it forever. The recorded released token
1584
+ * authorizes completing exactly that release: the record must still carry the
1585
+ * same token with `released: true` — any other content is a successor's claim
1586
+ * and stays untouched.
1587
+ */
1588
+ async function recoverPendingTransitionRelease(
1589
+ transitionDir: string,
1590
+ recoveryKey: string,
1591
+ quarantineName: string,
1592
+ ): Promise<boolean> {
1593
+ let pendingKey = recoveryKey;
1594
+ let pending = pendingTransitionReleases.get(pendingKey);
1595
+ if (pending === undefined) {
1596
+ // Parent realpath can be transiently unavailable during release. A later alias
1597
+ // may therefore compute a different lexical key; generation identity is the
1598
+ // authority for finding that stranded entry, never the pathname spelling.
1599
+ const current = await fs.lstat(transitionDir, { bigint: true }).catch(() => null);
1600
+ if (!current?.isDirectory()) return false;
1601
+ for (const [key, candidate] of pendingTransitionReleases) {
1602
+ if (
1603
+ candidate.generation &&
1604
+ sameTransitionGeneration(candidate.generation, {
1605
+ dev: current.dev,
1606
+ ino: current.ino,
1607
+ mode: current.mode,
1608
+ nlink: current.nlink,
1609
+ mtimeNs: current.mtimeNs,
1610
+ ctimeNs: current.ctimeNs,
1611
+ })
1612
+ ) {
1613
+ pendingKey = key;
1614
+ pending = candidate;
1615
+ break;
1616
+ }
1617
+ }
1618
+ }
1619
+ if (pending === undefined) return false;
1620
+ // The record is armed before release's fallible phases, but it is not authority
1621
+ // for a contender while that release is still in progress. The owner that armed
1622
+ // it flips this bit only when a phase actually throws.
1623
+ if (!pending.recoverable) return false;
1624
+ if (pending.recovery) return await pending.recovery;
1625
+ const recovery = (async (): Promise<boolean> => {
1626
+ const ownerFile = `${transitionDir}.owner`;
1627
+ if (!pending.generation) {
1628
+ // A generation-less pending setup has no pathname authority. The only
1629
+ // safe recovery source is the descriptor retained immediately after mkdir;
1630
+ // never snapshot an arbitrary successor now occupying the alias.
1631
+ if (!pending.generationHandle) return false;
1632
+ try {
1633
+ pending.generation = await captureTransitionDirectoryGenerationFromHandle(pending.generationHandle);
1634
+ } catch {
1635
+ return false;
1636
+ }
1637
+ }
1638
+ const pendingGeneration = pending.generation;
1639
+ if (!pendingGeneration) return false;
1640
+ let current: fsSync.BigIntStats | null;
1641
+ try {
1642
+ current = await fs.lstat(transitionDir, { bigint: true });
1643
+ } catch (error) {
1644
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1645
+ clearPendingTransitionRelease(pendingKey, pending);
1646
+ return true;
1647
+ }
1648
+ return false;
1649
+ }
1650
+ // A successor may have replaced the claim after the original release failed.
1651
+ // Never let this cleanup remove that new generation, even if its owner sidecar
1652
+ // happens to carry the same released token.
1653
+ if (
1654
+ !current.isDirectory() ||
1655
+ !sameTransitionGeneration(pendingGeneration, {
1656
+ dev: current.dev,
1657
+ ino: current.ino,
1658
+ mode: current.mode,
1659
+ nlink: current.nlink,
1660
+ mtimeNs: current.mtimeNs,
1661
+ ctimeNs: current.ctimeNs,
1662
+ })
1663
+ ) {
1664
+ clearPendingTransitionRelease(pendingKey, pending);
1665
+ return false;
1666
+ }
1667
+ if (!(await repairPendingOwnerRecord(pending))) return false;
1668
+ if (pending.repairSnapshot && pending.held && sameLockOwnerObject(pending.repairSnapshot, pending.held))
1669
+ pending.held = pending.repairSnapshot;
1670
+ if (pending.phase === "setup") {
1671
+ try {
1672
+ if (pending.held) {
1673
+ const outcome = exactUnlinkOwnerRecord(ownerFile, pending.held, quarantineName);
1674
+ if (outcome !== "removed" && outcome !== "absent") {
1675
+ clearPendingTransitionRelease(pendingKey, pending);
1676
+ return false;
1677
+ }
1678
+ } else if (await captureRegularLockOwner(ownerFile)) {
1679
+ return false;
1680
+ }
1681
+ await removeTransitionDir(transitionDir);
1682
+ clearPendingTransitionRelease(pendingKey, pending);
1683
+ return true;
1684
+ } catch {
1685
+ return false;
1686
+ }
1687
+ }
1688
+ if (!pending.held || !pending.releasedOwner) {
1689
+ clearPendingTransitionRelease(pendingKey, pending);
1690
+ return false;
1691
+ }
1692
+ try {
1693
+ let pendingOwner = await captureRegularLockOwner(ownerFile);
1694
+ if (!pendingOwner) return false;
1695
+ let parsed: Partial<SessionStateLockOwner>;
1696
+ try {
1697
+ parsed = JSON.parse(pendingOwner.bytes) as Partial<SessionStateLockOwner>;
1698
+ } catch {
1699
+ // A rewrite can truncate the held inode and fail before replacement
1700
+ // bytes land. The malformed payload is still ours only when its object
1701
+ // identity is the held inode; exact unlink then re-proves the current
1702
+ // bytes before removing it. A different inode is a successor and stays.
1703
+ if (!sameLockOwnerObject(pendingOwner, pending.held)) {
1704
+ clearPendingTransitionRelease(pendingKey, pending);
1705
+ return false;
1706
+ }
1707
+ const outcome = exactUnlinkOwnerRecord(ownerFile, pendingOwner, quarantineName);
1708
+ if (outcome !== "removed" && outcome !== "absent") return false;
1709
+ await removeTransitionDir(transitionDir);
1710
+ clearPendingTransitionRelease(pendingKey, pending);
1711
+ return true;
1712
+ }
1713
+ if (parsed.token !== pending.token || parsed.released !== true) {
1714
+ // A rewrite may have failed before touching the inode. Retry it only when
1715
+ // the owner path is still the exact record this transition held; any other
1716
+ // bytes belong to a successor and invalidate this pending authority.
1717
+ if (!sameLockOwnerSnapshot(pendingOwner, pending.held)) {
1718
+ clearPendingTransitionRelease(pendingKey, pending);
1719
+ return false;
1720
+ }
1721
+ try {
1722
+ await rewriteHeldOwnerRecord(ownerFile, pendingOwner, pending.releasedOwner);
1723
+ } catch {
1724
+ // Keep the generation record. A later contender can retry this
1725
+ // release without replaying the already-completed transition.
1726
+ return false;
1727
+ }
1728
+ pendingOwner = await captureRegularLockOwner(ownerFile);
1729
+ if (!pendingOwner) return false;
1730
+ try {
1731
+ parsed = JSON.parse(pendingOwner.bytes) as Partial<SessionStateLockOwner>;
1732
+ } catch {
1733
+ return false;
1734
+ }
1735
+ }
1736
+ if (parsed.token !== pending.token || parsed.released !== true) return false;
1737
+ let nativeTransitionPath = pending.nativePath;
1738
+ if (nativeTransitionPath === undefined) {
1739
+ try {
1740
+ nativeTransitionPath = await canonicalOwnedTransitionPath(transitionDir);
1741
+ pending.nativePath = nativeTransitionPath;
1742
+ } catch {
1743
+ return false;
1744
+ }
1745
+ }
1746
+ const captured = nativeSessionStateLock().snapshotDirectoryTree(nativeTransitionPath);
1747
+ if (captured.code === "sharing_violation") return false;
1748
+ if (
1749
+ !captured.ok ||
1750
+ !captured.snapshot ||
1751
+ captured.snapshot.rootDev !== String(current.dev) ||
1752
+ captured.snapshot.rootIno !== String(current.ino)
1753
+ ) {
1754
+ clearPendingTransitionRelease(pendingKey, pending);
1755
+ return false;
1756
+ }
1757
+ const capturedRoot = captured.snapshot.entries.find(entry => entry.relativePath === "");
1758
+ if (
1759
+ !capturedRoot ||
1760
+ capturedRoot.dev !== String(pendingGeneration.dev) ||
1761
+ capturedRoot.ino !== String(pendingGeneration.ino) ||
1762
+ capturedRoot.nlink !== String(pendingGeneration.nlink) ||
1763
+ capturedRoot.mtimeNs !== String(pendingGeneration.mtimeNs) ||
1764
+ capturedRoot.ctimeNs !== String(pendingGeneration.ctimeNs)
1765
+ ) {
1766
+ clearPendingTransitionRelease(pendingKey, pending);
1767
+ return false;
1768
+ }
1769
+ const removed = nativeSessionStateLock().exactRemoveDirectoryTree(nativeTransitionPath, captured.snapshot);
1770
+ if (removed.code === "sharing_violation") return false;
1771
+ if (removed.ok || removed.code === "not_found") {
1772
+ clearPendingTransitionRelease(pendingKey, pending);
1773
+ return true;
1774
+ }
1775
+ if (removed.code === "identity_mismatch") {
1776
+ clearPendingTransitionRelease(pendingKey, pending);
1777
+ return false;
1778
+ }
1779
+ if (
1780
+ removed.code === "cleanup_pending" &&
1781
+ removed.payloadDurable === true &&
1782
+ removed.detachedPath === `${nativeTransitionPath}.removing` &&
1783
+ removed.retainedSuccessorPath === undefined &&
1784
+ removed.retainedUnknownPath === undefined &&
1785
+ removed.retainedPlaceholderPath === undefined
1786
+ ) {
1787
+ clearPendingTransitionRelease(pendingKey, pending);
1788
+ return true;
1789
+ }
1790
+ return false;
1791
+ } catch (error) {
1792
+ if (isTransientLockError(error)) return false;
1793
+ return false;
1794
+ }
1795
+ })();
1796
+ pending.recovery = recovery;
1797
+ try {
1798
+ return await recovery;
1799
+ } finally {
1800
+ if (pending.recovery === recovery) pending.recovery = undefined;
1801
+ }
1802
+ }
1803
+
1804
+ /** Run one pathname transition under an atomic `mkdir`/`rmdir` claim. */
1805
+ async function withLockPathTransition<T>(
1806
+ lockFile: string,
1807
+ transition: () => Promise<T>,
1808
+ quarantineName = lockQuarantineName(),
1809
+ retryBudget?: LockRetryBudget,
1810
+ ): Promise<T> {
1811
+ if (ownerAccessStrategy() === "unsupported")
1812
+ throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
978
1813
  const transitionDir = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
979
1814
  const ownerFile = `${transitionDir}.owner`;
1815
+ const recoveryKey = await transitionRecoveryKey(transitionDir);
980
1816
  const owner = await newLockOwner();
981
- for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
1817
+ const budget = retryBudget ?? lockRetryBudget();
1818
+ for (;;) {
1819
+ if (ownerAccessStrategy() === "unsupported" && fsSync.existsSync(transitionDir))
1820
+ throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
1821
+ if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) {
1822
+ // The claim this process stranded in an earlier failed release is gone;
1823
+ // fall through and retry the mkdir immediately.
1824
+ }
982
1825
  try {
983
- await fs.mkdir(transitionDir);
1826
+ await fs.mkdir(transitionDir, { mode: 0o700 });
1827
+ await fs.chmod(transitionDir, 0o700);
984
1828
  } catch (error) {
985
1829
  const code = (error as NodeJS.ErrnoException).code;
986
- if (code !== "EEXIST" && code !== "EPERM") throw new SessionStateLockUnavailableError(error);
987
- await reclaimStaleTransitionClaim(transitionDir);
988
- await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
1830
+ if (code !== "EEXIST" && !isTransientLockError(error)) throw new SessionStateLockUnavailableError(error);
1831
+ await reclaimStaleTransitionClaim(transitionDir, quarantineName);
1832
+ if (!(await waitForLockRetry(budget)))
1833
+ throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
989
1834
  continue;
990
1835
  }
1836
+ const pendingSetup: PendingTransitionRelease = {
1837
+ phase: "setup",
1838
+ token: owner.token,
1839
+ recoverable: false,
1840
+ };
1841
+ pendingTransitionReleases.set(recoveryKey, pendingSetup);
1842
+ let transitionGeneration: TransitionDirectoryGeneration;
1843
+ try {
1844
+ if (process.platform === "win32") {
1845
+ // Windows has no no-follow directory descriptor through Node's fs
1846
+ // flags. Capture the generation immediately after the exclusive mkdir and
1847
+ // before canonicalization, so a replacement cannot become our authority.
1848
+ const transitionStat = await fs.lstat(transitionDir, { bigint: true });
1849
+ if (!transitionStat.isDirectory()) throw new Error("Transition claim is no longer a directory.");
1850
+ transitionGeneration = transitionGenerationFromStat(transitionStat);
1851
+ pendingSetup.generation = transitionGeneration;
1852
+ await SessionStateLockTestHooks.beforeTransitionSetupLstat?.(transitionDir);
1853
+ pendingSetup.nativePath = await canonicalOwnedTransitionPath(transitionDir);
1854
+ const rebound = await fs.lstat(transitionDir, { bigint: true });
1855
+ if (
1856
+ !rebound.isDirectory() ||
1857
+ !sameTransitionGeneration(transitionGenerationFromStat(rebound), transitionGeneration)
1858
+ )
1859
+ throw new Error("Transition claim changed during physical path capture.");
1860
+ } else {
1861
+ // Retain no-follow authority before the fault seam and before any
1862
+ // recovery pathname lookup. A later lstat cannot distinguish this
1863
+ // claim from a successor that replaced the name after setup failed.
1864
+ pendingSetup.generationHandle = await fs.open(transitionDir, TRANSITION_DIRECTORY_OPEN_FLAGS);
1865
+ transitionGeneration = await captureTransitionDirectoryGenerationFromHandle(pendingSetup.generationHandle);
1866
+ await SessionStateLockTestHooks.beforeTransitionSetupLstat?.(transitionDir);
1867
+ pendingSetup.nativePath = await canonicalOwnedTransitionPath(transitionDir);
1868
+ await pendingSetup.generationHandle.close();
1869
+ pendingSetup.generationHandle = undefined;
1870
+ }
1871
+ pendingSetup.generation ??= transitionGeneration;
1872
+ } catch (error) {
1873
+ pendingSetup.recoverable = true;
1874
+ // If opening the just-created claim itself failed, no descriptor or
1875
+ // generation proves which object the pathname names. Retain the claim
1876
+ // fail-closed rather than spinning recovery against an unproven path.
1877
+ if (!pendingSetup.generationHandle && !pendingSetup.generation)
1878
+ throw new SessionStateLockUnavailableError(error);
1879
+ for (;;) {
1880
+ if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) break;
1881
+ if (!pendingTransitionReleases.has(recoveryKey)) break;
1882
+ if (!(await waitForLockRetry(budget)))
1883
+ throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
1884
+ }
1885
+ throw new SessionStateLockUnavailableError(error);
1886
+ }
1887
+ if (!transitionGeneration)
1888
+ throw new SessionStateLockUnavailableError(new Error("Transition claim generation unavailable."));
991
1889
  let held: LockOwnerSnapshot;
992
1890
  try {
993
- held = await acquireOwnerLock(ownerFile, owner);
1891
+ held = await acquireOwnerLock(ownerFile, owner, quarantineName, failedHeld => {
1892
+ pendingSetup.held = failedHeld;
1893
+ });
994
1894
  } catch (error) {
995
- await fs.rmdir(transitionDir).catch(() => undefined);
1895
+ const pending = pendingTransitionReleases.get(recoveryKey);
1896
+ // `acquireOwnerLock` owns the only descriptor that can authorize
1897
+ // cleanup of a failed create. Once it returns an error that descriptor
1898
+ // is gone; a pathname capture here could be a successor's owner and
1899
+ // must never be handed to exact-unlink as our authority.
1900
+ if (pending) {
1901
+ const rewriteFailure = error as OwnerRewriteFailure;
1902
+ if (
1903
+ rewriteFailure.repairSnapshot &&
1904
+ pending.held &&
1905
+ sameLockOwnerObject(rewriteFailure.repairSnapshot, pending.held)
1906
+ )
1907
+ pending.held = rewriteFailure.repairSnapshot;
1908
+ if (rewriteFailure.repairHandle) {
1909
+ pending.repairHandle = rewriteFailure.repairHandle;
1910
+ pending.repairBytes = rewriteFailure.repairBytes;
1911
+ }
1912
+ pending.recoverable = true;
1913
+ }
1914
+ try {
1915
+ // If no owner pathname exists, setup never established an owner and
1916
+ // this claim can be removed only when its captured directory identity
1917
+ // still matches. A raw rmdir could delete an empty successor claim.
1918
+ const ownerStat = await fs.lstat(ownerFile).catch(error => {
1919
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1920
+ throw error;
1921
+ });
1922
+ if (!ownerStat) {
1923
+ if (
1924
+ ownerAccessStrategy() !== "unsupported" &&
1925
+ pending?.nativePath &&
1926
+ pending.generation &&
1927
+ removeOwnedTransitionClaim(pending.nativePath, pending.generation)
1928
+ ) {
1929
+ clearPendingTransitionRelease(recoveryKey, pending);
1930
+ }
1931
+ }
1932
+ } catch {
1933
+ if (pending) pending.recoverable = true;
1934
+ }
996
1935
  throw error;
997
1936
  }
998
1937
  const outcome = await transition().then(
@@ -1000,8 +1939,41 @@ async function withLockPathTransition<T>(lockFile: string, transition: () => Pro
1000
1939
  error => ({ ok: false as const, error }),
1001
1940
  );
1002
1941
  try {
1003
- await releaseTransitionClaim(transitionDir, ownerFile, held);
1942
+ await releaseTransitionClaim(
1943
+ transitionDir,
1944
+ ownerFile,
1945
+ held,
1946
+ recoveryKey,
1947
+ transitionGeneration!,
1948
+ pendingSetup.nativePath!,
1949
+ );
1004
1950
  } catch (releaseError) {
1951
+ if (ownerAccessStrategy() === "unsupported") {
1952
+ if (outcome.ok)
1953
+ throw new SessionStateLockUnavailableError(
1954
+ new AggregateError([releaseError], "Transition claim cannot be released safely."),
1955
+ );
1956
+ throw new SessionStateLockUnavailableError(
1957
+ new AggregateError([outcome.error, releaseError], "Lock path transition and release both failed."),
1958
+ );
1959
+ }
1960
+ // The release rewrite may itself have succeeded before the claim-dir
1961
+ // removal was denied (transient sharing denial). Recover the stranded
1962
+ // claim in-process without replaying a transition that already succeeded.
1963
+ if (pendingTransitionReleases.has(recoveryKey)) {
1964
+ for (;;) {
1965
+ if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) {
1966
+ if (outcome.ok) return outcome.value;
1967
+ throw outcome.error;
1968
+ }
1969
+ if (!pendingTransitionReleases.has(recoveryKey)) break;
1970
+ if (!(await waitForLockRetry(budget))) break;
1971
+ }
1972
+ }
1973
+ if (outcome.ok)
1974
+ throw new SessionStateLockUnavailableError(
1975
+ new Error("Successful pathname transition could not release its claim."),
1976
+ );
1005
1977
  if (!outcome.ok)
1006
1978
  throw new SessionStateLockUnavailableError(
1007
1979
  new AggregateError([outcome.error, releaseError], "Lock path transition and release both failed."),
@@ -1011,7 +1983,6 @@ async function withLockPathTransition<T>(lockFile: string, transition: () => Pro
1011
1983
  if (!outcome.ok) throw outcome.error;
1012
1984
  return outcome.value;
1013
1985
  }
1014
- throw new SessionStateLockUnavailableError();
1015
1986
  }
1016
1987
 
1017
1988
  /**
@@ -1023,14 +1994,25 @@ async function withLockPathTransition<T>(lockFile: string, transition: () => Pro
1023
1994
  * window, and the identity-bound delete keeps a BASE writer — which takes no claim and
1024
1995
  * just creates the pathname — from having its brand-new lock unlinked.
1025
1996
  */
1026
- async function reclaimStaleRegularLock(lockFile: string): Promise<void> {
1027
- await withLockPathTransition(
1997
+ async function reclaimStaleRegularLock(
1998
+ lockFile: string,
1999
+ quarantineName?: string,
2000
+ retryBudget?: LockRetryBudget,
2001
+ ): Promise<SessionStateLockReclaimResult> {
2002
+ const reserved = quarantineName ?? lockQuarantineName();
2003
+ return await withLockPathTransition(
1028
2004
  lockFile,
1029
2005
  async () =>
1030
- await reclaimStaleOwnerRecord(lockFile, {
1031
- afterInspection: SessionStateLockTestHooks.afterStaleInspection,
1032
- beforeRemoval: SessionStateLockTestHooks.beforeStaleRemoval,
1033
- }),
2006
+ await reclaimStaleOwnerRecord(
2007
+ lockFile,
2008
+ {
2009
+ afterInspection: SessionStateLockTestHooks.afterStaleInspection,
2010
+ beforeRemoval: SessionStateLockTestHooks.beforeStaleRemoval,
2011
+ },
2012
+ reserved,
2013
+ ),
2014
+ reserved,
2015
+ retryBudget,
1034
2016
  );
1035
2017
  }
1036
2018
 
@@ -1062,6 +2044,25 @@ function sameDirectoryTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: Nat
1062
2044
  });
1063
2045
  }
1064
2046
 
2047
+ function legacyDirectoryMatchesVerdict(
2048
+ snapshot: NativeDirectoryTreeSnapshot,
2049
+ identity: GenericFileLockDirIdentity,
2050
+ ): boolean {
2051
+ const info = snapshot.entries.find(entry => entry.relativePath === "info");
2052
+ return (
2053
+ snapshot.rootDev === identity.rootDev &&
2054
+ snapshot.rootIno === identity.rootIno &&
2055
+ info?.kind === "file" &&
2056
+ info.dev === identity.infoDev &&
2057
+ info.ino === identity.infoIno &&
2058
+ info.nlink === identity.infoNlink &&
2059
+ info.size === identity.infoSize &&
2060
+ info.mtimeNs === identity.infoMtimeNs &&
2061
+ info.ctimeNs === identity.infoCtimeNs &&
2062
+ info.sha256 === identity.infoSha256
2063
+ );
2064
+ }
2065
+
1065
2066
  /**
1066
2067
  * Capture the exact tree at `lockDir`, or prove there is nothing there to capture.
1067
2068
  *
@@ -1082,7 +2083,10 @@ function captureLegacyDirectoryTree(
1082
2083
  throw new SessionStateLockUnavailableError(error);
1083
2084
  }
1084
2085
  if (captured.ok && captured.snapshot) return captured.snapshot;
1085
- if (captured.code === "not_found") return null;
2086
+ if (captured.code === "not_found" || captured.code === "not_directory" || captured.code === "not_a_directory")
2087
+ return null;
2088
+ if (captured.code === "sharing_violation")
2089
+ throw new SessionStateLockUnavailableError(transientNativeResultError(captured.code));
1086
2090
  throw new SessionStateLockUnavailableError(
1087
2091
  new Error(`Legacy lock directory could not be captured: ${captured.code ?? "unknown"}.`),
1088
2092
  );
@@ -1114,54 +2118,103 @@ function captureLegacyDirectoryTree(
1114
2118
  *
1115
2119
  * Nothing creates such a directory at this path anymore.
1116
2120
  */
1117
- async function reclaimStaleDirectoryLock(lockFile: string): Promise<void> {
1118
- await withLockPathTransition(lockFile, async () => {
1119
- const native = nativeSessionStateLock();
1120
- const before = captureLegacyDirectoryTree(native, lockFile);
1121
- if (!before) return;
1122
- const ownerHostId =
1123
- SessionStateLockTestHooks.unqualifiedOwnerIsLocal === true ? undefined : await currentOwnerHostId();
1124
- let stale = await genericFileLockDirIsStale(lockFile, LOCK_STALE_MS, ownerHostId);
1125
- if (!stale && ownerHostId !== undefined)
1126
- stale = await genericFileLockDirIsStale(lockFile, LOCK_STALE_MS, await currentLegacyOwnerHostId());
1127
- if (!stale) return;
1128
- await SessionStateLockTestHooks.afterLegacyDirectoryStaleVerdict?.(lockFile);
1129
- const authorized = captureLegacyDirectoryTree(native, lockFile);
1130
- // The verdict spoke for `before`; only an unchanged tree carries that authority.
1131
- if (!authorized || !sameDirectoryTreeSnapshot(before, authorized)) return;
1132
- await SessionStateLockTestHooks.beforeLegacyDirectoryRemoval?.(lockFile);
1133
- let removed: NativeExactUnlinkResult;
1134
- try {
1135
- // The SAME verified capture the verdict was bound to, so a replacement that
1136
- // lands after this point is still refused by the primitive itself.
1137
- removed = native.exactRemoveDirectoryTree(lockFile, authorized);
1138
- } catch (error) {
1139
- throw new SessionStateLockUnavailableError(error);
1140
- }
1141
- if (removed.ok || removed.code === "not_found") return;
1142
- // Current natives may finish the security-critical phase by durably scrubbing the
1143
- // authorized tree and detaching it to the one replayable `.removing` name. That
1144
- // retained cleanup authority is not a live lock: acquisition may continue only when
1145
- // the typed receipt names exactly that sibling and the original namespace is still
1146
- // absent. A successor already at the lock path remains authoritative and fails closed.
1147
- if (
1148
- removed.code === "cleanup_pending" &&
1149
- removed.payloadDurable === true &&
1150
- removed.detachedPath === `${lockFile}.removing`
1151
- ) {
2121
+ async function reclaimStaleDirectoryLock(
2122
+ lockFile: string,
2123
+ quarantineName?: string,
2124
+ retryBudget?: LockRetryBudget,
2125
+ ): Promise<SessionStateLockReclaimResult> {
2126
+ const reserved = quarantineName ?? lockQuarantineName();
2127
+ return await withLockPathTransition(
2128
+ lockFile,
2129
+ async () => {
2130
+ const native = nativeSessionStateLock();
2131
+ const initial = captureLegacyDirectoryTree(native, lockFile);
2132
+ if (!initial) return "absent_or_changed";
2133
+ const initialRoot = initial.entries.find(entry => entry.relativePath === "");
2134
+ if (!initialRoot) return "legacy_directory_unprovenanced";
2135
+ if (initial.entries.length === 1) {
2136
+ if (Date.now() - Number(BigInt(initialRoot.mtimeNs) / 1_000_000n) < LOCK_STALE_MS)
2137
+ return "owner_record_fresh";
2138
+ await SessionStateLockTestHooks.afterLegacyDirectoryStaleVerdict?.(lockFile);
2139
+ const authorized = captureLegacyDirectoryTree(native, lockFile);
2140
+ if (!authorized || !sameDirectoryTreeSnapshot(initial, authorized)) return "absent_or_changed";
2141
+ await SessionStateLockTestHooks.beforeLegacyDirectoryRemoval?.(lockFile);
2142
+ let removed: NativeExactUnlinkResult;
2143
+ try {
2144
+ removed = native.exactRemoveDirectoryTree(lockFile, authorized);
2145
+ } catch (error) {
2146
+ throw new SessionStateLockUnavailableError(error);
2147
+ }
2148
+ if (removed.ok || removed.code === "not_found") return "reclaimed";
2149
+ if (removed.code === "identity_mismatch") return "absent_or_changed";
2150
+ if (
2151
+ removed.code === "cleanup_pending" &&
2152
+ removed.payloadDurable === true &&
2153
+ removed.detachedPath === `${lockFile}.removing` &&
2154
+ removed.retainedSuccessorPath === undefined &&
2155
+ removed.retainedUnknownPath === undefined &&
2156
+ removed.retainedPlaceholderPath === undefined
2157
+ ) {
2158
+ try {
2159
+ await fs.lstat(lockFile);
2160
+ } catch (error) {
2161
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "reclaimed";
2162
+ throw new SessionStateLockUnavailableError(error);
2163
+ }
2164
+ }
2165
+ throw new SessionStateLockUnavailableError(
2166
+ new Error(`Empty legacy lock directory could not be removed: ${removed.code ?? "unknown"}.`),
2167
+ );
2168
+ }
2169
+ const ownerHostId =
2170
+ SessionStateLockTestHooks.unqualifiedOwnerIsLocal === true ? undefined : await currentOwnerHostId();
2171
+ let verdict = await genericFileLockDirStaleVerdict(lockFile, LOCK_STALE_MS, ownerHostId);
2172
+ if (!verdict.stale && ownerHostId !== undefined)
2173
+ verdict = await genericFileLockDirStaleVerdict(lockFile, LOCK_STALE_MS, await currentLegacyOwnerHostId());
2174
+ if (!verdict.stale)
2175
+ return (await readFileLockObservationForGc(lockFile))
2176
+ ? "owner_live_or_unverifiable"
2177
+ : "legacy_directory_unprovenanced";
2178
+ const before = captureLegacyDirectoryTree(native, lockFile);
2179
+ if (!before || !legacyDirectoryMatchesVerdict(before, verdict.identity)) return "absent_or_changed";
2180
+ await SessionStateLockTestHooks.afterLegacyDirectoryStaleVerdict?.(lockFile);
2181
+ const authorized = captureLegacyDirectoryTree(native, lockFile);
2182
+ if (
2183
+ !authorized ||
2184
+ !legacyDirectoryMatchesVerdict(authorized, verdict.identity) ||
2185
+ !sameDirectoryTreeSnapshot(before, authorized)
2186
+ )
2187
+ return "absent_or_changed";
2188
+ await SessionStateLockTestHooks.beforeLegacyDirectoryRemoval?.(lockFile);
2189
+ let removed: NativeExactUnlinkResult;
1152
2190
  try {
1153
- await fs.lstat(lockFile);
2191
+ removed = native.exactRemoveDirectoryTree(lockFile, authorized);
1154
2192
  } catch (error) {
1155
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1156
2193
  throw new SessionStateLockUnavailableError(error);
1157
2194
  }
1158
- }
1159
- // The tree changed after it was captured, so it belongs to a successor now.
1160
- if (removed.code === "identity_mismatch") return;
1161
- throw new SessionStateLockUnavailableError(
1162
- new Error(`Legacy lock directory could not be removed: ${removed.code ?? "unknown"}.`),
1163
- );
1164
- });
2195
+ if (removed.ok || removed.code === "not_found") return "reclaimed";
2196
+ if (
2197
+ removed.code === "cleanup_pending" &&
2198
+ removed.payloadDurable === true &&
2199
+ removed.detachedPath === `${lockFile}.removing`
2200
+ ) {
2201
+ try {
2202
+ await fs.lstat(lockFile);
2203
+ } catch (error) {
2204
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "reclaimed";
2205
+ throw new SessionStateLockUnavailableError(error);
2206
+ }
2207
+ }
2208
+ if (removed.code === "sharing_violation")
2209
+ throw new SessionStateLockUnavailableError(transientNativeResultError(removed.code));
2210
+ if (removed.code === "identity_mismatch") return "absent_or_changed";
2211
+ throw new SessionStateLockUnavailableError(
2212
+ new Error(`Legacy lock directory could not be removed: ${removed.code ?? "unknown"}.`),
2213
+ );
2214
+ },
2215
+ reserved,
2216
+ retryBudget,
2217
+ );
1165
2218
  }
1166
2219
 
1167
2220
  /**
@@ -1170,24 +2223,36 @@ async function reclaimStaleDirectoryLock(lockFile: string): Promise<void> {
1170
2223
  * @internal exported as the seam these decisions are tested through: a live legacy
1171
2224
  * directory owner must be provably left alone without waiting out a real stale window.
1172
2225
  */
1173
- export async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
2226
+ async function reclaimStaleSessionStateLockWithinBudget(
2227
+ lockFile: string,
2228
+ quarantineName: string | undefined,
2229
+ retryBudget: LockRetryBudget,
2230
+ ): Promise<SessionStateLockReclaimResult> {
1174
2231
  let stat: fsSync.Stats;
1175
2232
  try {
1176
2233
  stat = await fs.lstat(lockFile);
1177
2234
  } catch (error) {
1178
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
2235
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent_or_changed";
1179
2236
  throw error;
1180
2237
  }
1181
2238
  if (stat.isDirectory()) {
1182
- await reclaimStaleDirectoryLock(lockFile);
1183
- return;
2239
+ return await reclaimStaleDirectoryLock(lockFile, quarantineName, retryBudget);
1184
2240
  }
1185
2241
  // A symlink, FIFO, socket, or device is not a shape either lock protocol writes.
1186
2242
  // Opening one follows an attacker-chosen target and a FIFO read blocks forever, so the
1187
2243
  // path is refused outright rather than inspected or removed.
1188
- if (!stat.isFile()) throw new SessionStateLockUnavailableError();
2244
+ if (!stat.isFile()) throw lockUnavailable(lockFile, "unsafe_lock_path_type");
1189
2245
  await SessionStateLockTestHooks.afterLockTypeDecision?.(lockFile);
1190
- await reclaimStaleRegularLock(lockFile);
2246
+ return await reclaimStaleRegularLock(lockFile, quarantineName, retryBudget);
2247
+ }
2248
+
2249
+ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineName?: string): Promise<void> {
2250
+ const budget = lockRetryBudget();
2251
+ try {
2252
+ await reclaimStaleSessionStateLockWithinBudget(lockFile, quarantineName, budget);
2253
+ } catch (error) {
2254
+ throw lockDiagnostic(error, lockFile, "lock_inspection_failed", budget);
2255
+ }
1191
2256
  }
1192
2257
 
1193
2258
  /**
@@ -1200,15 +2265,31 @@ export async function reclaimStaleSessionStateLock(lockFile: string): Promise<vo
1200
2265
  */
1201
2266
  export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1202
2267
  const lockFile = `${stateFile}.lock`;
1203
- const owner = await newLockOwner();
1204
- await fs.mkdir(path.dirname(stateFile), { recursive: true });
1205
- for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
2268
+ const budget = lockRetryBudget();
2269
+ let owner: SessionStateLockOwner;
2270
+ try {
2271
+ owner = await newLockOwner();
2272
+ await ensureSessionStateParent(path.dirname(stateFile));
2273
+ } catch (error) {
2274
+ const cause = error instanceof SessionStateLockUnavailableError ? (error.cause ?? error) : error;
2275
+ throw lockUnavailable(lockFile, "lock_initialization_failed", budget, cause);
2276
+ }
2277
+ const cycleQuarantine = lockQuarantineName();
2278
+ let lastReason: SessionStateLockUnavailableReason = "acquire_timeout";
2279
+ for (;;) {
1206
2280
  let held: LockOwnerSnapshot | undefined;
2281
+ let callbackFailure = false;
2282
+ let callbackError: unknown;
1207
2283
  try {
1208
2284
  // Contention (`EEXIST`, or `EISDIR`/`EPERM` for a legacy directory owner)
1209
2285
  // propagates out of the claim to the evaluation below; the claim is released
1210
2286
  // first, so the reclaim that follows can take it.
1211
- held = await withLockPathTransition(lockFile, () => acquireOwnerLock(lockFile, owner));
2287
+ held = await withLockPathTransition(
2288
+ lockFile,
2289
+ () => acquireOwnerLock(lockFile, owner, cycleQuarantine),
2290
+ cycleQuarantine,
2291
+ budget,
2292
+ );
1212
2293
  let outcome: { ok: true; value: T } | { ok: false; error: unknown };
1213
2294
  try {
1214
2295
  outcome = { ok: true, value: await operation() };
@@ -1220,7 +2301,7 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
1220
2301
  // is no longer ours is left for its owner rather than unlinked by name.
1221
2302
  let releaseFailure: { error: unknown } | undefined;
1222
2303
  try {
1223
- await withLockPathTransition(lockFile, async () => releaseOwnerLock(lockFile, record));
2304
+ await withLockPathTransition(lockFile, async () => releaseOwnerLock(lockFile, record), cycleQuarantine);
1224
2305
  } catch (error) {
1225
2306
  releaseFailure = { error };
1226
2307
  }
@@ -1230,23 +2311,43 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
1230
2311
  [outcome.error, releaseFailure.error],
1231
2312
  "Session state operation failed and its owner record could not be released.",
1232
2313
  );
2314
+ callbackFailure = true;
2315
+ callbackError = outcome.error;
1233
2316
  throw outcome.error;
1234
2317
  }
1235
2318
  if (releaseFailure) throw releaseFailure.error;
1236
2319
  return outcome.value;
1237
2320
  } catch (error) {
2321
+ if (callbackFailure && error === callbackError) throw error;
1238
2322
  // A fault after the lock was taken belongs to the operation, not to acquisition.
1239
- if (held) throw error;
2323
+ if (held) throw lockDiagnostic(error, lockFile, "lock_release_failed");
2324
+ // Without a safe owner-record access strategy, retrying cannot make the
2325
+ // transition claim removable. Preserve it as a fail-closed fence instead
2326
+ // of spinning until the acquisition budget expires.
2327
+ if (ownerAccessStrategy() === "unsupported")
2328
+ throw lockDiagnostic(error, lockFile, "lock_inspection_failed", budget);
1240
2329
  // A legacy `<file>.lock/` directory reports EISDIR (EPERM on some platforms);
1241
2330
  // both are contention to be evaluated, not a hard failure.
1242
2331
  const code = (error as NodeJS.ErrnoException).code;
1243
- if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM")
1244
- throw error instanceof SessionStateLockUnavailableError
1245
- ? error
1246
- : new SessionStateLockUnavailableError(error);
1247
- await reclaimStaleSessionStateLock(lockFile);
1248
- await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
2332
+ if (code !== "EEXIST" && code !== "EISDIR" && !isTransientLockError(error))
2333
+ throw lockDiagnostic(
2334
+ error instanceof SessionStateLockUnavailableError ? error : new SessionStateLockUnavailableError(error),
2335
+ lockFile,
2336
+ "lock_inspection_failed",
2337
+ budget,
2338
+ );
2339
+ const reclaim = await reclaimStaleSessionStateLockWithinBudget(lockFile, cycleQuarantine, budget).catch(
2340
+ error => {
2341
+ throw lockDiagnostic(error, lockFile, "lock_inspection_failed", budget);
2342
+ },
2343
+ );
2344
+ if (reclaim === "owner_unprovenanced")
2345
+ throw lockUnavailable(lockFile, "lock_owner_record_unprovenanced", budget);
2346
+ if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
2347
+ else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
2348
+ else if (reclaim === "owner_record_fresh") lastReason = "lock_owner_record_fresh";
2349
+ SessionStateLockTestHooks.afterAcquireContention?.(lockFile, budget.attempts + 1, lockRetryElapsedMs(budget));
2350
+ if (!(await waitForLockRetry(budget))) throw lockUnavailable(lockFile, lastReason, budget);
1249
2351
  }
1250
2352
  }
1251
- throw new SessionStateLockUnavailableError();
1252
2353
  }