@gajae-code/coding-agent 0.15.5 → 0.16.0

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