@gajae-code/coding-agent 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -38,36 +38,97 @@ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca4959
|
|
|
38
38
|
const LOCK_ACQUIRE_RETRY_MS = 5;
|
|
39
39
|
const LOCK_ACQUIRE_MAX_RETRY_MS = 100;
|
|
40
40
|
const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
41
|
+
/**
|
|
42
|
+
* Ceiling on total waiting, however much progress the lock is making.
|
|
43
|
+
*
|
|
44
|
+
* `LOCK_ACQUIRE_TIMEOUT_MS` bounds waiting WITHOUT PROGRESS (see
|
|
45
|
+
* {@link creditLockHandoffProgress}); this bounds waiting altogether, so a contender behind
|
|
46
|
+
* an unboundedly busy lock still fails rather than blocking its caller forever. It matches
|
|
47
|
+
* the namespace-wide `locks/mutation.lock` budget that encloses these same writes, so the
|
|
48
|
+
* inner and outer locks cannot disagree about how long a write may take.
|
|
49
|
+
*/
|
|
50
|
+
const LOCK_ACQUIRE_MAX_WAIT_MS = 60_000;
|
|
41
51
|
const LOCK_STALE_MS = 30_000;
|
|
42
52
|
const RELEASED_TRANSITION_GRACE_MS = 1_000;
|
|
43
53
|
const SESSION_STATE_LOCK_BUSY = Symbol("session-state-lock-busy");
|
|
44
54
|
|
|
45
55
|
interface LockRetryBudget {
|
|
56
|
+
/** When the current no-progress window began; moved forward by an observed handoff. */
|
|
46
57
|
startedAt: number;
|
|
58
|
+
/** When this acquisition began waiting; never moved, and bounds total waiting. */
|
|
59
|
+
firstAttemptAt: number;
|
|
47
60
|
nextDelayMs: number;
|
|
48
61
|
attempts: number;
|
|
49
62
|
nonBlocking: boolean;
|
|
63
|
+
/** Identity of the holder observed on the previous contended attempt, if any. */
|
|
64
|
+
observedHolder?: string;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
function lockRetryBudget(nonBlocking = false): LockRetryBudget {
|
|
53
|
-
|
|
68
|
+
const now = performance.now();
|
|
69
|
+
return { startedAt: now, firstAttemptAt: now, nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0, nonBlocking };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Credit the acquisition budget when the lock is observed to have CHANGED HANDS.
|
|
74
|
+
*
|
|
75
|
+
* The acquisition deadline exists to bound waiting on a lock that is stuck, but on its own
|
|
76
|
+
* it cannot tell "stuck" from "busy": it expires just the same while peers take and release
|
|
77
|
+
* the lock normally in front of this contender. That is the starvation this protocol
|
|
78
|
+
* actually suffers from — several gjc processes write one `session-states/<id>.json`, each
|
|
79
|
+
* inside the 60s namespace transaction lock, so a critical section legitimately outlasting
|
|
80
|
+
* the 5s deadline costs every waiter its runtime-state update outright.
|
|
81
|
+
*
|
|
82
|
+
* A holder identity that differs from the one seen on the previous contended attempt, and
|
|
83
|
+
* that this contender did not itself produce, is positive proof that a PEER took and gave
|
|
84
|
+
* up the lock — so the no-progress window restarts from that observation. An UNCHANGED
|
|
85
|
+
* identity credits nothing, which keeps a genuinely wedged lock — the case the deadline is
|
|
86
|
+
* for — failing on exactly its original schedule.
|
|
87
|
+
*
|
|
88
|
+
* `selfCaused` is what separates a handoff from a livelock. A contender that reclaims an
|
|
89
|
+
* abandoned record sees a different identity next round purely because of its own removal;
|
|
90
|
+
* crediting that would let an endlessly recreated dead claim spin until the total-wait
|
|
91
|
+
* ceiling instead of failing on the acquisition deadline. Those rounds are already bounded
|
|
92
|
+
* by their own {@link lockRetryExhausted} check, so they credit nothing here.
|
|
93
|
+
*
|
|
94
|
+
* {@link LOCK_ACQUIRE_MAX_WAIT_MS} bounds total waiting regardless, so progress can never be
|
|
95
|
+
* credited indefinitely.
|
|
96
|
+
*/
|
|
97
|
+
function creditLockHandoffProgress(budget: LockRetryBudget, holder: string | undefined, selfCaused: boolean): void {
|
|
98
|
+
if (holder === undefined) return;
|
|
99
|
+
const previous = budget.observedHolder;
|
|
100
|
+
budget.observedHolder = holder;
|
|
101
|
+
if (selfCaused || previous === undefined || previous === holder) return;
|
|
102
|
+
budget.startedAt = performance.now();
|
|
103
|
+
// A handoff means the wait restarts from a fresh, responsive lock rather than from
|
|
104
|
+
// whatever backoff the previous holder's tenure had grown to.
|
|
105
|
+
budget.nextDelayMs = LOCK_ACQUIRE_RETRY_MS;
|
|
54
106
|
}
|
|
55
107
|
|
|
56
108
|
function lockRetryElapsedMs(budget: LockRetryBudget): number {
|
|
57
109
|
return Math.max(0, performance.now() - budget.startedAt);
|
|
58
110
|
}
|
|
59
111
|
|
|
112
|
+
/** Milliseconds left before either the no-progress or the total-wait bound is reached. */
|
|
113
|
+
function lockRetryRemainingMs(budget: LockRetryBudget): number {
|
|
114
|
+
const now = performance.now();
|
|
115
|
+
return Math.min(
|
|
116
|
+
LOCK_ACQUIRE_TIMEOUT_MS - Math.max(0, now - budget.startedAt),
|
|
117
|
+
LOCK_ACQUIRE_MAX_WAIT_MS - Math.max(0, now - budget.firstAttemptAt),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
60
121
|
function lockRetryExhausted(budget: LockRetryBudget): boolean {
|
|
61
|
-
return
|
|
122
|
+
return lockRetryRemainingMs(budget) <= 0;
|
|
62
123
|
}
|
|
63
124
|
|
|
64
125
|
async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
|
|
65
126
|
budget.attempts++;
|
|
66
|
-
const remainingMs =
|
|
127
|
+
const remainingMs = lockRetryRemainingMs(budget);
|
|
67
128
|
if (remainingMs <= 0) return false;
|
|
68
129
|
await Bun.sleep(Math.min(budget.nextDelayMs, remainingMs));
|
|
69
130
|
budget.nextDelayMs = Math.min(LOCK_ACQUIRE_MAX_RETRY_MS, budget.nextDelayMs * 2);
|
|
70
|
-
return
|
|
131
|
+
return !lockRetryExhausted(budget);
|
|
71
132
|
}
|
|
72
133
|
|
|
73
134
|
/**
|
|
@@ -128,7 +189,14 @@ function transitionGenerationFromStat(stat: fsSync.BigIntStats): TransitionDirec
|
|
|
128
189
|
};
|
|
129
190
|
}
|
|
130
191
|
|
|
131
|
-
interface
|
|
192
|
+
interface PendingOwnerRepair {
|
|
193
|
+
/** Descriptor retained when a rewrite fault defeats immediate repair. */
|
|
194
|
+
repairHandle?: fs.FileHandle;
|
|
195
|
+
repairBytes?: string;
|
|
196
|
+
repairSnapshot?: LockOwnerSnapshot;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface PendingTransitionRelease extends PendingOwnerRepair {
|
|
132
200
|
phase: "setup" | "release";
|
|
133
201
|
token: string;
|
|
134
202
|
generation?: TransitionDirectoryGeneration;
|
|
@@ -138,21 +206,24 @@ interface PendingTransitionRelease {
|
|
|
138
206
|
* authority when setup generation capture itself faults or the pathname is
|
|
139
207
|
* replaced before recovery gets to run. */
|
|
140
208
|
generationHandle?: fs.FileHandle;
|
|
141
|
-
/** Descriptor retained when a rewrite fault defeats immediate repair. */
|
|
142
|
-
repairHandle?: fs.FileHandle;
|
|
143
|
-
repairBytes?: string;
|
|
144
|
-
repairSnapshot?: LockOwnerSnapshot;
|
|
145
209
|
held?: LockOwnerSnapshot;
|
|
146
210
|
releasedOwner?: SessionStateLockOwner;
|
|
147
211
|
recoverable: boolean;
|
|
148
212
|
recovery?: Promise<boolean>;
|
|
149
213
|
}
|
|
150
214
|
|
|
215
|
+
interface PendingPrimaryOwnerRelease extends PendingOwnerRepair {
|
|
216
|
+
held: LockOwnerSnapshot;
|
|
217
|
+
recoverable: boolean;
|
|
218
|
+
recovery?: Promise<boolean>;
|
|
219
|
+
}
|
|
220
|
+
|
|
151
221
|
interface LocalTransitionQueue {
|
|
152
222
|
waiters: Array<() => void>;
|
|
153
223
|
}
|
|
154
224
|
|
|
155
225
|
const pendingTransitionReleases = new Map<string, PendingTransitionRelease>();
|
|
226
|
+
const pendingPrimaryOwnerReleases = new Map<string, PendingPrimaryOwnerRelease>();
|
|
156
227
|
const localTransitionQueues = new Map<string, LocalTransitionQueue>();
|
|
157
228
|
|
|
158
229
|
async function joinLocalTransitionQueue(
|
|
@@ -168,7 +239,14 @@ async function joinLocalTransitionQueue(
|
|
|
168
239
|
SessionStateLockTestHooks.afterLocalTransitionQueued?.(transitionDir);
|
|
169
240
|
queue.waiters.push(ready.resolve);
|
|
170
241
|
await ready.promise;
|
|
171
|
-
|
|
242
|
+
// A same-process queue wait is this runtime's own serialization, not filesystem
|
|
243
|
+
// contention: it is credited against BOTH bounds so a descheduled local owner cannot
|
|
244
|
+
// time out its own successor.
|
|
245
|
+
if (retryBudget) {
|
|
246
|
+
const queuedMs = Math.max(0, performance.now() - queuedAt);
|
|
247
|
+
retryBudget.startedAt += queuedMs;
|
|
248
|
+
retryBudget.firstAttemptAt += queuedMs;
|
|
249
|
+
}
|
|
172
250
|
return queue;
|
|
173
251
|
}
|
|
174
252
|
|
|
@@ -203,7 +281,18 @@ function clearPendingTransitionRelease(key: string, pending?: PendingTransitionR
|
|
|
203
281
|
}
|
|
204
282
|
}
|
|
205
283
|
|
|
206
|
-
|
|
284
|
+
function clearPendingPrimaryOwnerRelease(key: string, pending?: PendingPrimaryOwnerRelease): void {
|
|
285
|
+
const current = pendingPrimaryOwnerReleases.get(key);
|
|
286
|
+
if (pending !== undefined && current !== pending) return;
|
|
287
|
+
pendingPrimaryOwnerReleases.delete(key);
|
|
288
|
+
const repairHandle = (pending ?? current)?.repairHandle;
|
|
289
|
+
if (repairHandle) {
|
|
290
|
+
(pending ?? current)!.repairHandle = undefined;
|
|
291
|
+
void repairHandle.close().catch(() => undefined);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function repairPendingOwnerRecord(pending: PendingOwnerRepair): Promise<boolean> {
|
|
207
296
|
const handle = pending.repairHandle;
|
|
208
297
|
if (!handle || pending.repairBytes === undefined) return true;
|
|
209
298
|
try {
|
|
@@ -593,6 +682,36 @@ function persistFailureClass(error: unknown): string {
|
|
|
593
682
|
return `error:${boundedPersistFailureClassPart(persistFailureConstructorName(error), "Object")}:code=${boundedPersistFailureClassPart(code, "none")}:errno=${boundedPersistFailureClassPart(errno, "none")}`;
|
|
594
683
|
}
|
|
595
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The refusal reasons that mean ONLY "someone else held the lock the whole time I waited".
|
|
687
|
+
*
|
|
688
|
+
* Each is a contention verdict the acquisition protocol reached without learning anything
|
|
689
|
+
* about the state file itself, so the same write attempted later can still succeed. Every
|
|
690
|
+
* other reason is a standing condition — an unsafe path type, an unprovenanced owner, a
|
|
691
|
+
* failed release, an inspection fault — that a retry would only repeat, so those stay
|
|
692
|
+
* fail-closed and are reported once.
|
|
693
|
+
*/
|
|
694
|
+
const CONTENTION_RETRYABLE_LOCK_REASONS: ReadonlySet<SessionStateLockUnavailableReason> = new Set([
|
|
695
|
+
"acquire_timeout",
|
|
696
|
+
"lock_owner_live_or_unverifiable",
|
|
697
|
+
"lock_owner_record_fresh",
|
|
698
|
+
"transition_claim_timeout",
|
|
699
|
+
]);
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Whether a failed persist lost only a race and is worth attempting again.
|
|
703
|
+
*
|
|
704
|
+
* A coordinator runtime-state update that is dropped on contention is not recoverable by
|
|
705
|
+
* any later write: the coordinator keeps reporting a stale lifecycle/activity snapshot for
|
|
706
|
+
* that session until some unrelated event happens to win the lock. Retrying a contention
|
|
707
|
+
* refusal is therefore repairing a dropped update, not masking a fault — and it is
|
|
708
|
+
* deliberately limited to the reasons that carry no information about the document.
|
|
709
|
+
*/
|
|
710
|
+
export function isRetryableSessionStateLockContention(error: unknown): boolean {
|
|
711
|
+
const reason = sessionStateLockFailureFields(error).reason;
|
|
712
|
+
return reason !== undefined && CONTENTION_RETRYABLE_LOCK_REASONS.has(reason);
|
|
713
|
+
}
|
|
714
|
+
|
|
596
715
|
export const PERSIST_FAILURE_WARN_WINDOW_MS = 30_000;
|
|
597
716
|
const persistFailureLastWarnedAt = new Map<string, number>();
|
|
598
717
|
|
|
@@ -943,10 +1062,22 @@ function sameRegularFileIdentity(left: fsSync.BigIntStats, right: fsSync.BigIntS
|
|
|
943
1062
|
);
|
|
944
1063
|
}
|
|
945
1064
|
|
|
946
|
-
|
|
1065
|
+
/** Whether two path/descriptor stats name the same Windows file object. */
|
|
1066
|
+
function sameWindowsFileObject(left: fsSync.BigIntStats, right: fsSync.BigIntStats): boolean {
|
|
1067
|
+
return left.isFile() && right.isFile() && left.dev === right.dev && left.ino === right.ino;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function ownerSnapshotFrom(
|
|
1071
|
+
stat: fsSync.BigIntStats,
|
|
1072
|
+
bytes: Buffer,
|
|
1073
|
+
identityStat: fsSync.BigIntStats = stat,
|
|
1074
|
+
): LockOwnerSnapshot {
|
|
947
1075
|
return {
|
|
948
|
-
dev
|
|
949
|
-
|
|
1076
|
+
// Windows may refresh descriptor dev/ino metadata after an in-place
|
|
1077
|
+
// truncate/write even though the pathname still names the same file
|
|
1078
|
+
// object. Preserve the pre-mutation identity when one brackets the read.
|
|
1079
|
+
dev: identityStat.dev,
|
|
1080
|
+
ino: identityStat.ino,
|
|
950
1081
|
nlink: stat.nlink,
|
|
951
1082
|
size: stat.size,
|
|
952
1083
|
mtimeNs: stat.mtimeNs,
|
|
@@ -1023,12 +1154,12 @@ async function captureWindowsLockOwner(lockFile: string, flags: number): Promise
|
|
|
1023
1154
|
throw new SessionStateLockUnavailableError(new Error("Lock path became a reparse point under the read."));
|
|
1024
1155
|
// The open landed on the object the pre-`lstat` judged, and the pathname still
|
|
1025
1156
|
// names it. Either disagreement means the bytes have no attributable identity.
|
|
1026
|
-
if (!
|
|
1027
|
-
if (!relinked || !
|
|
1157
|
+
if (!sameWindowsFileObject(before, opened)) return null;
|
|
1158
|
+
if (!relinked || !sameWindowsFileObject(before, relinked)) return null;
|
|
1028
1159
|
const bytes = await handle.readFile();
|
|
1029
1160
|
const settled = await handle.stat({ bigint: true });
|
|
1030
1161
|
if (!sameRegularFileIdentity(opened, settled) || settled.size !== BigInt(bytes.byteLength)) return null;
|
|
1031
|
-
return ownerSnapshotFrom(settled, bytes);
|
|
1162
|
+
return ownerSnapshotFrom(settled, bytes, before);
|
|
1032
1163
|
} finally {
|
|
1033
1164
|
await handle.close().catch(() => undefined);
|
|
1034
1165
|
}
|
|
@@ -1361,11 +1492,11 @@ async function rewriteHeldOwnerRecord(
|
|
|
1361
1492
|
try {
|
|
1362
1493
|
const opened = await handle.stat({ bigint: true });
|
|
1363
1494
|
if (!opened.isFile()) throw new SessionStateLockUnavailableError(new Error("Owner record is not regular."));
|
|
1364
|
-
if (before && !
|
|
1495
|
+
if (before && !sameWindowsFileObject(before, opened))
|
|
1365
1496
|
throw new SessionStateLockUnavailableError(new Error("Owner record changed while opening for rewrite."));
|
|
1366
1497
|
if (before) {
|
|
1367
1498
|
const relinked = await fs.lstat(file, { bigint: true }).catch(() => null);
|
|
1368
|
-
if (!relinked || !
|
|
1499
|
+
if (!relinked || !sameWindowsFileObject(opened, relinked))
|
|
1369
1500
|
throw new SessionStateLockUnavailableError(new Error("Owner pathname changed while opening for rewrite."));
|
|
1370
1501
|
}
|
|
1371
1502
|
const currentBytes = await handle.readFile();
|
|
@@ -1381,9 +1512,12 @@ async function rewriteHeldOwnerRecord(
|
|
|
1381
1512
|
await SessionStateLockTestHooks.beforeOwnerRecordRewrite?.(file);
|
|
1382
1513
|
rewriteHookPassed = true;
|
|
1383
1514
|
await writeOwnerBytes(handle, replacementBytes);
|
|
1384
|
-
const rewritten = await handle.stat({ bigint: true });
|
|
1385
1515
|
const canonical = await captureRegularLockOwner(file);
|
|
1386
|
-
|
|
1516
|
+
// On Windows, an in-place truncate/write can expose refreshed descriptor
|
|
1517
|
+
// metadata even though the file object is unchanged. The descriptor opened
|
|
1518
|
+
// before mutation is the stable identity bracket; the path capture still
|
|
1519
|
+
// proves that the pathname resolves to that same object after the rewrite.
|
|
1520
|
+
if (!canonical || canonical.dev !== opened.dev || canonical.ino !== opened.ino)
|
|
1387
1521
|
throw new SessionStateLockUnavailableError(new Error("Owner pathname changed after rewrite."));
|
|
1388
1522
|
if (canonical.bytes !== replacementBytes.toString("utf8")) {
|
|
1389
1523
|
let successor: unknown;
|
|
@@ -2038,6 +2172,82 @@ async function recoverPendingTransitionRelease(
|
|
|
2038
2172
|
}
|
|
2039
2173
|
}
|
|
2040
2174
|
|
|
2175
|
+
/**
|
|
2176
|
+
* Complete a primary owner release that failed after the caller's operation had settled.
|
|
2177
|
+
*
|
|
2178
|
+
* This authority is deliberately separate from the transition claim record: the transition
|
|
2179
|
+
* wrapper may have released its own claim successfully even though `releaseOwnerLock`
|
|
2180
|
+
* failed, leaving the primary owner live. Recovery always takes a NEW transition claim and
|
|
2181
|
+
* re-proves the exact held owner snapshot before rewriting it. A changed or missing path
|
|
2182
|
+
* invalidates the remembered authority without touching whatever replaced it.
|
|
2183
|
+
*/
|
|
2184
|
+
async function recoverPendingPrimaryOwnerRelease(
|
|
2185
|
+
lockFile: string,
|
|
2186
|
+
recoveryKey: string,
|
|
2187
|
+
quarantineName: string,
|
|
2188
|
+
budget: LockRetryBudget,
|
|
2189
|
+
): Promise<boolean> {
|
|
2190
|
+
const pending = pendingPrimaryOwnerReleases.get(recoveryKey);
|
|
2191
|
+
if (!pending?.recoverable) return false;
|
|
2192
|
+
if (pending.recovery) {
|
|
2193
|
+
if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
2194
|
+
return await pending.recovery;
|
|
2195
|
+
}
|
|
2196
|
+
const recovery = (async (): Promise<boolean> => {
|
|
2197
|
+
let released = false;
|
|
2198
|
+
try {
|
|
2199
|
+
await withLockPathTransition(
|
|
2200
|
+
lockFile,
|
|
2201
|
+
async () => {
|
|
2202
|
+
if (pendingPrimaryOwnerReleases.get(recoveryKey) !== pending) return false;
|
|
2203
|
+
if (!(await repairPendingOwnerRecord(pending))) return false;
|
|
2204
|
+
if (pending.repairSnapshot && sameLockOwnerObject(pending.repairSnapshot, pending.held))
|
|
2205
|
+
pending.held = pending.repairSnapshot;
|
|
2206
|
+
pending.repairSnapshot = undefined;
|
|
2207
|
+
|
|
2208
|
+
const current = await captureRegularLockOwner(lockFile);
|
|
2209
|
+
if (!current || !sameLockOwnerSnapshot(current, pending.held)) {
|
|
2210
|
+
clearPendingPrimaryOwnerRelease(recoveryKey, pending);
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
try {
|
|
2214
|
+
await releaseOwnerLock(lockFile, pending.held);
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
const rewriteFailure = error as OwnerRewriteFailure;
|
|
2217
|
+
if (rewriteFailure.repairSnapshot && sameLockOwnerObject(rewriteFailure.repairSnapshot, pending.held))
|
|
2218
|
+
pending.held = rewriteFailure.repairSnapshot;
|
|
2219
|
+
if (rewriteFailure.repairHandle) {
|
|
2220
|
+
const previous = pending.repairHandle;
|
|
2221
|
+
pending.repairHandle = rewriteFailure.repairHandle;
|
|
2222
|
+
pending.repairBytes = rewriteFailure.repairBytes ?? pending.held.bytes;
|
|
2223
|
+
if (previous && previous !== rewriteFailure.repairHandle)
|
|
2224
|
+
void previous.close().catch(() => undefined);
|
|
2225
|
+
}
|
|
2226
|
+
pending.recoverable = true;
|
|
2227
|
+
return false;
|
|
2228
|
+
}
|
|
2229
|
+
clearPendingPrimaryOwnerRelease(recoveryKey, pending);
|
|
2230
|
+
released = true;
|
|
2231
|
+
return true;
|
|
2232
|
+
},
|
|
2233
|
+
quarantineName,
|
|
2234
|
+
budget,
|
|
2235
|
+
);
|
|
2236
|
+
} catch {
|
|
2237
|
+
// The remembered owner remains recoverable. A later admission gets a fresh
|
|
2238
|
+
// transition claim and retries the exact same release without replaying work.
|
|
2239
|
+
return false;
|
|
2240
|
+
}
|
|
2241
|
+
return released;
|
|
2242
|
+
})();
|
|
2243
|
+
pending.recovery = recovery;
|
|
2244
|
+
try {
|
|
2245
|
+
return await recovery;
|
|
2246
|
+
} finally {
|
|
2247
|
+
if (pending.recovery === recovery) pending.recovery = undefined;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2041
2251
|
/**
|
|
2042
2252
|
* Run one pathname transition under an atomic `mkdir`/`rmdir` claim.
|
|
2043
2253
|
*
|
|
@@ -2119,7 +2329,11 @@ async function runLockPathTransition<T>(
|
|
|
2119
2329
|
throw new SessionStateLockUnavailableError(error);
|
|
2120
2330
|
if (await joinLocalTurn()) continue;
|
|
2121
2331
|
await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
|
|
2332
|
+
// Claims are held only for pathname bookkeeping, so a changed claim identity is
|
|
2333
|
+
// proof that contenders are cycling through rather than that one is wedged.
|
|
2334
|
+
const observedClaim = await observedLockHolderIdentity(transitionDir);
|
|
2122
2335
|
const reclaimed = await reclaimStaleTransitionClaim(transitionDir, quarantineName);
|
|
2336
|
+
creditLockHandoffProgress(budget, observedClaim, reclaimed);
|
|
2123
2337
|
// One safe reclaim may make progress, but a try-acquire never follows a
|
|
2124
2338
|
// succession of contenders. A later maintenance pass can take the freed claim.
|
|
2125
2339
|
if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
@@ -2516,6 +2730,29 @@ async function reclaimStaleDirectoryLock(
|
|
|
2516
2730
|
);
|
|
2517
2731
|
}
|
|
2518
2732
|
|
|
2733
|
+
/**
|
|
2734
|
+
* Identity of whatever currently occupies a lock or transition-claim pathname, for the
|
|
2735
|
+
* sole purpose of telling a lock that is HANDING OFF from one that is stuck.
|
|
2736
|
+
*
|
|
2737
|
+
* Every owner record carries a fresh `randomUUID` token and every release rewrites the
|
|
2738
|
+
* record, so hashing the exact bytes read distinguishes successive holders without
|
|
2739
|
+
* parsing, trusting, or acting on their contents. Inode identity is folded in so that a
|
|
2740
|
+
* record replaced by a byte-identical one still reads as a different holder.
|
|
2741
|
+
*
|
|
2742
|
+
* This is advisory only: it never authorizes a reclaim, and an unreadable or absent path
|
|
2743
|
+
* yields `undefined`, which credits no progress. Failing to observe a handoff can only
|
|
2744
|
+
* make this contender give up on its original schedule.
|
|
2745
|
+
*/
|
|
2746
|
+
async function observedLockHolderIdentity(lockPath: string): Promise<string | undefined> {
|
|
2747
|
+
const snapshot = await captureRegularLockOwner(lockPath).catch(() => null);
|
|
2748
|
+
if (snapshot) return `f:${snapshot.dev}:${snapshot.ino}:${snapshot.sha256}`;
|
|
2749
|
+
// A legacy directory lock and a transition claim are directories; their identity is
|
|
2750
|
+
// the directory incarnation itself, since a successor claim is a new inode.
|
|
2751
|
+
const stat = await fs.lstat(lockPath, { bigint: true }).catch(() => null);
|
|
2752
|
+
if (!stat?.isDirectory()) return undefined;
|
|
2753
|
+
return `d:${stat.dev}:${stat.ino}:${stat.ctimeNs}`;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2519
2756
|
/**
|
|
2520
2757
|
* Decide what actually occupies the lock path, without following it.
|
|
2521
2758
|
*
|
|
@@ -2561,6 +2798,11 @@ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineN
|
|
|
2561
2798
|
* Taking the path, cleaning up a failed write, and releasing are all pathname transitions,
|
|
2562
2799
|
* so each runs under the transition claim — never the caller's operation, which holds the
|
|
2563
2800
|
* lock file itself and may run for as long as it needs.
|
|
2801
|
+
*
|
|
2802
|
+
* The acquisition deadline measures LACK OF PROGRESS, not total waiting: see
|
|
2803
|
+
* {@link creditLockHandoffProgress}. A contender behind a queue of peers that are each
|
|
2804
|
+
* taking and releasing the lock normally keeps waiting; one behind a lock that never moves
|
|
2805
|
+
* still refuses promptly.
|
|
2564
2806
|
*/
|
|
2565
2807
|
export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
2566
2808
|
return await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget());
|
|
@@ -2591,9 +2833,11 @@ async function runWithSessionStateFileLock<T>(
|
|
|
2591
2833
|
): Promise<T> {
|
|
2592
2834
|
const lockFile = `${stateFile}.lock`;
|
|
2593
2835
|
let owner: SessionStateLockOwner;
|
|
2836
|
+
let primaryRecoveryKey: string;
|
|
2594
2837
|
try {
|
|
2595
2838
|
owner = await newLockOwner();
|
|
2596
2839
|
await ensureSessionStateParent(path.dirname(stateFile));
|
|
2840
|
+
primaryRecoveryKey = await transitionRecoveryKey(`${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`);
|
|
2597
2841
|
} catch (error) {
|
|
2598
2842
|
const cause = error instanceof SessionStateLockUnavailableError ? (error.cause ?? error) : error;
|
|
2599
2843
|
throw lockUnavailable(lockFile, "lock_initialization_failed", budget, cause);
|
|
@@ -2604,13 +2848,24 @@ async function runWithSessionStateFileLock<T>(
|
|
|
2604
2848
|
let held: LockOwnerSnapshot | undefined;
|
|
2605
2849
|
let callbackFailure = false;
|
|
2606
2850
|
let callbackError: unknown;
|
|
2851
|
+
let acquiredInsideTransition: LockOwnerSnapshot | undefined;
|
|
2607
2852
|
try {
|
|
2853
|
+
// A prior call may settle and fail release while this contender is waiting.
|
|
2854
|
+
// Retry its remembered release under a fresh claim before each admission.
|
|
2855
|
+
await recoverPendingPrimaryOwnerRelease(lockFile, primaryRecoveryKey, cycleQuarantine, budget);
|
|
2608
2856
|
// Contention (`EEXIST`, or `EISDIR`/`EPERM` for a legacy directory owner)
|
|
2609
2857
|
// propagates out of the claim to the evaluation below; the claim is released
|
|
2610
2858
|
// first, so the reclaim that follows can take it.
|
|
2611
2859
|
held = await withLockPathTransition(
|
|
2612
2860
|
lockFile,
|
|
2613
|
-
() =>
|
|
2861
|
+
async () => {
|
|
2862
|
+
const acquired = await acquireOwnerLock(lockFile, owner, cycleQuarantine);
|
|
2863
|
+
// A stale pending release can only survive when the old pathname was
|
|
2864
|
+
// removed or replaced; this successful acquisition supersedes it.
|
|
2865
|
+
clearPendingPrimaryOwnerRelease(primaryRecoveryKey);
|
|
2866
|
+
acquiredInsideTransition = acquired;
|
|
2867
|
+
return acquired;
|
|
2868
|
+
},
|
|
2614
2869
|
cycleQuarantine,
|
|
2615
2870
|
budget,
|
|
2616
2871
|
);
|
|
@@ -2624,9 +2879,42 @@ async function runWithSessionStateFileLock<T>(
|
|
|
2624
2879
|
// Released against the identity captured when it was written, so a record that
|
|
2625
2880
|
// is no longer ours is left for its owner rather than unlinked by name.
|
|
2626
2881
|
let releaseFailure: { error: unknown } | undefined;
|
|
2882
|
+
const pending: PendingPrimaryOwnerRelease = {
|
|
2883
|
+
held: record,
|
|
2884
|
+
recoverable: false,
|
|
2885
|
+
};
|
|
2886
|
+
clearPendingPrimaryOwnerRelease(primaryRecoveryKey);
|
|
2887
|
+
pendingPrimaryOwnerReleases.set(primaryRecoveryKey, pending);
|
|
2627
2888
|
try {
|
|
2628
|
-
await withLockPathTransition(
|
|
2889
|
+
await withLockPathTransition(
|
|
2890
|
+
lockFile,
|
|
2891
|
+
async () => {
|
|
2892
|
+
try {
|
|
2893
|
+
await releaseOwnerLock(lockFile, record);
|
|
2894
|
+
clearPendingPrimaryOwnerRelease(primaryRecoveryKey, pending);
|
|
2895
|
+
} catch (error) {
|
|
2896
|
+
const rewriteFailure = error as OwnerRewriteFailure;
|
|
2897
|
+
if (
|
|
2898
|
+
rewriteFailure.repairSnapshot &&
|
|
2899
|
+
sameLockOwnerObject(rewriteFailure.repairSnapshot, pending.held)
|
|
2900
|
+
)
|
|
2901
|
+
pending.held = rewriteFailure.repairSnapshot;
|
|
2902
|
+
if (rewriteFailure.repairHandle) {
|
|
2903
|
+
const previous = pending.repairHandle;
|
|
2904
|
+
pending.repairHandle = rewriteFailure.repairHandle;
|
|
2905
|
+
pending.repairBytes = rewriteFailure.repairBytes ?? pending.held.bytes;
|
|
2906
|
+
if (previous && previous !== rewriteFailure.repairHandle)
|
|
2907
|
+
void previous.close().catch(() => undefined);
|
|
2908
|
+
}
|
|
2909
|
+
throw error;
|
|
2910
|
+
}
|
|
2911
|
+
},
|
|
2912
|
+
cycleQuarantine,
|
|
2913
|
+
);
|
|
2629
2914
|
} catch (error) {
|
|
2915
|
+
// The operation and release wrapper have settled, even when claiming
|
|
2916
|
+
// the release transition failed before its callback could run.
|
|
2917
|
+
pending.recoverable = true;
|
|
2630
2918
|
releaseFailure = { error };
|
|
2631
2919
|
}
|
|
2632
2920
|
if (!outcome.ok) {
|
|
@@ -2642,6 +2930,17 @@ async function runWithSessionStateFileLock<T>(
|
|
|
2642
2930
|
if (releaseFailure) throw releaseFailure.error;
|
|
2643
2931
|
return outcome.value;
|
|
2644
2932
|
} catch (error) {
|
|
2933
|
+
if (!held && acquiredInsideTransition) {
|
|
2934
|
+
// The callback acquired the primary owner, but the wrapper rejected
|
|
2935
|
+
// before handing that value back (for example, claim cleanup exhausted).
|
|
2936
|
+
// The callback has settled now, so retain its exact snapshot for the next
|
|
2937
|
+
// admission to release under a fresh transition.
|
|
2938
|
+
clearPendingPrimaryOwnerRelease(primaryRecoveryKey);
|
|
2939
|
+
pendingPrimaryOwnerReleases.set(primaryRecoveryKey, {
|
|
2940
|
+
held: acquiredInsideTransition,
|
|
2941
|
+
recoverable: true,
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2645
2944
|
if (callbackFailure && error === callbackError) throw error;
|
|
2646
2945
|
// A fault after the lock was taken belongs to the operation, not to acquisition.
|
|
2647
2946
|
if (held) throw lockDiagnostic(error, lockFile, "lock_release_failed");
|
|
@@ -2676,6 +2975,14 @@ async function runWithSessionStateFileLock<T>(
|
|
|
2676
2975
|
if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
|
|
2677
2976
|
else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
|
|
2678
2977
|
else if (reclaim === "owner_record_fresh") lastReason = "lock_owner_record_fresh";
|
|
2978
|
+
// A holder that differs from the one seen last round proves the lock is moving,
|
|
2979
|
+
// so this contender is queued behind live peers rather than behind a wedge. A
|
|
2980
|
+
// change this attempt's own reclaim produced is not a peer handoff.
|
|
2981
|
+
creditLockHandoffProgress(
|
|
2982
|
+
budget,
|
|
2983
|
+
await observedLockHolderIdentity(lockFile),
|
|
2984
|
+
reclaim === "owner_removed" || reclaim === "reclaimed",
|
|
2985
|
+
);
|
|
2679
2986
|
SessionStateLockTestHooks.afterAcquireContention?.(lockFile, budget.attempts + 1, lockRetryElapsedMs(budget));
|
|
2680
2987
|
if (!(await waitForLockRetry(budget))) throw lockUnavailable(lockFile, lastReason, budget);
|
|
2681
2988
|
}
|