@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
package/src/config/file-lock.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
-
import type {
|
|
2
|
+
import type { BigIntStats } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import type { NativeDirectoryTreeResult, NativeExactUnlinkResult, NativeNoReplaceResult } from "@gajae-code/natives";
|
|
6
|
+
import { renameDirectoryNoReplacePathAsync, renameNoReplacePathAsync } from "@gajae-code/natives";
|
|
7
|
+
import { isEnoent } from "@gajae-code/utils/fs-error";
|
|
6
8
|
import { nativeProcessBindings } from "@gajae-code/utils/native-process";
|
|
7
9
|
|
|
8
10
|
export interface FileLockOptions {
|
|
@@ -25,10 +27,33 @@ const DEFAULT_OPTIONS: Required<
|
|
|
25
27
|
retryDelayMs: 100,
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
/** Release retries cover transient Windows/Dropbox handle denial without extending the lock indefinitely. */
|
|
31
|
+
export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 5;
|
|
32
|
+
export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 10;
|
|
33
|
+
const PROCESS_START_TIME_FORMAT = "utc-v1";
|
|
34
|
+
|
|
35
|
+
type LocalLockState = {
|
|
36
|
+
owner: FileLockOwnerToken;
|
|
37
|
+
status: "held" | "release_pending" | "releasing";
|
|
38
|
+
releasePromise?: Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Process-local ownership is deliberately separate from PID liveness. A PID only says
|
|
43
|
+
* that a process exists; this table says which exact acquisition generation this process
|
|
44
|
+
* created, so a nested contender cannot steal a lock from a still-running holder.
|
|
45
|
+
*/
|
|
46
|
+
const localLockStates = new Map<string, LocalLockState>();
|
|
47
|
+
|
|
28
48
|
type LockInfo = FileLockOwnerToken;
|
|
29
49
|
|
|
30
50
|
export const FileLockTestHooks: {
|
|
31
51
|
afterParentMkdir?: (lockPath: string) => void | Promise<void>;
|
|
52
|
+
nativePublicationBindings?: () => {
|
|
53
|
+
renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
|
|
54
|
+
renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
|
|
55
|
+
};
|
|
56
|
+
nativeQuarantineBindings?: () => NativeFileLockBindings;
|
|
32
57
|
} = {};
|
|
33
58
|
|
|
34
59
|
/**
|
|
@@ -51,7 +76,11 @@ export function processStartTime(pid: number): string | null {
|
|
|
51
76
|
}
|
|
52
77
|
}
|
|
53
78
|
try {
|
|
54
|
-
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
79
|
+
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
80
|
+
stdout: "pipe",
|
|
81
|
+
stderr: "ignore",
|
|
82
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" },
|
|
83
|
+
});
|
|
55
84
|
if (result.exitCode !== 0) return null;
|
|
56
85
|
const startTime = new TextDecoder().decode(result.stdout).trim();
|
|
57
86
|
return startTime || null;
|
|
@@ -79,35 +108,215 @@ function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, s
|
|
|
79
108
|
|
|
80
109
|
function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
|
|
81
110
|
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
82
|
-
if (!owner.start_time) return true;
|
|
111
|
+
if (!owner.start_time || owner.start_time === "unknown") return true;
|
|
83
112
|
const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
|
|
84
|
-
|
|
113
|
+
if (currentStartTime === null || currentStartTime === owner.start_time) return true;
|
|
114
|
+
// A start-time mismatch proves PID reuse only for records that identify the
|
|
115
|
+
// canonical UTC encoding. Legacy records did not identify their timestamp format,
|
|
116
|
+
// so a locale/timezone change can make a live holder look different and must never
|
|
117
|
+
// authorize its removal.
|
|
118
|
+
return owner.start_time_format !== PROCESS_START_TIME_FORMAT;
|
|
85
119
|
}
|
|
86
120
|
|
|
87
|
-
function lockInfo(ownerHostId
|
|
121
|
+
function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
|
|
88
122
|
return {
|
|
89
123
|
pid: process.pid,
|
|
90
124
|
start_time: currentProcessStartTime(),
|
|
125
|
+
start_time_format: PROCESS_START_TIME_FORMAT,
|
|
91
126
|
timestamp: Date.now(),
|
|
127
|
+
owner_token: ownerToken,
|
|
92
128
|
...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
|
|
93
129
|
};
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
function writeLockInfo(lockPath: string, info: LockInfo): Promise<LockInfo> {
|
|
97
|
-
|
|
133
|
+
// Owner metadata must stay readable by its own process under a restrictive
|
|
134
|
+
// umask: release re-reads this record to authorize removal, and an info file
|
|
135
|
+
// born mode 000 under umask 0777 would wedge the lock at first release.
|
|
136
|
+
return Bun.write(`${lockPath}/info`, JSON.stringify(info), { mode: 0o600 })
|
|
137
|
+
.then(() => fs.chmod(`${lockPath}/info`, 0o600))
|
|
138
|
+
.then(() => info);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type LockInfoFileState = {
|
|
142
|
+
dev: bigint;
|
|
143
|
+
ino: bigint;
|
|
144
|
+
mode: bigint;
|
|
145
|
+
size: bigint;
|
|
146
|
+
mtimeNs: bigint;
|
|
147
|
+
ctimeNs: bigint;
|
|
148
|
+
nlink: bigint;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
type LockInfoPathState = {
|
|
152
|
+
root: {
|
|
153
|
+
dev: bigint;
|
|
154
|
+
ino: bigint;
|
|
155
|
+
mode: bigint;
|
|
156
|
+
};
|
|
157
|
+
file: LockInfoFileState;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
|
|
161
|
+
if (stats.isSymbolicLink() || !stats.isFile()) return null;
|
|
162
|
+
return {
|
|
163
|
+
dev: stats.dev,
|
|
164
|
+
ino: stats.ino,
|
|
165
|
+
mode: stats.mode,
|
|
166
|
+
size: stats.size,
|
|
167
|
+
mtimeNs: stats.mtimeNs,
|
|
168
|
+
ctimeNs: stats.ctimeNs,
|
|
169
|
+
nlink: stats.nlink,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sameLockInfoFileState(left: LockInfoFileState, right: LockInfoFileState): boolean {
|
|
174
|
+
return (
|
|
175
|
+
left.dev === right.dev &&
|
|
176
|
+
left.ino === right.ino &&
|
|
177
|
+
left.mode === right.mode &&
|
|
178
|
+
left.size === right.size &&
|
|
179
|
+
left.mtimeNs === right.mtimeNs &&
|
|
180
|
+
left.ctimeNs === right.ctimeNs &&
|
|
181
|
+
left.nlink === right.nlink
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function lockInfoPathState(lockPath: string): Promise<LockInfoPathState | null> {
|
|
186
|
+
let root: BigIntStats;
|
|
187
|
+
try {
|
|
188
|
+
root = await fs.lstat(lockPath, { bigint: true });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (isEnoent(error)) return null;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
if (root.isSymbolicLink() || !root.isDirectory()) return null;
|
|
194
|
+
|
|
195
|
+
let info: BigIntStats;
|
|
196
|
+
try {
|
|
197
|
+
info = await fs.lstat(path.join(lockPath, "info"), { bigint: true });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isEnoent(error)) return null;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
const file = lockInfoFileState(info);
|
|
203
|
+
if (!file) return null;
|
|
204
|
+
return {
|
|
205
|
+
root: { dev: root.dev, ino: root.ino, mode: root.mode },
|
|
206
|
+
file,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sameLockInfoPathState(left: LockInfoPathState, right: LockInfoPathState): boolean {
|
|
211
|
+
return (
|
|
212
|
+
left.root.dev === right.root.dev &&
|
|
213
|
+
left.root.ino === right.root.ino &&
|
|
214
|
+
left.root.mode === right.root.mode &&
|
|
215
|
+
sameLockInfoFileState(left.file, right.file)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function fileLockDirIdentityFromPathState(state: LockInfoPathState, bytes: string): GenericFileLockDirIdentity {
|
|
220
|
+
return {
|
|
221
|
+
rootDev: String(state.root.dev),
|
|
222
|
+
rootIno: String(state.root.ino),
|
|
223
|
+
infoDev: String(state.file.dev),
|
|
224
|
+
infoIno: String(state.file.ino),
|
|
225
|
+
infoNlink: String(state.file.nlink),
|
|
226
|
+
infoSize: String(state.file.size),
|
|
227
|
+
infoMtimeNs: String(state.file.mtimeNs),
|
|
228
|
+
infoCtimeNs: String(state.file.ctimeNs),
|
|
229
|
+
infoSha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Capture the exact root/info identity that a later stale verdict may authorize. */
|
|
234
|
+
async function captureFileLockDirIdentity(lockDir: string): Promise<GenericFileLockDirIdentity | null> {
|
|
235
|
+
const observation = await readLockInfoObservation(lockDir);
|
|
236
|
+
return observation ? fileLockDirIdentityFromPathState(observation.state, observation.bytes) : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve parent aliases without following the mutable lock-dir final component. */
|
|
240
|
+
async function canonicalLockPathPreservingFinal(lockPath: string): Promise<string> {
|
|
241
|
+
const parent = path.dirname(lockPath);
|
|
242
|
+
try {
|
|
243
|
+
return path.join(await fs.realpath(parent), path.basename(lockPath));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
|
|
246
|
+
return lockPath;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeLockKey(lockPath: string): string {
|
|
251
|
+
return path.normalize(lockPath);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const LOCK_INFO_OPEN_FLAGS =
|
|
255
|
+
fs.constants.O_RDONLY |
|
|
256
|
+
(process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read metadata from the exact regular file that was validated, never through a
|
|
260
|
+
* pathname after the descriptor is opened. The no-follow flag prevents final
|
|
261
|
+
* component symlinks on POSIX; lstat/fstat/path revalidation supplies the same
|
|
262
|
+
* rejection on Windows, where O_NOFOLLOW is unavailable.
|
|
263
|
+
*/
|
|
264
|
+
type LockInfoObservation = { bytes: string; state: LockInfoPathState };
|
|
265
|
+
|
|
266
|
+
async function readLockInfoObservation(lockPath: string): Promise<LockInfoObservation | null> {
|
|
267
|
+
const infoPath = path.join(lockPath, "info");
|
|
268
|
+
const initial = await lockInfoPathState(lockPath);
|
|
269
|
+
if (!initial) return null;
|
|
270
|
+
|
|
271
|
+
let handle: fs.FileHandle | undefined;
|
|
272
|
+
try {
|
|
273
|
+
handle = await fs.open(infoPath, LOCK_INFO_OPEN_FLAGS);
|
|
274
|
+
const opened = lockInfoFileState(await handle.stat({ bigint: true }));
|
|
275
|
+
const beforeRead = await lockInfoPathState(lockPath);
|
|
276
|
+
if (
|
|
277
|
+
!opened ||
|
|
278
|
+
!beforeRead ||
|
|
279
|
+
!sameLockInfoFileState(initial.file, opened) ||
|
|
280
|
+
!sameLockInfoPathState(initial, beforeRead)
|
|
281
|
+
)
|
|
282
|
+
return null;
|
|
283
|
+
|
|
284
|
+
const bytes = await handle.readFile();
|
|
285
|
+
const afterRead = lockInfoFileState(await handle.stat({ bigint: true }));
|
|
286
|
+
const afterPath = await lockInfoPathState(lockPath);
|
|
287
|
+
if (
|
|
288
|
+
!afterRead ||
|
|
289
|
+
!afterPath ||
|
|
290
|
+
!sameLockInfoFileState(initial.file, afterRead) ||
|
|
291
|
+
!sameLockInfoPathState(initial, afterPath)
|
|
292
|
+
)
|
|
293
|
+
return null;
|
|
294
|
+
return { bytes: bytes.toString("utf8"), state: afterPath };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (isEnoent(error)) return null;
|
|
297
|
+
throw error;
|
|
298
|
+
} finally {
|
|
299
|
+
await handle?.close().catch(() => undefined);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function readLockInfoBytes(lockPath: string): Promise<string | null> {
|
|
304
|
+
return (await readLockInfoObservation(lockPath))?.bytes ?? null;
|
|
98
305
|
}
|
|
99
306
|
|
|
100
307
|
async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
101
308
|
let parsed: unknown;
|
|
102
309
|
try {
|
|
103
|
-
|
|
310
|
+
const bytes = await readLockInfoBytes(lockPath);
|
|
311
|
+
if (bytes === null) return null;
|
|
312
|
+
parsed = JSON.parse(bytes);
|
|
104
313
|
} catch (error) {
|
|
105
314
|
if (isEnoent(error) || error instanceof SyntaxError) return null;
|
|
106
315
|
throw error;
|
|
107
316
|
}
|
|
108
317
|
|
|
109
318
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
110
|
-
const { pid, start_time, timestamp, owner_host_id } = parsed as Partial<LockInfo>;
|
|
319
|
+
const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
|
|
111
320
|
if (
|
|
112
321
|
typeof pid !== "number" ||
|
|
113
322
|
!Number.isInteger(pid) ||
|
|
@@ -115,43 +324,282 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
|
115
324
|
typeof timestamp !== "number" ||
|
|
116
325
|
!Number.isFinite(timestamp) ||
|
|
117
326
|
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
|
|
118
|
-
(
|
|
327
|
+
(start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
|
|
328
|
+
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
|
|
329
|
+
(owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
|
|
119
330
|
)
|
|
120
331
|
return null;
|
|
121
|
-
return { pid, start_time, timestamp, owner_host_id };
|
|
332
|
+
return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function parseLockInfoBytes(bytes: string): LockInfo | null {
|
|
336
|
+
let parsed: unknown;
|
|
337
|
+
try {
|
|
338
|
+
parsed = JSON.parse(bytes);
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
343
|
+
const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
|
|
344
|
+
if (
|
|
345
|
+
typeof pid !== "number" ||
|
|
346
|
+
!Number.isInteger(pid) ||
|
|
347
|
+
pid <= 0 ||
|
|
348
|
+
typeof timestamp !== "number" ||
|
|
349
|
+
!Number.isFinite(timestamp) ||
|
|
350
|
+
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
|
|
351
|
+
(start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
|
|
352
|
+
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
|
|
353
|
+
(owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
|
|
354
|
+
)
|
|
355
|
+
return null;
|
|
356
|
+
return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @internal */
|
|
360
|
+
export interface FileLockGcObservation {
|
|
361
|
+
info: FileLockOwnerToken;
|
|
362
|
+
bytes: string;
|
|
363
|
+
identity: GenericFileLockDirIdentity;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Capture owner bytes and the identity proving those exact bytes came from this tree. */
|
|
367
|
+
export async function readFileLockObservationForGc(lockDir: string): Promise<FileLockGcObservation | null> {
|
|
368
|
+
const observation = await readLockInfoObservation(lockDir);
|
|
369
|
+
if (!observation) return null;
|
|
370
|
+
const info = parseLockInfoBytes(observation.bytes);
|
|
371
|
+
if (!info) return null;
|
|
372
|
+
const identity = fileLockDirIdentityFromPathState(observation.state, observation.bytes);
|
|
373
|
+
fileLockDirIdentities.set(info, identity);
|
|
374
|
+
return { info, bytes: observation.bytes, identity };
|
|
122
375
|
}
|
|
123
376
|
|
|
124
377
|
/** @internal */
|
|
125
378
|
export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOwnerToken | null> {
|
|
126
|
-
return await
|
|
379
|
+
return (await readFileLockObservationForGc(lockDir))?.info ?? null;
|
|
127
380
|
}
|
|
128
381
|
|
|
129
382
|
/** Owner identity stamped into a `<file>.lock/info` record. */
|
|
130
383
|
export interface FileLockOwnerToken {
|
|
131
384
|
pid: number;
|
|
132
385
|
start_time?: string;
|
|
386
|
+
/** Encoding marker for the canonical UTC process-start identity. */
|
|
387
|
+
start_time_format?: string;
|
|
133
388
|
owner_host_id?: string;
|
|
389
|
+
/** Unique acquisition generation, present on locks created by this runtime. */
|
|
390
|
+
owner_token?: string;
|
|
134
391
|
timestamp: number;
|
|
135
392
|
}
|
|
136
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Identity captured before a stale/liveness verdict. Kept out of the owner JSON: it is
|
|
396
|
+
* authorization evidence for the in-memory call that produced the verdict, not metadata
|
|
397
|
+
* another process may copy into a new lock generation.
|
|
398
|
+
*/
|
|
399
|
+
const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
|
|
400
|
+
|
|
137
401
|
function getLockPath(filePath: string): string {
|
|
138
402
|
return `${filePath}.lock`;
|
|
139
403
|
}
|
|
140
404
|
|
|
141
|
-
|
|
142
|
-
|
|
405
|
+
async function ensureLockParent(directory: string): Promise<void> {
|
|
406
|
+
const missing: string[] = [];
|
|
407
|
+
let current = path.resolve(directory);
|
|
408
|
+
for (;;) {
|
|
409
|
+
try {
|
|
410
|
+
await fs.lstat(current);
|
|
411
|
+
break;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (!isEnoent(error)) throw error;
|
|
414
|
+
missing.push(current);
|
|
415
|
+
const parent = path.dirname(current);
|
|
416
|
+
if (parent === current) throw error;
|
|
417
|
+
current = parent;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const created of missing.reverse()) {
|
|
421
|
+
try {
|
|
422
|
+
await fs.mkdir(created, { mode: 0o700 });
|
|
423
|
+
await fs.chmod(created, 0o700);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
143
429
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
430
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
431
|
+
return (
|
|
432
|
+
typeof value === "object" &&
|
|
433
|
+
value !== null &&
|
|
434
|
+
!Array.isArray(value) &&
|
|
435
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
436
|
+
);
|
|
149
437
|
}
|
|
150
438
|
|
|
439
|
+
function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceResult {
|
|
440
|
+
if (!isPlainRecord(value)) return false;
|
|
441
|
+
const expectedKeys = [
|
|
442
|
+
"ok",
|
|
443
|
+
"code",
|
|
444
|
+
"mutationState",
|
|
445
|
+
"durabilityState",
|
|
446
|
+
"reason",
|
|
447
|
+
"primitive",
|
|
448
|
+
"phase",
|
|
449
|
+
"diagnostic",
|
|
450
|
+
];
|
|
451
|
+
if (Object.keys(value).some(key => !expectedKeys.includes(key))) return false;
|
|
452
|
+
if (
|
|
453
|
+
typeof value.ok !== "boolean" ||
|
|
454
|
+
(value.code !== undefined && (typeof value.code !== "string" || !/^[a-z0-9_]{1,64}$/.test(value.code))) ||
|
|
455
|
+
!(["not_committed", "committed", "unknown"] as const).includes(value.mutationState as never) ||
|
|
456
|
+
!(["not_attempted", "proven", "not_provable"] as const).includes(value.durabilityState as never) ||
|
|
457
|
+
!(
|
|
458
|
+
[
|
|
459
|
+
"none",
|
|
460
|
+
"destination_exists",
|
|
461
|
+
"atomic_unavailable",
|
|
462
|
+
"cross_device",
|
|
463
|
+
"permission_denied",
|
|
464
|
+
"io_failure",
|
|
465
|
+
"invalid_request",
|
|
466
|
+
"interrupted",
|
|
467
|
+
"identity_violation",
|
|
468
|
+
"durability_not_provable",
|
|
469
|
+
"unknown",
|
|
470
|
+
] as const
|
|
471
|
+
).includes(value.reason as never) ||
|
|
472
|
+
!(
|
|
473
|
+
[
|
|
474
|
+
"renameat2_noreplace",
|
|
475
|
+
"linkat_noreplace",
|
|
476
|
+
"mkdirat_renameat_noreplace",
|
|
477
|
+
"renameatx_np_excl",
|
|
478
|
+
"windows_rename_noreplace",
|
|
479
|
+
"unsupported",
|
|
480
|
+
"unknown",
|
|
481
|
+
] as const
|
|
482
|
+
).includes(value.primitive as never) ||
|
|
483
|
+
!(
|
|
484
|
+
[
|
|
485
|
+
"preflight",
|
|
486
|
+
"file_sync",
|
|
487
|
+
"rename",
|
|
488
|
+
"source_unlink",
|
|
489
|
+
"source_parent_sync",
|
|
490
|
+
"destination_parent_sync",
|
|
491
|
+
"terminal_identity",
|
|
492
|
+
"complete",
|
|
493
|
+
"unknown",
|
|
494
|
+
] as const
|
|
495
|
+
).includes(value.phase as never)
|
|
496
|
+
)
|
|
497
|
+
return false;
|
|
498
|
+
if (!isPlainRecord(value.diagnostic)) return false;
|
|
499
|
+
if (
|
|
500
|
+
Object.keys(value.diagnostic).some(
|
|
501
|
+
key => !["schemaVersion", "collectionState", "osCode", "syncFailures"].includes(key),
|
|
502
|
+
) ||
|
|
503
|
+
value.diagnostic.schemaVersion !== 1 ||
|
|
504
|
+
!(["complete", "partial", "unavailable"] as const).includes(value.diagnostic.collectionState as never) ||
|
|
505
|
+
(value.diagnostic.osCode !== undefined &&
|
|
506
|
+
(typeof value.diagnostic.osCode !== "number" || !Number.isInteger(value.diagnostic.osCode))) ||
|
|
507
|
+
(value.diagnostic.syncFailures !== undefined && !Array.isArray(value.diagnostic.syncFailures))
|
|
508
|
+
)
|
|
509
|
+
return false;
|
|
510
|
+
if (value.ok)
|
|
511
|
+
return (
|
|
512
|
+
value.mutationState === "committed" &&
|
|
513
|
+
value.reason === "none" &&
|
|
514
|
+
value.phase === "complete" &&
|
|
515
|
+
(value.durabilityState === "not_attempted" || value.durabilityState === "proven")
|
|
516
|
+
);
|
|
517
|
+
if (value.mutationState === "not_committed") return value.durabilityState === "not_attempted";
|
|
518
|
+
return value.mutationState === "unknown" && value.durabilityState === "not_provable";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Only a complete native envelope proving that no namespace mutation happened
|
|
523
|
+
* may authorize the directory fallback. A legacy or malformed result is
|
|
524
|
+
* treated as an unknown publication outcome and never followed by another
|
|
525
|
+
* mutating primitive.
|
|
526
|
+
*/
|
|
527
|
+
function isPreMutationUnsupportedRenameResult(value: unknown): value is NativeNoReplaceResult {
|
|
528
|
+
if (!isValidNativeNoReplaceResult(value)) return false;
|
|
529
|
+
if (
|
|
530
|
+
value.ok !== false ||
|
|
531
|
+
(value.code !== "invalid_request" && value.code !== "atomic_unavailable") ||
|
|
532
|
+
value.mutationState !== "not_committed" ||
|
|
533
|
+
value.durabilityState !== "not_attempted" ||
|
|
534
|
+
value.reason !== value.code ||
|
|
535
|
+
(value.primitive !== "renameat2_noreplace" && value.primitive !== "renameatx_np_excl") ||
|
|
536
|
+
(value.phase !== "preflight" && value.phase !== "rename")
|
|
537
|
+
)
|
|
538
|
+
return false;
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function localLockKey(lockPath: string): Promise<string> {
|
|
543
|
+
try {
|
|
544
|
+
return normalizeLockKey(await fs.realpath(lockPath));
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
|
|
547
|
+
}
|
|
548
|
+
const parent = path.dirname(lockPath);
|
|
549
|
+
let canonicalParent: string;
|
|
550
|
+
try {
|
|
551
|
+
canonicalParent = await fs.realpath(parent);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
|
|
554
|
+
canonicalParent = path.resolve(parent);
|
|
555
|
+
}
|
|
556
|
+
const key = path.join(canonicalParent, path.basename(lockPath));
|
|
557
|
+
return normalizeLockKey(key);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
|
|
561
|
+
if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
|
|
562
|
+
return false;
|
|
563
|
+
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
564
|
+
const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
|
|
565
|
+
return currentStartTime !== null && currentStartTime !== owner.start_time;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
|
|
569
|
+
export type FileLockGcRemoval = "removed" | "owner_changed" | "missing" | "cleanup_failed";
|
|
570
|
+
|
|
151
571
|
type LockStaleSnapshot =
|
|
152
572
|
| { stale: false }
|
|
153
|
-
| { stale: true; owner: FileLockOwnerToken }
|
|
154
|
-
|
|
573
|
+
| { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity };
|
|
574
|
+
|
|
575
|
+
/** Identity evidence carried by the generic stale verdict into a later removal. */
|
|
576
|
+
export interface GenericFileLockDirIdentity {
|
|
577
|
+
rootDev: string;
|
|
578
|
+
rootIno: string;
|
|
579
|
+
infoDev: string;
|
|
580
|
+
infoIno: string;
|
|
581
|
+
infoNlink: string;
|
|
582
|
+
infoSize: string;
|
|
583
|
+
infoMtimeNs: string;
|
|
584
|
+
infoCtimeNs: string;
|
|
585
|
+
infoSha256: string;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right: GenericFileLockDirIdentity): boolean {
|
|
589
|
+
return (
|
|
590
|
+
left.rootDev === right.rootDev &&
|
|
591
|
+
left.rootIno === right.rootIno &&
|
|
592
|
+
left.infoDev === right.infoDev &&
|
|
593
|
+
left.infoIno === right.infoIno &&
|
|
594
|
+
left.infoNlink === right.infoNlink &&
|
|
595
|
+
left.infoSize === right.infoSize &&
|
|
596
|
+
left.infoMtimeNs === right.infoMtimeNs &&
|
|
597
|
+
left.infoCtimeNs === right.infoCtimeNs &&
|
|
598
|
+
left.infoSha256 === right.infoSha256
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export type GenericFileLockDirStaleVerdict = { stale: false } | { stale: true; identity: GenericFileLockDirIdentity };
|
|
155
603
|
|
|
156
604
|
/**
|
|
157
605
|
* @internal
|
|
@@ -173,19 +621,109 @@ type LockStaleSnapshot =
|
|
|
173
621
|
export async function removeFileLockDirForGc(
|
|
174
622
|
lockDir: string,
|
|
175
623
|
expected: FileLockOwnerToken,
|
|
624
|
+
preVerdictIdentity?: GenericFileLockDirIdentity,
|
|
176
625
|
): Promise<FileLockGcRemoval> {
|
|
177
|
-
|
|
178
|
-
|
|
626
|
+
// A generic release/quarantine call is only authorized by evidence captured
|
|
627
|
+
// before its stale verdict. Capturing the current pathname here would let a
|
|
628
|
+
// fresh successor inherit an old owner's release authority.
|
|
629
|
+
const expectedIdentity = preVerdictIdentity ?? fileLockDirIdentities.get(expected);
|
|
630
|
+
let onDiskBytes: string | null;
|
|
631
|
+
try {
|
|
632
|
+
onDiskBytes = await readLockInfoBytes(lockDir);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (isEnoent(error)) return "missing";
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
const current = onDiskBytes === null ? null : parseLockInfoBytes(onDiskBytes);
|
|
638
|
+
if (!current || onDiskBytes === null) return "missing";
|
|
639
|
+
if (!expectedIdentity) return "owner_changed";
|
|
179
640
|
if (
|
|
180
641
|
current.pid !== expected.pid ||
|
|
181
642
|
(expected.start_time !== undefined && current.start_time !== expected.start_time) ||
|
|
182
643
|
current.owner_host_id !== expected.owner_host_id ||
|
|
644
|
+
(expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
|
|
183
645
|
current.timestamp !== expected.timestamp
|
|
184
646
|
) {
|
|
185
647
|
return "owner_changed";
|
|
186
648
|
}
|
|
187
|
-
|
|
188
|
-
|
|
649
|
+
// The token comparison above authorizes the content that was judged, not the
|
|
650
|
+
// pathname. When the caller carried pre-verdict root/info identity, require
|
|
651
|
+
// the post-verdict native snapshot to match that same object before removal;
|
|
652
|
+
// a clone or successor can therefore never inherit the stale authorization.
|
|
653
|
+
// The canonical native path may refuse a symlinked parent ("reparse_point");
|
|
654
|
+
// canonicalize first so the identity-bound capture sees the real directory,
|
|
655
|
+
// mirroring how localLockKey canonicalizes the lock pathname.
|
|
656
|
+
let nativeCapturePath = lockDir;
|
|
657
|
+
try {
|
|
658
|
+
nativeCapturePath = await canonicalLockPathPreservingFinal(lockDir);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) return "cleanup_failed";
|
|
661
|
+
}
|
|
662
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
|
|
663
|
+
if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
|
|
664
|
+
if (!captured.ok || !captured.snapshot) return "owner_changed";
|
|
665
|
+
const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
|
|
666
|
+
if (!infoEntry?.sha256) return "owner_changed";
|
|
667
|
+
const judgedDigest = crypto.createHash("sha256").update(onDiskBytes).digest("hex");
|
|
668
|
+
if (infoEntry.sha256 !== judgedDigest) return "owner_changed";
|
|
669
|
+
if (
|
|
670
|
+
!captured.snapshot.rootDev ||
|
|
671
|
+
captured.snapshot.rootDev !== expectedIdentity.rootDev ||
|
|
672
|
+
captured.snapshot.rootIno !== expectedIdentity.rootIno ||
|
|
673
|
+
infoEntry.dev !== expectedIdentity.infoDev ||
|
|
674
|
+
infoEntry.ino !== expectedIdentity.infoIno ||
|
|
675
|
+
infoEntry.nlink !== expectedIdentity.infoNlink ||
|
|
676
|
+
infoEntry.size !== expectedIdentity.infoSize ||
|
|
677
|
+
infoEntry.mtimeNs !== expectedIdentity.infoMtimeNs ||
|
|
678
|
+
infoEntry.ctimeNs !== expectedIdentity.infoCtimeNs ||
|
|
679
|
+
infoEntry.sha256 !== expectedIdentity.infoSha256
|
|
680
|
+
)
|
|
681
|
+
return "owner_changed";
|
|
682
|
+
let removed: NativeExactUnlinkResult;
|
|
683
|
+
try {
|
|
684
|
+
removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
// Keep the #2478 transient retry contract: sharing denials surface with
|
|
687
|
+
// their transient code so callers retry, everything else is a refusal.
|
|
688
|
+
if (isTransientReleaseError(error)) throw error;
|
|
689
|
+
return "cleanup_failed";
|
|
690
|
+
}
|
|
691
|
+
if (removed.ok) return "removed";
|
|
692
|
+
// The canonical name may already be detached: the security-critical phase is
|
|
693
|
+
// done once the verified tree is durably scrubbed and parked under the
|
|
694
|
+
// no-replace quarantine name with no successor retained. Finish that replay
|
|
695
|
+
// deterministically by deleting the retained quarantine — the same completion
|
|
696
|
+
// contract gc-runtime applies to its own exact removals. Any other outcome —
|
|
697
|
+
// including a retained successor or placeholder — leaves the judged object (or
|
|
698
|
+
// its replacement) in place and reports the removal as refused.
|
|
699
|
+
const detachedPath = removed.detachedPath;
|
|
700
|
+
const verifiedDetach =
|
|
701
|
+
detachedPath !== undefined &&
|
|
702
|
+
path.resolve(detachedPath) !== path.resolve(lockDir) &&
|
|
703
|
+
removed.retainedSuccessorPath === undefined &&
|
|
704
|
+
removed.retainedPlaceholderPath === undefined &&
|
|
705
|
+
removed.retainedUnknownPath === undefined;
|
|
706
|
+
if (verifiedDetach && detachedPath !== undefined) {
|
|
707
|
+
// The security-critical phase is done: the verified tree was detached from
|
|
708
|
+
// the canonical name and durably parked under the no-replace quarantine
|
|
709
|
+
// name with no successor retained. Finish that replay deterministically by
|
|
710
|
+
// deleting the retained quarantine — the same completion contract gc-runtime
|
|
711
|
+
// applies to its own exact removals — then report the release as done.
|
|
712
|
+
try {
|
|
713
|
+
await fs.rm(detachedPath, { recursive: true, force: true });
|
|
714
|
+
} catch {
|
|
715
|
+
// The canonical pathname is free either way; a retained quarantine is
|
|
716
|
+
// recoverable debris, not a live lock.
|
|
717
|
+
}
|
|
718
|
+
return "removed";
|
|
719
|
+
}
|
|
720
|
+
if (removed.code === "not_found") return "removed";
|
|
721
|
+
if (removed.code === "identity_mismatch") return "owner_changed";
|
|
722
|
+
const refusal: NodeJS.ErrnoException = new Error(
|
|
723
|
+
`Failed to remove file lock tree: ${removed.code ?? "unknown"}.`,
|
|
724
|
+
) as NodeJS.ErrnoException;
|
|
725
|
+
refusal.code = "EACCES";
|
|
726
|
+
throw refusal;
|
|
189
727
|
}
|
|
190
728
|
|
|
191
729
|
type OwnerLiveness = "alive" | "dead" | "unknown";
|
|
@@ -204,26 +742,22 @@ function ownerLiveness(pid: number): OwnerLiveness {
|
|
|
204
742
|
}
|
|
205
743
|
}
|
|
206
744
|
|
|
207
|
-
function statToken(stats: Stats): LockDirStatToken {
|
|
208
|
-
return {
|
|
209
|
-
dev: stats.dev,
|
|
210
|
-
ino: stats.ino,
|
|
211
|
-
mtimeMs: stats.mtimeMs,
|
|
212
|
-
ctimeMs: stats.ctimeMs,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function sameStatToken(a: LockDirStatToken, b: LockDirStatToken): boolean {
|
|
217
|
-
return a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
745
|
async function staleLockSnapshot(
|
|
221
746
|
lockPath: string,
|
|
222
|
-
|
|
747
|
+
_staleMs: number,
|
|
223
748
|
ownerHostId?: string,
|
|
224
749
|
previousOwnerHostIds: readonly string[] = [],
|
|
225
750
|
startTimeCache?: Map<string, string | null>,
|
|
226
751
|
): Promise<LockStaleSnapshot> {
|
|
752
|
+
// Capture the root and info inode BEFORE asking whether the owner is stale. A later
|
|
753
|
+
// snapshot alone would let a copied successor inherit the stale verdict's authority.
|
|
754
|
+
let judgedIdentity: GenericFileLockDirIdentity | null = null;
|
|
755
|
+
try {
|
|
756
|
+
judgedIdentity = await captureFileLockDirIdentity(lockPath);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
227
761
|
let info: LockInfo | null;
|
|
228
762
|
try {
|
|
229
763
|
info = await readLockInfo(lockPath);
|
|
@@ -231,19 +765,15 @@ async function staleLockSnapshot(
|
|
|
231
765
|
// Windows can transiently deny reads of a just-created lock metadata file
|
|
232
766
|
// while another contender is publishing it. Treat that as active
|
|
233
767
|
// contention and retry rather than failing the caller or reaping by path.
|
|
234
|
-
if (
|
|
768
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
235
769
|
throw error;
|
|
236
770
|
}
|
|
237
|
-
if (!info && ownerHostId !== undefined) return { stale: false };
|
|
238
771
|
if (!info) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (isEnoent(err)) return { stale: false };
|
|
245
|
-
throw err;
|
|
246
|
-
}
|
|
772
|
+
// A directory without a valid owner record is either a contender between
|
|
773
|
+
// native mkdirat ownership and metadata publication, or malformed/foreign
|
|
774
|
+
// state. Neither case carries enough identity evidence for stale removal;
|
|
775
|
+
// elapsed mtime must never make this empty namespace reclaimable.
|
|
776
|
+
return { stale: false };
|
|
247
777
|
}
|
|
248
778
|
|
|
249
779
|
// A host-qualified lock may only be reclaimed after proving that its owner is
|
|
@@ -255,33 +785,44 @@ async function staleLockSnapshot(
|
|
|
255
785
|
!previousOwnerHostIds.includes(info.owner_host_id ?? "")
|
|
256
786
|
)
|
|
257
787
|
return { stale: false };
|
|
788
|
+
if (ownerHostId === undefined && info.owner_host_id !== undefined) return { stale: false };
|
|
789
|
+
if (ownerIncarnationChanged(info, startTimeCache)) {
|
|
790
|
+
if (!judgedIdentity) return { stale: false };
|
|
791
|
+
let currentIdentity: GenericFileLockDirIdentity | null;
|
|
792
|
+
try {
|
|
793
|
+
currentIdentity = await captureFileLockDirIdentity(lockPath);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
if (!currentIdentity || !sameGenericFileLockDirIdentity(judgedIdentity, currentIdentity)) return { stale: false };
|
|
799
|
+
fileLockDirIdentities.set(info, judgedIdentity);
|
|
800
|
+
return { stale: true, owner: info, identity: judgedIdentity };
|
|
801
|
+
}
|
|
258
802
|
// Never reap a live owner by elapsed time: a long legitimate critical section must
|
|
259
|
-
// not have its lock stolen (#652). Reclaim
|
|
260
|
-
//
|
|
803
|
+
// not have its lock stolen (#652). Reclaim only when the OS proves the owner is dead;
|
|
804
|
+
// indeterminate liveness remains protected regardless of elapsed time.
|
|
261
805
|
if (ownerIsAlive(info, startTimeCache)) return { stale: false };
|
|
262
|
-
|
|
263
|
-
|
|
806
|
+
const liveness = ownerLiveness(info.pid);
|
|
807
|
+
if (liveness === "dead") {
|
|
808
|
+
if (!judgedIdentity) return { stale: false };
|
|
809
|
+
let currentIdentity: GenericFileLockDirIdentity | null;
|
|
810
|
+
try {
|
|
811
|
+
currentIdentity = await captureFileLockDirIdentity(lockPath);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
if (!currentIdentity || !sameGenericFileLockDirIdentity(judgedIdentity, currentIdentity)) return { stale: false };
|
|
817
|
+
fileLockDirIdentities.set(info, judgedIdentity);
|
|
818
|
+
return { stale: true, owner: info, identity: judgedIdentity };
|
|
264
819
|
}
|
|
265
820
|
return { stale: false };
|
|
266
821
|
}
|
|
267
822
|
|
|
268
823
|
async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
|
|
269
824
|
if (!snapshot.stale) return false;
|
|
270
|
-
|
|
271
|
-
return (await removeFileLockDirForGc(lockPath, snapshot.owner)) === "removed";
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const currentInfo = await readLockInfo(lockPath);
|
|
275
|
-
if (currentInfo) return false;
|
|
276
|
-
try {
|
|
277
|
-
const currentStats = await fs.stat(lockPath);
|
|
278
|
-
if (!sameStatToken(statToken(currentStats), snapshot.stat)) return false;
|
|
279
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
280
|
-
return true;
|
|
281
|
-
} catch (err) {
|
|
282
|
-
if (isEnoent(err)) return false;
|
|
283
|
-
throw err;
|
|
284
|
-
}
|
|
825
|
+
return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
|
|
285
826
|
}
|
|
286
827
|
|
|
287
828
|
/**
|
|
@@ -306,58 +847,286 @@ export async function genericFileLockDirIsStale(
|
|
|
306
847
|
staleMs: number,
|
|
307
848
|
ownerHostId?: string,
|
|
308
849
|
): Promise<boolean> {
|
|
309
|
-
return (await
|
|
850
|
+
return (await genericFileLockDirStaleVerdict(lockDir, staleMs, ownerHostId)).stale;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Render a generic stale verdict together with the root and owner-file identity that
|
|
855
|
+
* verdict actually observed. A caller that removes the directory must require a later
|
|
856
|
+
* native snapshot to carry this same identity; a snapshot taken only after a clone was
|
|
857
|
+
* installed is not authority for the stale verdict.
|
|
858
|
+
*/
|
|
859
|
+
export async function genericFileLockDirStaleVerdict(
|
|
860
|
+
lockDir: string,
|
|
861
|
+
staleMs: number,
|
|
862
|
+
ownerHostId?: string,
|
|
863
|
+
): Promise<GenericFileLockDirStaleVerdict> {
|
|
864
|
+
const verdict = await staleLockSnapshot(lockDir, staleMs, ownerHostId);
|
|
865
|
+
if (!verdict.stale) return { stale: false };
|
|
866
|
+
return {
|
|
867
|
+
stale: true,
|
|
868
|
+
identity: verdict.identity,
|
|
869
|
+
};
|
|
310
870
|
}
|
|
311
871
|
|
|
312
872
|
async function tryAcquireLock(
|
|
313
873
|
lockPath: string,
|
|
314
874
|
ownerHostId?: string,
|
|
875
|
+
ownerToken = crypto.randomUUID(),
|
|
315
876
|
onAcquired?: () => void,
|
|
316
877
|
): Promise<LockInfo | null> {
|
|
317
|
-
await
|
|
878
|
+
await ensureLockParent(path.dirname(lockPath));
|
|
318
879
|
const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
|
|
319
880
|
if (afterParentMkdir) await afterParentMkdir(lockPath);
|
|
320
|
-
|
|
881
|
+
const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
|
|
882
|
+
const owner = lockInfo(ownerHostId, ownerToken);
|
|
883
|
+
try {
|
|
884
|
+
await fs.mkdir(pendingPath, { mode: 0o700 });
|
|
885
|
+
await fs.chmod(pendingPath, 0o700);
|
|
886
|
+
await writeLockInfo(pendingPath, owner);
|
|
887
|
+
// A plain POSIX rename replaces an existing empty directory. The legacy
|
|
888
|
+
// directory lock is a real holder, so publication must use the native
|
|
889
|
+
// no-replace primitive rather than treating an empty destination as free.
|
|
890
|
+
// The native primitive also rejects symlinked/reparse parents. Resolve the
|
|
891
|
+
// already-created staging entry and its parent before publication so aliases
|
|
892
|
+
// retain the same lock identity as the ordinary path.
|
|
893
|
+
const canonicalParent = await fs.realpath(path.dirname(lockPath));
|
|
894
|
+
const destinationPath = path.join(canonicalParent, path.basename(lockPath));
|
|
895
|
+
// Resolve only the stable parent: resolving the mutable staging final
|
|
896
|
+
// component would follow an attacker-replaced symlink before native no-follow
|
|
897
|
+
// validation gets a chance to reject it.
|
|
898
|
+
const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
|
|
899
|
+
const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
|
|
900
|
+
renameNoReplacePathAsync,
|
|
901
|
+
renameDirectoryNoReplacePathAsync,
|
|
902
|
+
};
|
|
903
|
+
const published = await publication.renameNoReplacePathAsync(canonicalPendingPath, destinationPath);
|
|
904
|
+
let publishedSuccessfully = published.ok;
|
|
905
|
+
if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
|
|
906
|
+
const fallback = await publication.renameDirectoryNoReplacePathAsync(canonicalPendingPath, destinationPath);
|
|
907
|
+
if (fallback.ok) {
|
|
908
|
+
publishedSuccessfully = true;
|
|
909
|
+
} else if (fallback.reason === "destination_exists") {
|
|
910
|
+
return null;
|
|
911
|
+
} else {
|
|
912
|
+
const failure = new Error(
|
|
913
|
+
`Failed to publish file lock: ${fallback.code ?? fallback.reason ?? "unknown"}.`,
|
|
914
|
+
) as NodeJS.ErrnoException;
|
|
915
|
+
if (fallback.code) failure.code = fallback.code;
|
|
916
|
+
throw failure;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (!publishedSuccessfully) {
|
|
920
|
+
if (published.reason === "destination_exists") return null;
|
|
921
|
+
const failure = new Error(
|
|
922
|
+
`Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
|
|
923
|
+
) as NodeJS.ErrnoException;
|
|
924
|
+
if (published.code) failure.code = published.code;
|
|
925
|
+
throw failure;
|
|
926
|
+
}
|
|
927
|
+
// Published above, so an onAcquired failure must propagate instead of
|
|
928
|
+
// retrying an acquisition that already owns the lock.
|
|
929
|
+
onAcquired?.();
|
|
930
|
+
return owner;
|
|
931
|
+
} finally {
|
|
932
|
+
await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function isTransientReleaseError(error: unknown): boolean {
|
|
937
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
938
|
+
return (
|
|
939
|
+
code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function throwTransientNativeResult(code: string): never {
|
|
944
|
+
throw Object.assign(new Error(`Native lock operation is transiently unavailable: ${code}.`), { code });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
type NativeFileLockBindings = {
|
|
948
|
+
snapshotDirectoryTree(lockPath: string): NativeDirectoryTreeResult;
|
|
949
|
+
exactRemoveDirectoryTree(
|
|
950
|
+
lockPath: string,
|
|
951
|
+
snapshot: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
|
|
952
|
+
): NativeExactUnlinkResult;
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
let nativeFileLockBindingCache: NativeFileLockBindings | undefined;
|
|
956
|
+
|
|
957
|
+
function nativeFileLockBindings(): NativeFileLockBindings {
|
|
958
|
+
if (FileLockTestHooks.nativeQuarantineBindings) return FileLockTestHooks.nativeQuarantineBindings();
|
|
959
|
+
if (nativeFileLockBindingCache) return nativeFileLockBindingCache;
|
|
960
|
+
try {
|
|
961
|
+
nativeFileLockBindingCache = require("@gajae-code/natives") as NativeFileLockBindings;
|
|
962
|
+
return nativeFileLockBindingCache;
|
|
963
|
+
} catch (error) {
|
|
964
|
+
throw Object.assign(new Error("Native identity-bound lock quarantine is unavailable."), { cause: error });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function quarantineReleasedLock(
|
|
969
|
+
lockPath: string,
|
|
970
|
+
owner: FileLockOwnerToken,
|
|
971
|
+
preVerdictIdentity?: GenericFileLockDirIdentity,
|
|
972
|
+
): Promise<boolean> {
|
|
973
|
+
const expectedIdentity = preVerdictIdentity ?? fileLockDirIdentities.get(owner);
|
|
974
|
+
// Quarantine is only a release fallback after earlier transient failures. It must not
|
|
975
|
+
// make a fresh post-failure snapshot authoritative for an object this release never
|
|
976
|
+
// judged; without pre-verdict evidence, refuse and let the caller surface the retry
|
|
977
|
+
// failure instead of risking a successor lock.
|
|
978
|
+
if (!expectedIdentity) return false;
|
|
979
|
+
let captured: NativeDirectoryTreeResult;
|
|
980
|
+
const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
|
|
981
|
+
try {
|
|
982
|
+
captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
|
|
983
|
+
if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (isTransientReleaseError(error)) return false;
|
|
986
|
+
throw error;
|
|
987
|
+
}
|
|
988
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
989
|
+
const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
|
|
990
|
+
if (!infoEntry?.sha256) return false;
|
|
991
|
+
if (
|
|
992
|
+
captured.snapshot.rootDev !== expectedIdentity.rootDev ||
|
|
993
|
+
captured.snapshot.rootIno !== expectedIdentity.rootIno ||
|
|
994
|
+
infoEntry.dev !== expectedIdentity.infoDev ||
|
|
995
|
+
infoEntry.ino !== expectedIdentity.infoIno ||
|
|
996
|
+
infoEntry.nlink !== expectedIdentity.infoNlink ||
|
|
997
|
+
infoEntry.size !== expectedIdentity.infoSize ||
|
|
998
|
+
infoEntry.mtimeNs !== expectedIdentity.infoMtimeNs ||
|
|
999
|
+
infoEntry.ctimeNs !== expectedIdentity.infoCtimeNs ||
|
|
1000
|
+
infoEntry.sha256 !== expectedIdentity.infoSha256
|
|
1001
|
+
)
|
|
1002
|
+
return false;
|
|
1003
|
+
// Bind the owner generation to the snapshot before exact removal. A successor
|
|
1004
|
+
// installed after this snapshot is rejected by the native identity check instead of
|
|
1005
|
+
// being moved into quarantine by a pathname-only rename.
|
|
1006
|
+
const expectedDigest = crypto.createHash("sha256").update(JSON.stringify(owner)).digest("hex");
|
|
1007
|
+
if (infoEntry.sha256 !== expectedDigest) return false;
|
|
1008
|
+
let removed: NativeExactUnlinkResult;
|
|
1009
|
+
try {
|
|
1010
|
+
removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (isTransientReleaseError(error)) return false;
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
if (removed.ok || removed.code === "not_found") return true;
|
|
1016
|
+
if (
|
|
1017
|
+
removed.detachedPath !== undefined &&
|
|
1018
|
+
path.resolve(removed.detachedPath) !== path.resolve(lockPath) &&
|
|
1019
|
+
removed.retainedSuccessorPath === undefined &&
|
|
1020
|
+
removed.retainedPlaceholderPath === undefined &&
|
|
1021
|
+
removed.retainedUnknownPath === undefined
|
|
1022
|
+
) {
|
|
321
1023
|
try {
|
|
322
|
-
await fs.
|
|
323
|
-
|
|
324
|
-
return await writeLockInfo(lockPath, lockInfo());
|
|
1024
|
+
await fs.lstat(nativeCapturePath);
|
|
1025
|
+
return false;
|
|
325
1026
|
} catch (error) {
|
|
326
|
-
if ((error
|
|
1027
|
+
if (isEnoent(error)) {
|
|
1028
|
+
nativeFileLockBindings().exactRemoveDirectoryTree(removed.detachedPath, captured.snapshot);
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
327
1031
|
throw error;
|
|
328
1032
|
}
|
|
329
1033
|
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
330
1036
|
|
|
331
|
-
|
|
332
|
-
|
|
1037
|
+
async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
|
|
1038
|
+
let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
|
|
333
1039
|
try {
|
|
334
|
-
await
|
|
335
|
-
|
|
1040
|
+
preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
|
|
1041
|
+
if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
1044
|
+
}
|
|
1045
|
+
let lastTransientError: unknown;
|
|
1046
|
+
for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
|
|
336
1047
|
try {
|
|
337
|
-
await
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
342
|
-
if (code === "EEXIST" || code === "ENOTEMPTY") return null;
|
|
343
|
-
if (code === "EPERM") {
|
|
344
|
-
try {
|
|
345
|
-
await fs.stat(lockPath);
|
|
346
|
-
return null;
|
|
347
|
-
} catch (statError) {
|
|
348
|
-
if (!isEnoent(statError)) throw statError;
|
|
349
|
-
}
|
|
1048
|
+
const outcome = await removeFileLockDirForGc(lockPath, owner, preVerdictIdentity);
|
|
1049
|
+
if (outcome === "removed" || outcome === "missing") {
|
|
1050
|
+
if (outcome === "missing") throw new Error("Failed to release file lock: missing.");
|
|
1051
|
+
return;
|
|
350
1052
|
}
|
|
351
|
-
throw
|
|
1053
|
+
throw new Error(`Failed to release file lock: ${outcome}.`);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
1056
|
+
lastTransientError = error;
|
|
1057
|
+
if (attempt + 1 < FILE_LOCK_RELEASE_RETRY_ATTEMPTS) await Bun.sleep(FILE_LOCK_RELEASE_RETRY_DELAY_MS);
|
|
352
1058
|
}
|
|
1059
|
+
}
|
|
1060
|
+
if (await quarantineReleasedLock(lockPath, owner, preVerdictIdentity)) return;
|
|
1061
|
+
throw lastTransientError ?? new Error("Failed to release file lock: transient removal failure.");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function retryPendingLocalRelease(lockPath: string, knownKey?: string): Promise<void> {
|
|
1065
|
+
const key = knownKey ?? (await localLockKey(lockPath));
|
|
1066
|
+
const state = localLockStates.get(key);
|
|
1067
|
+
if (!state || state.status === "held") return;
|
|
1068
|
+
if (state.releasePromise) {
|
|
1069
|
+
await state.releasePromise.catch(() => undefined);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
state.status = "releasing";
|
|
1073
|
+
const releasePromise = releaseOwnedLock(lockPath, state.owner);
|
|
1074
|
+
state.releasePromise = releasePromise;
|
|
1075
|
+
try {
|
|
1076
|
+
await releasePromise;
|
|
1077
|
+
if (localLockStates.get(key) === state) localLockStates.delete(key);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
state.status = "release_pending";
|
|
1080
|
+
throw error;
|
|
353
1081
|
} finally {
|
|
354
|
-
|
|
1082
|
+
if (state.releasePromise === releasePromise) state.releasePromise = undefined;
|
|
355
1083
|
}
|
|
356
1084
|
}
|
|
357
1085
|
|
|
358
|
-
async function
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
1086
|
+
async function pendingLocalReleaseKey(lockPath: string, localKey: string): Promise<string | undefined> {
|
|
1087
|
+
const direct = localLockStates.get(localKey);
|
|
1088
|
+
if (direct) return direct.status === "held" ? undefined : localKey;
|
|
1089
|
+
let info: LockInfo | null;
|
|
1090
|
+
try {
|
|
1091
|
+
info = await readLockInfo(lockPath);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (isTransientReleaseError(error)) return undefined;
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
const ownerToken = info?.owner_token;
|
|
1097
|
+
if (!ownerToken) return undefined;
|
|
1098
|
+
for (const [key, state] of localLockStates) {
|
|
1099
|
+
if (key !== localKey && state.status !== "held" && state.owner.owner_token === ownerToken) return key;
|
|
1100
|
+
}
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey?: string): Promise<void> {
|
|
1105
|
+
const key = knownKey ?? (await localLockKey(lockPath));
|
|
1106
|
+
const state = localLockStates.get(key);
|
|
1107
|
+
if (!state || state.owner.owner_token !== owner.owner_token) {
|
|
1108
|
+
throw new Error("Failed to release file lock: local owner generation is unknown.");
|
|
1109
|
+
}
|
|
1110
|
+
if (state.status === "release_pending") {
|
|
1111
|
+
await retryPendingLocalRelease(lockPath, key);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (state.releasePromise) {
|
|
1115
|
+
await state.releasePromise;
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
state.status = "releasing";
|
|
1119
|
+
const releasePromise = releaseOwnedLock(lockPath, owner);
|
|
1120
|
+
state.releasePromise = releasePromise;
|
|
1121
|
+
try {
|
|
1122
|
+
await releasePromise;
|
|
1123
|
+
if (localLockStates.get(key) === state) localLockStates.delete(key);
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
state.status = "release_pending";
|
|
1126
|
+
throw error;
|
|
1127
|
+
} finally {
|
|
1128
|
+
if (state.releasePromise === releasePromise) state.releasePromise = undefined;
|
|
1129
|
+
}
|
|
361
1130
|
}
|
|
362
1131
|
/**
|
|
363
1132
|
* Bounded, actionable description of who holds `lockPath` at exhaustion time.
|
|
@@ -405,16 +1174,38 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
|
|
|
405
1174
|
}
|
|
406
1175
|
|
|
407
1176
|
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
|
|
1177
|
+
const requestedFilePath: unknown = filePath;
|
|
1178
|
+
if (typeof requestedFilePath !== "string" || requestedFilePath.length === 0 || !path.isAbsolute(requestedFilePath))
|
|
1179
|
+
throw new TypeError("filePath must be a non-empty absolute path");
|
|
1180
|
+
if (requestedFilePath.includes("\0")) throw new TypeError("filePath must not contain NUL bytes");
|
|
408
1181
|
if (options.ownerHostId !== undefined && !options.ownerHostId) throw new Error("ownerHostId must be non-empty");
|
|
409
1182
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
410
1183
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
411
1184
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
1185
|
+
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
412
1186
|
const lockPath = getLockPath(filePath);
|
|
1187
|
+
await ensureLockParent(path.dirname(lockPath));
|
|
1188
|
+
const ownerToken = crypto.randomUUID();
|
|
413
1189
|
const contentionStartTimes = new Map<string, string | null>();
|
|
414
1190
|
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
415
1191
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
416
|
-
const
|
|
417
|
-
|
|
1192
|
+
const localKey = await localLockKey(lockPath);
|
|
1193
|
+
const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
|
|
1194
|
+
if (owner) {
|
|
1195
|
+
localLockStates.set(localKey, { owner, status: "held" });
|
|
1196
|
+
return () => releaseLock(lockPath, owner, localKey);
|
|
1197
|
+
}
|
|
1198
|
+
const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
|
|
1199
|
+
const localState = localLockStates.get(pendingKey ?? localKey);
|
|
1200
|
+
if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
|
|
1201
|
+
try {
|
|
1202
|
+
await retryPendingLocalRelease(lockPath, pendingKey);
|
|
1203
|
+
continue;
|
|
1204
|
+
} catch {
|
|
1205
|
+
// Keep contending below. A failed local retry is not authority to steal a
|
|
1206
|
+
// lock; the owner generation remains fenced until release succeeds.
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
418
1209
|
const stale = await staleLockSnapshot(
|
|
419
1210
|
lockPath,
|
|
420
1211
|
opts.staleMs,
|