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