@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
|
|
3
|
+
* Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
|
|
4
|
+
*
|
|
5
|
+
* Failure model (issue #33): acquisition failures are classified.
|
|
6
|
+
* - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
|
|
7
|
+
* force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
|
|
8
|
+
* wait/steal contract (see test/locks.test.mjs).
|
|
9
|
+
* - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
|
|
10
|
+
* MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
|
|
11
|
+
* deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { ensureDir } from "./atomic.mjs";
|
|
17
|
+
import { isAlive } from "./pid.mjs";
|
|
18
|
+
import * as P from "./paths.mjs";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
21
|
+
/** Minimum contention window before a fresh lock can be force-stolen. */
|
|
22
|
+
const MIN_WINDOW_MS = 250;
|
|
23
|
+
const WAIT_TICK_MS = 20;
|
|
24
|
+
/** Max stale-lock steal attempts before giving up. */
|
|
25
|
+
const MAX_STEAL_ATTEMPTS = 2;
|
|
26
|
+
/** Max quick retries for environmental failures before giving up. */
|
|
27
|
+
const MAX_ENV_ATTEMPTS = 3;
|
|
28
|
+
|
|
29
|
+
export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @template T
|
|
33
|
+
* @param {string} lockPath
|
|
34
|
+
* @param {() => T} fn
|
|
35
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
36
|
+
* @returns {T}
|
|
37
|
+
*/
|
|
38
|
+
export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
39
|
+
const fs = opts.fs ?? defaultLocksFs;
|
|
40
|
+
acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
result = fn();
|
|
44
|
+
} finally {
|
|
45
|
+
releaseLock(lockPath, fs);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @template T
|
|
52
|
+
* @param {string} root
|
|
53
|
+
* @param {string} viewId
|
|
54
|
+
* @param {string} name
|
|
55
|
+
* @param {() => T} fn
|
|
56
|
+
* @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
|
|
57
|
+
* @returns {T}
|
|
58
|
+
*/
|
|
59
|
+
export function withViewLockSync(root, viewId, name, fn, opts = {}) {
|
|
60
|
+
return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
64
|
+
function acquireLock(lockPath, staleMs, fs) {
|
|
65
|
+
const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
|
|
66
|
+
let steals = 0;
|
|
67
|
+
let envAttempts = 0;
|
|
68
|
+
for (;;) {
|
|
69
|
+
let created = false;
|
|
70
|
+
try {
|
|
71
|
+
// Re-run every attempt: a parent deleted mid-acquisition self-heals here.
|
|
72
|
+
ensureDir(path.dirname(lockPath));
|
|
73
|
+
fs.mkdirSync(lockPath);
|
|
74
|
+
created = true;
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
path.join(lockPath, "owner.json"),
|
|
77
|
+
JSON.stringify({ pid: process.pid, at: Date.now() }),
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err && err.code === "EEXIST") {
|
|
83
|
+
const expired = Date.now() >= deadline;
|
|
84
|
+
if (isLockStale(lockPath, staleMs, fs) || expired) {
|
|
85
|
+
if (steals >= MAX_STEAL_ATTEMPTS) {
|
|
86
|
+
throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
|
|
87
|
+
}
|
|
88
|
+
steals += 1;
|
|
89
|
+
releaseLock(lockPath, fs);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
sleep(WAIT_TICK_MS);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Environmental failure: bounded quick retries, then fail fast.
|
|
96
|
+
if (created) releaseLock(lockPath, fs);
|
|
97
|
+
envAttempts += 1;
|
|
98
|
+
if (envAttempts >= MAX_ENV_ATTEMPTS) {
|
|
99
|
+
const reason = (err && (err.code || err.message)) || "unknown error";
|
|
100
|
+
throw lockError(lockPath, `lock path unusable (${reason})`);
|
|
101
|
+
}
|
|
102
|
+
sleep(WAIT_TICK_MS);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string} lockPath @param {string} reason */
|
|
108
|
+
function lockError(lockPath, reason) {
|
|
109
|
+
const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
|
|
110
|
+
err.code = "LOCK_TIMEOUT";
|
|
111
|
+
return err;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {number} ms */
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
|
|
120
|
+
function isLockStale(lockPath, staleMs, fs) {
|
|
121
|
+
try {
|
|
122
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
123
|
+
const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
|
|
124
|
+
const owner = JSON.parse(raw);
|
|
125
|
+
return Date.now() - Number(owner.at ?? 0) > staleMs;
|
|
126
|
+
} catch {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {string} lockPath @param {typeof defaultLocksFs} fs */
|
|
132
|
+
function releaseLock(lockPath, fs) {
|
|
133
|
+
try {
|
|
134
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
135
|
+
} catch {
|
|
136
|
+
/* best effort */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- owner-safe leases (issue #70) ----------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Lease fs = lock fs + rename (candidate publish + quarantine reclaim). */
|
|
143
|
+
const defaultLeaseFs = Object.freeze({ ...defaultLocksFs, renameSync });
|
|
144
|
+
/** Max stale-reclaim → republish rounds inside a single acquire attempt. */
|
|
145
|
+
const MAX_LEASE_RECLAIM_ATTEMPTS = 3;
|
|
146
|
+
/**
|
|
147
|
+
* Age past which an identity-less lock is treated as an orphan candidate.
|
|
148
|
+
* Host-meta holds are millisecond-scale critical sections, so no legitimate
|
|
149
|
+
* holder reaches this (issue #112).
|
|
150
|
+
*/
|
|
151
|
+
const ORPHAN_LEASE_AGE_MS = 5 * 60_000;
|
|
152
|
+
/** Age past which a heartbeating lease with no live proof of ownership is an orphan. */
|
|
153
|
+
export const LEASE_HEARTBEAT_STALE_MS = 60_000;
|
|
154
|
+
/** Consecutive supervised heartbeat failures after which a holder must vacate. */
|
|
155
|
+
export const MAX_CONSECUTIVE_TOUCH_FAILURES = 3;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A token-fenced lease over the view-lock directory. Every operation re-reads
|
|
159
|
+
* owner.json and only acts while it still carries this lease's token, so a
|
|
160
|
+
* superseded owner can never touch — heartbeat or delete — the current owner's
|
|
161
|
+
* lock. Unlike withFileLockSync, a held lock is never stolen by age alone; it
|
|
162
|
+
* can only be reclaimed when its recorded owner process is provably dead, or
|
|
163
|
+
* when a heartbeating owner stopped heartbeating (issue #224).
|
|
164
|
+
* @typedef {Object} LeaseReclaimed
|
|
165
|
+
* @property {number} ownerPid
|
|
166
|
+
* @property {number|null} heartbeatAgeMs
|
|
167
|
+
* @property {number} lockAgeMs
|
|
168
|
+
* @property {"declared" | "legacy"} eligibility
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @typedef {Object} Lease
|
|
173
|
+
* @property {string} token
|
|
174
|
+
* @property {LeaseReclaimed|null} reclaimed metadata when this acquire reclaimed a stale lease
|
|
175
|
+
* @property {() => boolean} touch
|
|
176
|
+
* @property {() => boolean} isOwner
|
|
177
|
+
* @property {() => boolean} release
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {string} root
|
|
182
|
+
* @param {string} viewId
|
|
183
|
+
* @param {string} name
|
|
184
|
+
* @param {{ waitMs?: number, identity?: object|null, heartbeatMs?: number, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
|
|
185
|
+
* @returns {Lease}
|
|
186
|
+
* @throws {Error & { code: "LOCK_TIMEOUT" }} when the lock is still held after waitMs
|
|
187
|
+
*/
|
|
188
|
+
export function acquireOwnedViewLock(root, viewId, name, opts = {}) {
|
|
189
|
+
const lockPath = P.viewLockPath(root, viewId, name);
|
|
190
|
+
const deadline = Date.now() + Math.max(0, Number(opts.waitMs ?? 0));
|
|
191
|
+
for (;;) {
|
|
192
|
+
const attempt = attemptAcquireLease(lockPath, opts);
|
|
193
|
+
if (typeof attempt !== "string") return attempt;
|
|
194
|
+
if (Date.now() >= deadline) throw lockError(lockPath, `lease not acquired (${attempt})`);
|
|
195
|
+
sleep(WAIT_TICK_MS);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} root
|
|
201
|
+
* @param {string} viewId
|
|
202
|
+
* @param {string} name
|
|
203
|
+
* @param {{ identity?: object|null, heartbeatMs?: number, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
|
|
204
|
+
* @returns {{ acquired: true, lease: Lease } | { acquired: false, reason: "busy"|"blocked" }}
|
|
205
|
+
*/
|
|
206
|
+
export function tryAcquireOwnedViewLock(root, viewId, name, opts = {}) {
|
|
207
|
+
const attempt = attemptAcquireLease(P.viewLockPath(root, viewId, name), opts);
|
|
208
|
+
if (typeof attempt === "string") return { acquired: false, reason: attempt };
|
|
209
|
+
return { acquired: true, lease: attempt };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether a publish-rename failure means "the lock path already exists"
|
|
214
|
+
* (contention) rather than a genuine filesystem error.
|
|
215
|
+
*
|
|
216
|
+
* POSIX reports EEXIST/ENOTEMPTY when renaming a directory onto an existing
|
|
217
|
+
* one; Windows reports EPERM (errno -4048) for the same situation — this op's
|
|
218
|
+
* platform equivalent of EEXIST (issue #114: a crashed owner's lease could
|
|
219
|
+
* never be reclaimed because EPERM was rethrown before reclaimOrBlock).
|
|
220
|
+
* Routing a genuine permission error here stays safe: reclaimability is still
|
|
221
|
+
* decided solely by `classifyLeaseOwner`, so it resolves `blocked`/`busy`.
|
|
222
|
+
* @param {string|undefined|null} code
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
export function isPublishConflictCode(code) {
|
|
226
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Single-shot acquire round: publish a complete candidate lock (owner.json
|
|
231
|
+
* written BEFORE the lock path exists) via atomic rename, and on contention
|
|
232
|
+
* either reclaim a provably-dead owner's lock via quarantine or report.
|
|
233
|
+
* @param {string} lockPath
|
|
234
|
+
* @param {{ identity?: object|null, heartbeatMs?: number, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} opts
|
|
235
|
+
* @returns {Lease | "busy" | "blocked"}
|
|
236
|
+
*/
|
|
237
|
+
function attemptAcquireLease(lockPath, opts) {
|
|
238
|
+
const fs = { ...defaultLeaseFs, ...(opts.fs ?? {}) };
|
|
239
|
+
const now = opts.clock ?? Date.now;
|
|
240
|
+
const isProcessDead = opts.isProcessDead ?? ((pid) => !isAlive(pid));
|
|
241
|
+
const identity = opts.identity ?? null;
|
|
242
|
+
const heartbeatMs = Number(opts.heartbeatMs ?? 0);
|
|
243
|
+
// Declaring the period is what makes this lease reclaimable on staleness
|
|
244
|
+
// alone (issue #224 D2); unset keeps the pre-#224 record shape.
|
|
245
|
+
const declaresHeartbeat = Number.isFinite(heartbeatMs) && heartbeatMs > 0;
|
|
246
|
+
let reclaimed = null;
|
|
247
|
+
for (let attempt = 0; attempt < MAX_LEASE_RECLAIM_ATTEMPTS; attempt++) {
|
|
248
|
+
const token = randomBytes(16).toString("hex");
|
|
249
|
+
const candidate = `${lockPath}.candidate.${token}`;
|
|
250
|
+
ensureDir(path.dirname(lockPath));
|
|
251
|
+
try {
|
|
252
|
+
// Candidate dir is unique per token; a rename publishes the COMPLETE
|
|
253
|
+
// record atomically — no observer can see a lock without owner.json.
|
|
254
|
+
fs.mkdirSync(candidate);
|
|
255
|
+
fs.writeFileSync(
|
|
256
|
+
path.join(candidate, "owner.json"),
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
token, pid: process.pid, identity, startedAt: now(),
|
|
259
|
+
...(declaresHeartbeat ? { heartbeatMs } : {}),
|
|
260
|
+
}),
|
|
261
|
+
"utf8",
|
|
262
|
+
);
|
|
263
|
+
fs.renameSync(candidate, lockPath);
|
|
264
|
+
return makeLease(lockPath, token, fs, now, reclaimed);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
try { fs.rmSync(candidate, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
267
|
+
const code = /** @type {NodeJS.ErrnoException|undefined} */ (err)?.code;
|
|
268
|
+
if (!isPublishConflictCode(code)) throw err;
|
|
269
|
+
const outcome = reclaimOrBlock(lockPath, token, fs, isProcessDead, now);
|
|
270
|
+
if (typeof outcome === "string") return outcome;
|
|
271
|
+
reclaimed = outcome.reclaimed;
|
|
272
|
+
// Reclaimed a stale owner's lock — retry the publish on the next round.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return "busy";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Decide what to do with an inspected lease owner. Pure: the caller supplies
|
|
280
|
+
* `now`, a pid-liveness probe and the observed heartbeat age.
|
|
281
|
+
*
|
|
282
|
+
* Heartbeat eligibility comes first, because a reused pid looks alive while the
|
|
283
|
+
* recorded owner is long gone (issue #224). Two mutually exclusive paths:
|
|
284
|
+
* - declared: the owner wrote `heartbeatMs` into owner.json. Threshold is
|
|
285
|
+
* `max(heartbeatStaleMs, 3 × heartbeatMs)`; a missing heartbeat falls back to
|
|
286
|
+
* the lock age. This path applies to identity and identity-less owners and
|
|
287
|
+
* bypasses the identity-less age gate, or a crash would take 5 minutes to heal.
|
|
288
|
+
* - legacy: a `heartbeat.<token>` file exists (hence `heartbeatAgeMs != null`)
|
|
289
|
+
* and the lock is past `orphanAgeMs`. The age gate keeps millisecond-scale
|
|
290
|
+
* short-hold leases (`host-meta`, `host-start`) out of this path.
|
|
291
|
+
*
|
|
292
|
+
* Without heartbeat evidence the pre-#224 liveness rules apply unchanged: a
|
|
293
|
+
* full identity (pid + startToken) reclaims exactly when its pid is dead; an
|
|
294
|
+
* identity-less owner is only reclaimable past `orphanAgeMs` with a provably
|
|
295
|
+
* dead pid, so fresh identity-less locks stay blocked (issue #112).
|
|
296
|
+
* @param {any} owner parsed owner.json
|
|
297
|
+
* @param {number} now
|
|
298
|
+
* @param {(pid: number) => boolean} isProcessDead
|
|
299
|
+
* @param {{ orphanAgeMs?: number, heartbeatAgeMs?: number|null, heartbeatMs?: number, heartbeatStaleMs?: number }} [opts]
|
|
300
|
+
* @returns {"reclaim" | "busy" | "blocked"}
|
|
301
|
+
*/
|
|
302
|
+
export function classifyLeaseOwner(owner, now, isProcessDead, opts = {}) {
|
|
303
|
+
if (!owner || typeof owner !== "object") return "blocked";
|
|
304
|
+
const identityPid = Number(owner?.identity?.pid ?? 0);
|
|
305
|
+
const hasIdentity = Number.isFinite(identityPid) && identityPid > 0 && typeof owner?.identity?.startToken === "string";
|
|
306
|
+
const pid = Number(owner?.identity?.pid ?? owner?.pid ?? 0);
|
|
307
|
+
if (!Number.isFinite(pid) || pid <= 0) return "blocked";
|
|
308
|
+
// Quarantine-mode reclaim verifies by token that it renamed the lock it
|
|
309
|
+
// inspected — without one nothing may be deleted.
|
|
310
|
+
const reclaimable = typeof owner.token === "string";
|
|
311
|
+
const orphanAgeMs = Number(opts.orphanAgeMs ?? ORPHAN_LEASE_AGE_MS);
|
|
312
|
+
// The declared period may arrive via opts (I/O shell) or already be on the
|
|
313
|
+
// parsed owner record (pure callers); opts wins.
|
|
314
|
+
const heartbeatMs = Number(opts.heartbeatMs ?? owner?.heartbeatMs ?? 0);
|
|
315
|
+
const declared = Number.isFinite(heartbeatMs) && heartbeatMs > 0;
|
|
316
|
+
const heartbeatStaleMs = Number(opts.heartbeatStaleMs ?? LEASE_HEARTBEAT_STALE_MS);
|
|
317
|
+
const lockAgeMs = Number(now) - Number(owner?.startedAt ?? 0);
|
|
318
|
+
if (declared) {
|
|
319
|
+
const heartbeatAgeMs = Math.max(0, Number(opts.heartbeatAgeMs ?? lockAgeMs));
|
|
320
|
+
if (heartbeatAgeMs >= Math.max(heartbeatStaleMs, 3 * heartbeatMs)) return reclaimable ? "reclaim" : "blocked";
|
|
321
|
+
} else if (opts.heartbeatAgeMs != null && lockAgeMs >= orphanAgeMs) {
|
|
322
|
+
const heartbeatAgeMs = Math.max(0, Number(opts.heartbeatAgeMs));
|
|
323
|
+
if (heartbeatAgeMs >= heartbeatStaleMs) return reclaimable ? "reclaim" : "blocked";
|
|
324
|
+
}
|
|
325
|
+
if (hasIdentity) {
|
|
326
|
+
if (!isProcessDead(pid)) return "busy";
|
|
327
|
+
return reclaimable ? "reclaim" : "blocked";
|
|
328
|
+
}
|
|
329
|
+
if (!declared && lockAgeMs < orphanAgeMs) return "blocked";
|
|
330
|
+
if (!isProcessDead(pid)) return "busy";
|
|
331
|
+
return reclaimable ? "reclaim" : "blocked";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Read the owner-written heartbeat and return its age in ms. A future `at`
|
|
336
|
+
* (clock skew) clamps to 0: a heartbeat must never look older than it is.
|
|
337
|
+
* @param {string} lockPath
|
|
338
|
+
* @param {any} token the recorded owner token (names the heartbeat file)
|
|
339
|
+
* @param {{ readFileSync: (path: string, encoding: string) => string }} [fs]
|
|
340
|
+
* @param {number} [now]
|
|
341
|
+
* @returns {number|null} null when the file is missing or its `at` is unusable
|
|
342
|
+
*/
|
|
343
|
+
export function readLeaseHeartbeat(lockPath, token, fs = defaultLocksFs, now = Date.now()) {
|
|
344
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
345
|
+
let parsed;
|
|
346
|
+
try {
|
|
347
|
+
parsed = JSON.parse(fs.readFileSync(path.join(lockPath, `heartbeat.${token}`), "utf8"));
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const at = parsed?.at;
|
|
352
|
+
if (typeof at !== "number" || !Number.isFinite(at)) return null;
|
|
353
|
+
return Math.max(0, now - at);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Decide whether an existing lock may be reclaimed. Verdicts come from the
|
|
358
|
+
* pure `classifyLeaseOwner`; reclaim itself quarantines via rename first so a
|
|
359
|
+
* concurrent winner can only ever delete the directory it itself renamed.
|
|
360
|
+
*
|
|
361
|
+
* The observed heartbeat age is the only I/O this adds: a missing or broken
|
|
362
|
+
* heartbeat file resolves to null, which grants no eligibility to an
|
|
363
|
+
* undeclared lease (fail closed) and falls back to the lock age for a declared
|
|
364
|
+
* one (issue #224 D3).
|
|
365
|
+
* @param {string} lockPath
|
|
366
|
+
* @param {string} token the caller's own token (names the quarantine dir)
|
|
367
|
+
* @param {typeof defaultLeaseFs} fs
|
|
368
|
+
* @param {(pid: number) => boolean} isProcessDead
|
|
369
|
+
* @param {() => number} now clock (matching the caller's candidate timestamps)
|
|
370
|
+
* @returns {{ reclaimed: LeaseReclaimed } | "busy" | "blocked"}
|
|
371
|
+
*/
|
|
372
|
+
function reclaimOrBlock(lockPath, token, fs, isProcessDead, now) {
|
|
373
|
+
let owner;
|
|
374
|
+
try {
|
|
375
|
+
owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
|
|
376
|
+
} catch {
|
|
377
|
+
return "blocked";
|
|
378
|
+
}
|
|
379
|
+
const at = now();
|
|
380
|
+
const heartbeatAgeMs = readLeaseHeartbeat(lockPath, owner?.token, fs, at);
|
|
381
|
+
const declaredHeartbeatMs = Number(owner?.heartbeatMs ?? 0);
|
|
382
|
+
const declared = Number.isFinite(declaredHeartbeatMs) && declaredHeartbeatMs > 0;
|
|
383
|
+
const verdict = classifyLeaseOwner(owner, at, isProcessDead, {
|
|
384
|
+
heartbeatAgeMs,
|
|
385
|
+
heartbeatMs: declared ? declaredHeartbeatMs : undefined,
|
|
386
|
+
heartbeatStaleMs: LEASE_HEARTBEAT_STALE_MS,
|
|
387
|
+
});
|
|
388
|
+
if (verdict !== "reclaim") return verdict;
|
|
389
|
+
// classifyLeaseOwner only returns "reclaim" when owner.token is a string.
|
|
390
|
+
const inspectedToken = owner.token;
|
|
391
|
+
const quarantine = `${lockPath}.reclaim.${token}`;
|
|
392
|
+
try {
|
|
393
|
+
fs.renameSync(lockPath, quarantine);
|
|
394
|
+
} catch {
|
|
395
|
+
// Someone else released/reclaimed concurrently — plain contention.
|
|
396
|
+
return "busy";
|
|
397
|
+
}
|
|
398
|
+
// Verify we quarantined exactly the lock we inspected: a concurrent winner
|
|
399
|
+
// may have reclaimed the dead owner and published a fresh lock at lockPath
|
|
400
|
+
// between our read and this rename. If so, restore it and report contention
|
|
401
|
+
// instead of deleting a successor's lock.
|
|
402
|
+
let quarantinedToken = null;
|
|
403
|
+
try {
|
|
404
|
+
quarantinedToken = JSON.parse(fs.readFileSync(path.join(quarantine, "owner.json"), "utf8"))?.token ?? null;
|
|
405
|
+
} catch { /* unreadable quarantine content */ }
|
|
406
|
+
if (quarantinedToken !== inspectedToken) {
|
|
407
|
+
try { fs.renameSync(quarantine, lockPath); } catch { /* best effort restore */ }
|
|
408
|
+
return "busy";
|
|
409
|
+
}
|
|
410
|
+
try { fs.rmSync(quarantine, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
411
|
+
return {
|
|
412
|
+
reclaimed: {
|
|
413
|
+
ownerPid: Number(owner?.identity?.pid ?? owner?.pid ?? 0),
|
|
414
|
+
heartbeatAgeMs,
|
|
415
|
+
lockAgeMs: at - Number(owner?.startedAt ?? 0),
|
|
416
|
+
eligibility: declared ? "declared" : "legacy",
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Summarize why an existing lock is not acquirable, so the coordinator can
|
|
423
|
+
* record a diagnostic instead of exiting silently (issue #224 D6).
|
|
424
|
+
*
|
|
425
|
+
* Returns exactly the six keys that become the `coordinator_lease_unavailable`
|
|
426
|
+
* details — no more, no less. `reason` is the raw `classifyLeaseOwner` verdict
|
|
427
|
+
* at call time, so on the acquisition-failure path it is `"busy"` or
|
|
428
|
+
* `"blocked"`.
|
|
429
|
+
* @param {string} root
|
|
430
|
+
* @param {string} viewId
|
|
431
|
+
* @param {string} name
|
|
432
|
+
* @param {{ fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
|
|
433
|
+
* @returns {{ reason: "reclaim"|"busy"|"blocked", ownerPid: number, hasIdentity: boolean, lockAgeMs: number, heartbeatAgeMs: number|null, heartbeatMs: number|null } | null}
|
|
434
|
+
*/
|
|
435
|
+
export function describeLeaseConflict(root, viewId, name, opts = {}) {
|
|
436
|
+
const fs = { ...defaultLeaseFs, ...(opts.fs ?? {}) };
|
|
437
|
+
const lockPath = P.viewLockPath(root, viewId, name);
|
|
438
|
+
let owner;
|
|
439
|
+
try {
|
|
440
|
+
owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
if (!owner || typeof owner !== "object") return null;
|
|
445
|
+
const at = (opts.clock ?? Date.now)();
|
|
446
|
+
const isProcessDead = opts.isProcessDead ?? ((pid) => !isAlive(pid));
|
|
447
|
+
const identityPid = Number(owner?.identity?.pid ?? 0);
|
|
448
|
+
const heartbeatAgeMs = readLeaseHeartbeat(lockPath, owner?.token, fs, at);
|
|
449
|
+
const declaredHeartbeatMs = Number(owner?.heartbeatMs ?? 0);
|
|
450
|
+
const heartbeatMs = Number.isFinite(declaredHeartbeatMs) && declaredHeartbeatMs > 0 ? declaredHeartbeatMs : null;
|
|
451
|
+
return {
|
|
452
|
+
reason: classifyLeaseOwner(owner, at, isProcessDead, {
|
|
453
|
+
heartbeatAgeMs,
|
|
454
|
+
heartbeatMs: heartbeatMs ?? undefined,
|
|
455
|
+
heartbeatStaleMs: LEASE_HEARTBEAT_STALE_MS,
|
|
456
|
+
}),
|
|
457
|
+
ownerPid: Number(owner?.identity?.pid ?? owner?.pid ?? 0),
|
|
458
|
+
hasIdentity: Number.isFinite(identityPid) && identityPid > 0 && typeof owner?.identity?.startToken === "string",
|
|
459
|
+
lockAgeMs: at - Number(owner?.startedAt ?? 0),
|
|
460
|
+
heartbeatAgeMs,
|
|
461
|
+
heartbeatMs,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Supervised heartbeat failure policy: a holder that cannot prove liveness for
|
|
467
|
+
* `maxFailures` consecutive ticks must vacate rather than be preempted later.
|
|
468
|
+
* @param {number} consecutiveFailures
|
|
469
|
+
* @param {number} [maxFailures]
|
|
470
|
+
* @returns {"continue" | "abandon"}
|
|
471
|
+
*/
|
|
472
|
+
export function heartbeatFailureAction(consecutiveFailures, maxFailures = MAX_CONSECUTIVE_TOUCH_FAILURES) {
|
|
473
|
+
return Number(consecutiveFailures) >= Number(maxFailures) ? "abandon" : "continue";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* @param {string} lockPath
|
|
478
|
+
* @param {string} token
|
|
479
|
+
* @param {typeof defaultLeaseFs} fs
|
|
480
|
+
* @param {() => number} now
|
|
481
|
+
* @param {LeaseReclaimed|null} [reclaimed]
|
|
482
|
+
* @returns {Lease}
|
|
483
|
+
*/
|
|
484
|
+
function makeLease(lockPath, token, fs, now, reclaimed = null) {
|
|
485
|
+
const ownerPath = path.join(lockPath, "owner.json");
|
|
486
|
+
const heartbeatPath = path.join(lockPath, `heartbeat.${token}`);
|
|
487
|
+
const stillOwns = () => {
|
|
488
|
+
try {
|
|
489
|
+
return JSON.parse(fs.readFileSync(ownerPath, "utf8"))?.token === token;
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
token,
|
|
496
|
+
reclaimed,
|
|
497
|
+
isOwner: () => stillOwns(),
|
|
498
|
+
touch() {
|
|
499
|
+
if (!stillOwns()) return false;
|
|
500
|
+
try {
|
|
501
|
+
// Heartbeat lives in a token-named file: old tokens can never
|
|
502
|
+
// overwrite the canonical owner record or a successor's heartbeat.
|
|
503
|
+
fs.writeFileSync(heartbeatPath, JSON.stringify({ at: now() }), "utf8");
|
|
504
|
+
return true;
|
|
505
|
+
} catch {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
release() {
|
|
510
|
+
if (!stillOwns()) return false;
|
|
511
|
+
try {
|
|
512
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
513
|
+
return true;
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Simulates a late release() from a superseded token. Exported so tests can
|
|
523
|
+
* prove old tokens can never delete a successor's lock.
|
|
524
|
+
* @visibleForTesting
|
|
525
|
+
* @param {string} root @param {string} viewId @param {string} name @param {string} token
|
|
526
|
+
* @returns {boolean}
|
|
527
|
+
*/
|
|
528
|
+
export function releaseWithToken(root, viewId, name, token) {
|
|
529
|
+
return makeLease(P.viewLockPath(root, viewId, name), token, defaultLeaseFs, Date.now).release();
|
|
530
|
+
}
|