@gajae-code/coding-agent 0.18.12 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
package/src/config/file-lock.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
import {
|
|
14
14
|
exactRestore,
|
|
15
15
|
exactUnlinkDirect,
|
|
16
|
+
exactUnlinkOpenFile,
|
|
16
17
|
openRecoveryFsRoot,
|
|
17
18
|
renameDirectoryNoReplacePathAsync,
|
|
18
19
|
renameNoReplacePathAsync,
|
|
@@ -38,6 +39,7 @@ export interface FileLockOptions {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export type FileLockAcquireReason = "acquire_timeout" | "orphan_transition";
|
|
42
|
+
export type FileLockPublicationFailureCode = "access_denied" | "sharing_violation";
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* Why the acquire path could not retire a lock directory whose owner it had already
|
|
@@ -68,6 +70,9 @@ export class FileLockAcquireError extends Error {
|
|
|
68
70
|
readonly reason: FileLockAcquireReason = "acquire_timeout",
|
|
69
71
|
readonly orphanPath?: string,
|
|
70
72
|
readonly removalFailure?: FileLockStaleRemovalFailure,
|
|
73
|
+
readonly publicationFailureCode?: FileLockPublicationFailureCode,
|
|
74
|
+
/** True only when exhaustion proved a same-host owner process is live. */
|
|
75
|
+
readonly holderIsLive = false,
|
|
71
76
|
) {
|
|
72
77
|
const detail =
|
|
73
78
|
reason === "orphan_transition" && orphanPath ? `blocked by retained removal transition ${orphanPath}` : holder;
|
|
@@ -79,17 +84,46 @@ export class FileLockAcquireError extends Error {
|
|
|
79
84
|
const cleanupDetail = removalFailure?.manualCleanupCommand
|
|
80
85
|
? `; manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ${removalFailure.manualCleanupCommand}`
|
|
81
86
|
: "";
|
|
87
|
+
const publicationDetail = publicationFailureCode
|
|
88
|
+
? `; last lock publication failed with ${publicationFailureCode}`
|
|
89
|
+
: "";
|
|
90
|
+
const contentionGuidance =
|
|
91
|
+
publicationFailureCode === "access_denied" && !holderIsLive
|
|
92
|
+
? ""
|
|
93
|
+
: "; a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying";
|
|
82
94
|
super(
|
|
83
|
-
`Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail} (${lockPath})
|
|
84
|
-
`a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying${cleanupDetail}`,
|
|
95
|
+
`Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail}${publicationDetail} (${lockPath})${contentionGuidance}${cleanupDetail}`,
|
|
85
96
|
);
|
|
86
97
|
this.code = reason;
|
|
87
98
|
this.name = "FileLockAcquireError";
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
export class FileLockStagingCleanupError extends Error {
|
|
103
|
+
readonly code: string | undefined;
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
readonly lockPath: string,
|
|
107
|
+
readonly stagingPath: string,
|
|
108
|
+
cause: unknown,
|
|
109
|
+
) {
|
|
110
|
+
const code =
|
|
111
|
+
typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
|
|
112
|
+
? cause.code
|
|
113
|
+
: undefined;
|
|
114
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
115
|
+
super(`Failed to clean staged file-lock publication at ${stagingPath}${code ? ` (${code})` : ""}: ${detail}`);
|
|
116
|
+
this.code = code;
|
|
117
|
+
this.name = "FileLockStagingCleanupError";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcquireError {
|
|
92
|
-
return
|
|
122
|
+
return (
|
|
123
|
+
error instanceof FileLockAcquireError &&
|
|
124
|
+
error.code === "acquire_timeout" &&
|
|
125
|
+
(error.publicationFailureCode !== "access_denied" || error.holderIsLive)
|
|
126
|
+
);
|
|
93
127
|
}
|
|
94
128
|
|
|
95
129
|
const DEFAULT_OPTIONS: Required<
|
|
@@ -103,14 +137,29 @@ const DEFAULT_OPTIONS: Required<
|
|
|
103
137
|
retryDelayMs: 100,
|
|
104
138
|
};
|
|
105
139
|
|
|
140
|
+
type FileLockStagingFileIdentity = { dev: string; ino: string };
|
|
141
|
+
type FileLockStagingRootIdentity = {
|
|
142
|
+
rootDev: string;
|
|
143
|
+
rootIno: string;
|
|
144
|
+
treeSnapshot?: NativeDirectoryTreeSnapshot;
|
|
145
|
+
infoFileIdentity?: FileLockStagingFileIdentity;
|
|
146
|
+
};
|
|
147
|
+
type PendingFileLockStagingCleanup = {
|
|
148
|
+
lockPath: string;
|
|
149
|
+
identity?: FileLockStagingRootIdentity;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const pendingStagingCleanups = new Map<string, PendingFileLockStagingCleanup>();
|
|
153
|
+
|
|
106
154
|
/**
|
|
107
|
-
* Windows can transiently deny a no-replace publication while another
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* publish twice or clean up a committed namespace change.
|
|
155
|
+
* Windows can transiently deny a no-replace publication while another process holds
|
|
156
|
+
* the staged or destination path open. Retry only validated pre-mutation receipts;
|
|
157
|
+
* persistent access-denied failures remain fatal rather than being reported as contention.
|
|
111
158
|
*/
|
|
112
|
-
const
|
|
113
|
-
const
|
|
159
|
+
const PUBLICATION_TRANSIENT_RETRY_ATTEMPTS = 3;
|
|
160
|
+
const PUBLICATION_TRANSIENT_RETRY_DELAY_MS = 10;
|
|
161
|
+
/** Allow one caller backoff for transient Windows denial without multiplying a persistent ACL failure by the full lock wait. */
|
|
162
|
+
const MAX_ACCESS_DENIED_PUBLICATION_ACQUISITION_ATTEMPTS = 2;
|
|
114
163
|
|
|
115
164
|
/** Grace period after which an unpublished removal transition becomes reclaimable as orphan. */
|
|
116
165
|
const REMOVAL_TRANSITION_GRACE_MS = 10_000;
|
|
@@ -131,6 +180,7 @@ const PROCESS_START_TIME_FORMAT = "utc-v1";
|
|
|
131
180
|
type LocalLockState = {
|
|
132
181
|
owner: FileLockOwnerToken;
|
|
133
182
|
status: "held" | "release_pending" | "releasing";
|
|
183
|
+
releasePath?: string;
|
|
134
184
|
releasePromise?: Promise<void>;
|
|
135
185
|
};
|
|
136
186
|
|
|
@@ -140,12 +190,30 @@ type LocalLockState = {
|
|
|
140
190
|
* created, so a nested contender cannot steal a lock from a still-running holder.
|
|
141
191
|
*/
|
|
142
192
|
const localLockStates = new Map<string, LocalLockState>();
|
|
193
|
+
const MAX_FILE_LOCK_REMOVAL_RECORD_BYTES = 64 * 1024;
|
|
143
194
|
|
|
144
195
|
type LockInfo = FileLockOwnerToken;
|
|
145
196
|
|
|
146
197
|
export const FileLockTestHooks: {
|
|
147
198
|
afterParentMkdir?: (lockPath: string) => void | Promise<void>;
|
|
199
|
+
afterStagingInfoWrite?: (stagingPath: string) => void | Promise<void>;
|
|
200
|
+
stagingInfoIdentity?: (
|
|
201
|
+
infoPath: string,
|
|
202
|
+
capture: () => Promise<FileLockStagingFileIdentity>,
|
|
203
|
+
) => Promise<FileLockStagingFileIdentity>;
|
|
204
|
+
stagingInfoCleanup?: (
|
|
205
|
+
infoPath: string,
|
|
206
|
+
callerFd: number,
|
|
207
|
+
quarantineName: string,
|
|
208
|
+
) => NativeExactUnlinkResult | Promise<NativeExactUnlinkResult>;
|
|
209
|
+
beforeDetachedLockCleanup?: (detachedPath: string) => boolean | Promise<boolean>;
|
|
210
|
+
afterRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
|
|
211
|
+
afterRemovalTransitionMissing?: (transitionPath: string) => void | Promise<void>;
|
|
212
|
+
afterForeignTransitionMissing?: (transitionPath: string) => void | Promise<void>;
|
|
213
|
+
beforeRemovalRecordUnlink?: (recordPath: string) => boolean | Promise<boolean>;
|
|
148
214
|
beforeRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
|
|
215
|
+
cleanupPendingStaging?: (stagingPath: string) => Promise<void>;
|
|
216
|
+
stagingRootIdentity?: (stagingPath: string) => FileLockStagingRootIdentity | Promise<FileLockStagingRootIdentity>;
|
|
149
217
|
nativePublicationBindings?: () => {
|
|
150
218
|
renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
|
|
151
219
|
renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
|
|
@@ -154,10 +222,353 @@ export const FileLockTestHooks: {
|
|
|
154
222
|
processIncarnation: string | null;
|
|
155
223
|
startTime: string | null;
|
|
156
224
|
};
|
|
225
|
+
processIncarnation?: (pid: number) => string | null;
|
|
157
226
|
nativeQuarantineBindings?: () => NativeFileLockBindings;
|
|
158
227
|
nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
|
|
159
228
|
} = {};
|
|
160
229
|
|
|
230
|
+
type FileLockAcquisitionRetryBudget = {
|
|
231
|
+
remainingDelays: number;
|
|
232
|
+
retryDelayMs: number;
|
|
233
|
+
signal?: AbortSignal;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function throwIfFileLockAcquisitionAborted(signal?: AbortSignal): void {
|
|
237
|
+
if (signal?.aborted) throw signal.reason ?? new Error("File lock acquisition aborted");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function sleepFileLockRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
241
|
+
if (!signal) {
|
|
242
|
+
await Bun.sleep(delayMs);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
throwIfFileLockAcquisitionAborted(signal);
|
|
246
|
+
const settled = Promise.withResolvers<void>();
|
|
247
|
+
const onAbort = (): void => {
|
|
248
|
+
settled.reject(signal.reason ?? new Error("File lock acquisition aborted"));
|
|
249
|
+
};
|
|
250
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
251
|
+
try {
|
|
252
|
+
if (signal.aborted) onAbort();
|
|
253
|
+
await Promise.race([Bun.sleep(Math.max(0, delayMs)), settled.promise]);
|
|
254
|
+
} finally {
|
|
255
|
+
signal.removeEventListener("abort", onAbort);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function captureFileLockStagingRootIdentity(
|
|
260
|
+
stagingPath: string,
|
|
261
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
262
|
+
): Promise<FileLockStagingRootIdentity> {
|
|
263
|
+
const capture = FileLockTestHooks.stagingRootIdentity;
|
|
264
|
+
if (capture) return await capture(stagingPath);
|
|
265
|
+
for (;;) {
|
|
266
|
+
try {
|
|
267
|
+
const root = await fs.lstat(stagingPath, { bigint: true });
|
|
268
|
+
if (!root.isDirectory() || root.isSymbolicLink())
|
|
269
|
+
throw Object.assign(new Error("Staged file-lock root is not a directory."), { code: "identity_mismatch" });
|
|
270
|
+
return {
|
|
271
|
+
rootDev: canonicalLockDeviceId(root.dev).toString(),
|
|
272
|
+
rootIno: canonicalLockFileId(root.ino).toString(),
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (!isTransientReleaseError(error) || retryBudget.remainingDelays === 0) throw error;
|
|
276
|
+
retryBudget.remainingDelays--;
|
|
277
|
+
await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function captureFileLockStagingTreeSnapshot(
|
|
283
|
+
stagingPath: string,
|
|
284
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
285
|
+
): Promise<NativeDirectoryTreeSnapshot> {
|
|
286
|
+
for (;;) {
|
|
287
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(stagingPath);
|
|
288
|
+
if (captured.ok && captured.snapshot) return captured.snapshot;
|
|
289
|
+
const code = captured.code ?? "snapshot_unavailable";
|
|
290
|
+
if (
|
|
291
|
+
code !== "access_denied" &&
|
|
292
|
+
code !== "io_error" &&
|
|
293
|
+
code !== "permission_denied" &&
|
|
294
|
+
code !== "sharing_violation"
|
|
295
|
+
) {
|
|
296
|
+
throw Object.assign(new Error(`Failed to snapshot staged file lock: ${code}.`), { code });
|
|
297
|
+
}
|
|
298
|
+
if (retryBudget.remainingDelays === 0)
|
|
299
|
+
throw Object.assign(new Error(`Failed to snapshot staged file lock: ${code}.`), { code });
|
|
300
|
+
retryBudget.remainingDelays--;
|
|
301
|
+
await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function bindFileLockStagingTreeSnapshot(
|
|
306
|
+
stagingPath: string,
|
|
307
|
+
identity: FileLockStagingRootIdentity,
|
|
308
|
+
snapshot: NativeDirectoryTreeSnapshot,
|
|
309
|
+
): FileLockStagingRootIdentity {
|
|
310
|
+
if (snapshot.rootDev !== identity.rootDev || snapshot.rootIno !== identity.rootIno)
|
|
311
|
+
throw Object.assign(new Error(`Staged file-lock root identity changed while snapshotting ${stagingPath}.`), {
|
|
312
|
+
code: "identity_mismatch",
|
|
313
|
+
});
|
|
314
|
+
return { ...identity, treeSnapshot: snapshot };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function matchesExpectedFileLockStagingTree(
|
|
318
|
+
snapshot: NativeDirectoryTreeSnapshot,
|
|
319
|
+
identity: FileLockStagingRootIdentity,
|
|
320
|
+
): boolean {
|
|
321
|
+
const root = snapshot.entries.find(entry => entry.relativePath === "");
|
|
322
|
+
if (
|
|
323
|
+
snapshot.rootDev !== identity.rootDev ||
|
|
324
|
+
snapshot.rootIno !== identity.rootIno ||
|
|
325
|
+
root?.kind !== "directory" ||
|
|
326
|
+
root.dev !== identity.rootDev ||
|
|
327
|
+
root.ino !== identity.rootIno
|
|
328
|
+
)
|
|
329
|
+
return false;
|
|
330
|
+
if (snapshot.entries.length === 1) return true;
|
|
331
|
+
if (snapshot.entries.length !== 2 || !identity.infoFileIdentity) return false;
|
|
332
|
+
const info = snapshot.entries.find(entry => entry.relativePath === "info");
|
|
333
|
+
return (
|
|
334
|
+
info?.kind === "file" &&
|
|
335
|
+
info.dev === identity.infoFileIdentity.dev &&
|
|
336
|
+
info.ino === identity.infoFileIdentity.ino &&
|
|
337
|
+
info.nlink === "1"
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function captureFileLockStagingCleanupIdentity(
|
|
342
|
+
stagingPath: string,
|
|
343
|
+
identity: FileLockStagingRootIdentity,
|
|
344
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
345
|
+
): Promise<FileLockStagingRootIdentity> {
|
|
346
|
+
const snapshot = await captureFileLockStagingTreeSnapshot(stagingPath, retryBudget);
|
|
347
|
+
// A replay can authorize only the empty root or that root plus this attempt's exact info inode.
|
|
348
|
+
if (!matchesExpectedFileLockStagingTree(snapshot, identity))
|
|
349
|
+
throw Object.assign(
|
|
350
|
+
new Error("Staged file-lock contents changed before cleanup authority was captured; refusing removal."),
|
|
351
|
+
{ code: "identity_unavailable" },
|
|
352
|
+
);
|
|
353
|
+
return bindFileLockStagingTreeSnapshot(stagingPath, identity, snapshot);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function hasPendingStagingRootIdentity(
|
|
357
|
+
stagingPath: string,
|
|
358
|
+
identity: FileLockStagingRootIdentity,
|
|
359
|
+
): Promise<boolean> {
|
|
360
|
+
let root: BigIntStats;
|
|
361
|
+
try {
|
|
362
|
+
root = await fs.lstat(stagingPath, { bigint: true });
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (isEnoent(error)) return false;
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
!root.isDirectory() ||
|
|
369
|
+
root.isSymbolicLink() ||
|
|
370
|
+
canonicalLockDeviceId(root.dev).toString() !== identity.rootDev ||
|
|
371
|
+
canonicalLockFileId(root.ino).toString() !== identity.rootIno
|
|
372
|
+
)
|
|
373
|
+
throw Object.assign(new Error("Staged file-lock root identity changed; refusing removal."), {
|
|
374
|
+
code: "identity_mismatch",
|
|
375
|
+
});
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function removePendingStaging(stagingPath: string, identity: FileLockStagingRootIdentity): Promise<void> {
|
|
380
|
+
const cleanup = FileLockTestHooks.cleanupPendingStaging;
|
|
381
|
+
if (cleanup) {
|
|
382
|
+
if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
|
|
383
|
+
await cleanup(stagingPath);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (!identity.infoFileIdentity && process.platform !== "win32") {
|
|
387
|
+
if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
|
|
388
|
+
const captured = nativeFileLockBindings().snapshotDirectoryTree(stagingPath);
|
|
389
|
+
const root = captured.snapshot?.entries.find(entry => entry.relativePath === "");
|
|
390
|
+
if (
|
|
391
|
+
!captured.ok ||
|
|
392
|
+
!captured.snapshot ||
|
|
393
|
+
captured.snapshot.entries.length !== 1 ||
|
|
394
|
+
captured.snapshot.rootDev !== identity.rootDev ||
|
|
395
|
+
captured.snapshot.rootIno !== identity.rootIno ||
|
|
396
|
+
root?.kind !== "directory" ||
|
|
397
|
+
root.dev !== identity.rootDev ||
|
|
398
|
+
root.ino !== identity.rootIno
|
|
399
|
+
)
|
|
400
|
+
throw Object.assign(
|
|
401
|
+
new Error("Staged file-lock root is not empty and its info identity is unavailable; refusing removal."),
|
|
402
|
+
{ code: captured.code ?? "identity_unavailable" },
|
|
403
|
+
);
|
|
404
|
+
// Unlike recursive removal, rmdir cannot consume an entry that appears after
|
|
405
|
+
// the empty-tree proof; it fails with ENOTEMPTY instead.
|
|
406
|
+
await fs.rmdir(stagingPath);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (process.platform === "win32") {
|
|
410
|
+
if (!identity.treeSnapshot && !(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
|
|
411
|
+
if (!identity.treeSnapshot)
|
|
412
|
+
throw Object.assign(new Error("Staged file-lock tree identity is unavailable; refusing removal."), {
|
|
413
|
+
code: "identity_unavailable",
|
|
414
|
+
});
|
|
415
|
+
// A pathname check followed by fs.rm can erase a replacement installed in
|
|
416
|
+
// between; Windows cleanup must revalidate the captured tree atomically.
|
|
417
|
+
const removal = nativeFileLockBindings().exactRemoveDirectoryTree(stagingPath, identity.treeSnapshot);
|
|
418
|
+
if (!removal.ok && removal.code !== "not_found") {
|
|
419
|
+
const detachedPath = validateDetachedStagingCleanupPath(stagingPath, removal.detachedPath);
|
|
420
|
+
throw Object.assign(new Error(`Failed to remove staged file-lock tree: ${removal.code ?? "unknown"}.`), {
|
|
421
|
+
code: removal.code ?? "cleanup_failed",
|
|
422
|
+
...(detachedPath === undefined ? {} : { detachedPath }),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (await hasPendingStagingRootIdentity(stagingPath, identity))
|
|
428
|
+
await fs.rm(stagingPath, { recursive: true, force: true });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function sameFileLockPath(left: string, right: string): boolean {
|
|
432
|
+
const resolvedLeft = path.resolve(left);
|
|
433
|
+
const resolvedRight = path.resolve(right);
|
|
434
|
+
return process.platform === "win32"
|
|
435
|
+
? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
|
436
|
+
: resolvedLeft === resolvedRight;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function validateDetachedStagingCleanupPath(stagingPath: string, detachedPath: string | undefined): string | undefined {
|
|
440
|
+
if (detachedPath === undefined) return undefined;
|
|
441
|
+
if (!sameFileLockPath(detachedPath, stagingPath) && !sameFileLockPath(detachedPath, `${stagingPath}.removing`))
|
|
442
|
+
throw Object.assign(new Error("Native staging cleanup returned an unexpected detached path."), {
|
|
443
|
+
code: "identity_mismatch",
|
|
444
|
+
});
|
|
445
|
+
return detachedPath;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function detachedStagingCleanupPath(stagingPath: string, error: unknown): string {
|
|
449
|
+
if (typeof error !== "object" || error === null || !("detachedPath" in error)) return stagingPath;
|
|
450
|
+
const detachedPath = (error as { detachedPath?: unknown }).detachedPath;
|
|
451
|
+
try {
|
|
452
|
+
return (
|
|
453
|
+
validateDetachedStagingCleanupPath(stagingPath, typeof detachedPath === "string" ? detachedPath : undefined) ??
|
|
454
|
+
stagingPath
|
|
455
|
+
);
|
|
456
|
+
} catch {
|
|
457
|
+
return stagingPath;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function retainPendingStagingCleanup(
|
|
462
|
+
lockPath: string,
|
|
463
|
+
stagingPath: string,
|
|
464
|
+
identity: FileLockStagingRootIdentity | undefined,
|
|
465
|
+
error: unknown,
|
|
466
|
+
): void {
|
|
467
|
+
pendingStagingCleanups.set(stagingPath, { lockPath, identity });
|
|
468
|
+
logger.debug("Retained file-lock staging cleanup", { stagingPath, error: String(error) });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function abortStagingCleanup(
|
|
472
|
+
lockPath: string,
|
|
473
|
+
stagingPath: string,
|
|
474
|
+
identity: FileLockStagingRootIdentity,
|
|
475
|
+
signal: AbortSignal,
|
|
476
|
+
): Promise<never> {
|
|
477
|
+
let cleanupPath = stagingPath;
|
|
478
|
+
try {
|
|
479
|
+
await removePendingStaging(cleanupPath, identity);
|
|
480
|
+
pendingStagingCleanups.delete(cleanupPath);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
cleanupPath = detachedStagingCleanupPath(cleanupPath, error);
|
|
483
|
+
if (cleanupPath !== stagingPath) pendingStagingCleanups.delete(stagingPath);
|
|
484
|
+
// The stage is still owned by this acquisition. Preserve an in-process retry
|
|
485
|
+
// until another acquisition can clean it; after process exit the orphan reaper
|
|
486
|
+
// can safely reclaim it once this publisher is no longer live.
|
|
487
|
+
retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
|
|
488
|
+
}
|
|
489
|
+
throw signal.reason ?? new Error("File lock acquisition aborted");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function retryPendingStagingCleanups(
|
|
493
|
+
lockPath: string,
|
|
494
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
const lockKey = await localLockKey(lockPath);
|
|
497
|
+
for (const [stagingPath, pending] of pendingStagingCleanups) {
|
|
498
|
+
if ((await localLockKey(pending.lockPath)) !== lockKey) continue;
|
|
499
|
+
if (!pending.identity) {
|
|
500
|
+
try {
|
|
501
|
+
await fs.lstat(stagingPath);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (isEnoent(error)) {
|
|
504
|
+
pendingStagingCleanups.delete(stagingPath);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
|
|
508
|
+
throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
|
|
509
|
+
}
|
|
510
|
+
const error = Object.assign(new Error("Retained staging cleanup has no captured identity."), {
|
|
511
|
+
code: "identity_unavailable",
|
|
512
|
+
});
|
|
513
|
+
throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
|
|
514
|
+
}
|
|
515
|
+
let identity = pending.identity;
|
|
516
|
+
if (process.platform === "win32" && !identity.treeSnapshot) {
|
|
517
|
+
try {
|
|
518
|
+
if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) {
|
|
519
|
+
pendingStagingCleanups.delete(stagingPath);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
identity = await captureFileLockStagingCleanupIdentity(stagingPath, identity, retryBudget);
|
|
523
|
+
pendingStagingCleanups.set(stagingPath, { ...pending, identity });
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
|
|
526
|
+
throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
await cleanupPendingStagingOrThrow(lockPath, stagingPath, identity, retryBudget);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function cleanupPendingStagingOrThrow(
|
|
534
|
+
lockPath: string,
|
|
535
|
+
stagingPath: string,
|
|
536
|
+
identity: FileLockStagingRootIdentity,
|
|
537
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
538
|
+
): Promise<void> {
|
|
539
|
+
let cleanupPath = stagingPath;
|
|
540
|
+
for (;;) {
|
|
541
|
+
if (retryBudget.signal?.aborted) await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
|
|
542
|
+
try {
|
|
543
|
+
await removePendingStaging(cleanupPath, identity);
|
|
544
|
+
pendingStagingCleanups.delete(cleanupPath);
|
|
545
|
+
return;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (retryBudget.signal?.aborted)
|
|
548
|
+
await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
|
|
549
|
+
const detachedPath = detachedStagingCleanupPath(cleanupPath, error);
|
|
550
|
+
if (detachedPath !== cleanupPath) {
|
|
551
|
+
pendingStagingCleanups.delete(cleanupPath);
|
|
552
|
+
cleanupPath = detachedPath;
|
|
553
|
+
}
|
|
554
|
+
if (!isTransientReleaseError(error) || retryBudget.remainingDelays <= 0) {
|
|
555
|
+
retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
|
|
556
|
+
throw new FileLockStagingCleanupError(lockPath, cleanupPath, error);
|
|
557
|
+
}
|
|
558
|
+
// An open child can deny both publication and removal on Windows. Keep the
|
|
559
|
+
// same staging identity and share the outer acquisition delay budget.
|
|
560
|
+
retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
|
|
561
|
+
retryBudget.remainingDelays--;
|
|
562
|
+
try {
|
|
563
|
+
await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
|
|
564
|
+
} catch (abortError) {
|
|
565
|
+
if (!retryBudget.signal?.aborted) throw abortError;
|
|
566
|
+
await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
161
572
|
/**
|
|
162
573
|
* Returns the OS-provided process start timestamp for PID-reuse detection.
|
|
163
574
|
* `ps` is available on the supported Unix hosts (macOS and Linux), unlike
|
|
@@ -199,7 +610,7 @@ function currentProcessStartTime(): string {
|
|
|
199
610
|
return ownProcessStartTime;
|
|
200
611
|
}
|
|
201
612
|
|
|
202
|
-
function
|
|
613
|
+
function nativeProcessIncarnation(pid: number): string | null {
|
|
203
614
|
try {
|
|
204
615
|
const incarnation = nativeProcessBindings().Process.fromPid(pid)?.incarnation;
|
|
205
616
|
return typeof incarnation === "string" && incarnation.length > 0 ? incarnation : null;
|
|
@@ -208,8 +619,17 @@ function processIncarnation(pid: number): string | null {
|
|
|
208
619
|
}
|
|
209
620
|
}
|
|
210
621
|
|
|
622
|
+
function processIncarnation(pid: number): string | null {
|
|
623
|
+
try {
|
|
624
|
+
const testHook = FileLockTestHooks.processIncarnation;
|
|
625
|
+
return testHook ? testHook(pid) : nativeProcessIncarnation(pid);
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
211
631
|
function currentProcessIncarnation(): string | null {
|
|
212
|
-
if (ownProcessIncarnation === undefined) ownProcessIncarnation =
|
|
632
|
+
if (ownProcessIncarnation === undefined) ownProcessIncarnation = nativeProcessIncarnation(process.pid);
|
|
213
633
|
return ownProcessIncarnation;
|
|
214
634
|
}
|
|
215
635
|
|
|
@@ -239,6 +659,17 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
|
|
|
239
659
|
return owner.start_time_format !== PROCESS_START_TIME_FORMAT;
|
|
240
660
|
}
|
|
241
661
|
|
|
662
|
+
function ownerIdentityIsProven(owner: FileLockOwnerToken): boolean {
|
|
663
|
+
if (owner.process_incarnation) {
|
|
664
|
+
const currentIncarnation = processIncarnation(owner.pid);
|
|
665
|
+
if (currentIncarnation !== null) return currentIncarnation === owner.process_incarnation;
|
|
666
|
+
}
|
|
667
|
+
if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
|
|
668
|
+
return false;
|
|
669
|
+
const currentStartTime = cachedProcessStartTime(owner);
|
|
670
|
+
return currentStartTime !== null && currentStartTime === owner.start_time;
|
|
671
|
+
}
|
|
672
|
+
|
|
242
673
|
function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
|
|
243
674
|
const incarnation = currentProcessIncarnation();
|
|
244
675
|
return {
|
|
@@ -252,13 +683,70 @@ function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo
|
|
|
252
683
|
};
|
|
253
684
|
}
|
|
254
685
|
|
|
255
|
-
function writeLockInfo(
|
|
686
|
+
async function writeLockInfo(
|
|
687
|
+
lockPath: string,
|
|
688
|
+
info: LockInfo,
|
|
689
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
690
|
+
onFileCreated: (identity: FileLockStagingFileIdentity) => void,
|
|
691
|
+
): Promise<LockInfo> {
|
|
256
692
|
// Owner metadata must stay readable by its own process under a restrictive
|
|
257
693
|
// umask: release re-reads this record to authorize removal, and an info file
|
|
258
694
|
// born mode 000 under umask 0777 would wedge the lock at first release.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
695
|
+
// Keep the created file's identity through setup so an early cleanup can reject
|
|
696
|
+
// a same-name replacement instead of treating a later tree snapshot as authority.
|
|
697
|
+
const handle = await fs.open(`${lockPath}/info`, "wx", 0o600);
|
|
698
|
+
try {
|
|
699
|
+
let identity: FileLockStagingFileIdentity;
|
|
700
|
+
try {
|
|
701
|
+
for (;;) {
|
|
702
|
+
try {
|
|
703
|
+
const captureIdentity = async (): Promise<FileLockStagingFileIdentity> => {
|
|
704
|
+
const stats = await handle.stat({ bigint: true });
|
|
705
|
+
return {
|
|
706
|
+
dev: canonicalLockDeviceId(stats.dev).toString(),
|
|
707
|
+
ino: canonicalLockFileId(stats.ino).toString(),
|
|
708
|
+
};
|
|
709
|
+
};
|
|
710
|
+
const testCaptureIdentity = FileLockTestHooks.stagingInfoIdentity;
|
|
711
|
+
identity = testCaptureIdentity
|
|
712
|
+
? await testCaptureIdentity(`${lockPath}/info`, captureIdentity)
|
|
713
|
+
: await captureIdentity();
|
|
714
|
+
break;
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (!isTransientReleaseError(error) || retryBudget.remainingDelays === 0) throw error;
|
|
717
|
+
retryBudget.remainingDelays--;
|
|
718
|
+
await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} catch (error) {
|
|
722
|
+
// Without the handle's inode identity, retaining this empty info entry would
|
|
723
|
+
// make both immediate cleanup and the later orphan reaper fail closed.
|
|
724
|
+
try {
|
|
725
|
+
const infoPath = `${lockPath}/info`;
|
|
726
|
+
const quarantineName = `${path.basename(infoPath)}.cleanup.${crypto.randomUUID()}`;
|
|
727
|
+
const cleanup = await (FileLockTestHooks.stagingInfoCleanup
|
|
728
|
+
? FileLockTestHooks.stagingInfoCleanup(infoPath, handle.fd, quarantineName)
|
|
729
|
+
: exactUnlinkOpenFile(infoPath, handle.fd, quarantineName));
|
|
730
|
+
if (!cleanup.ok && cleanup.code !== "not_found")
|
|
731
|
+
throw Object.assign(
|
|
732
|
+
new Error(`Identity-bound cleanup of staged file-lock info failed: ${cleanup.code ?? "unknown"}.`),
|
|
733
|
+
{ code: cleanup.code ?? "identity_unavailable" },
|
|
734
|
+
);
|
|
735
|
+
} catch (cleanupError) {
|
|
736
|
+
throw new AggregateError(
|
|
737
|
+
[error, cleanupError],
|
|
738
|
+
`Failed to remove staged file-lock info after identity capture failed at ${lockPath}.`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
onFileCreated(identity);
|
|
744
|
+
await handle.writeFile(JSON.stringify(info));
|
|
745
|
+
await handle.chmod(0o600);
|
|
746
|
+
return info;
|
|
747
|
+
} finally {
|
|
748
|
+
await handle.close();
|
|
749
|
+
}
|
|
262
750
|
}
|
|
263
751
|
|
|
264
752
|
type LockInfoFileState = {
|
|
@@ -580,10 +1068,24 @@ async function removeDetachedLockQuarantineOnDisk(
|
|
|
580
1068
|
) {
|
|
581
1069
|
return false;
|
|
582
1070
|
}
|
|
1071
|
+
if ((await FileLockTestHooks.beforeDetachedLockCleanup?.(detachedPath)) === false) return false;
|
|
583
1072
|
await fs.rm(detachedPath, { recursive: true, force: true });
|
|
584
1073
|
return true;
|
|
585
1074
|
}
|
|
586
1075
|
|
|
1076
|
+
function markLocalRemovalResumable(transitionPath: string, rootDev: string, rootIno: string): void {
|
|
1077
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1078
|
+
const local = localRemovalRecords.get(recordPath);
|
|
1079
|
+
if (!local) return;
|
|
1080
|
+
try {
|
|
1081
|
+
const record = JSON.parse(local.bytes) as Partial<RemovalTransitionOwnerRecord>;
|
|
1082
|
+
if (record.rootDev === rootDev && record.rootIno === rootIno)
|
|
1083
|
+
localRemovalRecords.set(recordPath, { ...local, resumable: true });
|
|
1084
|
+
} catch {
|
|
1085
|
+
// A malformed local receipt cannot authorize resuming a detached tree.
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
587
1089
|
async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
|
|
588
1090
|
const pending = pendingDetachedLockCleanups.get(owner);
|
|
589
1091
|
if (!pending) return false;
|
|
@@ -602,7 +1104,7 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
|
|
|
602
1104
|
// finish the quarantine its own fallback created, so finish on disk instead.
|
|
603
1105
|
if (process.platform === "win32" && (await isNativeExactRemovalUsable())) {
|
|
604
1106
|
const removal = nativeFileLockBindings().exactRemoveDirectoryTree(pending.path, pending.snapshot);
|
|
605
|
-
if (removal.code === "cleanup_pending") return false;
|
|
1107
|
+
if (removal.code === "cleanup_pending" || (!removal.ok && isTransientReleaseError(removal))) return false;
|
|
606
1108
|
if (!removal.ok && removal.code !== "not_found")
|
|
607
1109
|
throw new Error(`Failed to finish detached file lock cleanup: ${removal.code ?? "unknown"}.`);
|
|
608
1110
|
} else {
|
|
@@ -621,7 +1123,22 @@ function getLockPath(filePath: string): string {
|
|
|
621
1123
|
}
|
|
622
1124
|
|
|
623
1125
|
function fileLockRemovalTransitionPath(lockPath: string): string {
|
|
624
|
-
return
|
|
1126
|
+
return derivedFileLockPath(lockPath, ".removing");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Keep derived siblings bounded without changing the canonical lock identity. */
|
|
1130
|
+
function derivedFileLockPath(sourcePath: string, suffix: string): string {
|
|
1131
|
+
const name = path.basename(sourcePath);
|
|
1132
|
+
const derived = `${name}${suffix}`;
|
|
1133
|
+
const bounded =
|
|
1134
|
+
Buffer.byteLength(derived, "utf8") <= 255
|
|
1135
|
+
? derived
|
|
1136
|
+
: `.gjc-lock-${crypto.createHash("sha256").update(name).digest("hex")}${suffix}`;
|
|
1137
|
+
return path.join(path.dirname(sourcePath), bounded);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function removalRecordPath(transitionPath: string): string {
|
|
1141
|
+
return derivedFileLockPath(transitionPath, ".owner");
|
|
625
1142
|
}
|
|
626
1143
|
|
|
627
1144
|
type RemovalTransitionOwnerRecord = {
|
|
@@ -630,7 +1147,9 @@ type RemovalTransitionOwnerRecord = {
|
|
|
630
1147
|
rootIno: string;
|
|
631
1148
|
};
|
|
632
1149
|
|
|
633
|
-
|
|
1150
|
+
type LocalRemovalRecord = { bytes: string; resumable: boolean; inFlight: boolean };
|
|
1151
|
+
|
|
1152
|
+
const localRemovalRecords = new Map<string, LocalRemovalRecord>();
|
|
634
1153
|
|
|
635
1154
|
function sameRemovalRecordBytes(left: string, right: string): boolean {
|
|
636
1155
|
try {
|
|
@@ -651,19 +1170,50 @@ function sameRemovalRecordBytes(left: string, right: string): boolean {
|
|
|
651
1170
|
}
|
|
652
1171
|
|
|
653
1172
|
function isResumableLocalRemoval(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
|
|
654
|
-
const local = localRemovalRecords.get(
|
|
655
|
-
return local?.resumable === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
|
|
1173
|
+
const local = localRemovalRecords.get(removalRecordPath(transitionPath));
|
|
1174
|
+
return local?.resumable === true && !local.inFlight && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function isLocalRemovalInFlight(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
|
|
1178
|
+
const local = localRemovalRecords.get(removalRecordPath(transitionPath));
|
|
1179
|
+
return local?.inFlight === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function markLocalRemovalCompleted(transitionPath: string, expectedBytes?: string): void {
|
|
1183
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1184
|
+
const local = localRemovalRecords.get(recordPath);
|
|
1185
|
+
if (!local?.inFlight || (expectedBytes !== undefined && !sameRemovalRecordBytes(local.bytes, expectedBytes))) return;
|
|
1186
|
+
localRemovalRecords.set(recordPath, { ...local, inFlight: false });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function readBoundedRemovalRecordBytes(recordPath: string): Promise<Buffer | null> {
|
|
1190
|
+
const bytes = Buffer.from(
|
|
1191
|
+
await Bun.file(recordPath)
|
|
1192
|
+
.slice(0, MAX_FILE_LOCK_REMOVAL_RECORD_BYTES + 1)
|
|
1193
|
+
.arrayBuffer(),
|
|
1194
|
+
);
|
|
1195
|
+
return bytes.length > MAX_FILE_LOCK_REMOVAL_RECORD_BYTES ? null : bytes;
|
|
656
1196
|
}
|
|
657
1197
|
|
|
658
1198
|
type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
|
|
659
1199
|
type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
|
|
660
|
-
type
|
|
1200
|
+
type FileLockPublicationRetry = { kind: "publication_retry"; code: FileLockPublicationFailureCode };
|
|
1201
|
+
type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | FileLockPublicationRetry | null;
|
|
661
1202
|
|
|
662
1203
|
async function readRemovalTransitionOwnerRecord(transitionPath: string): Promise<RemovalTransitionOwnerRecord | null> {
|
|
663
1204
|
try {
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
1205
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1206
|
+
const stat = await fs.lstat(recordPath, { bigint: true });
|
|
1207
|
+
if (
|
|
1208
|
+
!stat.isFile() ||
|
|
1209
|
+
stat.isSymbolicLink() ||
|
|
1210
|
+
stat.nlink !== 1n ||
|
|
1211
|
+
stat.size > BigInt(MAX_FILE_LOCK_REMOVAL_RECORD_BYTES)
|
|
1212
|
+
)
|
|
1213
|
+
return null;
|
|
1214
|
+
const bytes = await readBoundedRemovalRecordBytes(recordPath);
|
|
1215
|
+
if (!bytes) return null;
|
|
1216
|
+
const record = JSON.parse(bytes.toString("utf8")) as RemovalTransitionOwnerRecord;
|
|
667
1217
|
const owner = parseLockInfoBytes(JSON.stringify(record.owner));
|
|
668
1218
|
return owner && typeof record.rootDev === "string" && typeof record.rootIno === "string"
|
|
669
1219
|
? { owner, rootDev: record.rootDev, rootIno: record.rootIno }
|
|
@@ -687,30 +1237,60 @@ async function classifyFileLockRemovalTransition(
|
|
|
687
1237
|
transition = await fs.lstat(transitionPath, { bigint: true });
|
|
688
1238
|
if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
|
|
689
1239
|
} catch (error) {
|
|
690
|
-
if (isEnoent(error))
|
|
1240
|
+
if (isEnoent(error)) {
|
|
1241
|
+
await FileLockTestHooks.afterRemovalTransitionMissing?.(transitionPath);
|
|
1242
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1243
|
+
const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
1244
|
+
if (!recordedOwner) {
|
|
1245
|
+
try {
|
|
1246
|
+
await fs.lstat(recordPath);
|
|
1247
|
+
return "active";
|
|
1248
|
+
} catch (recordError) {
|
|
1249
|
+
return isEnoent(recordError) ? null : "active";
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const localRecord = localRemovalRecords.get(recordPath);
|
|
1253
|
+
if (localRecord && sameRemovalRecordBytes(localRecord.bytes, JSON.stringify(recordedOwner))) {
|
|
1254
|
+
if (localRecord.inFlight) return "active";
|
|
1255
|
+
if (!(await clearCompletedLocalRemovalRecord(lockPath, transitionPath, recordedOwner, localRecord)))
|
|
1256
|
+
return "active";
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
if (lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)) {
|
|
1260
|
+
// Never probe a foreign PID. Absence of both captured names proves
|
|
1261
|
+
// retirement completed, rather than a live remover awaiting detach.
|
|
1262
|
+
return (await clearCompletedForeignRemovalRecord(lockPath, transitionPath, recordedOwner))
|
|
1263
|
+
? null
|
|
1264
|
+
: "active";
|
|
1265
|
+
}
|
|
1266
|
+
// Another same-host process may have completed native removal but been
|
|
1267
|
+
// unable to unlink its receipt. Prove the captured generation is absent
|
|
1268
|
+
// before treating its still-live publisher PID as an active retirement.
|
|
1269
|
+
if (await removalTransitionIsComplete(lockPath, transitionPath, recordedOwner))
|
|
1270
|
+
return (await removeRemovalRecord(recordPath, JSON.stringify(recordedOwner))) ? null : "active";
|
|
1271
|
+
if (ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown") return "active";
|
|
1272
|
+
return (await removeRemovalRecord(recordPath, JSON.stringify(recordedOwner))) ? null : "active";
|
|
1273
|
+
}
|
|
691
1274
|
throw error;
|
|
692
1275
|
}
|
|
693
1276
|
|
|
694
1277
|
// A sibling record survives rename and scrub without mutating the captured tree.
|
|
695
1278
|
const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
696
1279
|
if (recordedOwner) {
|
|
697
|
-
if (
|
|
698
|
-
recordedOwner.owner.owner_host_id !== undefined &&
|
|
699
|
-
lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)
|
|
700
|
-
)
|
|
701
|
-
return "active";
|
|
1280
|
+
if (lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)) return "active";
|
|
702
1281
|
if (
|
|
703
1282
|
canonicalLockDeviceId(transition.dev).toString() !== recordedOwner.rootDev ||
|
|
704
1283
|
canonicalLockFileId(transition.ino).toString() !== recordedOwner.rootIno
|
|
705
1284
|
)
|
|
706
1285
|
return "active";
|
|
1286
|
+
if (isLocalRemovalInFlight(transitionPath, recordedOwner)) return "active";
|
|
707
1287
|
if (isResumableLocalRemoval(transitionPath, recordedOwner)) return "orphan_transition"; // The remover itself may resume its own interrupted cleanup.
|
|
708
1288
|
return ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown"
|
|
709
1289
|
? "active"
|
|
710
1290
|
: "orphan_transition";
|
|
711
1291
|
}
|
|
712
1292
|
try {
|
|
713
|
-
await fs.lstat(
|
|
1293
|
+
await fs.lstat(removalRecordPath(transitionPath));
|
|
714
1294
|
return "active";
|
|
715
1295
|
} catch (error) {
|
|
716
1296
|
if (!isEnoent(error)) return "active";
|
|
@@ -741,6 +1321,10 @@ function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is
|
|
|
741
1321
|
return value !== null && "kind" in value && value.kind === "orphan_transition";
|
|
742
1322
|
}
|
|
743
1323
|
|
|
1324
|
+
function isFileLockPublicationRetry(value: FileLockAcquisitionResult): value is FileLockPublicationRetry {
|
|
1325
|
+
return value !== null && "kind" in value && value.kind === "publication_retry";
|
|
1326
|
+
}
|
|
1327
|
+
|
|
744
1328
|
/** Without an owner, only an aged, completely empty directory is reclaimable. */
|
|
745
1329
|
function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs: number): boolean {
|
|
746
1330
|
const root = snapshot.entries[0];
|
|
@@ -752,9 +1336,11 @@ function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs
|
|
|
752
1336
|
async function removeRemovalRecord(recordPath: string, expectedBytes: string): Promise<boolean> {
|
|
753
1337
|
try {
|
|
754
1338
|
const stat = await fs.lstat(recordPath, { bigint: true });
|
|
755
|
-
if (!stat.isFile() || stat.isSymbolicLink()
|
|
756
|
-
|
|
757
|
-
|
|
1339
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(MAX_FILE_LOCK_REMOVAL_RECORD_BYTES))
|
|
1340
|
+
return false;
|
|
1341
|
+
const bytes = await readBoundedRemovalRecordBytes(recordPath);
|
|
1342
|
+
if (!bytes || !sameRemovalRecordBytes(bytes.toString("utf8"), expectedBytes)) return false;
|
|
1343
|
+
if ((await FileLockTestHooks.beforeRemovalRecordUnlink?.(recordPath)) === false) return false;
|
|
758
1344
|
return exactUnlinkDirect(recordPath, {
|
|
759
1345
|
dev: canonicalLockDeviceId(stat.dev),
|
|
760
1346
|
ino: canonicalLockFileId(stat.ino),
|
|
@@ -762,7 +1348,9 @@ async function removeRemovalRecord(recordPath: string, expectedBytes: string): P
|
|
|
762
1348
|
mtimeNs: stat.mtimeNs,
|
|
763
1349
|
nlink: stat.nlink,
|
|
764
1350
|
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
765
|
-
quarantineName:
|
|
1351
|
+
quarantineName: path.basename(
|
|
1352
|
+
derivedFileLockPath(recordPath, `.retired.${crypto.randomBytes(12).toString("base64url")}`),
|
|
1353
|
+
),
|
|
766
1354
|
}).ok;
|
|
767
1355
|
} catch (error) {
|
|
768
1356
|
if (isEnoent(error)) return true;
|
|
@@ -771,43 +1359,145 @@ async function removeRemovalRecord(recordPath: string, expectedBytes: string): P
|
|
|
771
1359
|
}
|
|
772
1360
|
|
|
773
1361
|
async function clearRemovalRecord(transitionPath: string): Promise<void> {
|
|
774
|
-
const recordPath =
|
|
775
|
-
const
|
|
776
|
-
if (!
|
|
1362
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1363
|
+
const local = localRemovalRecords.get(recordPath);
|
|
1364
|
+
if (!local || local.inFlight) return;
|
|
1365
|
+
const bytes = local.bytes;
|
|
777
1366
|
try {
|
|
778
|
-
await fs.lstat(transitionPath);
|
|
779
|
-
|
|
1367
|
+
const transition = await fs.lstat(transitionPath, { bigint: true });
|
|
1368
|
+
const recorded = JSON.parse(bytes) as Partial<RemovalTransitionOwnerRecord>;
|
|
1369
|
+
if (
|
|
1370
|
+
transition.isDirectory() &&
|
|
1371
|
+
typeof recorded.rootDev === "string" &&
|
|
1372
|
+
typeof recorded.rootIno === "string" &&
|
|
1373
|
+
canonicalLockDeviceId(transition.dev).toString() === recorded.rootDev &&
|
|
1374
|
+
canonicalLockFileId(transition.ino).toString() === recorded.rootIno
|
|
1375
|
+
)
|
|
1376
|
+
return;
|
|
780
1377
|
} catch (error) {
|
|
781
1378
|
if (!isEnoent(error)) throw error;
|
|
782
1379
|
}
|
|
783
1380
|
if (await removeRemovalRecord(recordPath, bytes)) localRemovalRecords.delete(recordPath);
|
|
784
1381
|
}
|
|
785
1382
|
|
|
1383
|
+
async function removalTransitionIsComplete(
|
|
1384
|
+
lockPath: string,
|
|
1385
|
+
transitionPath: string,
|
|
1386
|
+
record: RemovalTransitionOwnerRecord,
|
|
1387
|
+
afterTransitionMissing?: (transitionPath: string) => void | Promise<void>,
|
|
1388
|
+
): Promise<boolean> {
|
|
1389
|
+
try {
|
|
1390
|
+
await fs.lstat(transitionPath);
|
|
1391
|
+
return false;
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
if (!isEnoent(error)) throw error;
|
|
1394
|
+
}
|
|
1395
|
+
await afterTransitionMissing?.(transitionPath);
|
|
1396
|
+
try {
|
|
1397
|
+
const canonical = await fs.lstat(lockPath, { bigint: true });
|
|
1398
|
+
if (
|
|
1399
|
+
canonicalLockDeviceId(canonical.dev).toString() === record.rootDev &&
|
|
1400
|
+
canonicalLockFileId(canonical.ino).toString() === record.rootIno
|
|
1401
|
+
)
|
|
1402
|
+
return false;
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
if (!isEnoent(error)) throw error;
|
|
1405
|
+
}
|
|
1406
|
+
try {
|
|
1407
|
+
await fs.lstat(transitionPath);
|
|
1408
|
+
return false;
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (!isEnoent(error)) throw error;
|
|
1411
|
+
}
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async function clearCompletedLocalRemovalRecord(
|
|
1416
|
+
lockPath: string,
|
|
1417
|
+
transitionPath: string,
|
|
1418
|
+
record: RemovalTransitionOwnerRecord,
|
|
1419
|
+
localRecord: LocalRemovalRecord,
|
|
1420
|
+
): Promise<boolean> {
|
|
1421
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1422
|
+
const current = localRemovalRecords.get(recordPath);
|
|
1423
|
+
if (
|
|
1424
|
+
current !== localRecord ||
|
|
1425
|
+
current.inFlight ||
|
|
1426
|
+
!(await removalTransitionIsComplete(lockPath, transitionPath, record))
|
|
1427
|
+
)
|
|
1428
|
+
return false;
|
|
1429
|
+
const latest = localRemovalRecords.get(recordPath);
|
|
1430
|
+
if (
|
|
1431
|
+
!latest ||
|
|
1432
|
+
latest.inFlight ||
|
|
1433
|
+
!sameRemovalRecordBytes(latest.bytes, localRecord.bytes) ||
|
|
1434
|
+
!(await removeRemovalRecord(recordPath, localRecord.bytes))
|
|
1435
|
+
)
|
|
1436
|
+
return false;
|
|
1437
|
+
const afterRemoval = localRemovalRecords.get(recordPath);
|
|
1438
|
+
if (afterRemoval?.inFlight || (afterRemoval && !sameRemovalRecordBytes(afterRemoval.bytes, localRecord.bytes)))
|
|
1439
|
+
return false;
|
|
1440
|
+
if (afterRemoval) localRemovalRecords.delete(recordPath);
|
|
1441
|
+
return true;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function clearCompletedForeignRemovalRecord(
|
|
1445
|
+
lockPath: string,
|
|
1446
|
+
transitionPath: string,
|
|
1447
|
+
record: RemovalTransitionOwnerRecord,
|
|
1448
|
+
): Promise<boolean> {
|
|
1449
|
+
if (
|
|
1450
|
+
!(await removalTransitionIsComplete(
|
|
1451
|
+
lockPath,
|
|
1452
|
+
transitionPath,
|
|
1453
|
+
record,
|
|
1454
|
+
FileLockTestHooks.afterForeignTransitionMissing,
|
|
1455
|
+
))
|
|
1456
|
+
)
|
|
1457
|
+
return false;
|
|
1458
|
+
return removeRemovalRecord(removalRecordPath(transitionPath), JSON.stringify(record));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
786
1461
|
async function publishRemovalRecord(
|
|
787
1462
|
transitionPath: string,
|
|
788
1463
|
snapshot: NativeDirectoryTreeSnapshot,
|
|
789
1464
|
ownerHostId?: string,
|
|
790
1465
|
resumable = false,
|
|
791
1466
|
): Promise<boolean> {
|
|
792
|
-
const recordPath =
|
|
1467
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1468
|
+
// A pre-detach refusal can leave a completed local receipt whose exact
|
|
1469
|
+
// unlink was temporarily refused. Release itself must retry that cleanup.
|
|
1470
|
+
await clearRemovalRecord(transitionPath);
|
|
793
1471
|
if (localRemovalRecords.has(recordPath)) return false;
|
|
794
1472
|
const bytes = JSON.stringify({
|
|
795
1473
|
owner: lockInfo(ownerHostId, crypto.randomUUID()),
|
|
796
1474
|
rootDev: snapshot.rootDev,
|
|
797
1475
|
rootIno: snapshot.rootIno,
|
|
798
1476
|
});
|
|
799
|
-
|
|
1477
|
+
if (Buffer.byteLength(bytes, "utf8") > MAX_FILE_LOCK_REMOVAL_RECORD_BYTES) return false;
|
|
1478
|
+
const staged = derivedFileLockPath(recordPath, `.${process.pid}.${crypto.randomBytes(12).toString("base64url")}`);
|
|
800
1479
|
await Bun.write(staged, bytes, { mode: 0o600 });
|
|
1480
|
+
let publishedLocally = false;
|
|
801
1481
|
try {
|
|
802
1482
|
await fs.chmod(staged, 0o600);
|
|
803
1483
|
await FileLockTestHooks.beforeRemovalOwnerPublish?.(transitionPath);
|
|
804
1484
|
const published = await renameNoReplacePathAsync(staged, recordPath);
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1485
|
+
if (published.ok) {
|
|
1486
|
+
localRemovalRecords.set(recordPath, { bytes, resumable, inFlight: true });
|
|
1487
|
+
publishedLocally = true;
|
|
1488
|
+
}
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
if (publishedLocally) markLocalRemovalCompleted(transitionPath, bytes);
|
|
1491
|
+
await fs.rm(staged, { force: true }).catch(() => undefined);
|
|
1492
|
+
throw error;
|
|
1493
|
+
}
|
|
1494
|
+
try {
|
|
809
1495
|
await fs.rm(staged, { force: true });
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
if (publishedLocally) markLocalRemovalCompleted(transitionPath, bytes);
|
|
1498
|
+
throw error;
|
|
810
1499
|
}
|
|
1500
|
+
return publishedLocally;
|
|
811
1501
|
}
|
|
812
1502
|
|
|
813
1503
|
/** Publish outside the captured tree, atomically and before native rename. */
|
|
@@ -818,24 +1508,32 @@ async function exactRemoveLockTree(
|
|
|
818
1508
|
parentIdentity?: { dev: bigint; ino: bigint },
|
|
819
1509
|
detachOnly?: boolean,
|
|
820
1510
|
): Promise<NativeExactUnlinkResult> {
|
|
821
|
-
if (
|
|
1511
|
+
if (lockPath.endsWith(".removing"))
|
|
822
1512
|
return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
|
|
823
1513
|
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
824
|
-
|
|
825
|
-
const prior = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
826
|
-
if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
|
|
827
|
-
// A foreign transition remains untouchable. Once its tree is gone, only
|
|
828
|
-
// the remover's final record clear is pending: wait without probing its
|
|
829
|
-
// PID or deleting its record, even for an unqualified successor.
|
|
1514
|
+
if (process.platform === "win32") {
|
|
830
1515
|
try {
|
|
831
1516
|
await fs.lstat(transitionPath);
|
|
1517
|
+
// Native removal can resume this generation when the canonical name is
|
|
1518
|
+
// already detached. A different generation at `.removing` is left for
|
|
1519
|
+
// acquisition-time adoption; never attach this owner record to it.
|
|
1520
|
+
try {
|
|
1521
|
+
await fs.lstat(lockPath);
|
|
1522
|
+
return { ok: false, code: "quarantine_collision" };
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
if (!isEnoent(error)) throw error;
|
|
1525
|
+
}
|
|
1526
|
+
return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
|
|
832
1527
|
} catch (error) {
|
|
833
1528
|
if (!isEnoent(error)) throw error;
|
|
834
|
-
return { ok: false, code: "removal_record_busy" };
|
|
835
1529
|
}
|
|
836
|
-
return { ok: false, code: "identity_mismatch" };
|
|
837
1530
|
}
|
|
838
|
-
|
|
1531
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
1532
|
+
const prior = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
1533
|
+
if (prior && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
|
|
1534
|
+
if (!(await clearCompletedForeignRemovalRecord(lockPath, transitionPath, prior)))
|
|
1535
|
+
return { ok: false, code: "removal_record_busy" };
|
|
1536
|
+
} else if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
|
|
839
1537
|
try {
|
|
840
1538
|
await fs.lstat(transitionPath);
|
|
841
1539
|
return { ok: false, code: "identity_mismatch" };
|
|
@@ -851,15 +1549,48 @@ async function exactRemoveLockTree(
|
|
|
851
1549
|
// final and strands this live owner's lock for every other process.
|
|
852
1550
|
if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
|
|
853
1551
|
return { ok: false, code: "removal_record_busy" };
|
|
1552
|
+
const publishedRecordBytes = localRemovalRecords.get(recordPath)?.bytes;
|
|
854
1553
|
try {
|
|
855
|
-
|
|
1554
|
+
await FileLockTestHooks.afterRemovalOwnerPublish?.(transitionPath);
|
|
1555
|
+
const bindings = nativeFileLockBindings();
|
|
1556
|
+
if (process.platform === "win32" && !detachOnly) {
|
|
1557
|
+
const detached = bindings.exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, true);
|
|
1558
|
+
if (!detached.ok) return detached;
|
|
1559
|
+
if (
|
|
1560
|
+
detached.code !== undefined ||
|
|
1561
|
+
detached.detachedPath !== transitionPath ||
|
|
1562
|
+
Object.keys(detached).some(key => key !== "ok" && key !== "detachedPath")
|
|
1563
|
+
)
|
|
1564
|
+
return { ok: false, code: "identity_mismatch", detachedPath: detached.detachedPath };
|
|
1565
|
+
try {
|
|
1566
|
+
const removeResult = bindings.exactRemoveDirectoryTree(transitionPath, snapshot, parentIdentity);
|
|
1567
|
+
// Handle returned transient failures (not thrown exceptions):
|
|
1568
|
+
// when native pre-open fails with e.g. sharing_violation, the result
|
|
1569
|
+
// is returned without detachedPath. Mark the receipt resumable before
|
|
1570
|
+
// returning so that in-process retry or cleanup can complete the removal.
|
|
1571
|
+
if (!removeResult.ok && isResultTransientFailure(removeResult)) {
|
|
1572
|
+
markLocalRemovalResumable(transitionPath, snapshot.rootDev, snapshot.rootIno);
|
|
1573
|
+
// Transitionpath was successfully detached; include it in the result
|
|
1574
|
+
// so cleanup can continue in-process or by a later invocation.
|
|
1575
|
+
return { ...removeResult, detachedPath: transitionPath };
|
|
1576
|
+
}
|
|
1577
|
+
return removeResult;
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
// Also handle thrown errors (if implementation changes in future)
|
|
1580
|
+
if (isTransientReleaseError(error))
|
|
1581
|
+
markLocalRemovalResumable(transitionPath, snapshot.rootDev, snapshot.rootIno);
|
|
1582
|
+
throw error;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return bindings.exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
|
|
856
1586
|
} finally {
|
|
1587
|
+
markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
|
|
857
1588
|
await clearRemovalRecord(transitionPath);
|
|
858
1589
|
}
|
|
859
1590
|
}
|
|
860
1591
|
|
|
861
1592
|
/**
|
|
862
|
-
* Adopt-and-finish a provably orphaned
|
|
1593
|
+
* Adopt-and-finish a provably orphaned removal transition.
|
|
863
1594
|
*
|
|
864
1595
|
* The prepublished sibling carries the remover's process generation and the
|
|
865
1596
|
* captured root identity until cleanup finishes. A dead remover permits replay
|
|
@@ -872,7 +1603,7 @@ async function adoptOrphanedFileLockRemovalTransition(
|
|
|
872
1603
|
orphanAgeMs: number,
|
|
873
1604
|
ownerHostId?: string,
|
|
874
1605
|
previousOwnerHostIds: readonly string[] = [],
|
|
875
|
-
): Promise<boolean> {
|
|
1606
|
+
): Promise<boolean | "retry"> {
|
|
876
1607
|
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
877
1608
|
const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
878
1609
|
if (recordedOwner && ownerIsAlive(recordedOwner.owner) && !isResumableLocalRemoval(transitionPath, recordedOwner))
|
|
@@ -890,53 +1621,100 @@ async function adoptOrphanedFileLockRemovalTransition(
|
|
|
890
1621
|
if (!recordedOwner && !isEmptyRemovalTransition(snapshot, orphanAgeMs)) return false;
|
|
891
1622
|
if (recordedOwner && (snapshot.rootDev !== recordedOwner.rootDev || snapshot.rootIno !== recordedOwner.rootIno))
|
|
892
1623
|
return false;
|
|
1624
|
+
const recordPath = removalRecordPath(transitionPath);
|
|
893
1625
|
// Claim an ownerless generation without replacing any live publisher's record.
|
|
894
1626
|
// The snapshot binds both the grace verdict and subsequent native removal.
|
|
895
|
-
if (
|
|
1627
|
+
if (recordedOwner) {
|
|
1628
|
+
const current = localRemovalRecords.get(recordPath);
|
|
1629
|
+
if (current?.inFlight) return false;
|
|
1630
|
+
localRemovalRecords.set(recordPath, {
|
|
1631
|
+
bytes: JSON.stringify(recordedOwner),
|
|
1632
|
+
resumable: true,
|
|
1633
|
+
inFlight: true,
|
|
1634
|
+
});
|
|
1635
|
+
} else if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) {
|
|
1636
|
+
return false;
|
|
1637
|
+
}
|
|
1638
|
+
const removalRecordBytes = localRemovalRecords.get(recordPath)?.bytes;
|
|
1639
|
+
if (!removalRecordBytes) return false;
|
|
896
1640
|
const finish = async (): Promise<boolean> => {
|
|
897
|
-
|
|
898
|
-
localRemovalRecords.set(`${transitionPath}.owner`, { bytes: JSON.stringify(recordedOwner), resumable: true });
|
|
899
|
-
}
|
|
1641
|
+
markLocalRemovalCompleted(transitionPath, removalRecordBytes);
|
|
900
1642
|
await clearRemovalRecord(transitionPath);
|
|
901
1643
|
logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
|
|
902
1644
|
return true;
|
|
903
1645
|
};
|
|
904
|
-
let removal: NativeExactUnlinkResult;
|
|
905
1646
|
try {
|
|
906
|
-
removal
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1647
|
+
let removal: NativeExactUnlinkResult;
|
|
1648
|
+
try {
|
|
1649
|
+
removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
if (isTransientReleaseError(error)) return "retry";
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
if (!removal.ok && isTransientReleaseError(removal)) return "retry";
|
|
1655
|
+
if (removal.ok === true) {
|
|
1656
|
+
return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
|
|
1657
|
+
}
|
|
1658
|
+
if (
|
|
1659
|
+
removal.ok === false &&
|
|
1660
|
+
removal.code === "not_found" &&
|
|
1661
|
+
Object.keys(removal).every(key => key === "ok" || key === "code")
|
|
1662
|
+
)
|
|
1663
|
+
return await finish();
|
|
1664
|
+
// POSIX cannot bind a namespace unlink to the verified descriptor, so the
|
|
1665
|
+
// primitive retains the scrubbed tree under its deterministic name and the
|
|
1666
|
+
// caller finishes the on-disk removal. Finish only the exact tree this call
|
|
1667
|
+
// scrubbed; a retained successor/placeholder/unknown path is refused so a
|
|
1668
|
+
// replacement is never deleted.
|
|
1669
|
+
if (
|
|
1670
|
+
removal.ok === false &&
|
|
1671
|
+
removal.code === "cleanup_pending" &&
|
|
1672
|
+
removal.payloadDurable === true &&
|
|
1673
|
+
removal.detachedPath !== undefined &&
|
|
1674
|
+
path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
|
|
1675
|
+
removal.retainedSuccessorPath === undefined &&
|
|
1676
|
+
removal.retainedPlaceholderPath === undefined &&
|
|
1677
|
+
removal.retainedUnknownPath === undefined &&
|
|
1678
|
+
Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
|
|
1679
|
+
) {
|
|
1680
|
+
try {
|
|
1681
|
+
const finished = await removeDetachedLockQuarantineOnDisk(
|
|
1682
|
+
transitionPath,
|
|
1683
|
+
snapshot.rootDev,
|
|
1684
|
+
snapshot.rootIno,
|
|
1685
|
+
);
|
|
1686
|
+
return finished && (await finish());
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
if (isTransientReleaseError(error)) return "retry";
|
|
1689
|
+
throw error;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
} finally {
|
|
1694
|
+
markLocalRemovalCompleted(transitionPath, removalRecordBytes);
|
|
938
1695
|
}
|
|
939
|
-
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Whether an abandoned transition's captured tree is safe to adopt: the root is
|
|
1700
|
+
* the transition directory, `info` is the only file, and every other entry is a
|
|
1701
|
+
* directory or an empty file. Any other payload means adoption refuses the
|
|
1702
|
+
* generation, so it must be reported as a block instead of a silent retry.
|
|
1703
|
+
*/
|
|
1704
|
+
function abandonedTransitionPayloadIsAdoptable(snapshot: NativeDirectoryTreeSnapshot): boolean {
|
|
1705
|
+
const root = snapshot.entries.find(entry => entry.relativePath === "");
|
|
1706
|
+
const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
|
|
1707
|
+
return (
|
|
1708
|
+
root?.kind === "directory" &&
|
|
1709
|
+
infoEntry?.kind === "file" &&
|
|
1710
|
+
!snapshot.entries.some(
|
|
1711
|
+
entry =>
|
|
1712
|
+
entry.relativePath !== "" &&
|
|
1713
|
+
entry.relativePath !== "info" &&
|
|
1714
|
+
entry.kind !== "directory" &&
|
|
1715
|
+
(entry.kind !== "file" || entry.size !== "0"),
|
|
1716
|
+
)
|
|
1717
|
+
);
|
|
940
1718
|
}
|
|
941
1719
|
|
|
942
1720
|
async function adoptAbandonedFileLockRemovalTransition(
|
|
@@ -960,20 +1738,9 @@ async function adoptAbandonedFileLockRemovalTransition(
|
|
|
960
1738
|
}
|
|
961
1739
|
if (!captured.ok || !captured.snapshot) return false;
|
|
962
1740
|
const snapshot = captured.snapshot;
|
|
963
|
-
|
|
1741
|
+
if (!abandonedTransitionPayloadIsAdoptable(snapshot)) return false;
|
|
964
1742
|
const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
|
|
965
|
-
if (
|
|
966
|
-
root?.kind !== "directory" ||
|
|
967
|
-
infoEntry?.kind !== "file" ||
|
|
968
|
-
snapshot.entries.some(
|
|
969
|
-
entry =>
|
|
970
|
-
entry.relativePath !== "" &&
|
|
971
|
-
entry.relativePath !== "info" &&
|
|
972
|
-
entry.kind !== "directory" &&
|
|
973
|
-
(entry.kind !== "file" || entry.size !== "0"),
|
|
974
|
-
)
|
|
975
|
-
)
|
|
976
|
-
return false;
|
|
1743
|
+
if (infoEntry?.kind !== "file") return false;
|
|
977
1744
|
const currentIdentity = await captureFileLockDirIdentity(transitionPath);
|
|
978
1745
|
if (!currentIdentity || !sameStableFileLockIdentity(currentIdentity, stale.identity)) return false;
|
|
979
1746
|
if (
|
|
@@ -984,38 +1751,45 @@ async function adoptAbandonedFileLockRemovalTransition(
|
|
|
984
1751
|
// Legacy info proves the old owner dead, but native scrub destroys that info.
|
|
985
1752
|
// Commit this remover's sibling identity before allowing any payload mutation.
|
|
986
1753
|
if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
|
|
987
|
-
|
|
988
|
-
|
|
1754
|
+
const publishedRecordBytes = localRemovalRecords.get(removalRecordPath(transitionPath))?.bytes;
|
|
1755
|
+
if (!publishedRecordBytes) return false;
|
|
989
1756
|
try {
|
|
990
|
-
removal
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1757
|
+
let removal: NativeExactUnlinkResult;
|
|
1758
|
+
try {
|
|
1759
|
+
removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
if (isTransientReleaseError(error)) return false;
|
|
1762
|
+
throw error;
|
|
1763
|
+
}
|
|
1764
|
+
let finished =
|
|
1765
|
+
removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
|
|
1766
|
+
if (
|
|
1767
|
+
removal.ok === false &&
|
|
1768
|
+
removal.code === "cleanup_pending" &&
|
|
1769
|
+
removal.payloadDurable === true &&
|
|
1770
|
+
removal.detachedPath !== undefined &&
|
|
1771
|
+
path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
|
|
1772
|
+
removal.retainedSuccessorPath === undefined &&
|
|
1773
|
+
removal.retainedPlaceholderPath === undefined &&
|
|
1774
|
+
removal.retainedUnknownPath === undefined &&
|
|
1775
|
+
Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
|
|
1776
|
+
)
|
|
1777
|
+
finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
|
|
1778
|
+
if (
|
|
1779
|
+
removal.ok === false &&
|
|
1780
|
+
removal.code === "not_found" &&
|
|
1781
|
+
Object.keys(removal).every(key => key === "ok" || key === "code")
|
|
1782
|
+
)
|
|
1783
|
+
finished = true;
|
|
1784
|
+
if (finished) {
|
|
1785
|
+
markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
|
|
1786
|
+
await clearRemovalRecord(transitionPath);
|
|
1787
|
+
logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
|
|
1788
|
+
}
|
|
1789
|
+
return finished;
|
|
1790
|
+
} finally {
|
|
1791
|
+
markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
|
|
1017
1792
|
}
|
|
1018
|
-
return finished;
|
|
1019
1793
|
}
|
|
1020
1794
|
|
|
1021
1795
|
function sameFileLockTreeAfterPublication(
|
|
@@ -1068,6 +1842,7 @@ async function rollbackPublishedFileLock(
|
|
|
1068
1842
|
lockPath: string,
|
|
1069
1843
|
pendingPath: string,
|
|
1070
1844
|
stagedSnapshot: NativeDirectoryTreeSnapshot,
|
|
1845
|
+
parentIdentity: { dev: bigint; ino: bigint },
|
|
1071
1846
|
): Promise<void> {
|
|
1072
1847
|
if (process.platform !== "linux") {
|
|
1073
1848
|
// RecoveryFsRoot is Linux-only. POSIX exact restore retains the parent
|
|
@@ -1077,6 +1852,8 @@ async function rollbackPublishedFileLock(
|
|
|
1077
1852
|
const rollback = exactRestore(lockPath, pendingPath, {
|
|
1078
1853
|
dev: BigInt(stagedSnapshot.rootDev),
|
|
1079
1854
|
ino: BigInt(stagedSnapshot.rootIno),
|
|
1855
|
+
parentDev: parentIdentity.dev,
|
|
1856
|
+
parentIno: parentIdentity.ino,
|
|
1080
1857
|
size: BigInt(root.size),
|
|
1081
1858
|
mtimeNs: BigInt(root.mtimeNs),
|
|
1082
1859
|
directory: true,
|
|
@@ -1217,6 +1994,7 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
|
|
|
1217
1994
|
"atomic_unavailable",
|
|
1218
1995
|
"cross_device",
|
|
1219
1996
|
"permission_denied",
|
|
1997
|
+
"access_denied",
|
|
1220
1998
|
"io_failure",
|
|
1221
1999
|
"invalid_request",
|
|
1222
2000
|
"interrupted",
|
|
@@ -1346,18 +2124,44 @@ function isPreMutationPublicationContention(value: unknown): boolean {
|
|
|
1346
2124
|
);
|
|
1347
2125
|
}
|
|
1348
2126
|
|
|
1349
|
-
|
|
2127
|
+
function isPreMutationAccessDeniedPublication(value: unknown): boolean {
|
|
2128
|
+
return (
|
|
2129
|
+
isValidNativeNoReplaceResult(value) &&
|
|
2130
|
+
!value.ok &&
|
|
2131
|
+
value.code === "access_denied" &&
|
|
2132
|
+
value.mutationState === "not_committed" &&
|
|
2133
|
+
value.durabilityState === "not_attempted" &&
|
|
2134
|
+
value.reason === "access_denied" &&
|
|
2135
|
+
value.primitive === "windows_rename_noreplace" &&
|
|
2136
|
+
value.phase === "rename"
|
|
2137
|
+
);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function isPreMutationPublicationRetryable(value: unknown): boolean {
|
|
2141
|
+
return isPreMutationPublicationContention(value) || isPreMutationAccessDeniedPublication(value);
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function preMutationPublicationFailureCode(value: unknown): FileLockPublicationFailureCode | undefined {
|
|
2145
|
+
if (isPreMutationPublicationContention(value)) return "sharing_violation";
|
|
2146
|
+
if (isPreMutationAccessDeniedPublication(value)) return "access_denied";
|
|
2147
|
+
return undefined;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
async function publishNoReplaceWithTransientRetry(
|
|
1350
2151
|
publish: (sourcePath: string, destinationPath: string) => Promise<NativeNoReplaceResult>,
|
|
1351
2152
|
sourcePath: string,
|
|
1352
2153
|
destinationPath: string,
|
|
2154
|
+
signal?: AbortSignal,
|
|
1353
2155
|
): Promise<NativeNoReplaceResult> {
|
|
2156
|
+
throwIfFileLockAcquisitionAborted(signal);
|
|
1354
2157
|
let result = await publish(sourcePath, destinationPath);
|
|
1355
2158
|
for (
|
|
1356
2159
|
let attempt = 1;
|
|
1357
|
-
attempt <
|
|
2160
|
+
attempt < PUBLICATION_TRANSIENT_RETRY_ATTEMPTS && isPreMutationPublicationRetryable(result);
|
|
1358
2161
|
attempt++
|
|
1359
2162
|
) {
|
|
1360
|
-
await
|
|
2163
|
+
await sleepFileLockRetry(PUBLICATION_TRANSIENT_RETRY_DELAY_MS * attempt, signal);
|
|
2164
|
+
throwIfFileLockAcquisitionAborted(signal);
|
|
1361
2165
|
result = await publish(sourcePath, destinationPath);
|
|
1362
2166
|
}
|
|
1363
2167
|
return result;
|
|
@@ -1426,12 +2230,14 @@ export interface GenericFileLockDirIdentity {
|
|
|
1426
2230
|
}
|
|
1427
2231
|
|
|
1428
2232
|
function sameStableFileLockIdentity(left: GenericFileLockDirIdentity, right: GenericFileLockDirIdentity): boolean {
|
|
2233
|
+
const birthtimeMatches =
|
|
2234
|
+
left.infoBirthtimeNs === "0" || right.infoBirthtimeNs === "0" || left.infoBirthtimeNs === right.infoBirthtimeNs;
|
|
1429
2235
|
return (
|
|
1430
2236
|
left.rootDev === right.rootDev &&
|
|
1431
2237
|
left.rootIno === right.rootIno &&
|
|
1432
2238
|
left.infoDev === right.infoDev &&
|
|
1433
2239
|
left.infoIno === right.infoIno &&
|
|
1434
|
-
|
|
2240
|
+
birthtimeMatches
|
|
1435
2241
|
);
|
|
1436
2242
|
}
|
|
1437
2243
|
|
|
@@ -1449,6 +2255,25 @@ function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right:
|
|
|
1449
2255
|
return sameStableFileLockIdentity(left, right) && sameFileLockContentEvidence(left, right);
|
|
1450
2256
|
}
|
|
1451
2257
|
|
|
2258
|
+
function fileLockDirIdentityFromNativeSnapshot(snapshot: NativeDirectoryTreeSnapshot): GenericFileLockDirIdentity {
|
|
2259
|
+
const info = snapshot.entries.find(entry => entry.relativePath === "info");
|
|
2260
|
+
if (info?.kind !== "file" || !info.sha256) throw new Error("file_lock_snapshot_missing_owner_identity");
|
|
2261
|
+
return {
|
|
2262
|
+
rootDev: snapshot.rootDev,
|
|
2263
|
+
rootIno: snapshot.rootIno,
|
|
2264
|
+
infoDev: info.dev,
|
|
2265
|
+
infoIno: info.ino,
|
|
2266
|
+
infoNlink: info.nlink,
|
|
2267
|
+
infoSize: info.size,
|
|
2268
|
+
infoMtimeNs: info.mtimeNs,
|
|
2269
|
+
infoCtimeNs: info.ctimeNs,
|
|
2270
|
+
// Native snapshots do not expose birthtime; the remaining inode and content
|
|
2271
|
+
// evidence still binds this exact staged generation across publication.
|
|
2272
|
+
infoBirthtimeNs: "0",
|
|
2273
|
+
infoSha256: info.sha256,
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
|
|
1452
2277
|
let nativeExactRemovalUsable: boolean | undefined;
|
|
1453
2278
|
let nativeExactRemovalProbePromise: Promise<boolean> | undefined;
|
|
1454
2279
|
|
|
@@ -1542,8 +2367,22 @@ async function removeVerifiedLockDirWithoutNative(
|
|
|
1542
2367
|
removal.retainedUnknownPath !== undefined
|
|
1543
2368
|
)
|
|
1544
2369
|
return "cleanup_failed";
|
|
1545
|
-
|
|
2370
|
+
let detachedCleanupCompleted: boolean;
|
|
2371
|
+
try {
|
|
2372
|
+
detachedCleanupCompleted = await removeDetachedLockQuarantineOnDisk(
|
|
2373
|
+
removal.detachedPath,
|
|
2374
|
+
expected.rootDev,
|
|
2375
|
+
expected.rootIno,
|
|
2376
|
+
);
|
|
2377
|
+
} catch (error) {
|
|
2378
|
+
markLocalRemovalResumable(removal.detachedPath, expected.rootDev, expected.rootIno);
|
|
2379
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
2380
|
+
return "cleanup_failed";
|
|
2381
|
+
}
|
|
2382
|
+
if (!detachedCleanupCompleted) {
|
|
2383
|
+
markLocalRemovalResumable(removal.detachedPath, expected.rootDev, expected.rootIno);
|
|
1546
2384
|
return "cleanup_failed";
|
|
2385
|
+
}
|
|
1547
2386
|
await clearRemovalRecord(removal.detachedPath);
|
|
1548
2387
|
return "removed";
|
|
1549
2388
|
}
|
|
@@ -1756,7 +2595,20 @@ export async function removeFileLockDirForGc(
|
|
|
1756
2595
|
rootIno: captured.snapshot.rootIno,
|
|
1757
2596
|
snapshot: captured.snapshot,
|
|
1758
2597
|
});
|
|
1759
|
-
|
|
2598
|
+
let cleanupCompleted = false;
|
|
2599
|
+
try {
|
|
2600
|
+
cleanupCompleted = await finishDetachedLockCleanup(expected);
|
|
2601
|
+
} catch (error) {
|
|
2602
|
+
// Treat an OS sharing denial like a cleanup_pending receipt: both leave
|
|
2603
|
+
// this exact detached generation for a later local retry.
|
|
2604
|
+
if (!isTransientReleaseError(error)) throw error;
|
|
2605
|
+
}
|
|
2606
|
+
if (cleanupCompleted) return "removed";
|
|
2607
|
+
// Stale-owner cleanup has no LocalLockState release handle to drive another
|
|
2608
|
+
// retry. Mark the receipt resumable only after the native attempt returns;
|
|
2609
|
+
// other processes still see its live owner and remain fenced from adoption.
|
|
2610
|
+
markLocalRemovalResumable(detachedPath, captured.snapshot.rootDev, captured.snapshot.rootIno);
|
|
2611
|
+
return "cleanup_failed";
|
|
1760
2612
|
}
|
|
1761
2613
|
if (removed.code === "not_found") return "removed";
|
|
1762
2614
|
if (removed.code === "identity_mismatch") return "owner_changed";
|
|
@@ -1840,9 +2692,10 @@ async function staleLockSnapshot(
|
|
|
1840
2692
|
throw error;
|
|
1841
2693
|
}
|
|
1842
2694
|
if (!info) {
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
2695
|
+
// This verdict can authorize cleanup only from a recorded owner generation.
|
|
2696
|
+
// Windows acquisition has a separate age- and identity-bound path for a
|
|
2697
|
+
// completely empty legacy directory; that path never treats it as a dead
|
|
2698
|
+
// owner or claims the existing directory as a new lock.
|
|
1846
2699
|
return { stale: false };
|
|
1847
2700
|
}
|
|
1848
2701
|
|
|
@@ -2030,22 +2883,43 @@ async function tryAcquireLock(
|
|
|
2030
2883
|
orphanTransitionAgeMs: number,
|
|
2031
2884
|
previousOwnerHostIds: readonly string[],
|
|
2032
2885
|
ownerToken = crypto.randomUUID(),
|
|
2033
|
-
onAcquired
|
|
2886
|
+
onAcquired: (() => void) | undefined,
|
|
2887
|
+
retryBudget: FileLockAcquisitionRetryBudget,
|
|
2888
|
+
onPublished?: (owner: LockInfo) => void,
|
|
2889
|
+
onPublicationRolledBack?: (owner: LockInfo) => void,
|
|
2890
|
+
onPublicationFailed?: (owner: LockInfo, identity: GenericFileLockDirIdentity, releasePath: string) => void,
|
|
2034
2891
|
): Promise<FileLockAcquisitionResult> {
|
|
2035
2892
|
await ensureLockParent(path.dirname(lockPath));
|
|
2036
2893
|
const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
|
|
2037
2894
|
if (afterParentMkdir) await afterParentMkdir(lockPath);
|
|
2038
2895
|
const stagingIdentity = currentFileLockStagingIdentity();
|
|
2039
|
-
const pendingPath =
|
|
2040
|
-
|
|
2041
|
-
|
|
2896
|
+
const pendingPath = derivedFileLockPath(
|
|
2897
|
+
lockPath,
|
|
2898
|
+
stagingIdentity
|
|
2899
|
+
? `.pending.${process.pid}.${stagingIdentity}.${crypto.randomBytes(12).toString("base64url")}`
|
|
2900
|
+
: `.pending.${process.pid}.${crypto.randomUUID()}`,
|
|
2901
|
+
);
|
|
2042
2902
|
const owner = lockInfo(ownerHostId, ownerToken);
|
|
2043
2903
|
let removePending = false;
|
|
2904
|
+
let pendingStagingIdentity: FileLockStagingRootIdentity | undefined;
|
|
2905
|
+
let failedPublicationPath = lockPath;
|
|
2044
2906
|
try {
|
|
2045
2907
|
await fs.mkdir(pendingPath, { mode: 0o700 });
|
|
2046
2908
|
removePending = true;
|
|
2909
|
+
try {
|
|
2910
|
+
pendingStagingIdentity = await captureFileLockStagingRootIdentity(pendingPath, retryBudget);
|
|
2911
|
+
} catch (error) {
|
|
2912
|
+
removePending = false;
|
|
2913
|
+
retainPendingStagingCleanup(lockPath, pendingPath, undefined, error);
|
|
2914
|
+
if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
|
|
2915
|
+
throw new FileLockStagingCleanupError(lockPath, pendingPath, error);
|
|
2916
|
+
}
|
|
2047
2917
|
await fs.chmod(pendingPath, 0o700);
|
|
2048
|
-
await writeLockInfo(pendingPath, owner
|
|
2918
|
+
await writeLockInfo(pendingPath, owner, retryBudget, infoFileIdentity => {
|
|
2919
|
+
if (!pendingStagingIdentity) throw new Error("Staged file-lock root identity is unavailable.");
|
|
2920
|
+
pendingStagingIdentity = { ...pendingStagingIdentity, infoFileIdentity };
|
|
2921
|
+
});
|
|
2922
|
+
await FileLockTestHooks.afterStagingInfoWrite?.(pendingPath);
|
|
2049
2923
|
// A plain POSIX rename replaces an existing empty directory. The legacy
|
|
2050
2924
|
// directory lock is a real holder, so publication must use the native
|
|
2051
2925
|
// no-replace primitive rather than treating an empty destination as free.
|
|
@@ -2054,56 +2928,68 @@ async function tryAcquireLock(
|
|
|
2054
2928
|
// retain the same lock identity as the ordinary path.
|
|
2055
2929
|
const canonicalParent = await fs.realpath(path.dirname(lockPath));
|
|
2056
2930
|
const destinationPath = path.join(canonicalParent, path.basename(lockPath));
|
|
2931
|
+
failedPublicationPath = destinationPath;
|
|
2932
|
+
const parent = await fs.lstat(canonicalParent, { bigint: true });
|
|
2933
|
+
const parentIdentity = {
|
|
2934
|
+
dev: canonicalLockDeviceId(parent.dev),
|
|
2935
|
+
ino: canonicalLockFileId(parent.ino),
|
|
2936
|
+
};
|
|
2057
2937
|
// Resolve only the stable parent: resolving the mutable staging final
|
|
2058
2938
|
// component would follow an attacker-replaced symlink before native no-follow
|
|
2059
2939
|
// validation gets a chance to reject it.
|
|
2060
2940
|
const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
|
|
2061
2941
|
let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2942
|
+
// A predecessor may retain this deterministic sibling while native cleanup
|
|
2943
|
+
// is in progress on either platform. Do not let a successor publish until
|
|
2944
|
+
// the transition is gone or its dead owner has been safely adopted.
|
|
2945
|
+
let transitionState = await classifyFileLockRemovalTransition(
|
|
2946
|
+
destinationPath,
|
|
2947
|
+
orphanTransitionAgeMs,
|
|
2948
|
+
ownerHostId,
|
|
2949
|
+
previousOwnerHostIds,
|
|
2950
|
+
);
|
|
2951
|
+
if (transitionState === "orphan_transition") {
|
|
2952
|
+
// A scrubbed, aged transition is the residue of a removal whose owner died
|
|
2953
|
+
// before its final on-disk cleanup. Adopt and finish it here so the wedged
|
|
2954
|
+
// namespace heals instead of aborting (or re-spinning the whole budget).
|
|
2955
|
+
const adopted = await adoptOrphanedFileLockRemovalTransition(
|
|
2067
2956
|
destinationPath,
|
|
2068
2957
|
orphanTransitionAgeMs,
|
|
2069
2958
|
ownerHostId,
|
|
2070
2959
|
previousOwnerHostIds,
|
|
2071
2960
|
);
|
|
2072
|
-
if (
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2961
|
+
if (adopted === "retry") return null;
|
|
2962
|
+
if (!adopted) return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
|
|
2963
|
+
transitionState = await classifyFileLockRemovalTransition(
|
|
2964
|
+
destinationPath,
|
|
2965
|
+
orphanTransitionAgeMs,
|
|
2966
|
+
ownerHostId,
|
|
2967
|
+
previousOwnerHostIds,
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
if (transitionState === "orphan_transition")
|
|
2971
|
+
return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
|
|
2972
|
+
if (transitionState === "abandoned") {
|
|
2973
|
+
if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
|
|
2085
2974
|
transitionState = await classifyFileLockRemovalTransition(
|
|
2086
2975
|
destinationPath,
|
|
2087
2976
|
orphanTransitionAgeMs,
|
|
2088
2977
|
ownerHostId,
|
|
2089
2978
|
previousOwnerHostIds,
|
|
2090
2979
|
);
|
|
2980
|
+
} else {
|
|
2981
|
+
return null;
|
|
2091
2982
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
previousOwnerHostIds,
|
|
2101
|
-
);
|
|
2102
|
-
} else {
|
|
2103
|
-
return null;
|
|
2104
|
-
}
|
|
2983
|
+
}
|
|
2984
|
+
if (transitionState !== null) return null;
|
|
2985
|
+
let stagedTree: NativeDirectoryTreeSnapshot;
|
|
2986
|
+
if (process.platform === "win32") {
|
|
2987
|
+
try {
|
|
2988
|
+
stagedTree = await captureFileLockStagingTreeSnapshot(canonicalPendingPath, retryBudget);
|
|
2989
|
+
} catch (error) {
|
|
2990
|
+
throw new FileLockStagingCleanupError(lockPath, pendingPath, error);
|
|
2105
2991
|
}
|
|
2106
|
-
|
|
2992
|
+
} else {
|
|
2107
2993
|
const staged = snapshotDirectoryTree(canonicalPendingPath);
|
|
2108
2994
|
if (!staged.ok || !staged.snapshot) {
|
|
2109
2995
|
const failure = new Error(
|
|
@@ -2112,29 +2998,40 @@ async function tryAcquireLock(
|
|
|
2112
2998
|
if (staged.code) failure.code = staged.code;
|
|
2113
2999
|
throw failure;
|
|
2114
3000
|
}
|
|
2115
|
-
|
|
3001
|
+
stagedTree = staged.snapshot;
|
|
2116
3002
|
}
|
|
3003
|
+
stagedSnapshot = stagedTree;
|
|
3004
|
+
if (pendingStagingIdentity)
|
|
3005
|
+
pendingStagingIdentity = bindFileLockStagingTreeSnapshot(
|
|
3006
|
+
canonicalPendingPath,
|
|
3007
|
+
pendingStagingIdentity,
|
|
3008
|
+
stagedTree,
|
|
3009
|
+
);
|
|
3010
|
+
const stagedIdentity = fileLockDirIdentityFromNativeSnapshot(stagedTree);
|
|
2117
3011
|
const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
|
|
2118
3012
|
renameNoReplacePathAsync,
|
|
2119
3013
|
renameDirectoryNoReplacePathAsync,
|
|
2120
3014
|
};
|
|
2121
|
-
const published = await
|
|
3015
|
+
const published = await publishNoReplaceWithTransientRetry(
|
|
2122
3016
|
publication.renameNoReplacePathAsync,
|
|
2123
3017
|
canonicalPendingPath,
|
|
2124
3018
|
destinationPath,
|
|
3019
|
+
retryBudget.signal,
|
|
2125
3020
|
);
|
|
2126
3021
|
let publishedSuccessfully = isSuccessfulNativePublication(published, "primary");
|
|
2127
3022
|
if (published.ok && !publishedSuccessfully)
|
|
2128
3023
|
throw new Error("Failed to publish file lock: invalid primary success receipt.");
|
|
2129
3024
|
if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
|
|
2130
|
-
const fallback = await
|
|
3025
|
+
const fallback = await publishNoReplaceWithTransientRetry(
|
|
2131
3026
|
publication.renameDirectoryNoReplacePathAsync,
|
|
2132
3027
|
canonicalPendingPath,
|
|
2133
3028
|
destinationPath,
|
|
3029
|
+
retryBudget.signal,
|
|
2134
3030
|
);
|
|
2135
3031
|
const fallbackSuccessfully = isSuccessfulNativePublication(fallback, "directory");
|
|
2136
3032
|
if (fallback.ok && !fallbackSuccessfully)
|
|
2137
3033
|
throw new Error("Failed to publish file lock: invalid directory success receipt.");
|
|
3034
|
+
const fallbackFailureCode = preMutationPublicationFailureCode(fallback);
|
|
2138
3035
|
let verifiedCommittedPublication = false;
|
|
2139
3036
|
if (isCommittedDirectoryVerificationFailure(fallback)) {
|
|
2140
3037
|
// The staged name has been consumed. Relinquish pathname cleanup even
|
|
@@ -2152,10 +3049,12 @@ async function tryAcquireLock(
|
|
|
2152
3049
|
}
|
|
2153
3050
|
if (fallbackSuccessfully || verifiedCommittedPublication) {
|
|
2154
3051
|
publishedSuccessfully = true;
|
|
2155
|
-
} else if (isPreMutationPublicationContention(fallback)) {
|
|
2156
|
-
return null;
|
|
2157
3052
|
} else if (fallback.reason === "destination_exists") {
|
|
2158
3053
|
return null;
|
|
3054
|
+
} else if (fallbackFailureCode) {
|
|
3055
|
+
removePending = false;
|
|
3056
|
+
await cleanupPendingStagingOrThrow(lockPath, pendingPath, pendingStagingIdentity, retryBudget);
|
|
3057
|
+
return { kind: "publication_retry", code: fallbackFailureCode };
|
|
2159
3058
|
} else {
|
|
2160
3059
|
const failure = new Error(
|
|
2161
3060
|
`Failed to publish file lock: ${fallback.code ?? fallback.reason ?? "unknown"}.`,
|
|
@@ -2165,9 +3064,15 @@ async function tryAcquireLock(
|
|
|
2165
3064
|
}
|
|
2166
3065
|
}
|
|
2167
3066
|
if (!publishedSuccessfully) {
|
|
2168
|
-
if (
|
|
3067
|
+
if (published.reason === "destination_exists") {
|
|
2169
3068
|
return null;
|
|
2170
3069
|
}
|
|
3070
|
+
const publicationFailureCode = preMutationPublicationFailureCode(published);
|
|
3071
|
+
if (publicationFailureCode) {
|
|
3072
|
+
removePending = false;
|
|
3073
|
+
await cleanupPendingStagingOrThrow(lockPath, pendingPath, pendingStagingIdentity, retryBudget);
|
|
3074
|
+
return { kind: "publication_retry", code: publicationFailureCode };
|
|
3075
|
+
}
|
|
2171
3076
|
const failure = new Error(
|
|
2172
3077
|
`Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
|
|
2173
3078
|
) as NodeJS.ErrnoException;
|
|
@@ -2175,55 +3080,110 @@ async function tryAcquireLock(
|
|
|
2175
3080
|
throw failure;
|
|
2176
3081
|
}
|
|
2177
3082
|
removePending = false;
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
);
|
|
2189
|
-
if (transitionState !== null) {
|
|
2190
|
-
const publishedSnapshot = snapshotDirectoryTree(destinationPath);
|
|
2191
|
-
if (
|
|
2192
|
-
!publishedSnapshot.ok ||
|
|
2193
|
-
!publishedSnapshot.snapshot ||
|
|
2194
|
-
!sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
|
|
2195
|
-
) {
|
|
2196
|
-
const failure = new Error(
|
|
2197
|
-
`Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
|
|
2198
|
-
) as NodeJS.ErrnoException;
|
|
2199
|
-
if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
|
|
2200
|
-
throw failure;
|
|
2201
|
-
}
|
|
2202
|
-
await rollbackPublishedFileLock(
|
|
2203
|
-
canonicalParent,
|
|
3083
|
+
try {
|
|
3084
|
+
// Preserve exact-generation release authority before post-publication checks
|
|
3085
|
+
// can throw; otherwise a live canonical owner can be left untracked locally.
|
|
3086
|
+
onPublished?.(owner);
|
|
3087
|
+
// The transition can appear after the pre-publication check only when the
|
|
3088
|
+
// predecessor atomically detaches its lock before this no-replace publish.
|
|
3089
|
+
// Roll this exact staged tree back to its UUID path before reporting
|
|
3090
|
+
// contention; never delete or rename the predecessor's `.removing` tree.
|
|
3091
|
+
if (stagedSnapshot) {
|
|
3092
|
+
const transitionState = await classifyFileLockRemovalTransition(
|
|
2204
3093
|
destinationPath,
|
|
2205
|
-
|
|
2206
|
-
|
|
3094
|
+
orphanTransitionAgeMs,
|
|
3095
|
+
ownerHostId,
|
|
3096
|
+
previousOwnerHostIds,
|
|
2207
3097
|
);
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
3098
|
+
if (transitionState !== null) {
|
|
3099
|
+
const publishedSnapshot = snapshotDirectoryTree(destinationPath);
|
|
3100
|
+
if (
|
|
3101
|
+
!publishedSnapshot.ok ||
|
|
3102
|
+
!publishedSnapshot.snapshot ||
|
|
3103
|
+
!sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
|
|
3104
|
+
) {
|
|
3105
|
+
const failure = new Error(
|
|
3106
|
+
`Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
|
|
3107
|
+
) as NodeJS.ErrnoException;
|
|
3108
|
+
if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
|
|
3109
|
+
throw failure;
|
|
3110
|
+
}
|
|
3111
|
+
try {
|
|
3112
|
+
await rollbackPublishedFileLock(
|
|
3113
|
+
canonicalParent,
|
|
3114
|
+
destinationPath,
|
|
3115
|
+
canonicalPendingPath,
|
|
3116
|
+
publishedSnapshot.snapshot,
|
|
3117
|
+
parentIdentity,
|
|
3118
|
+
);
|
|
3119
|
+
} catch (error) {
|
|
3120
|
+
// Rollback can commit before a later verification or authority-close
|
|
3121
|
+
// failure. Release the staged generation at the path that actually
|
|
3122
|
+
// retains it, rather than retrying against the now-vacant canonical name.
|
|
3123
|
+
try {
|
|
3124
|
+
const stagedAgain = snapshotDirectoryTree(canonicalPendingPath);
|
|
3125
|
+
if (
|
|
3126
|
+
stagedAgain.ok &&
|
|
3127
|
+
stagedAgain.snapshot &&
|
|
3128
|
+
sameFileLockTreeAfterPublication(publishedSnapshot.snapshot, stagedAgain.snapshot)
|
|
3129
|
+
)
|
|
3130
|
+
failedPublicationPath = canonicalPendingPath;
|
|
3131
|
+
} catch {
|
|
3132
|
+
// Keep the canonical target when the staging identity cannot be proven.
|
|
3133
|
+
}
|
|
3134
|
+
throw error;
|
|
3135
|
+
}
|
|
3136
|
+
removePending = true;
|
|
3137
|
+
onPublicationRolledBack?.(owner);
|
|
3138
|
+
return transitionState === "orphan_transition"
|
|
3139
|
+
? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
|
|
3140
|
+
: null;
|
|
3141
|
+
}
|
|
2212
3142
|
}
|
|
3143
|
+
// Published and transition-free above, so an onAcquired failure must
|
|
3144
|
+
// propagate instead of retrying an acquisition that already owns the lock.
|
|
3145
|
+
onAcquired?.();
|
|
3146
|
+
return owner;
|
|
3147
|
+
} catch (error) {
|
|
3148
|
+
onPublicationFailed?.(owner, stagedIdentity, failedPublicationPath);
|
|
3149
|
+
throw error;
|
|
2213
3150
|
}
|
|
2214
|
-
// Published and transition-free above, so an onAcquired failure must
|
|
2215
|
-
// propagate instead of retrying an acquisition that already owns the lock.
|
|
2216
|
-
onAcquired?.();
|
|
2217
|
-
return owner;
|
|
2218
3151
|
} finally {
|
|
2219
|
-
if (removePending
|
|
3152
|
+
if (removePending && pendingStagingIdentity) {
|
|
3153
|
+
try {
|
|
3154
|
+
if (process.platform === "win32" && !pendingStagingIdentity.treeSnapshot)
|
|
3155
|
+
pendingStagingIdentity = await captureFileLockStagingCleanupIdentity(
|
|
3156
|
+
pendingPath,
|
|
3157
|
+
pendingStagingIdentity,
|
|
3158
|
+
retryBudget,
|
|
3159
|
+
);
|
|
3160
|
+
await removePendingStaging(pendingPath, pendingStagingIdentity);
|
|
3161
|
+
pendingStagingCleanups.delete(pendingPath);
|
|
3162
|
+
} catch (cleanupError) {
|
|
3163
|
+
const cleanupPath = detachedStagingCleanupPath(pendingPath, cleanupError);
|
|
3164
|
+
if (cleanupPath !== pendingPath) pendingStagingCleanups.delete(pendingPath);
|
|
3165
|
+
retainPendingStagingCleanup(lockPath, cleanupPath, pendingStagingIdentity, cleanupError);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
2220
3168
|
}
|
|
2221
3169
|
}
|
|
2222
3170
|
|
|
3171
|
+
function isResultTransientFailure(result: { code?: string }): boolean {
|
|
3172
|
+
const code = result.code;
|
|
3173
|
+
return (
|
|
3174
|
+
code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
|
|
2223
3178
|
function isTransientReleaseError(error: unknown): boolean {
|
|
2224
3179
|
const code = (error as NodeJS.ErrnoException).code;
|
|
2225
3180
|
return (
|
|
2226
|
-
code === "EBUSY" ||
|
|
3181
|
+
code === "EBUSY" ||
|
|
3182
|
+
code === "EPERM" ||
|
|
3183
|
+
code === "EACCES" ||
|
|
3184
|
+
code === "ENOTEMPTY" ||
|
|
3185
|
+
code === "access_denied" ||
|
|
3186
|
+
code === "sharing_violation"
|
|
2227
3187
|
);
|
|
2228
3188
|
}
|
|
2229
3189
|
|
|
@@ -2392,7 +3352,7 @@ async function retryPendingLocalRelease(lockPath: string, knownKey?: string): Pr
|
|
|
2392
3352
|
return;
|
|
2393
3353
|
}
|
|
2394
3354
|
state.status = "releasing";
|
|
2395
|
-
const releasePromise = releaseOwnedLock(lockPath, state.owner);
|
|
3355
|
+
const releasePromise = releaseOwnedLock(state.releasePath ?? lockPath, state.owner);
|
|
2396
3356
|
state.releasePromise = releasePromise;
|
|
2397
3357
|
try {
|
|
2398
3358
|
await releasePromise;
|
|
@@ -2477,27 +3437,36 @@ function scheduleReleasePendingRetry(lockPath: string, key: string, state: Local
|
|
|
2477
3437
|
* Bounded, actionable description of who holds `lockPath` at exhaustion time.
|
|
2478
3438
|
* Never a stealing authority: purely diagnostic, read once after the last retry.
|
|
2479
3439
|
*/
|
|
3440
|
+
interface FileLockHolderDescription {
|
|
3441
|
+
description: string;
|
|
3442
|
+
isLive: boolean;
|
|
3443
|
+
}
|
|
3444
|
+
|
|
2480
3445
|
async function lockHolderDescription(
|
|
2481
3446
|
lockPath: string,
|
|
2482
3447
|
orphanTransitionAgeMs: number,
|
|
2483
3448
|
ownerHostId?: string,
|
|
2484
3449
|
previousOwnerHostIds: readonly string[] = [],
|
|
2485
|
-
): Promise<
|
|
3450
|
+
): Promise<FileLockHolderDescription> {
|
|
2486
3451
|
try {
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
3452
|
+
const transitionState = await classifyFileLockRemovalTransition(
|
|
3453
|
+
lockPath,
|
|
3454
|
+
orphanTransitionAgeMs,
|
|
3455
|
+
ownerHostId,
|
|
3456
|
+
previousOwnerHostIds,
|
|
3457
|
+
);
|
|
3458
|
+
if (transitionState === "orphan_transition")
|
|
3459
|
+
return { description: `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`, isLive: false };
|
|
3460
|
+
if (transitionState === "abandoned")
|
|
3461
|
+
return {
|
|
3462
|
+
description: `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`,
|
|
3463
|
+
isLive: false,
|
|
3464
|
+
};
|
|
3465
|
+
if (transitionState === "active")
|
|
3466
|
+
return {
|
|
3467
|
+
description: `blocked by retained removal transition ${fileLockRemovalTransitionPath(lockPath)}; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed`,
|
|
3468
|
+
isLive: false,
|
|
3469
|
+
};
|
|
2501
3470
|
let info = await readLockInfo(lockPath);
|
|
2502
3471
|
let bytes: string | null = null;
|
|
2503
3472
|
if (!info) {
|
|
@@ -2519,51 +3488,58 @@ async function lockHolderDescription(
|
|
|
2519
3488
|
// is missing instead of naming a host it does not have.
|
|
2520
3489
|
const provenance =
|
|
2521
3490
|
info.owner_host_id === undefined ? "on an unrecorded host" : `on host ${info.owner_host_id}`;
|
|
2522
|
-
return
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
3491
|
+
return {
|
|
3492
|
+
description:
|
|
3493
|
+
`held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
|
|
3494
|
+
` since ${new Date(info.timestamp).toISOString()}`,
|
|
3495
|
+
isLive: false,
|
|
3496
|
+
};
|
|
2526
3497
|
}
|
|
2527
|
-
// Same-host holder:
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
2530
|
-
const
|
|
2531
|
-
const
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
);
|
|
3498
|
+
// Same-host holder: label it live only when the PID is alive and its
|
|
3499
|
+
// recorded process identity matches exactly. Conservative uncertainty
|
|
3500
|
+
// still prevents reclamation, but is not proof for operator diagnostics.
|
|
3501
|
+
const pidLiveness = ownerLiveness(info.pid);
|
|
3502
|
+
const alive = pidLiveness === "alive" && ownerIdentityIsProven(info);
|
|
3503
|
+
const liveness = alive ? "live" : pidLiveness === "dead" ? "dead but not reaped" : "liveness unknown";
|
|
3504
|
+
return {
|
|
3505
|
+
description:
|
|
3506
|
+
`held by pid ${info.pid}` +
|
|
3507
|
+
// Keep the host on a host-qualified local record: on a shared volume the
|
|
3508
|
+
// same pathname is contended from several hosts, so naming the one whose
|
|
3509
|
+
// pid space this verdict came from is what makes it actionable.
|
|
3510
|
+
(info.owner_host_id === undefined ? "" : " on this host") +
|
|
3511
|
+
` (${liveness})` +
|
|
3512
|
+
` since ${new Date(info.timestamp).toISOString()}`,
|
|
3513
|
+
isLive: alive,
|
|
3514
|
+
};
|
|
2545
3515
|
}
|
|
2546
3516
|
try {
|
|
2547
3517
|
if (bytes !== null)
|
|
2548
|
-
return
|
|
3518
|
+
return {
|
|
3519
|
+
description:
|
|
3520
|
+
"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",
|
|
3521
|
+
isLive: false,
|
|
3522
|
+
};
|
|
2549
3523
|
await fs.stat(path.join(lockPath, "info"));
|
|
2550
|
-
return "held by an owner whose metadata is not yet readable";
|
|
3524
|
+
return { description: "held by an owner whose metadata is not yet readable", isLive: false };
|
|
2551
3525
|
} catch (error) {
|
|
2552
3526
|
if (!isEnoent(error)) throw error;
|
|
2553
3527
|
}
|
|
2554
3528
|
try {
|
|
2555
3529
|
if ((await fs.readdir(lockPath)).length === 0) {
|
|
2556
|
-
return
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
3530
|
+
return {
|
|
3531
|
+
description:
|
|
3532
|
+
"held by an ownerless empty lock directory; inspect and remove this exact directory manually only after " +
|
|
3533
|
+
`confirming no publisher remains: ${manualLockCleanupCommand(lockPath)}`,
|
|
3534
|
+
isLive: false,
|
|
3535
|
+
};
|
|
2560
3536
|
}
|
|
2561
3537
|
} catch (error) {
|
|
2562
3538
|
if (!isEnoent(error)) throw error;
|
|
2563
3539
|
}
|
|
2564
|
-
return "held by an unrecognized owner record";
|
|
3540
|
+
return { description: "held by an unrecognized owner record", isLive: false };
|
|
2565
3541
|
} catch (error) {
|
|
2566
|
-
return `held by an unreadable owner (${(error as Error).message})
|
|
3542
|
+
return { description: `held by an unreadable owner (${(error as Error).message})`, isLive: false };
|
|
2567
3543
|
}
|
|
2568
3544
|
}
|
|
2569
3545
|
|
|
@@ -2576,13 +3552,23 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2576
3552
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
2577
3553
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
2578
3554
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
3555
|
+
if (opts.retries !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(opts.retries) || opts.retries < 1))
|
|
3556
|
+
throw new RangeError("retries must be a positive safe integer");
|
|
2579
3557
|
const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
|
|
2580
3558
|
if (!Number.isFinite(orphanTransitionAgeMs) || orphanTransitionAgeMs < 0)
|
|
2581
3559
|
throw new RangeError("orphanTransitionAgeMs must be a finite, non-negative number");
|
|
2582
3560
|
|
|
2583
3561
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
2584
3562
|
const lockPath = getLockPath(filePath);
|
|
3563
|
+
// Bound staging cleanup across the outer retry budget: all staging cleanup retries
|
|
3564
|
+
// use a shared delay budget and are subject to the same abort signal as the lock acquisition.
|
|
3565
|
+
const retryBudget: FileLockAcquisitionRetryBudget = {
|
|
3566
|
+
remainingDelays: opts.retries - 1,
|
|
3567
|
+
retryDelayMs: opts.retryDelayMs,
|
|
3568
|
+
signal: opts.signal,
|
|
3569
|
+
};
|
|
2585
3570
|
await ensureLockParent(path.dirname(lockPath));
|
|
3571
|
+
await retryPendingStagingCleanups(lockPath, retryBudget);
|
|
2586
3572
|
try {
|
|
2587
3573
|
await reapOrphanedLockStagingDirs(lockPath);
|
|
2588
3574
|
} catch (error) {
|
|
@@ -2592,12 +3578,36 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2592
3578
|
const contentionStartTimes = new Map<string, string | null>();
|
|
2593
3579
|
let contentionObserved = false;
|
|
2594
3580
|
let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
|
|
2595
|
-
|
|
2596
|
-
|
|
3581
|
+
let publicationFailureCode: FileLockPublicationFailureCode | undefined;
|
|
3582
|
+
let accessDeniedPublicationAttempts = 0;
|
|
3583
|
+
let attemptsMade = 0;
|
|
3584
|
+
let attemptBudget = opts.retries;
|
|
3585
|
+
let recoveryExtensions = 0;
|
|
3586
|
+
const extendAttemptBudgetAfterCleanup = (): void => {
|
|
3587
|
+
// Reaping is progress, but it still needs a fresh publication attempt. Bound
|
|
3588
|
+
// extensions so a stream of dead generations cannot make acquisition unbounded.
|
|
3589
|
+
if (recoveryExtensions >= opts.retries) return;
|
|
3590
|
+
recoveryExtensions++;
|
|
3591
|
+
attemptBudget++;
|
|
3592
|
+
};
|
|
3593
|
+
let attempt = 0;
|
|
3594
|
+
for (; attempt < attemptBudget; attempt++) {
|
|
3595
|
+
attemptsMade = attempt + 1;
|
|
3596
|
+
throwIfFileLockAcquisitionAborted(opts.signal);
|
|
3597
|
+
await retryPendingStagingCleanups(lockPath, retryBudget);
|
|
2597
3598
|
const localKey = await localLockKey(lockPath);
|
|
3599
|
+
let publishedState: LocalLockState | undefined;
|
|
2598
3600
|
const priorRelease = localLockStates.get(localKey);
|
|
2599
|
-
if (
|
|
3601
|
+
if (
|
|
3602
|
+
priorRelease &&
|
|
3603
|
+
priorRelease.status !== "held" &&
|
|
3604
|
+
(pendingDetachedLockCleanups.has(priorRelease.owner) ||
|
|
3605
|
+
(priorRelease.status === "release_pending" &&
|
|
3606
|
+
priorRelease.releasePath !== undefined &&
|
|
3607
|
+
!priorRelease.releasePromise))
|
|
3608
|
+
) {
|
|
2600
3609
|
await retryPendingLocalRelease(lockPath, localKey);
|
|
3610
|
+
extendAttemptBudgetAfterCleanup();
|
|
2601
3611
|
continue;
|
|
2602
3612
|
}
|
|
2603
3613
|
const result = await tryAcquireLock(
|
|
@@ -2607,12 +3617,35 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2607
3617
|
opts.previousOwnerHostIds ?? [],
|
|
2608
3618
|
ownerToken,
|
|
2609
3619
|
opts.onAcquired,
|
|
3620
|
+
retryBudget,
|
|
3621
|
+
owner => {
|
|
3622
|
+
const state: LocalLockState = { owner, status: "held" };
|
|
3623
|
+
publishedState = state;
|
|
3624
|
+
localLockStates.set(localKey, state);
|
|
3625
|
+
},
|
|
3626
|
+
owner => {
|
|
3627
|
+
const state = publishedState;
|
|
3628
|
+
if (!state || state.owner.owner_token !== owner.owner_token) return;
|
|
3629
|
+
if (localLockStates.get(localKey) === state) localLockStates.delete(localKey);
|
|
3630
|
+
if (publishedState === state) publishedState = undefined;
|
|
3631
|
+
},
|
|
3632
|
+
(owner, identity, releasePath) => {
|
|
3633
|
+
const state = publishedState;
|
|
3634
|
+
if (!state || state.owner.owner_token !== owner.owner_token || localLockStates.get(localKey) !== state)
|
|
3635
|
+
return;
|
|
3636
|
+
fileLockDirIdentities.set(owner, identity);
|
|
3637
|
+
state.releasePath = releasePath;
|
|
3638
|
+
state.status = "release_pending";
|
|
3639
|
+
scheduleReleasePendingRetry(lockPath, localKey, state);
|
|
3640
|
+
},
|
|
2610
3641
|
);
|
|
2611
3642
|
if (isFileLockOrphanTransition(result)) {
|
|
2612
3643
|
// Adoption is identity-bound and can be refused by a transient cleanup race.
|
|
2613
3644
|
// Keep using the caller's bounded contention budget; the same generation is
|
|
2614
3645
|
// revalidated before every later adoption attempt.
|
|
2615
|
-
|
|
3646
|
+
publicationFailureCode = undefined;
|
|
3647
|
+
accessDeniedPublicationAttempts = 0;
|
|
3648
|
+
if (attempt + 1 === attemptBudget)
|
|
2616
3649
|
throw new FileLockAcquireError(
|
|
2617
3650
|
filePath,
|
|
2618
3651
|
lockPath,
|
|
@@ -2621,11 +3654,23 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2621
3654
|
"orphan_transition",
|
|
2622
3655
|
result.path,
|
|
2623
3656
|
);
|
|
2624
|
-
} else if (result) {
|
|
2625
|
-
|
|
2626
|
-
|
|
3657
|
+
} else if (isFileLockPublicationRetry(result)) {
|
|
3658
|
+
publicationFailureCode = result.code;
|
|
3659
|
+
accessDeniedPublicationAttempts = result.code === "access_denied" ? accessDeniedPublicationAttempts + 1 : 0;
|
|
3660
|
+
} else {
|
|
3661
|
+
publicationFailureCode = undefined;
|
|
3662
|
+
accessDeniedPublicationAttempts = 0;
|
|
3663
|
+
if (result) {
|
|
3664
|
+
const state = publishedState ?? { owner: result, status: "held" as const };
|
|
3665
|
+
state.status = "held";
|
|
3666
|
+
localLockStates.set(localKey, state);
|
|
3667
|
+
return () => releaseLock(lockPath, result, localKey);
|
|
3668
|
+
}
|
|
2627
3669
|
}
|
|
2628
|
-
|
|
3670
|
+
throwIfFileLockAcquisitionAborted(opts.signal);
|
|
3671
|
+
if (accessDeniedPublicationAttempts >= MAX_ACCESS_DENIED_PUBLICATION_ACQUISITION_ATTEMPTS) break;
|
|
3672
|
+
const publicationIsProvenContention = isFileLockPublicationRetry(result) && result.code === "sharing_violation";
|
|
3673
|
+
if (!contentionObserved && (!isFileLockPublicationRetry(result) || publicationIsProvenContention)) {
|
|
2629
3674
|
contentionObserved = true;
|
|
2630
3675
|
opts.onContended?.();
|
|
2631
3676
|
}
|
|
@@ -2634,6 +3679,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2634
3679
|
if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
|
|
2635
3680
|
try {
|
|
2636
3681
|
await retryPendingLocalRelease(lockPath, pendingKey);
|
|
3682
|
+
extendAttemptBudgetAfterCleanup();
|
|
2637
3683
|
continue;
|
|
2638
3684
|
} catch {
|
|
2639
3685
|
// Keep contending below. A failed local retry is not authority to steal a
|
|
@@ -2650,26 +3696,19 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2650
3696
|
const staleRemoval = await removeStaleLockForAcquire(lockPath, stale);
|
|
2651
3697
|
if (staleRemoval.removed) {
|
|
2652
3698
|
staleRemovalFailure = undefined;
|
|
3699
|
+
extendAttemptBudgetAfterCleanup();
|
|
2653
3700
|
continue;
|
|
2654
3701
|
}
|
|
2655
3702
|
staleRemovalFailure =
|
|
2656
3703
|
stale.stale && staleRemoval.failure
|
|
2657
3704
|
? { owner: stale.owner, identity: stale.identity, failure: staleRemoval.failure }
|
|
2658
3705
|
: undefined;
|
|
2659
|
-
if (
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
}
|
|
2663
|
-
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
2664
|
-
const onAbort = (): void => reject(opts.signal?.reason ?? new Error("File lock acquisition aborted"));
|
|
2665
|
-
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
2666
|
-
void Bun.sleep(opts.retryDelayMs).then(resolve);
|
|
2667
|
-
try {
|
|
2668
|
-
await promise;
|
|
2669
|
-
} finally {
|
|
2670
|
-
opts.signal.removeEventListener("abort", onAbort);
|
|
3706
|
+
if (retryBudget.remainingDelays > 0) {
|
|
3707
|
+
retryBudget.remainingDelays--;
|
|
3708
|
+
await sleepFileLockRetry(opts.retryDelayMs, opts.signal);
|
|
2671
3709
|
}
|
|
2672
3710
|
}
|
|
3711
|
+
throwIfFileLockAcquisitionAborted(opts.signal);
|
|
2673
3712
|
const holder = await lockHolderDescription(
|
|
2674
3713
|
lockPath,
|
|
2675
3714
|
orphanTransitionAgeMs,
|
|
@@ -2686,14 +3725,73 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2686
3725
|
throw new FileLockAcquireError(
|
|
2687
3726
|
filePath,
|
|
2688
3727
|
lockPath,
|
|
2689
|
-
|
|
2690
|
-
holder,
|
|
3728
|
+
attemptsMade,
|
|
3729
|
+
holder.description,
|
|
2691
3730
|
transitionState === "orphan_transition" ? "orphan_transition" : "acquire_timeout",
|
|
2692
3731
|
transitionPath,
|
|
2693
3732
|
await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
|
|
3733
|
+
publicationFailureCode,
|
|
3734
|
+
holder.isLive,
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
/**
|
|
3738
|
+
* Describes the retained removal transition blocking `filePath`, if one exists.
|
|
3739
|
+
*
|
|
3740
|
+
* `acquireFileLock` reports this on exhaustion, but a caller whose own budget
|
|
3741
|
+
* aborts the acquisition first would otherwise never learn that a stale
|
|
3742
|
+
* transition — not ordinary contention — is what blocked it. Such a caller can
|
|
3743
|
+
* ask directly after an abort so the block is surfaced instead of swallowed.
|
|
3744
|
+
*/
|
|
3745
|
+
export async function describeRetainedLockTransition(
|
|
3746
|
+
filePath: string,
|
|
3747
|
+
options: FileLockOptions = {},
|
|
3748
|
+
): Promise<FileLockAcquireError | undefined> {
|
|
3749
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
3750
|
+
const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
|
|
3751
|
+
const lockPath = getLockPath(filePath);
|
|
3752
|
+
const previousOwnerHostIds = opts.previousOwnerHostIds ?? [];
|
|
3753
|
+
const transitionState = await classifyFileLockRemovalTransition(
|
|
3754
|
+
lockPath,
|
|
3755
|
+
orphanTransitionAgeMs,
|
|
3756
|
+
opts.ownerHostId,
|
|
3757
|
+
previousOwnerHostIds,
|
|
3758
|
+
);
|
|
3759
|
+
const reportsOrphanTransition = transitionState === "orphan_transition";
|
|
3760
|
+
// An abandoned generation with payload outside the adoption shape is never
|
|
3761
|
+
// reclaimable by this path, so it blocks renewal exactly like a retained orphan
|
|
3762
|
+
// transition. Reporting only `orphan_transition` would leave that case idle.
|
|
3763
|
+
const reportsUnadoptableAbandoned =
|
|
3764
|
+
transitionState === "abandoned" && (await abandonedTransitionIsUnadoptable(lockPath));
|
|
3765
|
+
if (!reportsOrphanTransition && !reportsUnadoptableAbandoned) return undefined;
|
|
3766
|
+
const holder = await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, previousOwnerHostIds);
|
|
3767
|
+
return new FileLockAcquireError(
|
|
3768
|
+
filePath,
|
|
3769
|
+
lockPath,
|
|
3770
|
+
opts.retries,
|
|
3771
|
+
holder.description,
|
|
3772
|
+
"orphan_transition",
|
|
3773
|
+
fileLockRemovalTransitionPath(lockPath),
|
|
2694
3774
|
);
|
|
2695
3775
|
}
|
|
2696
3776
|
|
|
3777
|
+
/** A proven-dead abandoned generation whose payload adoption refuses on sight. */
|
|
3778
|
+
async function abandonedTransitionIsUnadoptable(lockPath: string): Promise<boolean> {
|
|
3779
|
+
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
3780
|
+
if (await readRemovalTransitionOwnerRecord(transitionPath)) return false;
|
|
3781
|
+
if (!(await isNativeExactRemovalUsable())) return false;
|
|
3782
|
+
let captured: NativeDirectoryTreeResult;
|
|
3783
|
+
try {
|
|
3784
|
+
captured = nativeFileLockBindings().snapshotDirectoryTree(transitionPath);
|
|
3785
|
+
} catch (error) {
|
|
3786
|
+
if (isTransientReleaseError(error)) return false;
|
|
3787
|
+
throw error;
|
|
3788
|
+
}
|
|
3789
|
+
if (!captured.ok || !captured.snapshot) return false;
|
|
3790
|
+
// A shape adoption accepts is retried by the ordinary remover; only a refused
|
|
3791
|
+
// payload is a permanent block.
|
|
3792
|
+
return !abandonedTransitionPayloadIsAdoptable(captured.snapshot);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
2697
3795
|
/**
|
|
2698
3796
|
* Serializes all contenders, including callers in the same process. Because this
|
|
2699
3797
|
* API exposes no ownership token, recursive acquisition is indistinguishable
|
|
@@ -2726,12 +3824,17 @@ type FileLockStagingOwner = { pid: number; identity?: FileLockStagingIdentity };
|
|
|
2726
3824
|
|
|
2727
3825
|
const FILE_LOCK_STAGING_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
2728
3826
|
const FILE_LOCK_STAGING_NAME_WITH_IDENTITY =
|
|
2729
|
-
|
|
2730
|
-
const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(
|
|
2731
|
-
|
|
3827
|
+
/^(?:.+\.lock|\.gjc-lock-[0-9a-f]{64})\.pending\.([1-9]\d*)\.([is][A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{16})$/;
|
|
3828
|
+
const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(
|
|
3829
|
+
`^(?:.+\\.lock|\\.gjc-lock-[0-9a-f]{64})\\.pending\\.([1-9]\\d*)\\.${FILE_LOCK_STAGING_UUID}$`,
|
|
3830
|
+
);
|
|
3831
|
+
const FILE_LOCK_STAGING_REMOVING_SUFFIX = ".removing";
|
|
2732
3832
|
function fileLockStagingOwner(name: string): FileLockStagingOwner | null {
|
|
2733
|
-
const
|
|
2734
|
-
|
|
3833
|
+
const stagingName = name.endsWith(FILE_LOCK_STAGING_REMOVING_SUFFIX)
|
|
3834
|
+
? name.slice(0, -FILE_LOCK_STAGING_REMOVING_SUFFIX.length)
|
|
3835
|
+
: name;
|
|
3836
|
+
const current = FILE_LOCK_STAGING_NAME_WITH_IDENTITY.exec(stagingName);
|
|
3837
|
+
const legacy = current ? null : LEGACY_FILE_LOCK_STAGING_NAME.exec(stagingName);
|
|
2735
3838
|
const match = current ?? legacy;
|
|
2736
3839
|
if (!match) return null;
|
|
2737
3840
|
const pid = Number(match[1]);
|
|
@@ -2875,6 +3978,28 @@ export async function inspectFileLockStagingDir(
|
|
|
2875
3978
|
reason: `file_lock_staging_owner_${status}`,
|
|
2876
3979
|
};
|
|
2877
3980
|
if (status !== "dead" || !remove) return result;
|
|
3981
|
+
if (path.basename(canonical).endsWith(FILE_LOCK_STAGING_REMOVING_SUFFIX)) {
|
|
3982
|
+
const removal = await exactRemoveLockTree(canonical, captured.snapshot);
|
|
3983
|
+
if (removal.ok || removal.code === "not_found") return { ...result, removed: true, reason: "removed" };
|
|
3984
|
+
const detachedTreeWasDurablyScrubbed =
|
|
3985
|
+
removal.code === "cleanup_pending" &&
|
|
3986
|
+
removal.payloadDurable === true &&
|
|
3987
|
+
removal.detachedPath !== undefined &&
|
|
3988
|
+
sameFileLockPath(removal.detachedPath, canonical) &&
|
|
3989
|
+
removal.retainedSuccessorPath === undefined &&
|
|
3990
|
+
removal.retainedPlaceholderPath === undefined &&
|
|
3991
|
+
removal.retainedUnknownPath === undefined &&
|
|
3992
|
+
Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key));
|
|
3993
|
+
if (detachedTreeWasDurablyScrubbed) {
|
|
3994
|
+
const removed = await removeDetachedLockQuarantineOnDisk(
|
|
3995
|
+
canonical,
|
|
3996
|
+
captured.snapshot.rootDev,
|
|
3997
|
+
captured.snapshot.rootIno,
|
|
3998
|
+
);
|
|
3999
|
+
return { ...result, removed, reason: removed ? "removed" : "cleanup_pending" };
|
|
4000
|
+
}
|
|
4001
|
+
return { ...result, reason: removal.code ?? "cleanup_failed" };
|
|
4002
|
+
}
|
|
2878
4003
|
if (observation) {
|
|
2879
4004
|
const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
|
|
2880
4005
|
return { ...result, removed: removal === "removed", reason: removal };
|
|
@@ -2924,10 +4049,12 @@ export async function reapOrphanedLockStagingDirs(lockPath: string): Promise<{
|
|
|
2924
4049
|
const summary: { removed: string[]; retained: FileLockStagingResult[] } = { removed: [], retained: [] };
|
|
2925
4050
|
const parent = path.dirname(lockPath);
|
|
2926
4051
|
const prefix = `${path.basename(lockPath)}.pending.`;
|
|
4052
|
+
const boundedPrefix = `.gjc-lock-${crypto.createHash("sha256").update(path.basename(lockPath)).digest("hex")}.pending.`;
|
|
2927
4053
|
const entries = await fs.readdir(parent);
|
|
2928
4054
|
let candidates = 0;
|
|
2929
4055
|
for (const entry of entries) {
|
|
2930
|
-
if (!entry.startsWith(prefix) || fileLockStagingOwnerPid(entry) === null)
|
|
4056
|
+
if ((!entry.startsWith(prefix) && !entry.startsWith(boundedPrefix)) || fileLockStagingOwnerPid(entry) === null)
|
|
4057
|
+
continue;
|
|
2931
4058
|
if (++candidates > 64) break;
|
|
2932
4059
|
const result = await inspectFileLockStagingDir(path.join(parent, entry), ownerLiveness, true);
|
|
2933
4060
|
if (result.removed) summary.removed.push(result.path);
|