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