@gajae-code/coding-agent 0.16.3 → 0.16.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 +113 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli/args.d.ts +2 -2
- package/dist/types/cli/customize-doctor.d.ts +22 -0
- package/dist/types/cli/root-flags.d.ts +4 -2
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/config/file-lock.d.ts +31 -3
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/model-resolver.d.ts +9 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +7 -0
- package/dist/types/discovery/claude-plugins.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +35 -0
- package/dist/types/exec/bash-executor.d.ts +17 -1
- package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
- package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-signals.d.ts +2 -0
- package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
- package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
- package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
- package/dist/types/exec/bash-shell-worker.d.ts +1 -0
- package/dist/types/exec/isolated-shell.d.ts +19 -0
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
- package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +46 -6
- package/dist/types/extensibility/extensions/types.d.ts +16 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/hooks/types.d.ts +4 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/extensibility/skill-management.d.ts +9 -4
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
- package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
- package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/bash-execution.d.ts +1 -0
- package/dist/types/modes/components/execution-shared.d.ts +1 -0
- package/dist/types/modes/components/settings-selector.d.ts +6 -1
- package/dist/types/modes/components/status-line/command.d.ts +35 -0
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +5 -0
- package/dist/types/modes/components/tool-status-header.d.ts +14 -0
- package/dist/types/modes/controllers/event-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/runtime-mcp/client.d.ts +9 -1
- package/dist/types/runtime-mcp/manager.d.ts +7 -1
- package/dist/types/runtime-mcp/pool.d.ts +22 -3
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/runtime-mcp/types.d.ts +22 -0
- package/dist/types/sdk/broker/ensure.d.ts +32 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
- package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
- package/dist/types/sdk/client/client.d.ts +6 -0
- package/dist/types/sdk/host/host.d.ts +3 -1
- package/dist/types/sdk/host/session-runtime.d.ts +26 -1
- package/dist/types/sdk/lifecycle/service.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +10 -2
- package/dist/types/sdk/session-api-key.d.ts +26 -0
- package/dist/types/sdk/session.d.ts +5 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +11 -0
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/setup/paseo/announce.d.ts +112 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
- package/dist/types/skill-state/canonical-skills.d.ts +4 -0
- package/dist/types/system-prompt.d.ts +10 -0
- package/dist/types/task/executor.d.ts +5 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +7 -0
- package/dist/types/tools/github-cache.d.ts +2 -1
- package/dist/types/tools/index.d.ts +10 -0
- package/dist/types/tools/path-utils.d.ts +2 -0
- package/dist/types/tools/search.d.ts +6 -0
- package/package.json +9 -7
- package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
- package/scripts/generate-sdk-operation-inventory.ts +11 -0
- package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
- package/src/capability/index.ts +14 -2
- package/src/capability/types.ts +4 -0
- package/src/cli/args.ts +20 -9
- package/src/cli/customize-doctor.ts +155 -12
- package/src/cli/root-flags.ts +5 -3
- package/src/cli.ts +22 -0
- package/src/commands/sdk.ts +74 -31
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/file-lock-gc.ts +40 -2
- package/src/config/file-lock.ts +379 -31
- package/src/config/model-preset-registry.ts +120 -19
- package/src/config/model-profiles.ts +40 -0
- package/src/config/model-registry.ts +73 -23
- package/src/config/model-resolver.ts +48 -0
- package/src/config/settings-schema.ts +33 -1
- package/src/config/settings.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +33 -2
- package/src/coordinator-mcp/server.ts +477 -101
- package/src/crash/sanitize.ts +1 -1
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
- package/src/discovery/builtin.ts +52 -55
- package/src/discovery/claude-plugins.ts +12 -4
- package/src/discovery/helpers.ts +307 -34
- package/src/exec/bash-executor.ts +330 -50
- package/src/exec/bash-shell-guardian.ts +388 -0
- package/src/exec/bash-shell-runtime-entry.ts +5 -0
- package/src/exec/bash-shell-signals.ts +29 -0
- package/src/exec/bash-shell-supervisor-entry.ts +3 -0
- package/src/exec/bash-shell-supervisor.ts +359 -0
- package/src/exec/bash-shell-worker-entry.ts +5 -0
- package/src/exec/bash-shell-worker-protocol.ts +36 -0
- package/src/exec/bash-shell-worker.ts +116 -0
- package/src/exec/isolated-shell.ts +393 -0
- package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
- package/src/extensibility/extensions/function-hooks.ts +959 -0
- package/src/extensibility/extensions/index.ts +22 -0
- package/src/extensibility/extensions/loader.ts +53 -2
- package/src/extensibility/extensions/runner.ts +895 -264
- package/src/extensibility/extensions/types.ts +42 -0
- package/src/extensibility/extensions/wrapper.ts +56 -15
- package/src/extensibility/gjc-plugins/compiler.ts +228 -60
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
- package/src/extensibility/gjc-plugins/installer.ts +13 -12
- package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
- package/src/extensibility/gjc-plugins/paths.ts +13 -0
- package/src/extensibility/gjc-plugins/registry.ts +113 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
- package/src/extensibility/gjc-plugins/schema.ts +121 -4
- package/src/extensibility/gjc-plugins/types.ts +25 -0
- package/src/extensibility/hooks/loader.ts +133 -16
- package/src/extensibility/hooks/types.ts +28 -0
- package/src/extensibility/runtime-skill-discovery.ts +149 -190
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skill-management.ts +200 -52
- package/src/extensibility/skills.ts +73 -25
- package/src/gjc-runtime/empty-delete-gc.ts +94 -4
- package/src/gjc-runtime/gc-runtime.ts +29 -0
- package/src/gjc-runtime/linux-proc.ts +14 -0
- package/src/gjc-runtime/repository-binding.ts +1 -1
- package/src/gjc-runtime/session-state-lock.ts +143 -10
- package/src/gjc-runtime/session-state-sidecar.ts +27 -11
- package/src/gjc-runtime/state-writer.ts +30 -29
- package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
- package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -0
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +12 -12
- package/src/lsp/config.ts +10 -3
- package/src/lsp/index.ts +1 -1
- package/src/lsp/path-trust.ts +69 -14
- package/src/main.ts +78 -35
- package/src/modes/DESIGN.md +54 -0
- package/src/modes/acp/acp-agent.ts +16 -3
- package/src/modes/components/assistant-message.ts +22 -9
- package/src/modes/components/bash-execution.ts +4 -1
- package/src/modes/components/execution-shared.ts +4 -1
- package/src/modes/components/model-selector.ts +166 -33
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/settings-selector.ts +1075 -235
- package/src/modes/components/status-line/command.ts +190 -0
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/segments.ts +32 -5
- package/src/modes/components/status-line/types.ts +5 -0
- package/src/modes/components/tool-status-header.ts +396 -22
- package/src/modes/controllers/command-controller.ts +3 -0
- package/src/modes/controllers/event-controller.ts +151 -7
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +6 -3
- package/src/modes/controllers/selector-controller.ts +21 -0
- package/src/modes/interactive-mode.ts +53 -6
- package/src/modes/print-mode.ts +5 -1
- package/src/modes/prompt-action-autocomplete.ts +54 -27
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/types.ts +17 -3
- package/src/modes/utils/ui-helpers.ts +2 -0
- package/src/prompts/system/system-prompt.md +8 -0
- package/src/prompts/tools/search.md +2 -0
- package/src/prompts/tools/subagent.md +1 -0
- package/src/runtime-mcp/client.ts +60 -17
- package/src/runtime-mcp/manager.ts +189 -14
- package/src/runtime-mcp/pool.test.ts +218 -0
- package/src/runtime-mcp/pool.ts +224 -23
- package/src/runtime-mcp/transports/stdio.ts +158 -24
- package/src/runtime-mcp/types.ts +24 -0
- package/src/sdk/broker/broker.ts +124 -32
- package/src/sdk/broker/ensure.ts +248 -41
- package/src/sdk/broker/lifecycle-ledger.ts +7 -17
- package/src/sdk/broker/lifecycle.ts +56 -6
- package/src/sdk/broker/startup-failure.ts +15 -6
- package/src/sdk/bus/chat-daemon-control.ts +3 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
- package/src/sdk/bus/chat-effect-journal.ts +132 -7
- package/src/sdk/bus/conversation-store.ts +29 -0
- package/src/sdk/bus/lifecycle-commands.ts +17 -11
- package/src/sdk/bus/slack-conversation.ts +1 -0
- package/src/sdk/bus/slack-daemon.ts +478 -49
- package/src/sdk/bus/slack-live-provider.ts +12 -0
- package/src/sdk/bus/slack-provider.ts +5 -0
- package/src/sdk/bus/telegram-daemon.ts +16 -2
- package/src/sdk/bus/topic-registry.ts +19 -0
- package/src/sdk/cli/master-cli.ts +1 -1
- package/src/sdk/cli/session-cli.ts +11 -8
- package/src/sdk/client/client.ts +66 -17
- package/src/sdk/host/host.ts +29 -9
- package/src/sdk/host/query/revision-store.ts +19 -13
- package/src/sdk/host/reverse-leases.ts +7 -4
- package/src/sdk/host/session-runtime.test.ts +52 -10
- package/src/sdk/host/session-runtime.ts +383 -44
- package/src/sdk/lifecycle/service.ts +4 -0
- package/src/sdk/protocol/operation-inventory.generated.json +77 -0
- package/src/sdk/router/session-router.ts +89 -38
- package/src/sdk/session-api-key.ts +94 -0
- package/src/sdk/session.ts +228 -55
- package/src/sdk/transport/relay.ts +35 -13
- package/src/sdk/transport/serve-cli.ts +3 -3
- package/src/session/agent-session.ts +240 -90
- package/src/session/contribution-prep.ts +214 -21
- package/src/session/fallback-chain-controller.ts +38 -0
- package/src/session/internal/managed-session-storage.ts +19 -5
- package/src/session/messages.ts +5 -0
- package/src/session/session-manager.ts +29 -5
- package/src/session-import/redact.ts +5 -1
- package/src/setup/paseo/announce.ts +456 -0
- package/src/setup/paseo/setup-deps.ts +15 -3
- package/src/skill-state/canonical-skills.ts +12 -0
- package/src/skills/skills.test.ts +41 -21
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/system-prompt.ts +29 -3
- package/src/task/executor.ts +7 -1
- package/src/task/index.ts +21 -4
- package/src/tools/bash.ts +63 -19
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +29 -1
- package/src/tools/gh.ts +26 -3
- package/src/tools/github-cache.ts +68 -16
- package/src/tools/index.ts +10 -0
- package/src/tools/job.ts +21 -4
- package/src/tools/monitor.ts +8 -2
- package/src/tools/path-utils.ts +7 -0
- package/src/tools/read.ts +14 -2
- package/src/tools/render-utils.ts +1 -3
- package/src/tools/resource-gc.ts +28 -8
- package/src/tools/search.ts +116 -58
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +37 -2
- package/src/tools/subagent.ts +6 -3
- package/src/tools/tool-catalog.generated.ts +10 -3
- package/src/tools/ultragoal-ask-guard.ts +2 -2
- package/src/web/insane/bridge.ts +113 -23
- package/src/web/search/providers/perplexity.ts +102 -41
package/src/config/file-lock.ts
CHANGED
|
@@ -2,8 +2,20 @@ import * as crypto from "node:crypto";
|
|
|
2
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 type {
|
|
6
|
-
|
|
5
|
+
import type {
|
|
6
|
+
NativeDirectoryTreeResult,
|
|
7
|
+
NativeDirectoryTreeSnapshot,
|
|
8
|
+
NativeExactUnlinkResult,
|
|
9
|
+
NativeNoReplaceResult,
|
|
10
|
+
RecoveryFsPublishResult,
|
|
11
|
+
} from "@gajae-code/natives";
|
|
12
|
+
import {
|
|
13
|
+
openRecoveryFsRoot,
|
|
14
|
+
renameDirectoryNoReplacePathAsync,
|
|
15
|
+
renameNoReplacePathAsync,
|
|
16
|
+
snapshotDirectoryTree,
|
|
17
|
+
} from "@gajae-code/natives";
|
|
18
|
+
import { logger } from "@gajae-code/utils";
|
|
7
19
|
import { isEnoent } from "@gajae-code/utils/fs-error";
|
|
8
20
|
import { nativeProcessBindings } from "@gajae-code/utils/native-process";
|
|
9
21
|
|
|
@@ -19,6 +31,23 @@ export interface FileLockOptions {
|
|
|
19
31
|
previousOwnerHostIds?: readonly string[];
|
|
20
32
|
}
|
|
21
33
|
|
|
34
|
+
export class FileLockAcquireError extends Error {
|
|
35
|
+
readonly code = "acquire_timeout";
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
readonly filePath: string,
|
|
39
|
+
readonly lockPath: string,
|
|
40
|
+
readonly attempts: number,
|
|
41
|
+
readonly holder: string,
|
|
42
|
+
) {
|
|
43
|
+
super(
|
|
44
|
+
`Failed to acquire lock for ${filePath} after ${attempts} attempts: ${holder} (${lockPath}); ` +
|
|
45
|
+
`a live owner is never displaced — if this is an SDK broker (gjc sdk status), it must finish or be stopped before retrying`,
|
|
46
|
+
);
|
|
47
|
+
this.name = "FileLockAcquireError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
const DEFAULT_OPTIONS: Required<
|
|
23
52
|
Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
|
|
24
53
|
> = {
|
|
@@ -27,9 +56,9 @@ const DEFAULT_OPTIONS: Required<
|
|
|
27
56
|
retryDelayMs: 100,
|
|
28
57
|
};
|
|
29
58
|
|
|
30
|
-
/** Release retries cover transient
|
|
31
|
-
export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS =
|
|
32
|
-
export const FILE_LOCK_RELEASE_RETRY_DELAY_MS =
|
|
59
|
+
/** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
|
|
60
|
+
export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
|
|
61
|
+
export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
|
|
33
62
|
const PROCESS_START_TIME_FORMAT = "utc-v1";
|
|
34
63
|
|
|
35
64
|
type LocalLockState = {
|
|
@@ -90,12 +119,27 @@ export function processStartTime(pid: number): string | null {
|
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
let ownProcessStartTime: string | undefined;
|
|
122
|
+
let ownProcessIncarnation: string | null | undefined;
|
|
93
123
|
|
|
94
124
|
function currentProcessStartTime(): string {
|
|
95
125
|
if (ownProcessStartTime === undefined) ownProcessStartTime = processStartTime(process.pid) ?? "unknown";
|
|
96
126
|
return ownProcessStartTime;
|
|
97
127
|
}
|
|
98
128
|
|
|
129
|
+
function processIncarnation(pid: number): string | null {
|
|
130
|
+
try {
|
|
131
|
+
const incarnation = nativeProcessBindings().Process.fromPid(pid)?.incarnation;
|
|
132
|
+
return typeof incarnation === "string" && incarnation.length > 0 ? incarnation : null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function currentProcessIncarnation(): string | null {
|
|
139
|
+
if (ownProcessIncarnation === undefined) ownProcessIncarnation = processIncarnation(process.pid);
|
|
140
|
+
return ownProcessIncarnation;
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, string | null>): string | null {
|
|
100
144
|
if (!cache) return processStartTime(owner.pid);
|
|
101
145
|
const key = `${owner.pid}:${owner.start_time ?? ""}`;
|
|
@@ -108,6 +152,10 @@ function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, s
|
|
|
108
152
|
|
|
109
153
|
function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
|
|
110
154
|
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
155
|
+
if (owner.process_incarnation) {
|
|
156
|
+
const currentIncarnation = processIncarnation(owner.pid);
|
|
157
|
+
return currentIncarnation === null || currentIncarnation === owner.process_incarnation;
|
|
158
|
+
}
|
|
111
159
|
if (!owner.start_time || owner.start_time === "unknown") return true;
|
|
112
160
|
const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
|
|
113
161
|
if (currentStartTime === null || currentStartTime === owner.start_time) return true;
|
|
@@ -119,10 +167,12 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
|
|
|
119
167
|
}
|
|
120
168
|
|
|
121
169
|
function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
|
|
170
|
+
const incarnation = currentProcessIncarnation();
|
|
122
171
|
return {
|
|
123
172
|
pid: process.pid,
|
|
124
173
|
start_time: currentProcessStartTime(),
|
|
125
174
|
start_time_format: PROCESS_START_TIME_FORMAT,
|
|
175
|
+
...(incarnation === null ? {} : { process_incarnation: incarnation }),
|
|
126
176
|
timestamp: Date.now(),
|
|
127
177
|
owner_token: ownerToken,
|
|
128
178
|
...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
|
|
@@ -316,7 +366,8 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
|
316
366
|
}
|
|
317
367
|
|
|
318
368
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
319
|
-
const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } =
|
|
369
|
+
const { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token } =
|
|
370
|
+
parsed as Partial<LockInfo>;
|
|
320
371
|
if (
|
|
321
372
|
typeof pid !== "number" ||
|
|
322
373
|
!Number.isInteger(pid) ||
|
|
@@ -325,11 +376,12 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
|
|
|
325
376
|
!Number.isFinite(timestamp) ||
|
|
326
377
|
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
|
|
327
378
|
(start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
|
|
379
|
+
(process_incarnation !== undefined && (typeof process_incarnation !== "string" || !process_incarnation)) ||
|
|
328
380
|
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
|
|
329
381
|
(owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
|
|
330
382
|
)
|
|
331
383
|
return null;
|
|
332
|
-
return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
|
|
384
|
+
return { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token };
|
|
333
385
|
}
|
|
334
386
|
|
|
335
387
|
function parseLockInfoBytes(bytes: string): LockInfo | null {
|
|
@@ -340,7 +392,8 @@ function parseLockInfoBytes(bytes: string): LockInfo | null {
|
|
|
340
392
|
return null;
|
|
341
393
|
}
|
|
342
394
|
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 } =
|
|
395
|
+
const { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token } =
|
|
396
|
+
parsed as Partial<LockInfo>;
|
|
344
397
|
if (
|
|
345
398
|
typeof pid !== "number" ||
|
|
346
399
|
!Number.isInteger(pid) ||
|
|
@@ -349,11 +402,12 @@ function parseLockInfoBytes(bytes: string): LockInfo | null {
|
|
|
349
402
|
!Number.isFinite(timestamp) ||
|
|
350
403
|
(start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
|
|
351
404
|
(start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
|
|
405
|
+
(process_incarnation !== undefined && (typeof process_incarnation !== "string" || !process_incarnation)) ||
|
|
352
406
|
(owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
|
|
353
407
|
(owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
|
|
354
408
|
)
|
|
355
409
|
return null;
|
|
356
|
-
return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
|
|
410
|
+
return { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token };
|
|
357
411
|
}
|
|
358
412
|
|
|
359
413
|
/** @internal */
|
|
@@ -382,6 +436,8 @@ export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOw
|
|
|
382
436
|
/** Owner identity stamped into a `<file>.lock/info` record. */
|
|
383
437
|
export interface FileLockOwnerToken {
|
|
384
438
|
pid: number;
|
|
439
|
+
/** Kernel-derived identity for the exact process generation owning `pid`. */
|
|
440
|
+
process_incarnation?: string;
|
|
385
441
|
start_time?: string;
|
|
386
442
|
/** Encoding marker for the canonical UTC process-start identity. */
|
|
387
443
|
start_time_format?: string;
|
|
@@ -397,11 +453,114 @@ export interface FileLockOwnerToken {
|
|
|
397
453
|
* another process may copy into a new lock generation.
|
|
398
454
|
*/
|
|
399
455
|
const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
|
|
456
|
+
const pendingDetachedLockCleanups = new WeakMap<object, { path: string; rootDev: string; rootIno: string }>();
|
|
457
|
+
|
|
458
|
+
async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
|
|
459
|
+
const pending = pendingDetachedLockCleanups.get(owner);
|
|
460
|
+
if (!pending) return false;
|
|
461
|
+
try {
|
|
462
|
+
const current = await fs.lstat(pending.path, { bigint: true });
|
|
463
|
+
if (
|
|
464
|
+
!current.isDirectory() ||
|
|
465
|
+
current.isSymbolicLink() ||
|
|
466
|
+
current.dev.toString() !== pending.rootDev ||
|
|
467
|
+
current.ino.toString() !== pending.rootIno
|
|
468
|
+
) {
|
|
469
|
+
throw new Error("Detached file lock cleanup identity changed; refusing removal");
|
|
470
|
+
}
|
|
471
|
+
await fs.rm(pending.path, { recursive: true, force: true });
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (!isEnoent(error)) throw error;
|
|
474
|
+
}
|
|
475
|
+
pendingDetachedLockCleanups.delete(owner);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
400
478
|
|
|
401
479
|
function getLockPath(filePath: string): string {
|
|
402
480
|
return `${filePath}.lock`;
|
|
403
481
|
}
|
|
404
482
|
|
|
483
|
+
function fileLockRemovalTransitionPath(lockPath: string): string {
|
|
484
|
+
return `${lockPath}.removing`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function fileLockRemovalTransitionExists(lockPath: string): Promise<boolean> {
|
|
488
|
+
try {
|
|
489
|
+
await fs.lstat(fileLockRemovalTransitionPath(lockPath));
|
|
490
|
+
return true;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (isEnoent(error)) return false;
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function sameFileLockTreeAfterPublication(
|
|
498
|
+
staged: NativeDirectoryTreeSnapshot,
|
|
499
|
+
published: NativeDirectoryTreeSnapshot,
|
|
500
|
+
): boolean {
|
|
501
|
+
return (
|
|
502
|
+
staged.rootDev === published.rootDev &&
|
|
503
|
+
staged.rootIno === published.rootIno &&
|
|
504
|
+
staged.entries.length === published.entries.length &&
|
|
505
|
+
staged.entries.every((entry, index) => {
|
|
506
|
+
const current = published.entries[index];
|
|
507
|
+
return (
|
|
508
|
+
current !== undefined &&
|
|
509
|
+
entry.relativePath === current.relativePath &&
|
|
510
|
+
entry.kind === current.kind &&
|
|
511
|
+
entry.dev === current.dev &&
|
|
512
|
+
entry.ino === current.ino &&
|
|
513
|
+
entry.nlink === current.nlink &&
|
|
514
|
+
entry.size === current.size &&
|
|
515
|
+
entry.mtimeNs === current.mtimeNs &&
|
|
516
|
+
(entry.relativePath === "" || entry.ctimeNs === current.ctimeNs) &&
|
|
517
|
+
entry.sha256 === current.sha256
|
|
518
|
+
);
|
|
519
|
+
})
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function rollbackPublishedFileLock(
|
|
524
|
+
canonicalParent: string,
|
|
525
|
+
lockPath: string,
|
|
526
|
+
pendingPath: string,
|
|
527
|
+
stagedSnapshot: NativeDirectoryTreeSnapshot,
|
|
528
|
+
): void {
|
|
529
|
+
const authority = openRecoveryFsRoot(canonicalParent);
|
|
530
|
+
let rollback: RecoveryFsPublishResult | undefined;
|
|
531
|
+
const errors: unknown[] = [];
|
|
532
|
+
try {
|
|
533
|
+
rollback = authority.renameManagedTreeNoReplace(
|
|
534
|
+
path.basename(lockPath),
|
|
535
|
+
path.basename(pendingPath),
|
|
536
|
+
stagedSnapshot,
|
|
537
|
+
);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
errors.push(error);
|
|
540
|
+
}
|
|
541
|
+
if (rollback && !rollback.ok) {
|
|
542
|
+
const failure = new Error(
|
|
543
|
+
`Failed to roll back file lock published during removal transition: ${rollback.code ?? "unknown"}.`,
|
|
544
|
+
) as NodeJS.ErrnoException;
|
|
545
|
+
if (rollback.code) failure.code = rollback.code;
|
|
546
|
+
errors.push(failure);
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const closed = authority.close();
|
|
550
|
+
if (!closed.ok && closed.code !== "closed") {
|
|
551
|
+
const failure = new Error(
|
|
552
|
+
`Failed to close file lock rollback authority: ${closed.code ?? "unknown"}.`,
|
|
553
|
+
) as NodeJS.ErrnoException;
|
|
554
|
+
if (closed.code) failure.code = closed.code;
|
|
555
|
+
errors.push(failure);
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
errors.push(error);
|
|
559
|
+
}
|
|
560
|
+
if (errors.length > 0) throw new AggregateError(errors, "File lock rollback authority failed");
|
|
561
|
+
if (!rollback) throw new Error("File lock rollback returned no result");
|
|
562
|
+
}
|
|
563
|
+
|
|
405
564
|
async function ensureLockParent(directory: string): Promise<void> {
|
|
406
565
|
const missing: string[] = [];
|
|
407
566
|
let current = path.resolve(directory);
|
|
@@ -558,6 +717,11 @@ async function localLockKey(lockPath: string): Promise<string> {
|
|
|
558
717
|
}
|
|
559
718
|
|
|
560
719
|
function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
|
|
720
|
+
if (owner.process_incarnation) {
|
|
721
|
+
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
722
|
+
const currentIncarnation = processIncarnation(owner.pid);
|
|
723
|
+
return currentIncarnation !== null && currentIncarnation !== owner.process_incarnation;
|
|
724
|
+
}
|
|
561
725
|
if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
|
|
562
726
|
return false;
|
|
563
727
|
if (ownerLiveness(owner.pid) !== "alive") return false;
|
|
@@ -639,6 +803,7 @@ export async function removeFileLockDirForGc(
|
|
|
639
803
|
if (!expectedIdentity) return "owner_changed";
|
|
640
804
|
if (
|
|
641
805
|
current.pid !== expected.pid ||
|
|
806
|
+
(expected.process_incarnation !== undefined && current.process_incarnation !== expected.process_incarnation) ||
|
|
642
807
|
(expected.start_time !== undefined && current.start_time !== expected.start_time) ||
|
|
643
808
|
current.owner_host_id !== expected.owner_host_id ||
|
|
644
809
|
(expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
|
|
@@ -704,17 +869,12 @@ export async function removeFileLockDirForGc(
|
|
|
704
869
|
removed.retainedPlaceholderPath === undefined &&
|
|
705
870
|
removed.retainedUnknownPath === undefined;
|
|
706
871
|
if (verifiedDetach && detachedPath !== undefined) {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
}
|
|
872
|
+
pendingDetachedLockCleanups.set(expected, {
|
|
873
|
+
path: detachedPath,
|
|
874
|
+
rootDev: captured.snapshot.rootDev,
|
|
875
|
+
rootIno: captured.snapshot.rootIno,
|
|
876
|
+
});
|
|
877
|
+
await finishDetachedLockCleanup(expected);
|
|
718
878
|
return "removed";
|
|
719
879
|
}
|
|
720
880
|
if (removed.code === "not_found") return "removed";
|
|
@@ -822,7 +982,15 @@ async function staleLockSnapshot(
|
|
|
822
982
|
|
|
823
983
|
async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
|
|
824
984
|
if (!snapshot.stale) return false;
|
|
825
|
-
|
|
985
|
+
try {
|
|
986
|
+
return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
|
|
987
|
+
} catch {
|
|
988
|
+
// Exact removal refusal is not authority to fail or mutate by another path.
|
|
989
|
+
// Keep contending: a concurrent reclaimer may already be completing the same
|
|
990
|
+
// dead generation, while a persistent refusal remains fail-closed until the
|
|
991
|
+
// normal retry budget reports the still-held lock.
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
826
994
|
}
|
|
827
995
|
|
|
828
996
|
/**
|
|
@@ -880,8 +1048,10 @@ async function tryAcquireLock(
|
|
|
880
1048
|
if (afterParentMkdir) await afterParentMkdir(lockPath);
|
|
881
1049
|
const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
|
|
882
1050
|
const owner = lockInfo(ownerHostId, ownerToken);
|
|
1051
|
+
let removePending = false;
|
|
883
1052
|
try {
|
|
884
1053
|
await fs.mkdir(pendingPath, { mode: 0o700 });
|
|
1054
|
+
removePending = true;
|
|
885
1055
|
await fs.chmod(pendingPath, 0o700);
|
|
886
1056
|
await writeLockInfo(pendingPath, owner);
|
|
887
1057
|
// A plain POSIX rename replaces an existing empty directory. The legacy
|
|
@@ -896,6 +1066,22 @@ async function tryAcquireLock(
|
|
|
896
1066
|
// component would follow an attacker-replaced symlink before native no-follow
|
|
897
1067
|
// validation gets a chance to reject it.
|
|
898
1068
|
const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
|
|
1069
|
+
let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
|
|
1070
|
+
if (process.platform === "linux") {
|
|
1071
|
+
// Linux exact tree removal owns this deterministic sibling from detach
|
|
1072
|
+
// until cleanup. The outer acquisition loop supplies the existing bounded
|
|
1073
|
+
// contention wait while the predecessor retains that namespace.
|
|
1074
|
+
if (await fileLockRemovalTransitionExists(destinationPath)) return null;
|
|
1075
|
+
const staged = snapshotDirectoryTree(canonicalPendingPath);
|
|
1076
|
+
if (!staged.ok || !staged.snapshot) {
|
|
1077
|
+
const failure = new Error(
|
|
1078
|
+
`Failed to snapshot staged file lock: ${staged.code ?? "unknown"}.`,
|
|
1079
|
+
) as NodeJS.ErrnoException;
|
|
1080
|
+
if (staged.code) failure.code = staged.code;
|
|
1081
|
+
throw failure;
|
|
1082
|
+
}
|
|
1083
|
+
stagedSnapshot = staged.snapshot;
|
|
1084
|
+
}
|
|
899
1085
|
const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
|
|
900
1086
|
renameNoReplacePathAsync,
|
|
901
1087
|
renameDirectoryNoReplacePathAsync,
|
|
@@ -924,12 +1110,34 @@ async function tryAcquireLock(
|
|
|
924
1110
|
if (published.code) failure.code = published.code;
|
|
925
1111
|
throw failure;
|
|
926
1112
|
}
|
|
927
|
-
|
|
928
|
-
//
|
|
1113
|
+
removePending = false;
|
|
1114
|
+
// The transition can appear after the pre-publication check only when the
|
|
1115
|
+
// predecessor atomically detaches its lock before this no-replace publish.
|
|
1116
|
+
// Roll this exact staged tree back to its UUID path before reporting
|
|
1117
|
+
// contention; never delete or rename the predecessor's `.removing` tree.
|
|
1118
|
+
if (stagedSnapshot && (await fileLockRemovalTransitionExists(destinationPath))) {
|
|
1119
|
+
const publishedSnapshot = snapshotDirectoryTree(destinationPath);
|
|
1120
|
+
if (
|
|
1121
|
+
!publishedSnapshot.ok ||
|
|
1122
|
+
!publishedSnapshot.snapshot ||
|
|
1123
|
+
!sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
|
|
1124
|
+
) {
|
|
1125
|
+
const failure = new Error(
|
|
1126
|
+
`Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
|
|
1127
|
+
) as NodeJS.ErrnoException;
|
|
1128
|
+
if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
|
|
1129
|
+
throw failure;
|
|
1130
|
+
}
|
|
1131
|
+
rollbackPublishedFileLock(canonicalParent, destinationPath, canonicalPendingPath, publishedSnapshot.snapshot);
|
|
1132
|
+
removePending = true;
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
// Published and transition-free above, so an onAcquired failure must
|
|
1136
|
+
// propagate instead of retrying an acquisition that already owns the lock.
|
|
929
1137
|
onAcquired?.();
|
|
930
1138
|
return owner;
|
|
931
1139
|
} finally {
|
|
932
|
-
await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
|
|
1140
|
+
if (removePending) await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
|
|
933
1141
|
}
|
|
934
1142
|
}
|
|
935
1143
|
|
|
@@ -1025,7 +1233,12 @@ async function quarantineReleasedLock(
|
|
|
1025
1233
|
return false;
|
|
1026
1234
|
} catch (error) {
|
|
1027
1235
|
if (isEnoent(error)) {
|
|
1028
|
-
|
|
1236
|
+
pendingDetachedLockCleanups.set(owner, {
|
|
1237
|
+
path: removed.detachedPath,
|
|
1238
|
+
rootDev: captured.snapshot.rootDev,
|
|
1239
|
+
rootIno: captured.snapshot.rootIno,
|
|
1240
|
+
});
|
|
1241
|
+
await finishDetachedLockCleanup(owner);
|
|
1029
1242
|
return true;
|
|
1030
1243
|
}
|
|
1031
1244
|
throw error;
|
|
@@ -1035,6 +1248,7 @@ async function quarantineReleasedLock(
|
|
|
1035
1248
|
}
|
|
1036
1249
|
|
|
1037
1250
|
async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
|
|
1251
|
+
if (await finishDetachedLockCleanup(owner)) return;
|
|
1038
1252
|
let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
|
|
1039
1253
|
try {
|
|
1040
1254
|
preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
|
|
@@ -1045,6 +1259,7 @@ async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Pr
|
|
|
1045
1259
|
let lastTransientError: unknown;
|
|
1046
1260
|
for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
|
|
1047
1261
|
try {
|
|
1262
|
+
if (await finishDetachedLockCleanup(owner)) return;
|
|
1048
1263
|
const outcome = await removeFileLockDirForGc(lockPath, owner, preVerdictIdentity);
|
|
1049
1264
|
if (outcome === "removed" || outcome === "missing") {
|
|
1050
1265
|
if (outcome === "missing") throw new Error("Failed to release file lock: missing.");
|
|
@@ -1134,6 +1349,9 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
|
|
|
1134
1349
|
*/
|
|
1135
1350
|
async function lockHolderDescription(lockPath: string): Promise<string> {
|
|
1136
1351
|
try {
|
|
1352
|
+
if (process.platform === "linux" && (await fileLockRemovalTransitionExists(lockPath))) {
|
|
1353
|
+
return "blocked by retained removal transition; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed";
|
|
1354
|
+
}
|
|
1137
1355
|
const info = await readLockInfo(lockPath);
|
|
1138
1356
|
if (info) {
|
|
1139
1357
|
// A lock record carrying a foreign owner_host_id belongs to another
|
|
@@ -1173,7 +1391,7 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
|
|
|
1173
1391
|
}
|
|
1174
1392
|
}
|
|
1175
1393
|
|
|
1176
|
-
async function
|
|
1394
|
+
export async function acquireFileLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
|
|
1177
1395
|
const requestedFilePath: unknown = filePath;
|
|
1178
1396
|
if (typeof requestedFilePath !== "string" || requestedFilePath.length === 0 || !path.isAbsolute(requestedFilePath))
|
|
1179
1397
|
throw new TypeError("filePath must be a non-empty absolute path");
|
|
@@ -1185,11 +1403,21 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
|
|
|
1185
1403
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
1186
1404
|
const lockPath = getLockPath(filePath);
|
|
1187
1405
|
await ensureLockParent(path.dirname(lockPath));
|
|
1406
|
+
try {
|
|
1407
|
+
await reapOrphanedLockStagingDirs(lockPath);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
logger.debug("Failed to reap orphaned file-lock staging directories", { lockPath, error: String(error) });
|
|
1410
|
+
}
|
|
1188
1411
|
const ownerToken = crypto.randomUUID();
|
|
1189
1412
|
const contentionStartTimes = new Map<string, string | null>();
|
|
1190
1413
|
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
1191
1414
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
1192
1415
|
const localKey = await localLockKey(lockPath);
|
|
1416
|
+
const priorRelease = localLockStates.get(localKey);
|
|
1417
|
+
if (priorRelease && priorRelease.status !== "held" && pendingDetachedLockCleanups.has(priorRelease.owner)) {
|
|
1418
|
+
await retryPendingLocalRelease(lockPath, localKey);
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1193
1421
|
const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
|
|
1194
1422
|
if (owner) {
|
|
1195
1423
|
localLockStates.set(localKey, { owner, status: "held" });
|
|
@@ -1228,10 +1456,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
|
|
|
1228
1456
|
opts.signal.removeEventListener("abort", onAbort);
|
|
1229
1457
|
}
|
|
1230
1458
|
}
|
|
1231
|
-
throw new
|
|
1232
|
-
`Failed to acquire lock for ${filePath} after ${opts.retries} attempts: ${await lockHolderDescription(lockPath)} (${lockPath}); ` +
|
|
1233
|
-
`a live owner is never displaced — if this is an SDK broker (gjc sdk status), it must finish or be stopped before retrying`,
|
|
1234
|
-
);
|
|
1459
|
+
throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
|
|
1235
1460
|
}
|
|
1236
1461
|
|
|
1237
1462
|
/**
|
|
@@ -1245,7 +1470,7 @@ export async function withFileLock<T>(
|
|
|
1245
1470
|
fn: () => Promise<T>,
|
|
1246
1471
|
options: FileLockOptions = {},
|
|
1247
1472
|
): Promise<T> {
|
|
1248
|
-
const release = await
|
|
1473
|
+
const release = await acquireFileLock(filePath, options);
|
|
1249
1474
|
let result: T;
|
|
1250
1475
|
try {
|
|
1251
1476
|
result = await fn();
|
|
@@ -1260,3 +1485,126 @@ export async function withFileLock<T>(
|
|
|
1260
1485
|
await release();
|
|
1261
1486
|
return result;
|
|
1262
1487
|
}
|
|
1488
|
+
|
|
1489
|
+
/** Strictly recognize the staging names emitted by tryAcquireLock. */
|
|
1490
|
+
export function fileLockStagingOwnerPid(name: string): number | null {
|
|
1491
|
+
const match = /^.+\.lock\.pending\.([1-9]\d*)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(
|
|
1492
|
+
name,
|
|
1493
|
+
);
|
|
1494
|
+
if (!match) return null;
|
|
1495
|
+
const pid = Number(match[1]);
|
|
1496
|
+
return Number.isSafeInteger(pid) ? pid : null;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
export interface FileLockStagingResult {
|
|
1500
|
+
path: string;
|
|
1501
|
+
pid?: number;
|
|
1502
|
+
status: OwnerLiveness;
|
|
1503
|
+
removed: boolean;
|
|
1504
|
+
reason: string;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** Observe identity before probing; neither a replacement nor a symlink inherits the verdict. */
|
|
1508
|
+
export async function inspectFileLockStagingDir(
|
|
1509
|
+
stagingPath: string,
|
|
1510
|
+
probe: (pid: number) => OwnerLiveness = ownerLiveness,
|
|
1511
|
+
remove = false,
|
|
1512
|
+
): Promise<FileLockStagingResult> {
|
|
1513
|
+
const kept: FileLockStagingResult = {
|
|
1514
|
+
path: stagingPath,
|
|
1515
|
+
status: "unknown",
|
|
1516
|
+
removed: false,
|
|
1517
|
+
reason: "unverified_staging_directory",
|
|
1518
|
+
};
|
|
1519
|
+
const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
|
|
1520
|
+
if (namePid === null) return kept;
|
|
1521
|
+
const canonical = await canonicalLockPathPreservingFinal(stagingPath);
|
|
1522
|
+
const root = await fs.lstat(canonical, { bigint: true });
|
|
1523
|
+
if (!root.isDirectory() || root.isSymbolicLink()) return kept;
|
|
1524
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
|
|
1525
|
+
if (
|
|
1526
|
+
!captured.ok ||
|
|
1527
|
+
!captured.snapshot ||
|
|
1528
|
+
captured.snapshot.rootDev !== root.dev.toString() ||
|
|
1529
|
+
captured.snapshot.rootIno !== root.ino.toString()
|
|
1530
|
+
)
|
|
1531
|
+
return kept;
|
|
1532
|
+
const observation = await readFileLockObservationForGc(canonical);
|
|
1533
|
+
let pid: number;
|
|
1534
|
+
if (observation) {
|
|
1535
|
+
if (
|
|
1536
|
+
observation.identity.rootDev !== captured.snapshot.rootDev ||
|
|
1537
|
+
observation.identity.rootIno !== captured.snapshot.rootIno
|
|
1538
|
+
)
|
|
1539
|
+
return kept;
|
|
1540
|
+
pid = observation.info.pid;
|
|
1541
|
+
if (observation.info.owner_host_id !== undefined) return { ...kept, pid, reason: "host_qualified_staging_owner" };
|
|
1542
|
+
} else {
|
|
1543
|
+
// Missing info is the only case where the filename is ownership evidence.
|
|
1544
|
+
try {
|
|
1545
|
+
await fs.lstat(path.join(canonical, "info"));
|
|
1546
|
+
return kept;
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
if (!isEnoent(error)) throw error;
|
|
1549
|
+
}
|
|
1550
|
+
if (captured.snapshot.entries.some(entry => entry.relativePath !== "")) return kept;
|
|
1551
|
+
pid = namePid;
|
|
1552
|
+
}
|
|
1553
|
+
if (pid === process.pid || namePid === process.pid) {
|
|
1554
|
+
return { ...kept, pid, status: "alive", reason: "in_process_staging_owner" };
|
|
1555
|
+
}
|
|
1556
|
+
let status = probe(pid);
|
|
1557
|
+
if (status === "alive" && observation && ownerIncarnationChanged(observation.info)) status = "dead";
|
|
1558
|
+
const result: FileLockStagingResult = {
|
|
1559
|
+
path: stagingPath,
|
|
1560
|
+
pid,
|
|
1561
|
+
status,
|
|
1562
|
+
removed: false,
|
|
1563
|
+
reason: `file_lock_staging_owner_${status}`,
|
|
1564
|
+
};
|
|
1565
|
+
if (status !== "dead" || !remove) return result;
|
|
1566
|
+
if (observation) {
|
|
1567
|
+
const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
|
|
1568
|
+
return { ...result, removed: removal === "removed", reason: removal };
|
|
1569
|
+
}
|
|
1570
|
+
const removal = nativeFileLockBindings().exactRemoveDirectoryTree(canonical, captured.snapshot);
|
|
1571
|
+
if (
|
|
1572
|
+
removal.detachedPath &&
|
|
1573
|
+
path.resolve(removal.detachedPath) !== path.resolve(canonical) &&
|
|
1574
|
+
!removal.retainedSuccessorPath &&
|
|
1575
|
+
!removal.retainedPlaceholderPath &&
|
|
1576
|
+
!removal.retainedUnknownPath
|
|
1577
|
+
) {
|
|
1578
|
+
const detached = await fs.lstat(removal.detachedPath, { bigint: true });
|
|
1579
|
+
if (
|
|
1580
|
+
detached.isDirectory() &&
|
|
1581
|
+
!detached.isSymbolicLink() &&
|
|
1582
|
+
detached.dev.toString() === captured.snapshot.rootDev &&
|
|
1583
|
+
detached.ino.toString() === captured.snapshot.rootIno
|
|
1584
|
+
) {
|
|
1585
|
+
await fs.rmdir(removal.detachedPath);
|
|
1586
|
+
return { ...result, removed: true, reason: "removed" };
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
return { ...result, removed: removal.ok, reason: removal.ok ? "removed" : (removal.code ?? "cleanup_failed") };
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** Bounded opportunistic cleanup; the acquisition caller treats failures as diagnostic only. */
|
|
1593
|
+
export async function reapOrphanedLockStagingDirs(lockPath: string): Promise<{
|
|
1594
|
+
removed: string[];
|
|
1595
|
+
retained: FileLockStagingResult[];
|
|
1596
|
+
}> {
|
|
1597
|
+
const summary: { removed: string[]; retained: FileLockStagingResult[] } = { removed: [], retained: [] };
|
|
1598
|
+
const parent = path.dirname(lockPath);
|
|
1599
|
+
const prefix = `${path.basename(lockPath)}.pending.`;
|
|
1600
|
+
const entries = await fs.readdir(parent);
|
|
1601
|
+
let candidates = 0;
|
|
1602
|
+
for (const entry of entries) {
|
|
1603
|
+
if (!entry.startsWith(prefix) || fileLockStagingOwnerPid(entry) === null) continue;
|
|
1604
|
+
if (++candidates > 64) break;
|
|
1605
|
+
const result = await inspectFileLockStagingDir(path.join(parent, entry), ownerLiveness, true);
|
|
1606
|
+
if (result.removed) summary.removed.push(result.path);
|
|
1607
|
+
else summary.retained.push(result);
|
|
1608
|
+
}
|
|
1609
|
+
return summary;
|
|
1610
|
+
}
|