@gajae-code/coding-agent 0.16.7 → 0.17.1
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 +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
package/src/config/file-lock.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
2
|
import type { BigIntStats } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
5
6
|
import type {
|
|
6
7
|
NativeDirectoryTreeResult,
|
|
@@ -10,6 +11,7 @@ import type {
|
|
|
10
11
|
RecoveryFsPublishResult,
|
|
11
12
|
} from "@gajae-code/natives";
|
|
12
13
|
import {
|
|
14
|
+
exactRestore,
|
|
13
15
|
openRecoveryFsRoot,
|
|
14
16
|
renameDirectoryNoReplacePathAsync,
|
|
15
17
|
renameNoReplacePathAsync,
|
|
@@ -31,23 +33,56 @@ export interface FileLockOptions {
|
|
|
31
33
|
previousOwnerHostIds?: readonly string[];
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
export type FileLockAcquireReason = "acquire_timeout" | "orphan_transition";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Why the acquire path could not retire a lock directory whose owner it had already
|
|
40
|
+
* proven dead. Attached to `FileLockAcquireError` so a host whose lock-removal path
|
|
41
|
+
* refuses the directory is not misreported as a live or unreaped owner. The acquire
|
|
42
|
+
* loop still contends after a refusal (a concurrent reclaimer may finish the same dead
|
|
43
|
+
* generation); this records the cause that exhaustion must report.
|
|
44
|
+
*/
|
|
45
|
+
export interface FileLockStaleRemovalFailure {
|
|
46
|
+
/** Guarded-removal outcome the acquire path can record, or `"error"` when the removal attempt threw. */
|
|
47
|
+
outcome: "cleanup_failed" | "error";
|
|
48
|
+
/** Native/errno code carried by the refusal, when one was present. */
|
|
49
|
+
code?: string;
|
|
50
|
+
/** Human-readable cause surfaced to the operator at exhaustion. */
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
export class FileLockAcquireError extends Error {
|
|
35
|
-
readonly code
|
|
55
|
+
readonly code: FileLockAcquireReason;
|
|
36
56
|
|
|
37
57
|
constructor(
|
|
38
58
|
readonly filePath: string,
|
|
39
59
|
readonly lockPath: string,
|
|
40
60
|
readonly attempts: number,
|
|
41
61
|
readonly holder: string,
|
|
62
|
+
readonly reason: FileLockAcquireReason = "acquire_timeout",
|
|
63
|
+
readonly orphanPath?: string,
|
|
64
|
+
readonly removalFailure?: FileLockStaleRemovalFailure,
|
|
42
65
|
) {
|
|
66
|
+
const detail = reason === "orphan_transition" && orphanPath ? `orphan_transition at ${orphanPath}` : holder;
|
|
67
|
+
const removalDetail = removalFailure
|
|
68
|
+
? ` The dead owner's lock directory could not be reaped on this host (${removalFailure.message}${
|
|
69
|
+
removalFailure.code ? ` [${removalFailure.code}]` : ""
|
|
70
|
+
})`
|
|
71
|
+
: "";
|
|
43
72
|
super(
|
|
44
|
-
`Failed to acquire lock for ${filePath} after ${attempts} attempts: ${
|
|
45
|
-
`a live owner is never displaced — if this is an SDK broker (gjc sdk
|
|
73
|
+
`Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail} (${lockPath}); ` +
|
|
74
|
+
`a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying; ` +
|
|
75
|
+
`the lock is a directory, remove it only by deleting the directory (${lockPath}) once no live owner remains`,
|
|
46
76
|
);
|
|
77
|
+
this.code = reason;
|
|
47
78
|
this.name = "FileLockAcquireError";
|
|
48
79
|
}
|
|
49
80
|
}
|
|
50
81
|
|
|
82
|
+
export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcquireError {
|
|
83
|
+
return error instanceof FileLockAcquireError && error.code === "acquire_timeout";
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
const DEFAULT_OPTIONS: Required<
|
|
52
87
|
Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
|
|
53
88
|
> = {
|
|
@@ -83,6 +118,7 @@ export const FileLockTestHooks: {
|
|
|
83
118
|
renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
|
|
84
119
|
};
|
|
85
120
|
nativeQuarantineBindings?: () => NativeFileLockBindings;
|
|
121
|
+
nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
|
|
86
122
|
} = {};
|
|
87
123
|
|
|
88
124
|
/**
|
|
@@ -453,7 +489,41 @@ export interface FileLockOwnerToken {
|
|
|
453
489
|
* another process may copy into a new lock generation.
|
|
454
490
|
*/
|
|
455
491
|
const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
|
|
456
|
-
const pendingDetachedLockCleanups = new WeakMap<
|
|
492
|
+
const pendingDetachedLockCleanups = new WeakMap<
|
|
493
|
+
object,
|
|
494
|
+
{ path: string; rootDev: string; rootIno: string; snapshot: NativeDirectoryTreeSnapshot }
|
|
495
|
+
>();
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Complete a detached lock quarantine without the native exact-removal primitive.
|
|
499
|
+
* The tree was already retired through a handle-bound no-replace detach, so the
|
|
500
|
+
* parked root identity is re-verified immediately before the filesystem removes
|
|
501
|
+
* it; an identity mismatch leaves the quarantine untouched for a later attempt.
|
|
502
|
+
* Returns true when the quarantine is gone (or was already absent).
|
|
503
|
+
*/
|
|
504
|
+
async function removeDetachedLockQuarantineOnDisk(
|
|
505
|
+
detachedPath: string,
|
|
506
|
+
rootDev: string,
|
|
507
|
+
rootIno: string,
|
|
508
|
+
): Promise<boolean> {
|
|
509
|
+
let current: BigIntStats;
|
|
510
|
+
try {
|
|
511
|
+
current = await fs.lstat(detachedPath, { bigint: true });
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (isEnoent(error)) return true;
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
if (
|
|
517
|
+
!current.isDirectory() ||
|
|
518
|
+
current.isSymbolicLink() ||
|
|
519
|
+
current.dev.toString() !== rootDev ||
|
|
520
|
+
current.ino.toString() !== rootIno
|
|
521
|
+
) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
await fs.rm(detachedPath, { recursive: true, force: true });
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
457
527
|
|
|
458
528
|
async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
|
|
459
529
|
const pending = pendingDetachedLockCleanups.get(owner);
|
|
@@ -468,7 +538,17 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
|
|
|
468
538
|
) {
|
|
469
539
|
throw new Error("Detached file lock cleanup identity changed; refusing removal");
|
|
470
540
|
}
|
|
471
|
-
|
|
541
|
+
// The native replay only exists where the native exact-removal primitive is
|
|
542
|
+
// usable. A host whose minifilter rejects that primitive would otherwise never
|
|
543
|
+
// finish the quarantine its own fallback created, so finish on disk instead.
|
|
544
|
+
if (process.platform === "win32" && (await isNativeExactRemovalUsable())) {
|
|
545
|
+
const removal = nativeFileLockBindings().exactRemoveDirectoryTree(pending.path, pending.snapshot);
|
|
546
|
+
if (removal.code === "cleanup_pending") return false;
|
|
547
|
+
if (!removal.ok && removal.code !== "not_found")
|
|
548
|
+
throw new Error(`Failed to finish detached file lock cleanup: ${removal.code ?? "unknown"}.`);
|
|
549
|
+
} else {
|
|
550
|
+
await fs.rm(pending.path, { recursive: true, force: true });
|
|
551
|
+
}
|
|
472
552
|
} catch (error) {
|
|
473
553
|
if (!isEnoent(error)) throw error;
|
|
474
554
|
}
|
|
@@ -484,14 +564,115 @@ function fileLockRemovalTransitionPath(lockPath: string): string {
|
|
|
484
564
|
return `${lockPath}.removing`;
|
|
485
565
|
}
|
|
486
566
|
|
|
487
|
-
|
|
567
|
+
type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
|
|
568
|
+
type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
|
|
569
|
+
type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | null;
|
|
570
|
+
|
|
571
|
+
async function classifyFileLockRemovalTransition(
|
|
572
|
+
lockPath: string,
|
|
573
|
+
orphanAgeMs: number,
|
|
574
|
+
ownerHostId?: string,
|
|
575
|
+
previousOwnerHostIds: readonly string[] = [],
|
|
576
|
+
): Promise<FileLockRemovalTransitionState | null> {
|
|
577
|
+
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
488
578
|
try {
|
|
489
|
-
await fs.lstat(
|
|
490
|
-
return
|
|
579
|
+
const transition = await fs.lstat(transitionPath);
|
|
580
|
+
if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
|
|
491
581
|
} catch (error) {
|
|
492
|
-
if (isEnoent(error)) return
|
|
582
|
+
if (isEnoent(error)) return null;
|
|
493
583
|
throw error;
|
|
494
584
|
}
|
|
585
|
+
|
|
586
|
+
const observation = await readLockInfoObservation(transitionPath);
|
|
587
|
+
if (!observation) return "active";
|
|
588
|
+
const info = parseLockInfoBytes(observation.bytes);
|
|
589
|
+
if (info) {
|
|
590
|
+
const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds);
|
|
591
|
+
return stale.stale ? "abandoned" : "active";
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const infoAgeMs = Date.now() - Number(observation.state.file.mtimeNs / 1_000_000n);
|
|
595
|
+
return Number.isFinite(infoAgeMs) && infoAgeMs >= orphanAgeMs ? "orphan_transition" : "active";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is FileLockOrphanTransition {
|
|
599
|
+
return value !== null && "kind" in value && value.kind === "orphan_transition";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Whether `snapshot` has the exact shape the native removal primitive leaves
|
|
604
|
+
* behind after its payload scrub: a directory tree in which every file entry
|
|
605
|
+
* has already been truncated to zero bytes. Only the identity-bound native
|
|
606
|
+
* exact-removal primitive produces that residue, and it does so after every
|
|
607
|
+
* authorized payload mutation is complete, so the shape itself is the proof
|
|
608
|
+
* that no live owner can still be using the tree.
|
|
609
|
+
*/
|
|
610
|
+
function isScrubbedRemovalTransition(snapshot: NativeDirectoryTreeSnapshot): boolean {
|
|
611
|
+
if (snapshot.entries.length === 0) return false;
|
|
612
|
+
const root = snapshot.entries.find(entry => entry.relativePath === "");
|
|
613
|
+
if (root?.kind !== "directory") return false;
|
|
614
|
+
return snapshot.entries.every(entry => entry.kind === "directory" || entry.size === "0");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Adopt-and-finish a provably orphaned POSIX removal transition.
|
|
619
|
+
*
|
|
620
|
+
* On POSIX the native exact-removal primitive detaches the verified tree to its
|
|
621
|
+
* deterministic `<lock>.removing` sibling, scrubs every authorized file payload
|
|
622
|
+
* (truncating each to zero bytes), and hands the retained tree to the caller for
|
|
623
|
+
* the final on-disk unlink. A process SIGKILLed in that gap leaves the scrubbed
|
|
624
|
+
* tree behind with a zero-byte `info`, so the original owner record is gone and
|
|
625
|
+
* no token or liveness proof can identify the owner. Ownership is therefore
|
|
626
|
+
* proven by the object: a plain `.removing` directory whose snapshot is the
|
|
627
|
+
* native scrub residue and whose `info` mtime is at least the full acquisition
|
|
628
|
+
* budget old. Finishing that removal deletes only already-scrubbed residue and
|
|
629
|
+
* never live lock state; any successor, placeholder, unscrubbed tree, or
|
|
630
|
+
* transient native refusal is returned unadopted so the caller keeps the typed
|
|
631
|
+
* `orphan_transition` diagnostic.
|
|
632
|
+
*/
|
|
633
|
+
async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAgeMs: number): Promise<boolean> {
|
|
634
|
+
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
635
|
+
// Re-validate at the moment of adoption: a successor that published a parsed
|
|
636
|
+
// owner or replaced the tree must never inherit an earlier orphan verdict.
|
|
637
|
+
if ((await classifyFileLockRemovalTransition(lockPath, orphanAgeMs)) !== "orphan_transition") return false;
|
|
638
|
+
const captured = snapshotDirectoryTree(transitionPath);
|
|
639
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
640
|
+
const snapshot = captured.snapshot;
|
|
641
|
+
if (!isScrubbedRemovalTransition(snapshot)) return false;
|
|
642
|
+
let removal: NativeExactUnlinkResult;
|
|
643
|
+
try {
|
|
644
|
+
removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (isTransientReleaseError(error)) return false;
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
if (removal.ok === true) {
|
|
650
|
+
return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
|
|
651
|
+
}
|
|
652
|
+
if (
|
|
653
|
+
removal.ok === false &&
|
|
654
|
+
removal.code === "not_found" &&
|
|
655
|
+
Object.keys(removal).every(key => key === "ok" || key === "code")
|
|
656
|
+
)
|
|
657
|
+
return true;
|
|
658
|
+
// POSIX cannot bind a namespace unlink to the verified descriptor, so the
|
|
659
|
+
// primitive retains the scrubbed tree under its deterministic name and the
|
|
660
|
+
// caller finishes the on-disk removal. Finish only the exact tree this call
|
|
661
|
+
// scrubbed; a retained successor/placeholder/unknown path is refused so a
|
|
662
|
+
// replacement is never deleted.
|
|
663
|
+
if (
|
|
664
|
+
removal.ok === false &&
|
|
665
|
+
removal.code === "cleanup_pending" &&
|
|
666
|
+
removal.payloadDurable === true &&
|
|
667
|
+
removal.detachedPath !== undefined &&
|
|
668
|
+
path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
|
|
669
|
+
removal.retainedSuccessorPath === undefined &&
|
|
670
|
+
removal.retainedPlaceholderPath === undefined &&
|
|
671
|
+
removal.retainedUnknownPath === undefined &&
|
|
672
|
+
Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
|
|
673
|
+
)
|
|
674
|
+
return await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
|
|
675
|
+
return false;
|
|
495
676
|
}
|
|
496
677
|
|
|
497
678
|
function sameFileLockTreeAfterPublication(
|
|
@@ -520,12 +701,84 @@ function sameFileLockTreeAfterPublication(
|
|
|
520
701
|
);
|
|
521
702
|
}
|
|
522
703
|
|
|
523
|
-
function
|
|
704
|
+
async function matchesCommittedFileLockPublication(
|
|
705
|
+
pendingPath: string,
|
|
706
|
+
lockPath: string,
|
|
707
|
+
staged: NativeDirectoryTreeSnapshot,
|
|
708
|
+
): Promise<boolean> {
|
|
709
|
+
try {
|
|
710
|
+
// A replacement, including a dangling symlink, is not a consumed staging name.
|
|
711
|
+
await fs.lstat(pendingPath);
|
|
712
|
+
return false;
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (!isEnoent(error)) throw error;
|
|
715
|
+
}
|
|
716
|
+
const published = snapshotDirectoryTree(lockPath);
|
|
717
|
+
return (
|
|
718
|
+
published.ok && published.snapshot !== undefined && sameFileLockTreeAfterPublication(staged, published.snapshot)
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function rollbackPublishedFileLock(
|
|
524
723
|
canonicalParent: string,
|
|
525
724
|
lockPath: string,
|
|
526
725
|
pendingPath: string,
|
|
527
726
|
stagedSnapshot: NativeDirectoryTreeSnapshot,
|
|
528
|
-
): void {
|
|
727
|
+
): Promise<void> {
|
|
728
|
+
if (process.platform !== "linux") {
|
|
729
|
+
// RecoveryFsRoot is Linux-only. POSIX exact restore retains the parent
|
|
730
|
+
// and checks the directory identity around a same-kind placeholder exchange.
|
|
731
|
+
const root = stagedSnapshot.entries.find(entry => entry.relativePath === "");
|
|
732
|
+
if (!root) throw new Error("File lock rollback snapshot has no root");
|
|
733
|
+
const rollback = exactRestore(lockPath, pendingPath, {
|
|
734
|
+
dev: BigInt(stagedSnapshot.rootDev),
|
|
735
|
+
ino: BigInt(stagedSnapshot.rootIno),
|
|
736
|
+
size: BigInt(root.size),
|
|
737
|
+
mtimeNs: BigInt(root.mtimeNs),
|
|
738
|
+
directory: true,
|
|
739
|
+
});
|
|
740
|
+
const placeholder = rollback.retainedPlaceholderPath;
|
|
741
|
+
const retainedPlaceholder =
|
|
742
|
+
!rollback.ok &&
|
|
743
|
+
rollback.code === "cleanup_pending" &&
|
|
744
|
+
placeholder !== undefined &&
|
|
745
|
+
rollback.detachedPath === undefined &&
|
|
746
|
+
rollback.retainedSuccessorPath === undefined &&
|
|
747
|
+
rollback.retainedUnknownPath === undefined;
|
|
748
|
+
if (!rollback.ok && !retainedPlaceholder) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`Failed to roll back file lock published during removal transition: ${rollback.code ?? "unknown"}.`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
// A root-only restore is not authority to delete substituted descendants.
|
|
754
|
+
// Leave the retained tree untouched unless the complete snapshot still matches.
|
|
755
|
+
const restored = snapshotDirectoryTree(pendingPath);
|
|
756
|
+
if (!restored.ok || !restored.snapshot || !sameFileLockTreeAfterPublication(stagedSnapshot, restored.snapshot)) {
|
|
757
|
+
throw new Error(`Failed to verify rolled back file lock: ${restored.code ?? "identity_mismatch"}.`);
|
|
758
|
+
}
|
|
759
|
+
if (retainedPlaceholder && placeholder !== undefined) {
|
|
760
|
+
// exactRestore returns a retained, empty exchange placeholder. Its native
|
|
761
|
+
// name encodes the identity it verified, not authority over an arbitrary path.
|
|
762
|
+
const identity = /^\.gjc-exact-unlink-placeholder-([0-9a-f]+)-([0-9a-f]+)$/.exec(placeholder);
|
|
763
|
+
if (!identity) throw new Error("File lock rollback placeholder identity is unavailable");
|
|
764
|
+
const placeholderPath = path.join(canonicalParent, placeholder);
|
|
765
|
+
try {
|
|
766
|
+
const current = await fs.lstat(placeholderPath, { bigint: true });
|
|
767
|
+
if (
|
|
768
|
+
!current.isDirectory() ||
|
|
769
|
+
current.isSymbolicLink() ||
|
|
770
|
+
current.dev !== BigInt(`0x${identity[1]}`) ||
|
|
771
|
+
current.ino !== BigInt(`0x${identity[2]}`)
|
|
772
|
+
) {
|
|
773
|
+
throw new Error("File lock rollback placeholder identity changed; refusing removal");
|
|
774
|
+
}
|
|
775
|
+
await fs.rmdir(placeholderPath);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (!isEnoent(error)) throw error;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
529
782
|
const authority = openRecoveryFsRoot(canonicalParent);
|
|
530
783
|
let rollback: RecoveryFsPublishResult | undefined;
|
|
531
784
|
const errors: unknown[] = [];
|
|
@@ -668,15 +921,59 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
|
|
|
668
921
|
return false;
|
|
669
922
|
if (value.ok)
|
|
670
923
|
return (
|
|
924
|
+
value.code === undefined &&
|
|
925
|
+
value.diagnostic.collectionState === "unavailable" &&
|
|
926
|
+
value.diagnostic.osCode === undefined &&
|
|
927
|
+
value.diagnostic.syncFailures === undefined &&
|
|
928
|
+
Object.keys(value.diagnostic).length === 2 &&
|
|
929
|
+
value.primitive !== "unsupported" &&
|
|
930
|
+
value.primitive !== "unknown" &&
|
|
671
931
|
value.mutationState === "committed" &&
|
|
672
932
|
value.reason === "none" &&
|
|
673
933
|
value.phase === "complete" &&
|
|
674
|
-
|
|
934
|
+
value.durabilityState === "not_attempted"
|
|
675
935
|
);
|
|
676
936
|
if (value.mutationState === "not_committed") return value.durabilityState === "not_attempted";
|
|
937
|
+
if (value.mutationState === "committed") {
|
|
938
|
+
// This is a committed namespace change, not authority to retry publication.
|
|
939
|
+
// DrvFS can hide the renamed directory until the native source handle closes.
|
|
940
|
+
return (
|
|
941
|
+
value.code === "destination_identity_changed" &&
|
|
942
|
+
value.durabilityState === "not_provable" &&
|
|
943
|
+
value.reason === "identity_violation" &&
|
|
944
|
+
value.primitive === "mkdirat_renameat_noreplace" &&
|
|
945
|
+
value.phase === "terminal_identity" &&
|
|
946
|
+
value.diagnostic.collectionState === "unavailable" &&
|
|
947
|
+
Object.keys(value.diagnostic).length === 2
|
|
948
|
+
);
|
|
949
|
+
}
|
|
677
950
|
return value.mutationState === "unknown" && value.durabilityState === "not_provable";
|
|
678
951
|
}
|
|
679
952
|
|
|
953
|
+
function isSuccessfulNativePublication(value: unknown, operation: "primary" | "directory"): boolean {
|
|
954
|
+
if (!isValidNativeNoReplaceResult(value) || !value.ok) return false;
|
|
955
|
+
if (operation === "directory") return value.primitive === "mkdirat_renameat_noreplace";
|
|
956
|
+
switch (process.platform) {
|
|
957
|
+
case "linux":
|
|
958
|
+
return value.primitive === "renameat2_noreplace";
|
|
959
|
+
case "darwin":
|
|
960
|
+
return value.primitive === "renameatx_np_excl";
|
|
961
|
+
case "win32":
|
|
962
|
+
return value.primitive === "windows_rename_noreplace";
|
|
963
|
+
default:
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function isCommittedDirectoryVerificationFailure(value: unknown): value is NativeNoReplaceResult {
|
|
969
|
+
return (
|
|
970
|
+
process.platform === "linux" &&
|
|
971
|
+
isValidNativeNoReplaceResult(value) &&
|
|
972
|
+
!value.ok &&
|
|
973
|
+
value.mutationState === "committed"
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
680
977
|
/**
|
|
681
978
|
* Only a complete native envelope proving that no namespace mutation happened
|
|
682
979
|
* may authorize the directory fallback. A legacy or malformed result is
|
|
@@ -763,6 +1060,136 @@ function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right:
|
|
|
763
1060
|
);
|
|
764
1061
|
}
|
|
765
1062
|
|
|
1063
|
+
let nativeExactRemovalUsable: boolean | undefined;
|
|
1064
|
+
let nativeExactRemovalProbePromise: Promise<boolean> | undefined;
|
|
1065
|
+
|
|
1066
|
+
async function probeNativeExactRemoval(): Promise<boolean> {
|
|
1067
|
+
const testProbe = FileLockTestHooks.nativeExactRemovalProbe;
|
|
1068
|
+
if (testProbe) return await testProbe();
|
|
1069
|
+
if (process.platform !== "win32") return true;
|
|
1070
|
+
|
|
1071
|
+
let root: string;
|
|
1072
|
+
try {
|
|
1073
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-lock-probe-"));
|
|
1074
|
+
} catch {
|
|
1075
|
+
// There is no evidence that native exact removal is unusable. Keep the
|
|
1076
|
+
// identity-bound path rather than changing cleanup semantics on an
|
|
1077
|
+
// unrelated temporary-directory failure.
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
const probeDir = path.join(root, "probe.lock");
|
|
1081
|
+
try {
|
|
1082
|
+
await fs.mkdir(probeDir, { recursive: true, mode: 0o700 });
|
|
1083
|
+
await fs.writeFile(path.join(probeDir, "info"), "probe");
|
|
1084
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(probeDir);
|
|
1085
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
1086
|
+
return nativeFileLockBindings().exactRemoveDirectoryTree(probeDir, captured.snapshot).ok === true;
|
|
1087
|
+
} catch {
|
|
1088
|
+
return false;
|
|
1089
|
+
} finally {
|
|
1090
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function isNativeExactRemovalUsable(): Promise<boolean> {
|
|
1095
|
+
if (FileLockTestHooks.nativeExactRemovalProbe) return await probeNativeExactRemoval();
|
|
1096
|
+
if (process.platform !== "win32") return true;
|
|
1097
|
+
if (FileLockTestHooks.nativeQuarantineBindings) return true;
|
|
1098
|
+
if (nativeExactRemovalUsable !== undefined) return nativeExactRemovalUsable;
|
|
1099
|
+
let probe = nativeExactRemovalProbePromise;
|
|
1100
|
+
if (!probe) {
|
|
1101
|
+
probe = probeNativeExactRemoval().then(usable => {
|
|
1102
|
+
nativeExactRemovalUsable = usable;
|
|
1103
|
+
return usable;
|
|
1104
|
+
});
|
|
1105
|
+
nativeExactRemovalProbePromise = probe;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
return await probe;
|
|
1109
|
+
} finally {
|
|
1110
|
+
if (nativeExactRemovalProbePromise === probe) nativeExactRemovalProbePromise = undefined;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function removeVerifiedLockDirWithoutNative(
|
|
1115
|
+
lockDir: string,
|
|
1116
|
+
expected: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
|
|
1117
|
+
owner?: FileLockOwnerToken,
|
|
1118
|
+
): Promise<FileLockGcRemoval> {
|
|
1119
|
+
let parent: BigIntStats;
|
|
1120
|
+
try {
|
|
1121
|
+
parent = await fs.lstat(path.dirname(lockDir), { bigint: true });
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (isEnoent(error)) return "owner_changed";
|
|
1124
|
+
if (isTransientReleaseError(error)) throw error;
|
|
1125
|
+
return "cleanup_failed";
|
|
1126
|
+
}
|
|
1127
|
+
const removal = nativeFileLockBindings().exactRemoveDirectoryTree(
|
|
1128
|
+
lockDir,
|
|
1129
|
+
expected,
|
|
1130
|
+
{ dev: parent.dev, ino: parent.ino },
|
|
1131
|
+
true,
|
|
1132
|
+
);
|
|
1133
|
+
if (removal.ok && !removal.detachedPath) return "removed";
|
|
1134
|
+
if (removal.code === "not_found") return "removed";
|
|
1135
|
+
if (removal.code === "identity_mismatch" || removal.code === "parent_mismatch") return "owner_changed";
|
|
1136
|
+
if (!removal.detachedPath) return "cleanup_failed";
|
|
1137
|
+
if (owner) {
|
|
1138
|
+
pendingDetachedLockCleanups.set(owner, {
|
|
1139
|
+
path: removal.detachedPath,
|
|
1140
|
+
rootDev: expected.rootDev,
|
|
1141
|
+
rootIno: expected.rootIno,
|
|
1142
|
+
snapshot: expected,
|
|
1143
|
+
});
|
|
1144
|
+
return (await finishDetachedLockCleanup(owner)) ? "removed" : "cleanup_failed";
|
|
1145
|
+
}
|
|
1146
|
+
// The native exact-removal primitive is unavailable by definition on this path.
|
|
1147
|
+
// Replaying it here would recreate the very failure the fallback exists to avoid,
|
|
1148
|
+
// so complete the parked tree with an identity-checked filesystem removal instead.
|
|
1149
|
+
if (
|
|
1150
|
+
removal.retainedSuccessorPath !== undefined ||
|
|
1151
|
+
removal.retainedPlaceholderPath !== undefined ||
|
|
1152
|
+
removal.retainedUnknownPath !== undefined
|
|
1153
|
+
)
|
|
1154
|
+
return "cleanup_failed";
|
|
1155
|
+
return (await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno))
|
|
1156
|
+
? "removed"
|
|
1157
|
+
: "cleanup_failed";
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function removeVerifiedOwnedLockDirWithoutNative(
|
|
1161
|
+
lockDir: string,
|
|
1162
|
+
expected: GenericFileLockDirIdentity,
|
|
1163
|
+
owner: FileLockOwnerToken,
|
|
1164
|
+
): Promise<FileLockGcRemoval> {
|
|
1165
|
+
try {
|
|
1166
|
+
await fs.lstat(lockDir, { bigint: true });
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
if (isEnoent(error)) return "removed";
|
|
1169
|
+
if (isTransientReleaseError(error)) throw error;
|
|
1170
|
+
return "cleanup_failed";
|
|
1171
|
+
}
|
|
1172
|
+
const current = await captureFileLockDirIdentity(lockDir);
|
|
1173
|
+
if (!current || !sameGenericFileLockDirIdentity(current, expected)) return "owner_changed";
|
|
1174
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(lockDir);
|
|
1175
|
+
if (!captured.ok || !captured.snapshot) return "owner_changed";
|
|
1176
|
+
const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
|
|
1177
|
+
if (
|
|
1178
|
+
captured.snapshot.rootDev !== expected.rootDev ||
|
|
1179
|
+
captured.snapshot.rootIno !== expected.rootIno ||
|
|
1180
|
+
!infoEntry ||
|
|
1181
|
+
infoEntry.dev !== expected.infoDev ||
|
|
1182
|
+
infoEntry.ino !== expected.infoIno ||
|
|
1183
|
+
infoEntry.nlink !== expected.infoNlink ||
|
|
1184
|
+
infoEntry.size !== expected.infoSize ||
|
|
1185
|
+
infoEntry.mtimeNs !== expected.infoMtimeNs ||
|
|
1186
|
+
infoEntry.ctimeNs !== expected.infoCtimeNs ||
|
|
1187
|
+
infoEntry.sha256 !== expected.infoSha256
|
|
1188
|
+
)
|
|
1189
|
+
return "owner_changed";
|
|
1190
|
+
return await removeVerifiedLockDirWithoutNative(lockDir, captured.snapshot, owner);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
766
1193
|
export type GenericFileLockDirStaleVerdict = { stale: false } | { stale: true; identity: GenericFileLockDirIdentity };
|
|
767
1194
|
|
|
768
1195
|
/**
|
|
@@ -801,16 +1228,11 @@ export async function removeFileLockDirForGc(
|
|
|
801
1228
|
const current = onDiskBytes === null ? null : parseLockInfoBytes(onDiskBytes);
|
|
802
1229
|
if (!current || onDiskBytes === null) return "missing";
|
|
803
1230
|
if (!expectedIdentity) return "owner_changed";
|
|
804
|
-
if (
|
|
805
|
-
current.pid !== expected.pid ||
|
|
806
|
-
(expected.process_incarnation !== undefined && current.process_incarnation !== expected.process_incarnation) ||
|
|
807
|
-
(expected.start_time !== undefined && current.start_time !== expected.start_time) ||
|
|
808
|
-
current.owner_host_id !== expected.owner_host_id ||
|
|
809
|
-
(expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
|
|
810
|
-
current.timestamp !== expected.timestamp
|
|
811
|
-
) {
|
|
1231
|
+
if (!sameFileLockOwnerToken(current, expected)) {
|
|
812
1232
|
return "owner_changed";
|
|
813
1233
|
}
|
|
1234
|
+
if (!(await isNativeExactRemovalUsable()))
|
|
1235
|
+
return await removeVerifiedOwnedLockDirWithoutNative(lockDir, expectedIdentity, expected);
|
|
814
1236
|
// The token comparison above authorizes the content that was judged, not the
|
|
815
1237
|
// pathname. When the caller carried pre-verdict root/info identity, require
|
|
816
1238
|
// the post-verdict native snapshot to match that same object before removal;
|
|
@@ -847,6 +1269,45 @@ export async function removeFileLockDirForGc(
|
|
|
847
1269
|
let removed: NativeExactUnlinkResult;
|
|
848
1270
|
try {
|
|
849
1271
|
removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
1272
|
+
const retainedPath = fileLockRemovalTransitionPath(nativeCapturePath);
|
|
1273
|
+
if (
|
|
1274
|
+
process.platform === "linux" &&
|
|
1275
|
+
!nativeCapturePath.endsWith(".removing") &&
|
|
1276
|
+
removed.ok === false &&
|
|
1277
|
+
removed.code === "identity_mismatch" &&
|
|
1278
|
+
removed.retainedSuccessorPath === retainedPath &&
|
|
1279
|
+
Object.keys(removed).every(key => ["ok", "code", "retainedSuccessorPath"].includes(key))
|
|
1280
|
+
) {
|
|
1281
|
+
// DrvFS may hide the detached name while native code retains the source
|
|
1282
|
+
// handle. The receipt alone never authorizes deleting a reported successor:
|
|
1283
|
+
// require the complete original tree, then replay exact removal once at
|
|
1284
|
+
// the deterministic retained name, where no further rename is necessary.
|
|
1285
|
+
const retained = nativeFileLockBindings().snapshotDirectoryTree(retainedPath);
|
|
1286
|
+
if (
|
|
1287
|
+
retained.ok &&
|
|
1288
|
+
retained.snapshot &&
|
|
1289
|
+
sameFileLockTreeAfterPublication(captured.snapshot, retained.snapshot)
|
|
1290
|
+
) {
|
|
1291
|
+
const replay = nativeFileLockBindings().exactRemoveDirectoryTree(retainedPath, retained.snapshot);
|
|
1292
|
+
if (
|
|
1293
|
+
replay.ok !== false ||
|
|
1294
|
+
replay.code !== "cleanup_pending" ||
|
|
1295
|
+
replay.payloadDurable !== true ||
|
|
1296
|
+
replay.detachedPath !== retainedPath ||
|
|
1297
|
+
Object.keys(replay).some(key => !["ok", "code", "payloadDurable", "detachedPath"].includes(key))
|
|
1298
|
+
) {
|
|
1299
|
+
// A replay failure can carry detachedPath without authorizing payload
|
|
1300
|
+
// cleanup. Never forward such a receipt to the generic detach handler.
|
|
1301
|
+
logger.debug("Detached file lock replay did not prove durable cleanup", {
|
|
1302
|
+
originalCode: removed.code,
|
|
1303
|
+
replayCode: replay.code,
|
|
1304
|
+
});
|
|
1305
|
+
return "cleanup_failed";
|
|
1306
|
+
}
|
|
1307
|
+
removed = replay;
|
|
1308
|
+
logger.debug("Replayed identity-matched detached file lock removal", { code: removed.code });
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
850
1311
|
} catch (error) {
|
|
851
1312
|
// Keep the #2478 transient retry contract: sharing denials surface with
|
|
852
1313
|
// their transient code so callers retry, everything else is a refusal.
|
|
@@ -873,9 +1334,9 @@ export async function removeFileLockDirForGc(
|
|
|
873
1334
|
path: detachedPath,
|
|
874
1335
|
rootDev: captured.snapshot.rootDev,
|
|
875
1336
|
rootIno: captured.snapshot.rootIno,
|
|
1337
|
+
snapshot: captured.snapshot,
|
|
876
1338
|
});
|
|
877
|
-
await finishDetachedLockCleanup(expected);
|
|
878
|
-
return "removed";
|
|
1339
|
+
return (await finishDetachedLockCleanup(expected)) ? "removed" : "cleanup_failed";
|
|
879
1340
|
}
|
|
880
1341
|
if (removed.code === "not_found") return "removed";
|
|
881
1342
|
if (removed.code === "identity_mismatch") return "owner_changed";
|
|
@@ -930,9 +1391,11 @@ async function staleLockSnapshot(
|
|
|
930
1391
|
}
|
|
931
1392
|
if (!info) {
|
|
932
1393
|
// A directory without a valid owner record is either a contender between
|
|
933
|
-
// native
|
|
934
|
-
//
|
|
935
|
-
//
|
|
1394
|
+
// native directory ownership and metadata publication, or malformed state
|
|
1395
|
+
// with no PID/incarnation/host proof of any process generation. Neither
|
|
1396
|
+
// case carries liveness evidence, so elapsed time and byte stability must
|
|
1397
|
+
// never make this namespace reclaimable: only an independently committed
|
|
1398
|
+
// owner record proving its process generation dead authorizes removal.
|
|
936
1399
|
return { stale: false };
|
|
937
1400
|
}
|
|
938
1401
|
|
|
@@ -980,16 +1443,63 @@ async function staleLockSnapshot(
|
|
|
980
1443
|
return { stale: false };
|
|
981
1444
|
}
|
|
982
1445
|
|
|
983
|
-
|
|
984
|
-
|
|
1446
|
+
type StaleLockRemovalAttempt = { removed: true } | { removed: false; failure?: FileLockStaleRemovalFailure };
|
|
1447
|
+
|
|
1448
|
+
type RecordedStaleRemovalFailure = { owner: FileLockOwnerToken; failure: FileLockStaleRemovalFailure };
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* A recorded refusal describes exactly one dead owner generation. Re-read the pathname at
|
|
1452
|
+
* exhaustion and report it only while that same generation still owns the directory, so a
|
|
1453
|
+
* refusal recorded for a dead generation is never pinned onto a live successor that took
|
|
1454
|
+
* the pathname over before the budget ran out.
|
|
1455
|
+
*/
|
|
1456
|
+
async function staleRemovalFailureForCurrentGeneration(
|
|
1457
|
+
lockPath: string,
|
|
1458
|
+
recorded: RecordedStaleRemovalFailure | undefined,
|
|
1459
|
+
): Promise<FileLockStaleRemovalFailure | undefined> {
|
|
1460
|
+
if (!recorded) return undefined;
|
|
985
1461
|
try {
|
|
986
|
-
|
|
1462
|
+
const current = await readLockInfo(lockPath);
|
|
1463
|
+
return current && sameFileLockOwnerToken(current, recorded.owner) ? recorded.failure : undefined;
|
|
987
1464
|
} catch {
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1465
|
+
return undefined;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
async function removeStaleLockForAcquire(
|
|
1470
|
+
lockPath: string,
|
|
1471
|
+
snapshot: LockStaleSnapshot,
|
|
1472
|
+
): Promise<StaleLockRemovalAttempt> {
|
|
1473
|
+
if (!snapshot.stale) return { removed: false };
|
|
1474
|
+
try {
|
|
1475
|
+
const outcome = await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity);
|
|
1476
|
+
if (outcome === "removed") return { removed: true };
|
|
1477
|
+
// `owner_changed`/`missing` mean a successor or a concurrent reclaimer already
|
|
1478
|
+
// owns the pathname; that is ordinary contention, not a host removal failure.
|
|
1479
|
+
if (outcome !== "cleanup_failed") return { removed: false };
|
|
1480
|
+
return {
|
|
1481
|
+
removed: false,
|
|
1482
|
+
failure: {
|
|
1483
|
+
outcome,
|
|
1484
|
+
message: (await isNativeExactRemovalUsable())
|
|
1485
|
+
? "the identity-bound lock-removal path refused the dead owner's lock directory"
|
|
1486
|
+
: "the native exact-removal primitive is unavailable on this host and the verified detach fallback was refused",
|
|
1487
|
+
},
|
|
1488
|
+
};
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
// A removal refusal — transient or not — is not authority to fail or mutate by
|
|
1491
|
+
// another path. Keep contending, because a concurrent reclaimer may already be
|
|
1492
|
+
// completing the same dead generation, and record the cause so exhaustion
|
|
1493
|
+
// reports *why* a dead owner's lock could not be reaped instead of only
|
|
1494
|
+
// "dead but not reaped".
|
|
1495
|
+
return {
|
|
1496
|
+
removed: false,
|
|
1497
|
+
failure: {
|
|
1498
|
+
outcome: "error",
|
|
1499
|
+
code: (error as NodeJS.ErrnoException).code,
|
|
1500
|
+
message: (error as Error).message,
|
|
1501
|
+
},
|
|
1502
|
+
};
|
|
993
1503
|
}
|
|
994
1504
|
}
|
|
995
1505
|
|
|
@@ -1039,10 +1549,12 @@ export async function genericFileLockDirStaleVerdict(
|
|
|
1039
1549
|
|
|
1040
1550
|
async function tryAcquireLock(
|
|
1041
1551
|
lockPath: string,
|
|
1042
|
-
ownerHostId
|
|
1552
|
+
ownerHostId: string | undefined,
|
|
1553
|
+
orphanTransitionAgeMs: number,
|
|
1554
|
+
previousOwnerHostIds: readonly string[],
|
|
1043
1555
|
ownerToken = crypto.randomUUID(),
|
|
1044
1556
|
onAcquired?: () => void,
|
|
1045
|
-
): Promise<
|
|
1557
|
+
): Promise<FileLockAcquisitionResult> {
|
|
1046
1558
|
await ensureLockParent(path.dirname(lockPath));
|
|
1047
1559
|
const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
|
|
1048
1560
|
if (afterParentMkdir) await afterParentMkdir(lockPath);
|
|
@@ -1067,11 +1579,32 @@ async function tryAcquireLock(
|
|
|
1067
1579
|
// validation gets a chance to reject it.
|
|
1068
1580
|
const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
|
|
1069
1581
|
let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
|
|
1070
|
-
if (process.platform
|
|
1071
|
-
//
|
|
1582
|
+
if (process.platform !== "win32") {
|
|
1583
|
+
// POSIX exact tree removal owns this deterministic sibling from detach
|
|
1072
1584
|
// until cleanup. The outer acquisition loop supplies the existing bounded
|
|
1073
1585
|
// contention wait while the predecessor retains that namespace.
|
|
1074
|
-
|
|
1586
|
+
let transitionState = await classifyFileLockRemovalTransition(
|
|
1587
|
+
destinationPath,
|
|
1588
|
+
orphanTransitionAgeMs,
|
|
1589
|
+
ownerHostId,
|
|
1590
|
+
previousOwnerHostIds,
|
|
1591
|
+
);
|
|
1592
|
+
if (transitionState === "orphan_transition") {
|
|
1593
|
+
// A scrubbed, aged transition is the residue of a removal whose owner died
|
|
1594
|
+
// before its final on-disk cleanup. Adopt and finish it here so the wedged
|
|
1595
|
+
// namespace heals instead of aborting (or re-spinning the whole budget).
|
|
1596
|
+
if (!(await adoptOrphanedFileLockRemovalTransition(destinationPath, orphanTransitionAgeMs)))
|
|
1597
|
+
return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
|
|
1598
|
+
transitionState = await classifyFileLockRemovalTransition(
|
|
1599
|
+
destinationPath,
|
|
1600
|
+
orphanTransitionAgeMs,
|
|
1601
|
+
ownerHostId,
|
|
1602
|
+
previousOwnerHostIds,
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
if (transitionState === "orphan_transition")
|
|
1606
|
+
return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
|
|
1607
|
+
if (transitionState !== null) return null;
|
|
1075
1608
|
const staged = snapshotDirectoryTree(canonicalPendingPath);
|
|
1076
1609
|
if (!staged.ok || !staged.snapshot) {
|
|
1077
1610
|
const failure = new Error(
|
|
@@ -1087,10 +1620,30 @@ async function tryAcquireLock(
|
|
|
1087
1620
|
renameDirectoryNoReplacePathAsync,
|
|
1088
1621
|
};
|
|
1089
1622
|
const published = await publication.renameNoReplacePathAsync(canonicalPendingPath, destinationPath);
|
|
1090
|
-
let publishedSuccessfully = published
|
|
1623
|
+
let publishedSuccessfully = isSuccessfulNativePublication(published, "primary");
|
|
1624
|
+
if (published.ok && !publishedSuccessfully)
|
|
1625
|
+
throw new Error("Failed to publish file lock: invalid primary success receipt.");
|
|
1091
1626
|
if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
|
|
1092
1627
|
const fallback = await publication.renameDirectoryNoReplacePathAsync(canonicalPendingPath, destinationPath);
|
|
1093
|
-
|
|
1628
|
+
const fallbackSuccessfully = isSuccessfulNativePublication(fallback, "directory");
|
|
1629
|
+
if (fallback.ok && !fallbackSuccessfully)
|
|
1630
|
+
throw new Error("Failed to publish file lock: invalid directory success receipt.");
|
|
1631
|
+
let verifiedCommittedPublication = false;
|
|
1632
|
+
if (isCommittedDirectoryVerificationFailure(fallback)) {
|
|
1633
|
+
// The staged name has been consumed. Relinquish pathname cleanup even
|
|
1634
|
+
// if completion is refused: a replacement there is not our staged tree.
|
|
1635
|
+
removePending = false;
|
|
1636
|
+
verifiedCommittedPublication =
|
|
1637
|
+
stagedSnapshot !== undefined &&
|
|
1638
|
+
(await matchesCommittedFileLockPublication(canonicalPendingPath, destinationPath, stagedSnapshot));
|
|
1639
|
+
if (verifiedCommittedPublication) {
|
|
1640
|
+
logger.debug("Verified committed file lock publication after native identity refusal", {
|
|
1641
|
+
code: fallback.code,
|
|
1642
|
+
primitive: fallback.primitive,
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (fallbackSuccessfully || verifiedCommittedPublication) {
|
|
1094
1647
|
publishedSuccessfully = true;
|
|
1095
1648
|
} else if (fallback.reason === "destination_exists") {
|
|
1096
1649
|
return null;
|
|
@@ -1115,22 +1668,37 @@ async function tryAcquireLock(
|
|
|
1115
1668
|
// predecessor atomically detaches its lock before this no-replace publish.
|
|
1116
1669
|
// Roll this exact staged tree back to its UUID path before reporting
|
|
1117
1670
|
// contention; never delete or rename the predecessor's `.removing` tree.
|
|
1118
|
-
if (stagedSnapshot
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1671
|
+
if (stagedSnapshot) {
|
|
1672
|
+
const transitionState = await classifyFileLockRemovalTransition(
|
|
1673
|
+
destinationPath,
|
|
1674
|
+
orphanTransitionAgeMs,
|
|
1675
|
+
ownerHostId,
|
|
1676
|
+
previousOwnerHostIds,
|
|
1677
|
+
);
|
|
1678
|
+
if (transitionState !== null) {
|
|
1679
|
+
const publishedSnapshot = snapshotDirectoryTree(destinationPath);
|
|
1680
|
+
if (
|
|
1681
|
+
!publishedSnapshot.ok ||
|
|
1682
|
+
!publishedSnapshot.snapshot ||
|
|
1683
|
+
!sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
|
|
1684
|
+
) {
|
|
1685
|
+
const failure = new Error(
|
|
1686
|
+
`Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
|
|
1687
|
+
) as NodeJS.ErrnoException;
|
|
1688
|
+
if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
|
|
1689
|
+
throw failure;
|
|
1690
|
+
}
|
|
1691
|
+
await rollbackPublishedFileLock(
|
|
1692
|
+
canonicalParent,
|
|
1693
|
+
destinationPath,
|
|
1694
|
+
canonicalPendingPath,
|
|
1695
|
+
publishedSnapshot.snapshot,
|
|
1696
|
+
);
|
|
1697
|
+
removePending = true;
|
|
1698
|
+
return transitionState === "orphan_transition"
|
|
1699
|
+
? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
|
|
1700
|
+
: null;
|
|
1130
1701
|
}
|
|
1131
|
-
rollbackPublishedFileLock(canonicalParent, destinationPath, canonicalPendingPath, publishedSnapshot.snapshot);
|
|
1132
|
-
removePending = true;
|
|
1133
|
-
return null;
|
|
1134
1702
|
}
|
|
1135
1703
|
// Published and transition-free above, so an onAcquired failure must
|
|
1136
1704
|
// propagate instead of retrying an acquisition that already owns the lock.
|
|
@@ -1152,11 +1720,28 @@ function throwTransientNativeResult(code: string): never {
|
|
|
1152
1720
|
throw Object.assign(new Error(`Native lock operation is transiently unavailable: ${code}.`), { code });
|
|
1153
1721
|
}
|
|
1154
1722
|
|
|
1723
|
+
/**
|
|
1724
|
+
* The owner-generation fields a guarded removal and a diagnostic both have to agree on
|
|
1725
|
+
* before either may act on, or speak about, that generation.
|
|
1726
|
+
*/
|
|
1727
|
+
function sameFileLockOwnerToken(current: LockInfo, expected: FileLockOwnerToken): boolean {
|
|
1728
|
+
return (
|
|
1729
|
+
current.pid === expected.pid &&
|
|
1730
|
+
(expected.process_incarnation === undefined || current.process_incarnation === expected.process_incarnation) &&
|
|
1731
|
+
(expected.start_time === undefined || current.start_time === expected.start_time) &&
|
|
1732
|
+
current.owner_host_id === expected.owner_host_id &&
|
|
1733
|
+
(expected.owner_token === undefined || current.owner_token === expected.owner_token) &&
|
|
1734
|
+
current.timestamp === expected.timestamp
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1155
1738
|
type NativeFileLockBindings = {
|
|
1156
1739
|
snapshotDirectoryTree(lockPath: string): NativeDirectoryTreeResult;
|
|
1157
1740
|
exactRemoveDirectoryTree(
|
|
1158
1741
|
lockPath: string,
|
|
1159
1742
|
snapshot: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
|
|
1743
|
+
parentIdentity?: { dev: bigint; ino: bigint },
|
|
1744
|
+
detachOnly?: boolean,
|
|
1160
1745
|
): NativeExactUnlinkResult;
|
|
1161
1746
|
};
|
|
1162
1747
|
|
|
@@ -1184,6 +1769,8 @@ async function quarantineReleasedLock(
|
|
|
1184
1769
|
// judged; without pre-verdict evidence, refuse and let the caller surface the retry
|
|
1185
1770
|
// failure instead of risking a successor lock.
|
|
1186
1771
|
if (!expectedIdentity) return false;
|
|
1772
|
+
if (!(await isNativeExactRemovalUsable()))
|
|
1773
|
+
return (await removeVerifiedOwnedLockDirWithoutNative(lockPath, expectedIdentity, owner)) === "removed";
|
|
1187
1774
|
let captured: NativeDirectoryTreeResult;
|
|
1188
1775
|
const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
|
|
1189
1776
|
try {
|
|
@@ -1237,9 +1824,9 @@ async function quarantineReleasedLock(
|
|
|
1237
1824
|
path: removed.detachedPath,
|
|
1238
1825
|
rootDev: captured.snapshot.rootDev,
|
|
1239
1826
|
rootIno: captured.snapshot.rootIno,
|
|
1827
|
+
snapshot: captured.snapshot,
|
|
1240
1828
|
});
|
|
1241
|
-
await finishDetachedLockCleanup(owner);
|
|
1242
|
-
return true;
|
|
1829
|
+
return await finishDetachedLockCleanup(owner);
|
|
1243
1830
|
}
|
|
1244
1831
|
throw error;
|
|
1245
1832
|
}
|
|
@@ -1347,12 +1934,33 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
|
|
|
1347
1934
|
* Bounded, actionable description of who holds `lockPath` at exhaustion time.
|
|
1348
1935
|
* Never a stealing authority: purely diagnostic, read once after the last retry.
|
|
1349
1936
|
*/
|
|
1350
|
-
async function lockHolderDescription(
|
|
1937
|
+
async function lockHolderDescription(
|
|
1938
|
+
lockPath: string,
|
|
1939
|
+
orphanTransitionAgeMs: number,
|
|
1940
|
+
ownerHostId?: string,
|
|
1941
|
+
previousOwnerHostIds: readonly string[] = [],
|
|
1942
|
+
): Promise<string> {
|
|
1351
1943
|
try {
|
|
1352
|
-
if (process.platform
|
|
1353
|
-
|
|
1944
|
+
if (process.platform !== "win32") {
|
|
1945
|
+
const transitionState = await classifyFileLockRemovalTransition(
|
|
1946
|
+
lockPath,
|
|
1947
|
+
orphanTransitionAgeMs,
|
|
1948
|
+
ownerHostId,
|
|
1949
|
+
previousOwnerHostIds,
|
|
1950
|
+
);
|
|
1951
|
+
if (transitionState === "orphan_transition")
|
|
1952
|
+
return `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`;
|
|
1953
|
+
if (transitionState === "abandoned")
|
|
1954
|
+
return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
|
|
1955
|
+
if (transitionState === "active")
|
|
1956
|
+
return "blocked by retained removal transition; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed";
|
|
1957
|
+
}
|
|
1958
|
+
let info = await readLockInfo(lockPath);
|
|
1959
|
+
let bytes: string | null = null;
|
|
1960
|
+
if (!info) {
|
|
1961
|
+
bytes = await readLockInfoBytes(lockPath);
|
|
1962
|
+
info = bytes === null ? null : parseLockInfoBytes(bytes);
|
|
1354
1963
|
}
|
|
1355
|
-
const info = await readLockInfo(lockPath);
|
|
1356
1964
|
if (info) {
|
|
1357
1965
|
// A lock record carrying a foreign owner_host_id belongs to another
|
|
1358
1966
|
// machine (shared-volume topic registry): its pid is meaningful only
|
|
@@ -1380,6 +1988,8 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
|
|
|
1380
1988
|
);
|
|
1381
1989
|
}
|
|
1382
1990
|
try {
|
|
1991
|
+
if (bytes !== null)
|
|
1992
|
+
return "held by an owner record that never became readable (empty, truncated, or non-JSON info); malformed records carry no liveness proof and are never reclaimed — inspect and remove the directory manually once no publisher remains";
|
|
1383
1993
|
await fs.stat(path.join(lockPath, "info"));
|
|
1384
1994
|
return "held by an owner whose metadata is not yet readable";
|
|
1385
1995
|
} catch (error) {
|
|
@@ -1400,6 +2010,8 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
1400
2010
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
1401
2011
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
1402
2012
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
2013
|
+
const orphanTransitionAgeMs = Math.max(0, opts.retries * opts.retryDelayMs);
|
|
2014
|
+
|
|
1403
2015
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
1404
2016
|
const lockPath = getLockPath(filePath);
|
|
1405
2017
|
await ensureLockParent(path.dirname(lockPath));
|
|
@@ -1410,6 +2022,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
1410
2022
|
}
|
|
1411
2023
|
const ownerToken = crypto.randomUUID();
|
|
1412
2024
|
const contentionStartTimes = new Map<string, string | null>();
|
|
2025
|
+
let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
|
|
1413
2026
|
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
1414
2027
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
1415
2028
|
const localKey = await localLockKey(lockPath);
|
|
@@ -1418,10 +2031,26 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
1418
2031
|
await retryPendingLocalRelease(lockPath, localKey);
|
|
1419
2032
|
continue;
|
|
1420
2033
|
}
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
2034
|
+
const result = await tryAcquireLock(
|
|
2035
|
+
lockPath,
|
|
2036
|
+
opts.ownerHostId,
|
|
2037
|
+
orphanTransitionAgeMs,
|
|
2038
|
+
opts.previousOwnerHostIds ?? [],
|
|
2039
|
+
ownerToken,
|
|
2040
|
+
opts.onAcquired,
|
|
2041
|
+
);
|
|
2042
|
+
if (isFileLockOrphanTransition(result))
|
|
2043
|
+
throw new FileLockAcquireError(
|
|
2044
|
+
filePath,
|
|
2045
|
+
lockPath,
|
|
2046
|
+
attempt + 1,
|
|
2047
|
+
`orphan transition retained at ${result.path}`,
|
|
2048
|
+
"orphan_transition",
|
|
2049
|
+
result.path,
|
|
2050
|
+
);
|
|
2051
|
+
if (result) {
|
|
2052
|
+
localLockStates.set(localKey, { owner: result, status: "held" });
|
|
2053
|
+
return () => releaseLock(lockPath, result, localKey);
|
|
1425
2054
|
}
|
|
1426
2055
|
const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
|
|
1427
2056
|
const localState = localLockStates.get(pendingKey ?? localKey);
|
|
@@ -1441,7 +2070,13 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
1441
2070
|
opts.previousOwnerHostIds,
|
|
1442
2071
|
contentionStartTimes,
|
|
1443
2072
|
);
|
|
1444
|
-
|
|
2073
|
+
const staleRemoval = await removeStaleLockForAcquire(lockPath, stale);
|
|
2074
|
+
if (staleRemoval.removed) {
|
|
2075
|
+
staleRemovalFailure = undefined;
|
|
2076
|
+
continue;
|
|
2077
|
+
}
|
|
2078
|
+
staleRemovalFailure =
|
|
2079
|
+
stale.stale && staleRemoval.failure ? { owner: stale.owner, failure: staleRemoval.failure } : undefined;
|
|
1445
2080
|
if (!opts.signal) {
|
|
1446
2081
|
await Bun.sleep(opts.retryDelayMs);
|
|
1447
2082
|
continue;
|
|
@@ -1456,7 +2091,15 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
1456
2091
|
opts.signal.removeEventListener("abort", onAbort);
|
|
1457
2092
|
}
|
|
1458
2093
|
}
|
|
1459
|
-
throw new FileLockAcquireError(
|
|
2094
|
+
throw new FileLockAcquireError(
|
|
2095
|
+
filePath,
|
|
2096
|
+
lockPath,
|
|
2097
|
+
opts.retries,
|
|
2098
|
+
await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
|
|
2099
|
+
"acquire_timeout",
|
|
2100
|
+
undefined,
|
|
2101
|
+
await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
|
|
2102
|
+
);
|
|
1460
2103
|
}
|
|
1461
2104
|
|
|
1462
2105
|
/**
|
|
@@ -1519,7 +2162,15 @@ export async function inspectFileLockStagingDir(
|
|
|
1519
2162
|
const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
|
|
1520
2163
|
if (namePid === null) return kept;
|
|
1521
2164
|
const canonical = await canonicalLockPathPreservingFinal(stagingPath);
|
|
1522
|
-
|
|
2165
|
+
// A winning acquirer publishes (renames) its staging directory concurrently, so the
|
|
2166
|
+
// candidate can disappear between the caller's readdir and this observation.
|
|
2167
|
+
let root: BigIntStats;
|
|
2168
|
+
try {
|
|
2169
|
+
root = await fs.lstat(canonical, { bigint: true });
|
|
2170
|
+
} catch (error) {
|
|
2171
|
+
if (!isEnoent(error)) throw error;
|
|
2172
|
+
return { ...kept, reason: "enoent_already_gone" };
|
|
2173
|
+
}
|
|
1523
2174
|
if (!root.isDirectory() || root.isSymbolicLink()) return kept;
|
|
1524
2175
|
const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
|
|
1525
2176
|
if (
|
|
@@ -1567,6 +2218,10 @@ export async function inspectFileLockStagingDir(
|
|
|
1567
2218
|
const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
|
|
1568
2219
|
return { ...result, removed: removal === "removed", reason: removal };
|
|
1569
2220
|
}
|
|
2221
|
+
if (!(await isNativeExactRemovalUsable())) {
|
|
2222
|
+
const removal = await removeVerifiedLockDirWithoutNative(canonical, captured.snapshot);
|
|
2223
|
+
return { ...result, removed: removal === "removed", reason: removal };
|
|
2224
|
+
}
|
|
1570
2225
|
const removal = nativeFileLockBindings().exactRemoveDirectoryTree(canonical, captured.snapshot);
|
|
1571
2226
|
if (
|
|
1572
2227
|
removal.detachedPath &&
|
|
@@ -1575,14 +2230,24 @@ export async function inspectFileLockStagingDir(
|
|
|
1575
2230
|
!removal.retainedPlaceholderPath &&
|
|
1576
2231
|
!removal.retainedUnknownPath
|
|
1577
2232
|
) {
|
|
1578
|
-
|
|
2233
|
+
let detached: BigIntStats | null;
|
|
2234
|
+
try {
|
|
2235
|
+
detached = await fs.lstat(removal.detachedPath, { bigint: true });
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
if (!isEnoent(error)) throw error;
|
|
2238
|
+
detached = null;
|
|
2239
|
+
}
|
|
1579
2240
|
if (
|
|
1580
|
-
detached
|
|
2241
|
+
detached?.isDirectory() &&
|
|
1581
2242
|
!detached.isSymbolicLink() &&
|
|
1582
2243
|
detached.dev.toString() === captured.snapshot.rootDev &&
|
|
1583
2244
|
detached.ino.toString() === captured.snapshot.rootIno
|
|
1584
2245
|
) {
|
|
1585
|
-
|
|
2246
|
+
try {
|
|
2247
|
+
await fs.rmdir(removal.detachedPath);
|
|
2248
|
+
} catch (error) {
|
|
2249
|
+
if (!isEnoent(error)) throw error;
|
|
2250
|
+
}
|
|
1586
2251
|
return { ...result, removed: true, reason: "removed" };
|
|
1587
2252
|
}
|
|
1588
2253
|
}
|