@gajae-code/coding-agent 0.15.5 → 0.15.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/update-cli.d.ts +2 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +96 -0
- package/dist/types/config/file-lock.d.ts +49 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +11 -5
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -5
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/sdk/broker/broker.d.ts +3 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +15 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +19 -7
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -3
- package/src/async/job-manager.ts +671 -39
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +3 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/update-cli.ts +40 -5
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/model-presets.ts +24 -0
- package/src/config/autorouting-tier-map.ts +32 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +775 -59
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2799 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +51 -9
- package/src/config/model-registry.ts +1021 -122
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +6 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +571 -0
- package/src/coordinator-mcp/server.ts +19 -24
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +9 -2
- package/src/modes/acp/acp-agent.ts +11 -5
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +23 -6
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +48 -21
- package/src/sdk/broker/lifecycle.ts +32 -6
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
- package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
- package/src/sdk/bus/prompt-reconciliation.ts +28 -4
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/cli/session-cli.ts +12 -1
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +371 -37
- package/src/sdk/host/session-runtime.ts +364 -52
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +2 -2
- package/src/sdk/protocol/operation-registry.ts +4 -1
- package/src/sdk/router/session-router.ts +228 -96
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +678 -103
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +5 -4
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +142 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +772 -67
- package/src/tools/index.ts +7 -5
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
package/src/config/file-lock.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
-
import type { Stats } from "node:fs";
|
|
2
|
+
import type { BigIntStats, Stats } 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 } from "@gajae-code/natives";
|
|
6
|
+
import { 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,29 @@ 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
|
+
nativeQuarantineBindings?: () => NativeFileLockBindings;
|
|
32
53
|
} = {};
|
|
33
54
|
|
|
34
55
|
/**
|
|
@@ -51,7 +72,11 @@ export function processStartTime(pid: number): string | null {
|
|
|
51
72
|
}
|
|
52
73
|
}
|
|
53
74
|
try {
|
|
54
|
-
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
75
|
+
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
76
|
+
stdout: "pipe",
|
|
77
|
+
stderr: "ignore",
|
|
78
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" },
|
|
79
|
+
});
|
|
55
80
|
if (result.exitCode !== 0) return null;
|
|
56
81
|
const startTime = new TextDecoder().decode(result.stdout).trim();
|
|
57
82
|
return startTime || null;
|
|
@@ -79,35 +104,239 @@ function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, s
|
|
|
79
104
|
|
|
80
105
|
function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
|
|
81
106
|
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
82
|
-
if (!owner.start_time) return true;
|
|
107
|
+
if (!owner.start_time || owner.start_time === "unknown") return true;
|
|
83
108
|
const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
|
|
84
|
-
|
|
109
|
+
if (currentStartTime === null || currentStartTime === owner.start_time) return true;
|
|
110
|
+
// A start-time mismatch proves PID reuse only for records that identify the
|
|
111
|
+
// canonical UTC encoding. Legacy records did not identify their timestamp format,
|
|
112
|
+
// so a locale/timezone change can make a live holder look different and must never
|
|
113
|
+
// authorize its removal.
|
|
114
|
+
return owner.start_time_format !== PROCESS_START_TIME_FORMAT;
|
|
85
115
|
}
|
|
86
116
|
|
|
87
|
-
function lockInfo(ownerHostId
|
|
117
|
+
function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
|
|
88
118
|
return {
|
|
89
119
|
pid: process.pid,
|
|
90
120
|
start_time: currentProcessStartTime(),
|
|
121
|
+
start_time_format: PROCESS_START_TIME_FORMAT,
|
|
91
122
|
timestamp: Date.now(),
|
|
123
|
+
owner_token: ownerToken,
|
|
92
124
|
...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
|
|
93
125
|
};
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
function writeLockInfo(lockPath: string, info: LockInfo): Promise<LockInfo> {
|
|
97
|
-
|
|
129
|
+
// Owner metadata must stay readable by its own process under a restrictive
|
|
130
|
+
// umask: release re-reads this record to authorize removal, and an info file
|
|
131
|
+
// born mode 000 under umask 0777 would wedge the lock at first release.
|
|
132
|
+
return Bun.write(`${lockPath}/info`, JSON.stringify(info), { mode: 0o600 })
|
|
133
|
+
.then(() => fs.chmod(`${lockPath}/info`, 0o600))
|
|
134
|
+
.then(() => info);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type LockInfoFileState = {
|
|
138
|
+
dev: bigint;
|
|
139
|
+
ino: bigint;
|
|
140
|
+
mode: bigint;
|
|
141
|
+
size: bigint;
|
|
142
|
+
mtimeNs: bigint;
|
|
143
|
+
ctimeNs: bigint;
|
|
144
|
+
nlink: bigint;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type LockInfoPathState = {
|
|
148
|
+
root: {
|
|
149
|
+
dev: bigint;
|
|
150
|
+
ino: bigint;
|
|
151
|
+
mode: bigint;
|
|
152
|
+
};
|
|
153
|
+
file: LockInfoFileState;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
|
|
157
|
+
if (stats.isSymbolicLink() || !stats.isFile()) return null;
|
|
158
|
+
return {
|
|
159
|
+
dev: stats.dev,
|
|
160
|
+
ino: stats.ino,
|
|
161
|
+
mode: stats.mode,
|
|
162
|
+
size: stats.size,
|
|
163
|
+
mtimeNs: stats.mtimeNs,
|
|
164
|
+
ctimeNs: stats.ctimeNs,
|
|
165
|
+
nlink: stats.nlink,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sameLockInfoFileState(left: LockInfoFileState, right: LockInfoFileState): boolean {
|
|
170
|
+
return (
|
|
171
|
+
left.dev === right.dev &&
|
|
172
|
+
left.ino === right.ino &&
|
|
173
|
+
left.mode === right.mode &&
|
|
174
|
+
left.size === right.size &&
|
|
175
|
+
left.mtimeNs === right.mtimeNs &&
|
|
176
|
+
left.ctimeNs === right.ctimeNs &&
|
|
177
|
+
left.nlink === right.nlink
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function lockInfoPathState(lockPath: string): Promise<LockInfoPathState | null> {
|
|
182
|
+
let root: BigIntStats;
|
|
183
|
+
try {
|
|
184
|
+
root = await fs.lstat(lockPath, { bigint: true });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (isEnoent(error)) return null;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
if (root.isSymbolicLink() || !root.isDirectory()) return null;
|
|
190
|
+
|
|
191
|
+
let info: BigIntStats;
|
|
192
|
+
try {
|
|
193
|
+
info = await fs.lstat(path.join(lockPath, "info"), { bigint: true });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (isEnoent(error)) return null;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const file = lockInfoFileState(info);
|
|
199
|
+
if (!file) return null;
|
|
200
|
+
return {
|
|
201
|
+
root: { dev: root.dev, ino: root.ino, mode: root.mode },
|
|
202
|
+
file,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sameLockInfoPathState(left: LockInfoPathState, right: LockInfoPathState): boolean {
|
|
207
|
+
return (
|
|
208
|
+
left.root.dev === right.root.dev &&
|
|
209
|
+
left.root.ino === right.root.ino &&
|
|
210
|
+
left.root.mode === right.root.mode &&
|
|
211
|
+
sameLockInfoFileState(left.file, right.file)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function fileLockDirIdentityFromPathState(state: LockInfoPathState, bytes: string): GenericFileLockDirIdentity {
|
|
216
|
+
return {
|
|
217
|
+
rootDev: String(state.root.dev),
|
|
218
|
+
rootIno: String(state.root.ino),
|
|
219
|
+
infoDev: String(state.file.dev),
|
|
220
|
+
infoIno: String(state.file.ino),
|
|
221
|
+
infoNlink: String(state.file.nlink),
|
|
222
|
+
infoSize: String(state.file.size),
|
|
223
|
+
infoMtimeNs: String(state.file.mtimeNs),
|
|
224
|
+
infoCtimeNs: String(state.file.ctimeNs),
|
|
225
|
+
infoSha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Capture the exact root/info identity that a later stale verdict may authorize. */
|
|
230
|
+
async function captureFileLockDirIdentity(lockDir: string): Promise<GenericFileLockDirIdentity | null> {
|
|
231
|
+
const observation = await readLockInfoObservation(lockDir);
|
|
232
|
+
return observation ? fileLockDirIdentityFromPathState(observation.state, observation.bytes) : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Resolve parent aliases without following the mutable lock-dir final component. */
|
|
236
|
+
async function canonicalLockPathPreservingFinal(lockPath: string): Promise<string> {
|
|
237
|
+
const parent = path.dirname(lockPath);
|
|
238
|
+
try {
|
|
239
|
+
return path.join(await fs.realpath(parent), path.basename(lockPath));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
|
|
242
|
+
return lockPath;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeLockKey(lockPath: string): string {
|
|
247
|
+
return path.normalize(lockPath);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const LOCK_INFO_OPEN_FLAGS =
|
|
251
|
+
fs.constants.O_RDONLY |
|
|
252
|
+
(process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read metadata from the exact regular file that was validated, never through a
|
|
256
|
+
* pathname after the descriptor is opened. The no-follow flag prevents final
|
|
257
|
+
* component symlinks on POSIX; lstat/fstat/path revalidation supplies the same
|
|
258
|
+
* rejection on Windows, where O_NOFOLLOW is unavailable.
|
|
259
|
+
*/
|
|
260
|
+
type LockInfoObservation = { bytes: string; state: LockInfoPathState };
|
|
261
|
+
|
|
262
|
+
async function readLockInfoObservation(lockPath: string): Promise<LockInfoObservation | null> {
|
|
263
|
+
const infoPath = path.join(lockPath, "info");
|
|
264
|
+
const initial = await lockInfoPathState(lockPath);
|
|
265
|
+
if (!initial) return null;
|
|
266
|
+
|
|
267
|
+
let handle: fs.FileHandle | undefined;
|
|
268
|
+
try {
|
|
269
|
+
handle = await fs.open(infoPath, LOCK_INFO_OPEN_FLAGS);
|
|
270
|
+
const opened = lockInfoFileState(await handle.stat({ bigint: true }));
|
|
271
|
+
const beforeRead = await lockInfoPathState(lockPath);
|
|
272
|
+
if (
|
|
273
|
+
!opened ||
|
|
274
|
+
!beforeRead ||
|
|
275
|
+
!sameLockInfoFileState(initial.file, opened) ||
|
|
276
|
+
!sameLockInfoPathState(initial, beforeRead)
|
|
277
|
+
)
|
|
278
|
+
return null;
|
|
279
|
+
|
|
280
|
+
const bytes = await handle.readFile();
|
|
281
|
+
const afterRead = lockInfoFileState(await handle.stat({ bigint: true }));
|
|
282
|
+
const afterPath = await lockInfoPathState(lockPath);
|
|
283
|
+
if (
|
|
284
|
+
!afterRead ||
|
|
285
|
+
!afterPath ||
|
|
286
|
+
!sameLockInfoFileState(initial.file, afterRead) ||
|
|
287
|
+
!sameLockInfoPathState(initial, afterPath)
|
|
288
|
+
)
|
|
289
|
+
return null;
|
|
290
|
+
return { bytes: bytes.toString("utf8"), state: afterPath };
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (isEnoent(error)) return null;
|
|
293
|
+
throw error;
|
|
294
|
+
} finally {
|
|
295
|
+
await handle?.close().catch(() => undefined);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function readLockInfoBytes(lockPath: string): Promise<string | null> {
|
|
300
|
+
return (await readLockInfoObservation(lockPath))?.bytes ?? null;
|
|
98
301
|
}
|
|
99
302
|
|
|
100
303
|
async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
101
304
|
let parsed: unknown;
|
|
102
305
|
try {
|
|
103
|
-
|
|
306
|
+
const bytes = await readLockInfoBytes(lockPath);
|
|
307
|
+
if (bytes === null) return null;
|
|
308
|
+
parsed = JSON.parse(bytes);
|
|
104
309
|
} catch (error) {
|
|
105
310
|
if (isEnoent(error) || error instanceof SyntaxError) return null;
|
|
106
311
|
throw error;
|
|
107
312
|
}
|
|
108
313
|
|
|
109
314
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
110
|
-
const { pid, start_time, timestamp, owner_host_id } = parsed as Partial<LockInfo>;
|
|
315
|
+
const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
|
|
316
|
+
if (
|
|
317
|
+
typeof pid !== "number" ||
|
|
318
|
+
!Number.isInteger(pid) ||
|
|
319
|
+
pid <= 0 ||
|
|
320
|
+
typeof timestamp !== "number" ||
|
|
321
|
+
!Number.isFinite(timestamp) ||
|
|
322
|
+
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
|
|
323
|
+
(start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
|
|
324
|
+
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
|
|
325
|
+
(owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
|
|
326
|
+
)
|
|
327
|
+
return null;
|
|
328
|
+
return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parseLockInfoBytes(bytes: string): LockInfo | null {
|
|
332
|
+
let parsed: unknown;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(bytes);
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
339
|
+
const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
|
|
111
340
|
if (
|
|
112
341
|
typeof pid !== "number" ||
|
|
113
342
|
!Number.isInteger(pid) ||
|
|
@@ -115,31 +344,113 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
|
115
344
|
typeof timestamp !== "number" ||
|
|
116
345
|
!Number.isFinite(timestamp) ||
|
|
117
346
|
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
|
|
118
|
-
(
|
|
347
|
+
(start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
|
|
348
|
+
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
|
|
349
|
+
(owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
|
|
119
350
|
)
|
|
120
351
|
return null;
|
|
121
|
-
return { pid, start_time, timestamp, owner_host_id };
|
|
352
|
+
return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** @internal */
|
|
356
|
+
export interface FileLockGcObservation {
|
|
357
|
+
info: FileLockOwnerToken;
|
|
358
|
+
bytes: string;
|
|
359
|
+
identity: GenericFileLockDirIdentity;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Capture owner bytes and the identity proving those exact bytes came from this tree. */
|
|
363
|
+
export async function readFileLockObservationForGc(lockDir: string): Promise<FileLockGcObservation | null> {
|
|
364
|
+
const observation = await readLockInfoObservation(lockDir);
|
|
365
|
+
if (!observation) return null;
|
|
366
|
+
const info = parseLockInfoBytes(observation.bytes);
|
|
367
|
+
if (!info) return null;
|
|
368
|
+
const identity = fileLockDirIdentityFromPathState(observation.state, observation.bytes);
|
|
369
|
+
fileLockDirIdentities.set(info, identity);
|
|
370
|
+
return { info, bytes: observation.bytes, identity };
|
|
122
371
|
}
|
|
123
372
|
|
|
124
373
|
/** @internal */
|
|
125
374
|
export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOwnerToken | null> {
|
|
126
|
-
return await
|
|
375
|
+
return (await readFileLockObservationForGc(lockDir))?.info ?? null;
|
|
127
376
|
}
|
|
128
377
|
|
|
129
378
|
/** Owner identity stamped into a `<file>.lock/info` record. */
|
|
130
379
|
export interface FileLockOwnerToken {
|
|
131
380
|
pid: number;
|
|
132
381
|
start_time?: string;
|
|
382
|
+
/** Encoding marker for the canonical UTC process-start identity. */
|
|
383
|
+
start_time_format?: string;
|
|
133
384
|
owner_host_id?: string;
|
|
385
|
+
/** Unique acquisition generation, present on locks created by this runtime. */
|
|
386
|
+
owner_token?: string;
|
|
134
387
|
timestamp: number;
|
|
135
388
|
}
|
|
136
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Identity captured before a stale/liveness verdict. Kept out of the owner JSON: it is
|
|
392
|
+
* authorization evidence for the in-memory call that produced the verdict, not metadata
|
|
393
|
+
* another process may copy into a new lock generation.
|
|
394
|
+
*/
|
|
395
|
+
const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
|
|
396
|
+
|
|
137
397
|
function getLockPath(filePath: string): string {
|
|
138
398
|
return `${filePath}.lock`;
|
|
139
399
|
}
|
|
140
400
|
|
|
401
|
+
async function ensureLockParent(directory: string): Promise<void> {
|
|
402
|
+
const missing: string[] = [];
|
|
403
|
+
let current = path.resolve(directory);
|
|
404
|
+
for (;;) {
|
|
405
|
+
try {
|
|
406
|
+
await fs.lstat(current);
|
|
407
|
+
break;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (!isEnoent(error)) throw error;
|
|
410
|
+
missing.push(current);
|
|
411
|
+
const parent = path.dirname(current);
|
|
412
|
+
if (parent === current) throw error;
|
|
413
|
+
current = parent;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const created of missing.reverse()) {
|
|
417
|
+
try {
|
|
418
|
+
await fs.mkdir(created, { mode: 0o700 });
|
|
419
|
+
await fs.chmod(created, 0o700);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function localLockKey(lockPath: string): Promise<string> {
|
|
427
|
+
try {
|
|
428
|
+
return normalizeLockKey(await fs.realpath(lockPath));
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
|
|
431
|
+
}
|
|
432
|
+
const parent = path.dirname(lockPath);
|
|
433
|
+
let canonicalParent: string;
|
|
434
|
+
try {
|
|
435
|
+
canonicalParent = await fs.realpath(parent);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) throw error;
|
|
438
|
+
canonicalParent = path.resolve(parent);
|
|
439
|
+
}
|
|
440
|
+
const key = path.join(canonicalParent, path.basename(lockPath));
|
|
441
|
+
return normalizeLockKey(key);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
|
|
445
|
+
if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
|
|
446
|
+
return false;
|
|
447
|
+
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
448
|
+
const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
|
|
449
|
+
return currentStartTime !== null && currentStartTime !== owner.start_time;
|
|
450
|
+
}
|
|
451
|
+
|
|
141
452
|
/** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
|
|
142
|
-
export type FileLockGcRemoval = "removed" | "owner_changed" | "missing";
|
|
453
|
+
export type FileLockGcRemoval = "removed" | "owner_changed" | "missing" | "cleanup_failed";
|
|
143
454
|
|
|
144
455
|
interface LockDirStatToken {
|
|
145
456
|
dev: number;
|
|
@@ -150,9 +461,38 @@ interface LockDirStatToken {
|
|
|
150
461
|
|
|
151
462
|
type LockStaleSnapshot =
|
|
152
463
|
| { stale: false }
|
|
153
|
-
| { stale: true; owner: FileLockOwnerToken }
|
|
464
|
+
| { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity }
|
|
154
465
|
| { stale: true; owner: null; stat: LockDirStatToken };
|
|
155
466
|
|
|
467
|
+
/** Identity evidence carried by the generic stale verdict into a later removal. */
|
|
468
|
+
export interface GenericFileLockDirIdentity {
|
|
469
|
+
rootDev: string;
|
|
470
|
+
rootIno: string;
|
|
471
|
+
infoDev: string;
|
|
472
|
+
infoIno: string;
|
|
473
|
+
infoNlink: string;
|
|
474
|
+
infoSize: string;
|
|
475
|
+
infoMtimeNs: string;
|
|
476
|
+
infoCtimeNs: string;
|
|
477
|
+
infoSha256: string;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right: GenericFileLockDirIdentity): boolean {
|
|
481
|
+
return (
|
|
482
|
+
left.rootDev === right.rootDev &&
|
|
483
|
+
left.rootIno === right.rootIno &&
|
|
484
|
+
left.infoDev === right.infoDev &&
|
|
485
|
+
left.infoIno === right.infoIno &&
|
|
486
|
+
left.infoNlink === right.infoNlink &&
|
|
487
|
+
left.infoSize === right.infoSize &&
|
|
488
|
+
left.infoMtimeNs === right.infoMtimeNs &&
|
|
489
|
+
left.infoCtimeNs === right.infoCtimeNs &&
|
|
490
|
+
left.infoSha256 === right.infoSha256
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export type GenericFileLockDirStaleVerdict = { stale: false } | { stale: true; identity: GenericFileLockDirIdentity };
|
|
495
|
+
|
|
156
496
|
/**
|
|
157
497
|
* @internal
|
|
158
498
|
* Fail-closed removal of a lock dir whose owner is expected to be dead or
|
|
@@ -173,19 +513,109 @@ type LockStaleSnapshot =
|
|
|
173
513
|
export async function removeFileLockDirForGc(
|
|
174
514
|
lockDir: string,
|
|
175
515
|
expected: FileLockOwnerToken,
|
|
516
|
+
preVerdictIdentity?: GenericFileLockDirIdentity,
|
|
176
517
|
): Promise<FileLockGcRemoval> {
|
|
177
|
-
|
|
178
|
-
|
|
518
|
+
// A generic release/quarantine call is only authorized by evidence captured
|
|
519
|
+
// before its stale verdict. Capturing the current pathname here would let a
|
|
520
|
+
// fresh successor inherit an old owner's release authority.
|
|
521
|
+
const expectedIdentity = preVerdictIdentity ?? fileLockDirIdentities.get(expected);
|
|
522
|
+
let onDiskBytes: string | null;
|
|
523
|
+
try {
|
|
524
|
+
onDiskBytes = await readLockInfoBytes(lockDir);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (isEnoent(error)) return "missing";
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
const current = onDiskBytes === null ? null : parseLockInfoBytes(onDiskBytes);
|
|
530
|
+
if (!current || onDiskBytes === null) return "missing";
|
|
531
|
+
if (!expectedIdentity) return "owner_changed";
|
|
179
532
|
if (
|
|
180
533
|
current.pid !== expected.pid ||
|
|
181
534
|
(expected.start_time !== undefined && current.start_time !== expected.start_time) ||
|
|
182
535
|
current.owner_host_id !== expected.owner_host_id ||
|
|
536
|
+
(expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
|
|
183
537
|
current.timestamp !== expected.timestamp
|
|
184
538
|
) {
|
|
185
539
|
return "owner_changed";
|
|
186
540
|
}
|
|
187
|
-
|
|
188
|
-
|
|
541
|
+
// The token comparison above authorizes the content that was judged, not the
|
|
542
|
+
// pathname. When the caller carried pre-verdict root/info identity, require
|
|
543
|
+
// the post-verdict native snapshot to match that same object before removal;
|
|
544
|
+
// a clone or successor can therefore never inherit the stale authorization.
|
|
545
|
+
// The canonical native path may refuse a symlinked parent ("reparse_point");
|
|
546
|
+
// canonicalize first so the identity-bound capture sees the real directory,
|
|
547
|
+
// mirroring how localLockKey canonicalizes the lock pathname.
|
|
548
|
+
let nativeCapturePath = lockDir;
|
|
549
|
+
try {
|
|
550
|
+
nativeCapturePath = await canonicalLockPathPreservingFinal(lockDir);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (!isEnoent(error) && !isTransientReleaseError(error)) return "cleanup_failed";
|
|
553
|
+
}
|
|
554
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
|
|
555
|
+
if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
|
|
556
|
+
if (!captured.ok || !captured.snapshot) return "owner_changed";
|
|
557
|
+
const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
|
|
558
|
+
if (!infoEntry?.sha256) return "owner_changed";
|
|
559
|
+
const judgedDigest = crypto.createHash("sha256").update(onDiskBytes).digest("hex");
|
|
560
|
+
if (infoEntry.sha256 !== judgedDigest) return "owner_changed";
|
|
561
|
+
if (
|
|
562
|
+
!captured.snapshot.rootDev ||
|
|
563
|
+
captured.snapshot.rootDev !== expectedIdentity.rootDev ||
|
|
564
|
+
captured.snapshot.rootIno !== expectedIdentity.rootIno ||
|
|
565
|
+
infoEntry.dev !== expectedIdentity.infoDev ||
|
|
566
|
+
infoEntry.ino !== expectedIdentity.infoIno ||
|
|
567
|
+
infoEntry.nlink !== expectedIdentity.infoNlink ||
|
|
568
|
+
infoEntry.size !== expectedIdentity.infoSize ||
|
|
569
|
+
infoEntry.mtimeNs !== expectedIdentity.infoMtimeNs ||
|
|
570
|
+
infoEntry.ctimeNs !== expectedIdentity.infoCtimeNs ||
|
|
571
|
+
infoEntry.sha256 !== expectedIdentity.infoSha256
|
|
572
|
+
)
|
|
573
|
+
return "owner_changed";
|
|
574
|
+
let removed: NativeExactUnlinkResult;
|
|
575
|
+
try {
|
|
576
|
+
removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
// Keep the #2478 transient retry contract: sharing denials surface with
|
|
579
|
+
// their transient code so callers retry, everything else is a refusal.
|
|
580
|
+
if (isTransientReleaseError(error)) throw error;
|
|
581
|
+
return "cleanup_failed";
|
|
582
|
+
}
|
|
583
|
+
if (removed.ok) return "removed";
|
|
584
|
+
// The canonical name may already be detached: the security-critical phase is
|
|
585
|
+
// done once the verified tree is durably scrubbed and parked under the
|
|
586
|
+
// no-replace quarantine name with no successor retained. Finish that replay
|
|
587
|
+
// deterministically by deleting the retained quarantine — the same completion
|
|
588
|
+
// contract gc-runtime applies to its own exact removals. Any other outcome —
|
|
589
|
+
// including a retained successor or placeholder — leaves the judged object (or
|
|
590
|
+
// its replacement) in place and reports the removal as refused.
|
|
591
|
+
const detachedPath = removed.detachedPath;
|
|
592
|
+
const verifiedDetach =
|
|
593
|
+
detachedPath !== undefined &&
|
|
594
|
+
path.resolve(detachedPath) !== path.resolve(lockDir) &&
|
|
595
|
+
removed.retainedSuccessorPath === undefined &&
|
|
596
|
+
removed.retainedPlaceholderPath === undefined &&
|
|
597
|
+
removed.retainedUnknownPath === undefined;
|
|
598
|
+
if (verifiedDetach && detachedPath !== undefined) {
|
|
599
|
+
// The security-critical phase is done: the verified tree was detached from
|
|
600
|
+
// the canonical name and durably parked under the no-replace quarantine
|
|
601
|
+
// name with no successor retained. Finish that replay deterministically by
|
|
602
|
+
// deleting the retained quarantine — the same completion contract gc-runtime
|
|
603
|
+
// applies to its own exact removals — then report the release as done.
|
|
604
|
+
try {
|
|
605
|
+
await fs.rm(detachedPath, { recursive: true, force: true });
|
|
606
|
+
} catch {
|
|
607
|
+
// The canonical pathname is free either way; a retained quarantine is
|
|
608
|
+
// recoverable debris, not a live lock.
|
|
609
|
+
}
|
|
610
|
+
return "removed";
|
|
611
|
+
}
|
|
612
|
+
if (removed.code === "not_found") return "removed";
|
|
613
|
+
if (removed.code === "identity_mismatch") return "owner_changed";
|
|
614
|
+
const refusal: NodeJS.ErrnoException = new Error(
|
|
615
|
+
`Failed to remove file lock tree: ${removed.code ?? "unknown"}.`,
|
|
616
|
+
) as NodeJS.ErrnoException;
|
|
617
|
+
refusal.code = "EACCES";
|
|
618
|
+
throw refusal;
|
|
189
619
|
}
|
|
190
620
|
|
|
191
621
|
type OwnerLiveness = "alive" | "dead" | "unknown";
|
|
@@ -224,6 +654,15 @@ async function staleLockSnapshot(
|
|
|
224
654
|
previousOwnerHostIds: readonly string[] = [],
|
|
225
655
|
startTimeCache?: Map<string, string | null>,
|
|
226
656
|
): Promise<LockStaleSnapshot> {
|
|
657
|
+
// Capture the root and info inode BEFORE asking whether the owner is stale. A later
|
|
658
|
+
// snapshot alone would let a copied successor inherit the stale verdict's authority.
|
|
659
|
+
let judgedIdentity: GenericFileLockDirIdentity | null = null;
|
|
660
|
+
try {
|
|
661
|
+
judgedIdentity = await captureFileLockDirIdentity(lockPath);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
227
666
|
let info: LockInfo | null;
|
|
228
667
|
try {
|
|
229
668
|
info = await readLockInfo(lockPath);
|
|
@@ -231,11 +670,21 @@ async function staleLockSnapshot(
|
|
|
231
670
|
// Windows can transiently deny reads of a just-created lock metadata file
|
|
232
671
|
// while another contender is publishing it. Treat that as active
|
|
233
672
|
// contention and retry rather than failing the caller or reaping by path.
|
|
234
|
-
if (
|
|
673
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
235
674
|
throw error;
|
|
236
675
|
}
|
|
237
676
|
if (!info && ownerHostId !== undefined) return { stale: false };
|
|
238
677
|
if (!info) {
|
|
678
|
+
// A present but malformed owner record is not equivalent to a lock directory
|
|
679
|
+
// caught between mkdir and metadata publication. Refuse stale fallback when the
|
|
680
|
+
// metadata path exists: an unreadable live holder must never be reclaimed by
|
|
681
|
+
// elapsed mtime alone.
|
|
682
|
+
try {
|
|
683
|
+
await fs.lstat(path.join(lockPath, "info"));
|
|
684
|
+
return { stale: false };
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (!isEnoent(error)) throw error;
|
|
687
|
+
}
|
|
239
688
|
try {
|
|
240
689
|
const stats = await fs.stat(lockPath);
|
|
241
690
|
if (Date.now() - stats.mtimeMs <= staleMs) return { stale: false };
|
|
@@ -255,12 +704,37 @@ async function staleLockSnapshot(
|
|
|
255
704
|
!previousOwnerHostIds.includes(info.owner_host_id ?? "")
|
|
256
705
|
)
|
|
257
706
|
return { stale: false };
|
|
707
|
+
if (ownerHostId === undefined && info.owner_host_id !== undefined) return { stale: false };
|
|
708
|
+
if (ownerIncarnationChanged(info, startTimeCache)) {
|
|
709
|
+
if (!judgedIdentity) return { stale: false };
|
|
710
|
+
let currentIdentity: GenericFileLockDirIdentity | null;
|
|
711
|
+
try {
|
|
712
|
+
currentIdentity = await captureFileLockDirIdentity(lockPath);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
if (!currentIdentity || !sameGenericFileLockDirIdentity(judgedIdentity, currentIdentity)) return { stale: false };
|
|
718
|
+
fileLockDirIdentities.set(info, judgedIdentity);
|
|
719
|
+
return { stale: true, owner: info, identity: judgedIdentity };
|
|
720
|
+
}
|
|
258
721
|
// Never reap a live owner by elapsed time: a long legitimate critical section must
|
|
259
|
-
// not have its lock stolen (#652). Reclaim
|
|
260
|
-
//
|
|
722
|
+
// not have its lock stolen (#652). Reclaim only when the OS proves the owner is dead;
|
|
723
|
+
// indeterminate liveness remains protected regardless of elapsed time.
|
|
261
724
|
if (ownerIsAlive(info, startTimeCache)) return { stale: false };
|
|
262
|
-
|
|
263
|
-
|
|
725
|
+
const liveness = ownerLiveness(info.pid);
|
|
726
|
+
if (liveness === "dead") {
|
|
727
|
+
if (!judgedIdentity) return { stale: false };
|
|
728
|
+
let currentIdentity: GenericFileLockDirIdentity | null;
|
|
729
|
+
try {
|
|
730
|
+
currentIdentity = await captureFileLockDirIdentity(lockPath);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
if (isTransientReleaseError(error)) return { stale: false };
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
if (!currentIdentity || !sameGenericFileLockDirIdentity(judgedIdentity, currentIdentity)) return { stale: false };
|
|
736
|
+
fileLockDirIdentities.set(info, judgedIdentity);
|
|
737
|
+
return { stale: true, owner: info, identity: judgedIdentity };
|
|
264
738
|
}
|
|
265
739
|
return { stale: false };
|
|
266
740
|
}
|
|
@@ -268,18 +742,35 @@ async function staleLockSnapshot(
|
|
|
268
742
|
async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
|
|
269
743
|
if (!snapshot.stale) return false;
|
|
270
744
|
if (snapshot.owner) {
|
|
271
|
-
return (await removeFileLockDirForGc(lockPath, snapshot.owner)) === "removed";
|
|
745
|
+
return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
|
|
272
746
|
}
|
|
273
747
|
|
|
274
|
-
|
|
748
|
+
let currentInfo: LockInfo | null;
|
|
749
|
+
try {
|
|
750
|
+
currentInfo = await readLockInfo(lockPath);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (isTransientReleaseError(error)) return false;
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
275
755
|
if (currentInfo) return false;
|
|
276
756
|
try {
|
|
277
|
-
const currentStats = await fs.
|
|
757
|
+
const currentStats = await fs.lstat(lockPath);
|
|
758
|
+
if (currentStats.isSymbolicLink() || !currentStats.isDirectory()) return false;
|
|
278
759
|
if (!sameStatToken(statToken(currentStats), snapshot.stat)) return false;
|
|
279
|
-
await
|
|
280
|
-
|
|
760
|
+
const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
|
|
761
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
|
|
762
|
+
if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
|
|
763
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
764
|
+
if (
|
|
765
|
+
captured.snapshot.rootDev !== String(currentStats.dev) ||
|
|
766
|
+
captured.snapshot.rootIno !== String(currentStats.ino)
|
|
767
|
+
)
|
|
768
|
+
return false;
|
|
769
|
+
const removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
770
|
+
return removed.ok || removed.code === "not_found";
|
|
281
771
|
} catch (err) {
|
|
282
772
|
if (isEnoent(err)) return false;
|
|
773
|
+
if (isTransientReleaseError(err)) return false;
|
|
283
774
|
throw err;
|
|
284
775
|
}
|
|
285
776
|
}
|
|
@@ -306,58 +797,265 @@ export async function genericFileLockDirIsStale(
|
|
|
306
797
|
staleMs: number,
|
|
307
798
|
ownerHostId?: string,
|
|
308
799
|
): Promise<boolean> {
|
|
309
|
-
return (await
|
|
800
|
+
return (await genericFileLockDirStaleVerdict(lockDir, staleMs, ownerHostId)).stale;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Render a generic stale verdict together with the root and owner-file identity that
|
|
805
|
+
* verdict actually observed. A caller that removes the directory must require a later
|
|
806
|
+
* native snapshot to carry this same identity; a snapshot taken only after a clone was
|
|
807
|
+
* installed is not authority for the stale verdict.
|
|
808
|
+
*/
|
|
809
|
+
export async function genericFileLockDirStaleVerdict(
|
|
810
|
+
lockDir: string,
|
|
811
|
+
staleMs: number,
|
|
812
|
+
ownerHostId?: string,
|
|
813
|
+
): Promise<GenericFileLockDirStaleVerdict> {
|
|
814
|
+
const verdict = await staleLockSnapshot(lockDir, staleMs, ownerHostId);
|
|
815
|
+
if (!verdict.stale || verdict.owner === null) return { stale: false };
|
|
816
|
+
return {
|
|
817
|
+
stale: true,
|
|
818
|
+
identity: verdict.identity,
|
|
819
|
+
};
|
|
310
820
|
}
|
|
311
821
|
|
|
312
822
|
async function tryAcquireLock(
|
|
313
823
|
lockPath: string,
|
|
314
824
|
ownerHostId?: string,
|
|
825
|
+
ownerToken = crypto.randomUUID(),
|
|
315
826
|
onAcquired?: () => void,
|
|
316
827
|
): Promise<LockInfo | null> {
|
|
317
|
-
await
|
|
828
|
+
await ensureLockParent(path.dirname(lockPath));
|
|
318
829
|
const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
|
|
319
830
|
if (afterParentMkdir) await afterParentMkdir(lockPath);
|
|
320
|
-
|
|
831
|
+
const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
|
|
832
|
+
const owner = lockInfo(ownerHostId, ownerToken);
|
|
833
|
+
try {
|
|
834
|
+
await fs.mkdir(pendingPath, { mode: 0o700 });
|
|
835
|
+
await fs.chmod(pendingPath, 0o700);
|
|
836
|
+
await writeLockInfo(pendingPath, owner);
|
|
837
|
+
// A plain POSIX rename replaces an existing empty directory. The legacy
|
|
838
|
+
// directory lock is a real holder, so publication must use the native
|
|
839
|
+
// no-replace primitive rather than treating an empty destination as free.
|
|
840
|
+
// The native primitive also rejects symlinked/reparse parents. Resolve the
|
|
841
|
+
// already-created staging entry and its parent before publication so aliases
|
|
842
|
+
// retain the same lock identity as the ordinary path.
|
|
843
|
+
const canonicalParent = await fs.realpath(path.dirname(lockPath));
|
|
844
|
+
const published = await renameNoReplacePathAsync(
|
|
845
|
+
await fs.realpath(pendingPath),
|
|
846
|
+
path.join(canonicalParent, path.basename(lockPath)),
|
|
847
|
+
);
|
|
848
|
+
if (!published.ok) {
|
|
849
|
+
if (published.reason === "destination_exists") return null;
|
|
850
|
+
const failure = new Error(
|
|
851
|
+
`Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
|
|
852
|
+
) as NodeJS.ErrnoException;
|
|
853
|
+
if (published.code) failure.code = published.code;
|
|
854
|
+
throw failure;
|
|
855
|
+
}
|
|
856
|
+
// Published above, so an onAcquired failure must propagate instead of
|
|
857
|
+
// retrying an acquisition that already owns the lock.
|
|
858
|
+
onAcquired?.();
|
|
859
|
+
return owner;
|
|
860
|
+
} finally {
|
|
861
|
+
await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function isTransientReleaseError(error: unknown): boolean {
|
|
866
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
867
|
+
return (
|
|
868
|
+
code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function throwTransientNativeResult(code: string): never {
|
|
873
|
+
throw Object.assign(new Error(`Native lock operation is transiently unavailable: ${code}.`), { code });
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
type NativeFileLockBindings = {
|
|
877
|
+
snapshotDirectoryTree(lockPath: string): NativeDirectoryTreeResult;
|
|
878
|
+
exactRemoveDirectoryTree(
|
|
879
|
+
lockPath: string,
|
|
880
|
+
snapshot: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
|
|
881
|
+
): NativeExactUnlinkResult;
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
let nativeFileLockBindingCache: NativeFileLockBindings | undefined;
|
|
885
|
+
|
|
886
|
+
function nativeFileLockBindings(): NativeFileLockBindings {
|
|
887
|
+
if (FileLockTestHooks.nativeQuarantineBindings) return FileLockTestHooks.nativeQuarantineBindings();
|
|
888
|
+
if (nativeFileLockBindingCache) return nativeFileLockBindingCache;
|
|
889
|
+
try {
|
|
890
|
+
nativeFileLockBindingCache = require("@gajae-code/natives") as NativeFileLockBindings;
|
|
891
|
+
return nativeFileLockBindingCache;
|
|
892
|
+
} catch (error) {
|
|
893
|
+
throw Object.assign(new Error("Native identity-bound lock quarantine is unavailable."), { cause: error });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function quarantineReleasedLock(
|
|
898
|
+
lockPath: string,
|
|
899
|
+
owner: FileLockOwnerToken,
|
|
900
|
+
preVerdictIdentity?: GenericFileLockDirIdentity,
|
|
901
|
+
): Promise<boolean> {
|
|
902
|
+
const expectedIdentity = preVerdictIdentity ?? fileLockDirIdentities.get(owner);
|
|
903
|
+
// Quarantine is only a release fallback after earlier transient failures. It must not
|
|
904
|
+
// make a fresh post-failure snapshot authoritative for an object this release never
|
|
905
|
+
// judged; without pre-verdict evidence, refuse and let the caller surface the retry
|
|
906
|
+
// failure instead of risking a successor lock.
|
|
907
|
+
if (!expectedIdentity) return false;
|
|
908
|
+
let captured: NativeDirectoryTreeResult;
|
|
909
|
+
const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
|
|
910
|
+
try {
|
|
911
|
+
captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
|
|
912
|
+
if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (isTransientReleaseError(error)) return false;
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
917
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
918
|
+
const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
|
|
919
|
+
if (!infoEntry?.sha256) return false;
|
|
920
|
+
if (
|
|
921
|
+
captured.snapshot.rootDev !== expectedIdentity.rootDev ||
|
|
922
|
+
captured.snapshot.rootIno !== expectedIdentity.rootIno ||
|
|
923
|
+
infoEntry.dev !== expectedIdentity.infoDev ||
|
|
924
|
+
infoEntry.ino !== expectedIdentity.infoIno ||
|
|
925
|
+
infoEntry.nlink !== expectedIdentity.infoNlink ||
|
|
926
|
+
infoEntry.size !== expectedIdentity.infoSize ||
|
|
927
|
+
infoEntry.mtimeNs !== expectedIdentity.infoMtimeNs ||
|
|
928
|
+
infoEntry.ctimeNs !== expectedIdentity.infoCtimeNs ||
|
|
929
|
+
infoEntry.sha256 !== expectedIdentity.infoSha256
|
|
930
|
+
)
|
|
931
|
+
return false;
|
|
932
|
+
// Bind the owner generation to the snapshot before exact removal. A successor
|
|
933
|
+
// installed after this snapshot is rejected by the native identity check instead of
|
|
934
|
+
// being moved into quarantine by a pathname-only rename.
|
|
935
|
+
const expectedDigest = crypto.createHash("sha256").update(JSON.stringify(owner)).digest("hex");
|
|
936
|
+
if (infoEntry.sha256 !== expectedDigest) return false;
|
|
937
|
+
let removed: NativeExactUnlinkResult;
|
|
938
|
+
try {
|
|
939
|
+
removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if (isTransientReleaseError(error)) return false;
|
|
942
|
+
throw error;
|
|
943
|
+
}
|
|
944
|
+
if (removed.ok || removed.code === "not_found") return true;
|
|
945
|
+
if (
|
|
946
|
+
removed.detachedPath !== undefined &&
|
|
947
|
+
path.resolve(removed.detachedPath) !== path.resolve(lockPath) &&
|
|
948
|
+
removed.retainedSuccessorPath === undefined &&
|
|
949
|
+
removed.retainedPlaceholderPath === undefined &&
|
|
950
|
+
removed.retainedUnknownPath === undefined
|
|
951
|
+
) {
|
|
321
952
|
try {
|
|
322
|
-
await fs.
|
|
323
|
-
|
|
324
|
-
return await writeLockInfo(lockPath, lockInfo());
|
|
953
|
+
await fs.lstat(nativeCapturePath);
|
|
954
|
+
return false;
|
|
325
955
|
} catch (error) {
|
|
326
|
-
if ((error
|
|
956
|
+
if (isEnoent(error)) {
|
|
957
|
+
nativeFileLockBindings().exactRemoveDirectoryTree(removed.detachedPath, captured.snapshot);
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
327
960
|
throw error;
|
|
328
961
|
}
|
|
329
962
|
}
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
330
965
|
|
|
331
|
-
|
|
332
|
-
|
|
966
|
+
async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
|
|
967
|
+
let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
|
|
333
968
|
try {
|
|
334
|
-
await
|
|
335
|
-
|
|
969
|
+
preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
|
|
970
|
+
if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
|
|
971
|
+
} catch (error) {
|
|
972
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
973
|
+
}
|
|
974
|
+
let lastTransientError: unknown;
|
|
975
|
+
for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
|
|
336
976
|
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
|
-
}
|
|
977
|
+
const outcome = await removeFileLockDirForGc(lockPath, owner, preVerdictIdentity);
|
|
978
|
+
if (outcome === "removed" || outcome === "missing") {
|
|
979
|
+
if (outcome === "missing") throw new Error("Failed to release file lock: missing.");
|
|
980
|
+
return;
|
|
350
981
|
}
|
|
351
|
-
throw
|
|
982
|
+
throw new Error(`Failed to release file lock: ${outcome}.`);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
985
|
+
lastTransientError = error;
|
|
986
|
+
if (attempt + 1 < FILE_LOCK_RELEASE_RETRY_ATTEMPTS) await Bun.sleep(FILE_LOCK_RELEASE_RETRY_DELAY_MS);
|
|
352
987
|
}
|
|
988
|
+
}
|
|
989
|
+
if (await quarantineReleasedLock(lockPath, owner, preVerdictIdentity)) return;
|
|
990
|
+
throw lastTransientError ?? new Error("Failed to release file lock: transient removal failure.");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function retryPendingLocalRelease(lockPath: string, knownKey?: string): Promise<void> {
|
|
994
|
+
const key = knownKey ?? (await localLockKey(lockPath));
|
|
995
|
+
const state = localLockStates.get(key);
|
|
996
|
+
if (!state || state.status === "held") return;
|
|
997
|
+
if (state.releasePromise) {
|
|
998
|
+
await state.releasePromise.catch(() => undefined);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
state.status = "releasing";
|
|
1002
|
+
const releasePromise = releaseOwnedLock(lockPath, state.owner);
|
|
1003
|
+
state.releasePromise = releasePromise;
|
|
1004
|
+
try {
|
|
1005
|
+
await releasePromise;
|
|
1006
|
+
if (localLockStates.get(key) === state) localLockStates.delete(key);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
state.status = "release_pending";
|
|
1009
|
+
throw error;
|
|
353
1010
|
} finally {
|
|
354
|
-
|
|
1011
|
+
if (state.releasePromise === releasePromise) state.releasePromise = undefined;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function pendingLocalReleaseKey(lockPath: string, localKey: string): Promise<string | undefined> {
|
|
1016
|
+
const direct = localLockStates.get(localKey);
|
|
1017
|
+
if (direct) return direct.status === "held" ? undefined : localKey;
|
|
1018
|
+
let info: LockInfo | null;
|
|
1019
|
+
try {
|
|
1020
|
+
info = await readLockInfo(lockPath);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
if (isTransientReleaseError(error)) return undefined;
|
|
1023
|
+
throw error;
|
|
1024
|
+
}
|
|
1025
|
+
const ownerToken = info?.owner_token;
|
|
1026
|
+
if (!ownerToken) return undefined;
|
|
1027
|
+
for (const [key, state] of localLockStates) {
|
|
1028
|
+
if (key !== localKey && state.status !== "held" && state.owner.owner_token === ownerToken) return key;
|
|
355
1029
|
}
|
|
1030
|
+
return undefined;
|
|
356
1031
|
}
|
|
357
1032
|
|
|
358
|
-
async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
|
|
359
|
-
const
|
|
360
|
-
|
|
1033
|
+
async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey?: string): Promise<void> {
|
|
1034
|
+
const key = knownKey ?? (await localLockKey(lockPath));
|
|
1035
|
+
const state = localLockStates.get(key);
|
|
1036
|
+
if (!state || state.owner.owner_token !== owner.owner_token) {
|
|
1037
|
+
throw new Error("Failed to release file lock: local owner generation is unknown.");
|
|
1038
|
+
}
|
|
1039
|
+
if (state.status === "release_pending") {
|
|
1040
|
+
await retryPendingLocalRelease(lockPath, key);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (state.releasePromise) {
|
|
1044
|
+
await state.releasePromise;
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
state.status = "releasing";
|
|
1048
|
+
const releasePromise = releaseOwnedLock(lockPath, owner);
|
|
1049
|
+
state.releasePromise = releasePromise;
|
|
1050
|
+
try {
|
|
1051
|
+
await releasePromise;
|
|
1052
|
+
if (localLockStates.get(key) === state) localLockStates.delete(key);
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
state.status = "release_pending";
|
|
1055
|
+
throw error;
|
|
1056
|
+
} finally {
|
|
1057
|
+
if (state.releasePromise === releasePromise) state.releasePromise = undefined;
|
|
1058
|
+
}
|
|
361
1059
|
}
|
|
362
1060
|
/**
|
|
363
1061
|
* Bounded, actionable description of who holds `lockPath` at exhaustion time.
|
|
@@ -409,12 +1107,30 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
|
|
|
409
1107
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
410
1108
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
411
1109
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
1110
|
+
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
412
1111
|
const lockPath = getLockPath(filePath);
|
|
1112
|
+
await ensureLockParent(path.dirname(lockPath));
|
|
1113
|
+
const ownerToken = crypto.randomUUID();
|
|
413
1114
|
const contentionStartTimes = new Map<string, string | null>();
|
|
414
1115
|
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
415
1116
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
416
|
-
const
|
|
417
|
-
|
|
1117
|
+
const localKey = await localLockKey(lockPath);
|
|
1118
|
+
const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
|
|
1119
|
+
if (owner) {
|
|
1120
|
+
localLockStates.set(localKey, { owner, status: "held" });
|
|
1121
|
+
return () => releaseLock(lockPath, owner, localKey);
|
|
1122
|
+
}
|
|
1123
|
+
const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
|
|
1124
|
+
const localState = localLockStates.get(pendingKey ?? localKey);
|
|
1125
|
+
if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
|
|
1126
|
+
try {
|
|
1127
|
+
await retryPendingLocalRelease(lockPath, pendingKey);
|
|
1128
|
+
continue;
|
|
1129
|
+
} catch {
|
|
1130
|
+
// Keep contending below. A failed local retry is not authority to steal a
|
|
1131
|
+
// lock; the owner generation remains fenced until release succeeds.
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
418
1134
|
const stale = await staleLockSnapshot(
|
|
419
1135
|
lockPath,
|
|
420
1136
|
opts.staleMs,
|