@bragi-gmbh/codebus 1.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/bin/codebus.js +2 -0
- package/dist/binding-guard.js +97 -0
- package/dist/binding-guard.js.map +1 -0
- package/dist/client.js +94 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/ack.js +47 -0
- package/dist/commands/ack.js.map +1 -0
- package/dist/commands/archive.js +21 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/as.js +299 -0
- package/dist/commands/as.js.map +1 -0
- package/dist/commands/ask.js +189 -0
- package/dist/commands/ask.js.map +1 -0
- package/dist/commands/auth.js +33 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/checkin.js +235 -0
- package/dist/commands/checkin.js.map +1 -0
- package/dist/commands/decision.js +52 -0
- package/dist/commands/decision.js.map +1 -0
- package/dist/commands/digest.js +117 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/doctor.js +106 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/factory.js +191 -0
- package/dist/commands/factory.js.map +1 -0
- package/dist/commands/github-login.js +205 -0
- package/dist/commands/github-login.js.map +1 -0
- package/dist/commands/graph.js +88 -0
- package/dist/commands/graph.js.map +1 -0
- package/dist/commands/health.js +34 -0
- package/dist/commands/health.js.map +1 -0
- package/dist/commands/inbox.js +83 -0
- package/dist/commands/inbox.js.map +1 -0
- package/dist/commands/ingest.js +553 -0
- package/dist/commands/ingest.js.map +1 -0
- package/dist/commands/init.js +97 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.js +520 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/invite.js +152 -0
- package/dist/commands/invite.js.map +1 -0
- package/dist/commands/jira.js +237 -0
- package/dist/commands/jira.js.map +1 -0
- package/dist/commands/join.js +96 -0
- package/dist/commands/join.js.map +1 -0
- package/dist/commands/links.js +66 -0
- package/dist/commands/links.js.map +1 -0
- package/dist/commands/login.js +84 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/morning.js +121 -0
- package/dist/commands/morning.js.map +1 -0
- package/dist/commands/next.js +66 -0
- package/dist/commands/next.js.map +1 -0
- package/dist/commands/onboarding.js +109 -0
- package/dist/commands/onboarding.js.map +1 -0
- package/dist/commands/pack.js +276 -0
- package/dist/commands/pack.js.map +1 -0
- package/dist/commands/plan-gh.js +425 -0
- package/dist/commands/plan-gh.js.map +1 -0
- package/dist/commands/plan.js +465 -0
- package/dist/commands/plan.js.map +1 -0
- package/dist/commands/pr.js +88 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/question.js +142 -0
- package/dist/commands/question.js.map +1 -0
- package/dist/commands/read.js +84 -0
- package/dist/commands/read.js.map +1 -0
- package/dist/commands/refs.js +357 -0
- package/dist/commands/refs.js.map +1 -0
- package/dist/commands/reply.js +38 -0
- package/dist/commands/reply.js.map +1 -0
- package/dist/commands/repo.js +1198 -0
- package/dist/commands/repo.js.map +1 -0
- package/dist/commands/report.js +41 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/resolve-refs.js +21 -0
- package/dist/commands/resolve-refs.js.map +1 -0
- package/dist/commands/resolve.js +93 -0
- package/dist/commands/resolve.js.map +1 -0
- package/dist/commands/send.js +168 -0
- package/dist/commands/send.js.map +1 -0
- package/dist/commands/sent.js +22 -0
- package/dist/commands/sent.js.map +1 -0
- package/dist/commands/session.js +426 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/statusline.js +43 -0
- package/dist/commands/statusline.js.map +1 -0
- package/dist/commands/task.js +140 -0
- package/dist/commands/task.js.map +1 -0
- package/dist/commands/tick.js +233 -0
- package/dist/commands/tick.js.map +1 -0
- package/dist/commands/upgrade.js +76 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/watch.js +169 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/commands/whoami.js +27 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/config.js +103 -0
- package/dist/config.js.map +1 -0
- package/dist/directives.js +102 -0
- package/dist/directives.js.map +1 -0
- package/dist/errors.js +110 -0
- package/dist/errors.js.map +1 -0
- package/dist/git-hooks.js +116 -0
- package/dist/git-hooks.js.map +1 -0
- package/dist/graph-bootstrap.js +95 -0
- package/dist/graph-bootstrap.js.map +1 -0
- package/dist/graph-remote.js +889 -0
- package/dist/graph-remote.js.map +1 -0
- package/dist/graph.js +335 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/graph-publish.mjs +400 -0
- package/dist/mail-latency.js +120 -0
- package/dist/mail-latency.js.map +1 -0
- package/dist/mcp-registry.js +859 -0
- package/dist/mcp-registry.js.map +1 -0
- package/dist/mcp.js +193 -0
- package/dist/mcp.js.map +1 -0
- package/dist/output.js +12 -0
- package/dist/output.js.map +1 -0
- package/dist/positioning.js +21 -0
- package/dist/positioning.js.map +1 -0
- package/dist/rolestore.js +89 -0
- package/dist/rolestore.js.map +1 -0
- package/dist/session.js +49 -0
- package/dist/session.js.map +1 -0
- package/dist/sessionstore.js +736 -0
- package/dist/sessionstore.js.map +1 -0
- package/dist/version-check.js +97 -0
- package/dist/version-check.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { Worker } from "node:worker_threads";
|
|
6
|
+
import { loadRoles } from "./rolestore.js";
|
|
7
|
+
import { SessionStoreError } from "./errors.js";
|
|
8
|
+
// Session-scoped identity: WHO a running agent session is, keyed on a
|
|
9
|
+
// durable per-conversation handle when the host provides one, and on process
|
|
10
|
+
// ancestry only as a last resort for hosts that don't.
|
|
11
|
+
//
|
|
12
|
+
// Why this exists: multiple agents must share one repository (researcher,
|
|
13
|
+
// planner, coder, verifier all working the same checkout), so the directory
|
|
14
|
+
// cannot be the identity key — a dir has one binding slot, and the second
|
|
15
|
+
// checkin used to displace the first (observed live: codex-host-ingress stole
|
|
16
|
+
// legacy-android-host-app from claude-master-planner's open session).
|
|
17
|
+
//
|
|
18
|
+
// THE KEY, in priority order (docs/plans/codebus-v2-reliability.md §7 Phase A,
|
|
19
|
+
// precedent #1 in §2.4 — "key identity to a durable handle, never a process",
|
|
20
|
+
// the tmux server model):
|
|
21
|
+
//
|
|
22
|
+
// 1. A durable per-conversation env id, ALONE — never mixed with pid.
|
|
23
|
+
// CLAUDE_CODE_SESSION_ID (Claude Code transcript UUID) or CODEX_THREAD_ID
|
|
24
|
+
// (Codex resume-thread UUID). Verified stable across days, CLI upgrades,
|
|
25
|
+
// /compact, --resume, and app relaunches — exactly the durability a pid
|
|
26
|
+
// can never offer. NEVER CLAUDE_CODE_HOST_SESSION_ID: that one is
|
|
27
|
+
// per-launch, not durable, and using it would silently reintroduce the
|
|
28
|
+
// mortal-key bug this design eliminates.
|
|
29
|
+
// 2. "pid@lstart" of the nearest non-plumbing ancestor — bare-terminal
|
|
30
|
+
// fallback only, when neither env var is set. start-time disambiguates
|
|
31
|
+
// pid reuse. This tier IS mortal: a restarted process gets a new key,
|
|
32
|
+
// and pruneDead below reaps it once it's actually dead (§2 below).
|
|
33
|
+
//
|
|
34
|
+
// Known coarseness, accepted: in a plain terminal the first non-shell ancestor
|
|
35
|
+
// is the terminal APP (iTerm/Terminal is one process for all tabs), so a
|
|
36
|
+
// terminal-side checkin scopes to the whole terminal app, not the tab. Agent
|
|
37
|
+
// sessions — the actual use case — each have their own durable env id and
|
|
38
|
+
// never hit this tier.
|
|
39
|
+
//
|
|
40
|
+
// EXCEPT hosts whose one process serves many conversations. Codex Desktop
|
|
41
|
+
// (field incident, 2026-08-13): the ChatGPT.app runs ONE core process for ALL
|
|
42
|
+
// conversations, so ancestry alone collapsed to app-wide last-checkin-wins —
|
|
43
|
+
// three lane threads overwrote each other's identity (lane-2 checked in, two
|
|
44
|
+
// minutes later its watch announced lane-1). This is exactly what the env-id
|
|
45
|
+
// tier exists to prevent: every shell command under such a host is stamped
|
|
46
|
+
// with its own thread id, so the key is the thread, full stop — no ancestry
|
|
47
|
+
// walk, no pid, needed or wanted.
|
|
48
|
+
const THREAD_ENV_VARS = ["CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"];
|
|
49
|
+
// A durable env-keyed entry outlives the process that wrote it BY DESIGN (the
|
|
50
|
+
// whole point is to survive restarts) — age is the only prune signal for one,
|
|
51
|
+
// short of an explicit `codebus session forget`. 30 days is generous: a
|
|
52
|
+
// session that hasn't re-checked-in in a month is almost certainly gone for
|
|
53
|
+
// good, and the cost of keeping it a little longer is a few bytes of JSON.
|
|
54
|
+
const ENV_KEY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
55
|
+
const sessionsFile = () => join(homedir(), ".codebus", "session-roles.json");
|
|
56
|
+
// Plumbing processes between a codebus invocation and the session process.
|
|
57
|
+
const SKIP = new Set(["node", "sh", "zsh", "bash", "dash", "fish", "codebus", "codebus-mcp",
|
|
58
|
+
"python", "python3", "perl", "ruby"]); // script wrappers are plumbing too — observed: a key computed under python3 stopped at python3 itself
|
|
59
|
+
function psLine(pid) {
|
|
60
|
+
try {
|
|
61
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "ppid=,lstart=,comm="], {
|
|
62
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
63
|
+
}).trim();
|
|
64
|
+
if (!out)
|
|
65
|
+
return null;
|
|
66
|
+
// "PPID Wed Aug 12 22:39:04 2026 /path/to/comm" — lstart is 5 tokens.
|
|
67
|
+
const m = out.match(/^\s*(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*)$/);
|
|
68
|
+
if (!m)
|
|
69
|
+
return null;
|
|
70
|
+
return { ppid: Number(m[1]), start: m[2], comm: m[3] };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const basename = (p) => p.split("/").filter(Boolean).pop() ?? p;
|
|
77
|
+
/** A key is pid-keyed (mortal, ancestry-based) iff it contains "@" — the
|
|
78
|
+
* separator between pid and lstart. Env-keyed (durable) keys are the raw env
|
|
79
|
+
* value verbatim and never contain "@" in practice (both hosts mint UUIDs). */
|
|
80
|
+
const isPidKeyed = (key) => key.includes("@");
|
|
81
|
+
/**
|
|
82
|
+
* Split a pid-keyed key into its pid and lstart parts, tolerating every key
|
|
83
|
+
* shape this store has ever written (verified against git history —
|
|
84
|
+
* a5db2fc..7920c1f, pre-Phase-A):
|
|
85
|
+
*
|
|
86
|
+
* - "pid@lstart" — the CURRENT format (bare-terminal fallback,
|
|
87
|
+
* sessionKey() above), and also the ORIGINAL
|
|
88
|
+
* format from this store's very first version.
|
|
89
|
+
* - "pid@lstart#<thread-id>" — a since-removed intermediate format
|
|
90
|
+
* (8ae7258 "key sessions on CODEX_THREAD_ID",
|
|
91
|
+
* 7920c1f "generalize to Claude Code") that
|
|
92
|
+
* stamped a per-conversation thread id onto a
|
|
93
|
+
* pid-keyed entry with '#' as the separator.
|
|
94
|
+
* Phase A (51d2011) moved that discriminator
|
|
95
|
+
* onto its OWN durable, env-keyed tier
|
|
96
|
+
* instead (sessionKey() above again) and this
|
|
97
|
+
* suffix shape stopped being WRITTEN — but an
|
|
98
|
+
* entry written by an older CLI, still
|
|
99
|
+
* sitting in ~/.codebus/session-roles.json
|
|
100
|
+
* across an upgrade, keeps this shape until
|
|
101
|
+
* something prunes or overwrites it.
|
|
102
|
+
*
|
|
103
|
+
* A liveness check must parse ONLY the pid@lstart portion in every case:
|
|
104
|
+
* psLine().start is the raw `ps -o lstart=` output and never contains a
|
|
105
|
+
* '#...' suffix, so comparing an un-stripped tail (e.key.slice(at+1), which
|
|
106
|
+
* DOES include '#<thread-id>' for the older shape) against it can never
|
|
107
|
+
* match — every legacy '#'-suffixed entry would be judged dead on the very
|
|
108
|
+
* next write after an upgrade, dropping an already-checked-in session back
|
|
109
|
+
* to a cwd binding (or no identity at all). Returns null for anything that
|
|
110
|
+
* doesn't structurally look like one of the shapes above (a non-numeric
|
|
111
|
+
* pid) — pruneDead treats that the same as "process not found": prune,
|
|
112
|
+
* rather than guess at a shape that was never actually written.
|
|
113
|
+
*/
|
|
114
|
+
function parsePidKeyedKey(key) {
|
|
115
|
+
const at = key.indexOf("@");
|
|
116
|
+
if (at < 0)
|
|
117
|
+
return null;
|
|
118
|
+
const pid = Number(key.slice(0, at));
|
|
119
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
120
|
+
return null;
|
|
121
|
+
const hash = key.indexOf("#", at + 1);
|
|
122
|
+
const start = hash < 0 ? key.slice(at + 1) : key.slice(at + 1, hash);
|
|
123
|
+
if (!start)
|
|
124
|
+
return null;
|
|
125
|
+
return { pid, start };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The current session's key: a durable per-conversation env id ALONE when the
|
|
129
|
+
* host provides one, or "pid@lstart" of the nearest non-plumbing ancestor for
|
|
130
|
+
* bare terminals. Null when neither is available — callers degrade to the
|
|
131
|
+
* directory-binding tier, never fail.
|
|
132
|
+
*/
|
|
133
|
+
export function sessionKey() {
|
|
134
|
+
// Durable handle wins outright — never mixed with pid (precedent §2.4-1:
|
|
135
|
+
// key identity to a durable handle, never a process). First match wins;
|
|
136
|
+
// a host only ever sets one of these.
|
|
137
|
+
const thread = THREAD_ENV_VARS.map((v) => process.env[v]).find(Boolean);
|
|
138
|
+
if (thread)
|
|
139
|
+
return thread;
|
|
140
|
+
// Bare-terminal fallback: mortal, ancestry-based.
|
|
141
|
+
let pid = process.ppid;
|
|
142
|
+
for (let hops = 0; hops < 12 && pid > 1; hops++) {
|
|
143
|
+
const info = psLine(pid);
|
|
144
|
+
if (!info)
|
|
145
|
+
return null;
|
|
146
|
+
if (!SKIP.has(basename(info.comm)))
|
|
147
|
+
return `${pid}@${info.start}`;
|
|
148
|
+
pid = info.ppid;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
/** Structural validation — a store that parses as JSON but isn't shaped like
|
|
153
|
+
* one is just as untrustworthy as one that doesn't parse at all (a half
|
|
154
|
+
* written object, an array where entries should be, a stray null). */
|
|
155
|
+
function isValidStore(v) {
|
|
156
|
+
if (!v || typeof v !== "object" || !Array.isArray(v.entries))
|
|
157
|
+
return false;
|
|
158
|
+
return v.entries.every((e) => !!e && typeof e === "object" &&
|
|
159
|
+
typeof e.key === "string" &&
|
|
160
|
+
typeof e.role === "string" &&
|
|
161
|
+
typeof e.added_at === "string");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Load the session store, distinguishing "doesn't exist yet" (ENOENT — a
|
|
165
|
+
* brand-new machine, or every entry has already been pruned/forgotten; the
|
|
166
|
+
* legitimate empty case) from every other failure (permission denied, a disk
|
|
167
|
+
* I/O error, truncated/corrupt JSON, an unexpected shape). Only the first is
|
|
168
|
+
* safe to treat as an empty store: any of the others means this session's
|
|
169
|
+
* entry might well be sitting in there, mangled, and silently returning
|
|
170
|
+
* `{ entries: [] }` is exactly how a truncated write used to make a durable,
|
|
171
|
+
* checked-in session look like it was never checked in at all — resolution
|
|
172
|
+
* would then fall through to a cwd binding and hand this session a DIFFERENT
|
|
173
|
+
* agent's identity (the foreign-identity takeover the session tier exists to
|
|
174
|
+
* eliminate). So anything past ENOENT throws SessionStoreError and resolution
|
|
175
|
+
* fails closed instead of guessing.
|
|
176
|
+
*/
|
|
177
|
+
function loadSessions() {
|
|
178
|
+
let raw;
|
|
179
|
+
try {
|
|
180
|
+
raw = readFileSync(sessionsFile(), "utf8");
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
if (e.code === "ENOENT")
|
|
184
|
+
return { entries: [] };
|
|
185
|
+
throw new SessionStoreError(`could not read ${sessionsFile()}: ${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(raw);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
throw new SessionStoreError(`${sessionsFile()} is not valid JSON (${e.message}) — truncated or corrupt write?`);
|
|
193
|
+
}
|
|
194
|
+
if (!isValidStore(parsed)) {
|
|
195
|
+
throw new SessionStoreError(`${sessionsFile()} has an unexpected shape (missing/invalid 'entries' array)`);
|
|
196
|
+
}
|
|
197
|
+
return parsed;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Keep pid-keyed entries only while their process is still alive with the
|
|
201
|
+
* same start time (pruneDead's original contract). Env-keyed entries are
|
|
202
|
+
* durable BY DESIGN — a checkin's whole point is to survive the process that
|
|
203
|
+
* made it — so death is never a prune signal for them; they age out instead
|
|
204
|
+
* (ENV_KEY_MAX_AGE_MS since their last (re)checkin) or are removed explicitly
|
|
205
|
+
* via `codebus session forget`. Pruning an env-keyed entry on process death
|
|
206
|
+
* was the FM-1/root-cause-2.1 bug: a restarting Codex/Claude process (new pid,
|
|
207
|
+
* SAME durable thread id) would otherwise lose its binding on every restart
|
|
208
|
+
* and silently fall through to the directory tier.
|
|
209
|
+
*/
|
|
210
|
+
function pruneDead(store) {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
store.entries = store.entries.filter((e) => {
|
|
213
|
+
if (!isPidKeyed(e.key)) {
|
|
214
|
+
const age = now - new Date(e.added_at).getTime();
|
|
215
|
+
return !(age > ENV_KEY_MAX_AGE_MS);
|
|
216
|
+
}
|
|
217
|
+
// parsePidKeyedKey strips a legacy '#<thread-id>' suffix before comparing
|
|
218
|
+
// — see its doc comment for the upgrade-path bug this fixes (every
|
|
219
|
+
// pre-Phase-A '#'-suffixed entry used to be pruned as dead unconditionally
|
|
220
|
+
// on the first write after upgrading, regardless of whether its process
|
|
221
|
+
// was actually still alive).
|
|
222
|
+
const parsed = parsePidKeyedKey(e.key);
|
|
223
|
+
if (!parsed)
|
|
224
|
+
return false; // "@" present but not a shape this store ever wrote — can't verify liveness, so treat like a dead/invalid entry
|
|
225
|
+
const info = psLine(parsed.pid);
|
|
226
|
+
return !!info && info.start === parsed.start;
|
|
227
|
+
});
|
|
228
|
+
return store;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Atomic write: a sibling temp file + rename, never a direct write to the
|
|
232
|
+
* live path. A plain writeFileSync to session-roles.json is visible to
|
|
233
|
+
* concurrent readers/writers mid-write — exactly how a reader can observe a
|
|
234
|
+
* truncated/partial file (loadSessions above then has to fail closed on it).
|
|
235
|
+
* rename(2) on the same filesystem is atomic, so every observer sees either
|
|
236
|
+
* the complete old file or the complete new one, never a partial one.
|
|
237
|
+
*/
|
|
238
|
+
function saveSessions(store) {
|
|
239
|
+
const dir = join(homedir(), ".codebus");
|
|
240
|
+
mkdirSync(dir, { recursive: true });
|
|
241
|
+
const file = sessionsFile();
|
|
242
|
+
const tmp = join(dir, `.session-roles.json.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
243
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
244
|
+
renameSync(tmp, file);
|
|
245
|
+
}
|
|
246
|
+
// ── interprocess lock ────────────────────────────────────────────────────────
|
|
247
|
+
// recordSessionRole/forgetSession are read-modify-write: load the whole store,
|
|
248
|
+
// mutate it, write the whole store back. Two such cycles racing (reproduced:
|
|
249
|
+
// 40 concurrent `recordSessionRole` calls survived only 10 entries) each read
|
|
250
|
+
// the same pre-write snapshot and the later save clobbers the earlier one's
|
|
251
|
+
// addition outright — atomic renames make individual writes non-corrupting,
|
|
252
|
+
// but they do nothing for a lost update between two overlapping read-then-write
|
|
253
|
+
// cycles. mkdir is the lock primitive: it's atomic and exclusive across
|
|
254
|
+
// processes on every platform Node runs on (POSIX mkdir(2) fails EEXIST if the
|
|
255
|
+
// directory exists; no separate lockfile library needed for that guarantee).
|
|
256
|
+
//
|
|
257
|
+
// Round-2 review: a stale-lock steal must never let two processes believe
|
|
258
|
+
// they hold the lock at once, and a release must never tear down a lock it
|
|
259
|
+
// no longer owns. The original steal/release both acted on the SAME fixed
|
|
260
|
+
// path (`dir`) independently via a bare, unconditional rmSync — a
|
|
261
|
+
// live-but-slow holder's lock mtime is never refreshed (a periodic heartbeat
|
|
262
|
+
// was ruled out as fragile), so it can look stale and get stolen while
|
|
263
|
+
// genuinely still held; when that original holder FINALLY finishes and calls
|
|
264
|
+
// its own release, an unconditional rmSync(dir) deletes whatever is AT `dir`
|
|
265
|
+
// by then — which by that point is the thief's LIVE lock, not the original
|
|
266
|
+
// holder's dead one — letting a third writer sneak in while the thief still
|
|
267
|
+
// believes it holds the lock.
|
|
268
|
+
//
|
|
269
|
+
// Fix has two layers:
|
|
270
|
+
//
|
|
271
|
+
// 1. Every acquire writes an owner token (pid + timestamp + nonce) INSIDE
|
|
272
|
+
// the lock dir. release() captures its own token in the closure and only
|
|
273
|
+
// ever tries to act on a generation it can verify is still its own.
|
|
274
|
+
//
|
|
275
|
+
// 2. Eviction — by release OR by a steal — never does a bare rmSync(dir)
|
|
276
|
+
// against the shared path. It goes through evictLockDir(): rename `dir`
|
|
277
|
+
// to a private, uniquely-named tomb FIRST. rename(2) atomically removes
|
|
278
|
+
// the source path the instant it succeeds, so of any number of
|
|
279
|
+
// concurrent callers racing to rename away the SAME still-existing
|
|
280
|
+
// `dir`, AT MOST ONE can ever win; everyone else gets ENOENT and does
|
|
281
|
+
// nothing further (no removal, no retry within that call). This is what
|
|
282
|
+
// makes two stealers racing the same stale lock race-free.
|
|
283
|
+
//
|
|
284
|
+
// A release additionally verifies, AFTER winning that race (i.e. once it
|
|
285
|
+
// safely and exclusively holds whatever was at `dir`, in its own private
|
|
286
|
+
// tomb that nobody else can see or touch), that the evicted generation's
|
|
287
|
+
// own owner token still matches its caller's. If it does, this really
|
|
288
|
+
// was the caller's own lock — discard the tomb, done. If it does NOT —
|
|
289
|
+
// meaning release's rename raced ahead of a legitimate steal/re-mkdir and
|
|
290
|
+
// scooped up a DIFFERENT, newer generation by accident — release does
|
|
291
|
+
// NOT discard it. It puts it back (renames the tomb back to `dir`) so
|
|
292
|
+
// the rightful holder's lock is undisturbed, rather than silently
|
|
293
|
+
// destroying a live owner's lock the way the original bug did.
|
|
294
|
+
//
|
|
295
|
+
// Residual, explicitly accepted window: restoring can itself only fail
|
|
296
|
+
// if a THIRD party recreates `dir` in the sub-microsecond gap between
|
|
297
|
+
// release's rename-away and its rename-back — vanishingly narrow (no
|
|
298
|
+
// intervening I/O or event-loop tick separates the two calls). Even
|
|
299
|
+
// then, the invariant that MUST hold still holds: at most one process
|
|
300
|
+
// ever has `dir` mkdir'd at a time (rename's atomicity alone guarantees
|
|
301
|
+
// that, independent of the token check), and no release ever corrupts a
|
|
302
|
+
// DIFFERENT, currently-valid claimant's lock — in that doubly-narrow
|
|
303
|
+
// case the wrongly-evicted generation is simply dropped (not restored),
|
|
304
|
+
// never double-held, and the fail-closed session-store validation (round-
|
|
305
|
+
// 2 finding #1's sibling, round-1 finding #1) means a resulting
|
|
306
|
+
// unreadable/half-written session-roles.json is caught on next read
|
|
307
|
+
// rather than silently misresolved.
|
|
308
|
+
//
|
|
309
|
+
// Round-2 CI failure, second pass: the two layers above make eviction SAFE
|
|
310
|
+
// (never corrupt a different live owner's lock) but don't stop a genuinely
|
|
311
|
+
// LIVE holder's lock from *looking* stale in the first place — the owner
|
|
312
|
+
// token's mtime is written once, at acquire, and never touched again while
|
|
313
|
+
// held. A critical section that legitimately runs longer than LOCK_STALE_MS
|
|
314
|
+
// (observed on a slower CI runner: a deliberately-long-held test lock aged
|
|
315
|
+
// past 10s while still genuinely live) ages exactly like a crashed one would,
|
|
316
|
+
// and a normally-polling second acquirer correctly-by-the-old-rules steals
|
|
317
|
+
// it — this is the reviewer's original "ownership/HEARTBEAT validation"
|
|
318
|
+
// finding; only the ownership half had landed. Fixed with a heartbeat: while
|
|
319
|
+
// a lock is held, a small worker_threads.Worker — on its OWN OS thread,
|
|
320
|
+
// independent of whatever the holder's main thread is doing — re-touches the
|
|
321
|
+
// owner file's mtime every HEARTBEAT_INTERVAL_MS via utimesSync, and
|
|
322
|
+
// staleness is judged against THAT mtime (not the lock dir's own, which only
|
|
323
|
+
// ever changes at creation). A worker thread, not a plain setInterval, is
|
|
324
|
+
// required here on purpose: recordSessionRole/forgetSession's critical
|
|
325
|
+
// section is synchronous by design (sleepSync above included — see its own
|
|
326
|
+
// comment), and a synchronous, non-yielding critical section blocks the
|
|
327
|
+
// entire JS main thread including any of its own timers; a heartbeat that
|
|
328
|
+
// has to keep ticking THROUGH such a critical section cannot live on that
|
|
329
|
+
// same thread. A genuinely crashed holder's heartbeat thread dies with it —
|
|
330
|
+
// nothing keeps refreshing the mtime — so LOCK_STALE_MS since the LAST
|
|
331
|
+
// heartbeat tick (not since acquire) is still exactly the right reap signal
|
|
332
|
+
// for a truly dead holder, while a live one, however long its critical
|
|
333
|
+
// section runs, is never mistaken for one.
|
|
334
|
+
//
|
|
335
|
+
// Round-4 ai-review, the theoretical tail: even with a heartbeat, staleness
|
|
336
|
+
// is fundamentally a HEURISTIC — this is a standard, unavoidable fact of
|
|
337
|
+
// distributed/lease-based locking, not a gap specific to this design. No
|
|
338
|
+
// scheme built on "no liveness signal in N seconds ⇒ presumed dead" can ever
|
|
339
|
+
// fully distinguish a crashed process from one merely stalled longer than N
|
|
340
|
+
// (OS scheduling, CPU starvation, filesystem latency, or — here — the
|
|
341
|
+
// heartbeat WORKER dying without the main thread dying too). So a genuinely
|
|
342
|
+
// live holder whose heartbeat stalls past LOCK_STALE_MS CAN be evicted while
|
|
343
|
+
// its critical section keeps running, unaware, concurrently with the new
|
|
344
|
+
// owner's. Two things follow from accepting that honestly instead of
|
|
345
|
+
// pretending a wider window or a fancier stale-check closes it:
|
|
346
|
+
//
|
|
347
|
+
// 1. BOUND it as tightly as practical: LOCK_STALE_MS below is generous
|
|
348
|
+
// (15 missed heartbeats) — a process stalled that long while genuinely
|
|
349
|
+
// holding this lock is overwhelmingly either dead or the machine is
|
|
350
|
+
// unusable for any other purpose either, evicted or not.
|
|
351
|
+
//
|
|
352
|
+
// 2. Make the ACTUAL WRITE safe regardless: recordSessionRole/forgetSession
|
|
353
|
+
// re-verify (verifyStillOwned, used via the `verifyOwned` callback
|
|
354
|
+
// withSessionLock hands to its critical section) that the lock is STILL
|
|
355
|
+
// theirs immediately before the atomic commit (saveSessions' temp+rename)
|
|
356
|
+
// that follows. An evicted-while-stalled holder that reaches this point
|
|
357
|
+
// gets SessionStoreError instead of a commit — its caller's ordinary
|
|
358
|
+
// error path handles it (the write simply didn't happen; retry the
|
|
359
|
+
// operation). This makes a lost update — the new owner's write being
|
|
360
|
+
// silently clobbered by a stale holder — IMPOSSIBLE: the stale holder
|
|
361
|
+
// either aborts before committing, or (see below) there was no window
|
|
362
|
+
// left in which it could have committed instead.
|
|
363
|
+
//
|
|
364
|
+
// The ONLY window this cannot close is between THIS check and the commit's
|
|
365
|
+
// own renameSync: microseconds, with no I/O or event-loop yield point for
|
|
366
|
+
// anything else to interleave through in between (both are synchronous,
|
|
367
|
+
// back-to-back calls in the same critical section). Closing even that would
|
|
368
|
+
// need a filesystem-level compare-and-swap this design doesn't have — this
|
|
369
|
+
// is the practical limit for a file-based lock; a further reviewer flagging
|
|
370
|
+
// that microsecond gap is an accept-with-documented-bound, not a rework.
|
|
371
|
+
const LOCK_STALE_MS = 30_000; // 15 missed 2s heartbeats ⇒ presumed crashed; stolen after this
|
|
372
|
+
const LOCK_STEAL_GRACE_MS = 250; // re-verify staleness once more before committing to a steal — narrows, but per the above cannot fully close, the false-positive-steal window (a steal landing in the instant between two heartbeat ticks)
|
|
373
|
+
const LOCK_POLL_MS = 15;
|
|
374
|
+
// Must comfortably exceed LOCK_STALE_MS: a waiter has to be willing to sit
|
|
375
|
+
// through one full staleness-detection cycle (so a crashed holder's lock
|
|
376
|
+
// actually gets reaped and handed to it, rather than giving up right before
|
|
377
|
+
// that would happen) AND tolerate a live holder whose legitimate critical
|
|
378
|
+
// section runs a while past LOCK_STALE_MS too (the whole point of the
|
|
379
|
+
// heartbeat above — the CI failure this fixes was exactly a well-behaved
|
|
380
|
+
// waiter's own patience mattering, not just the held lock's judged staleness).
|
|
381
|
+
const LOCK_TIMEOUT_MS = 45_000;
|
|
382
|
+
const HEARTBEAT_INTERVAL_MS = 2_000; // well under LOCK_STALE_MS, so a live holder's marker never has a chance to look stale
|
|
383
|
+
function lockDir() { return `${sessionsFile()}.lock`; }
|
|
384
|
+
function ownerFile(dir) { return join(dir, "owner"); }
|
|
385
|
+
/**
|
|
386
|
+
* Start a heartbeat for a held lock: a worker thread that re-touches the
|
|
387
|
+
* owner file's mtime every HEARTBEAT_INTERVAL_MS for as long as it's kept
|
|
388
|
+
* alive, independent of whatever the holder's own (possibly long-blocking,
|
|
389
|
+
* synchronous) critical section is doing on the main thread — see the
|
|
390
|
+
* section comment above for why this can't be a plain main-thread timer.
|
|
391
|
+
* `.unref()`d so an unterminated heartbeat (a bug, or a process that exits
|
|
392
|
+
* without going through release — should never happen given withSessionLock's
|
|
393
|
+
* try/finally) can never keep the process alive on its own. Errors inside the
|
|
394
|
+
* worker are swallowed: a heartbeat failing is a liveness-signal problem for
|
|
395
|
+
* OTHER processes to (rarely, harmlessly) misjudge as staleness, never a
|
|
396
|
+
* reason to crash the holder that's actually doing useful work.
|
|
397
|
+
*
|
|
398
|
+
* Round-5 ai-review (zombie heartbeat): stopHeartbeat() below fires
|
|
399
|
+
* worker.terminate() but does NOT (cannot — the store API is deliberately
|
|
400
|
+
* synchronous, so release() can't await it) wait for it to land before
|
|
401
|
+
* evictLockDir runs. In the gap between "terminate() requested" and "the
|
|
402
|
+
* worker thread actually stops", the worker can wake for one more tick and
|
|
403
|
+
* utimesSync the owner path — which by then may already belong to a NEW
|
|
404
|
+
* lock generation (this hold evicted-while-stalled, exactly the round-4
|
|
405
|
+
* scenario, and a fresh acquirer already recreated `dir` with its own
|
|
406
|
+
* token). That refresh would keep a lock the old process no longer owns
|
|
407
|
+
* looking alive, blocking anyone from ever reaping it — a zombie heartbeat,
|
|
408
|
+
* narrow but real (worst case: the new owner later crashes, and the dead
|
|
409
|
+
* lock is kept "alive" by the zombie until the OLD process eventually
|
|
410
|
+
* exits and its worker gets torn down with it).
|
|
411
|
+
*
|
|
412
|
+
* Fixed with generation-awareness instead of trying to make release()
|
|
413
|
+
* async: before EVERY touch, the worker reads the owner file's CURRENT
|
|
414
|
+
* content and compares it to the token it was started with. Touch only on
|
|
415
|
+
* an exact match; on a mismatch (a different generation now owns this path)
|
|
416
|
+
* or ENOENT (evicted and the file is gone entirely, or tombed) the worker
|
|
417
|
+
* exits itself — a stale heartbeat is a contradiction in terms once it can
|
|
418
|
+
* see it no longer owns what it's refreshing. stopHeartbeat's terminate()
|
|
419
|
+
* stays as belt-and-braces (the common, fast path: most releases happen
|
|
420
|
+
* long before the worker's next scheduled tick anyway).
|
|
421
|
+
*/
|
|
422
|
+
function startHeartbeat(dir, token) {
|
|
423
|
+
const worker = new Worker(`
|
|
424
|
+
const { workerData } = require("node:worker_threads");
|
|
425
|
+
const { utimesSync, readFileSync } = require("node:fs");
|
|
426
|
+
const sab = new SharedArrayBuffer(4);
|
|
427
|
+
const ia = new Int32Array(sab);
|
|
428
|
+
for (;;) {
|
|
429
|
+
Atomics.wait(ia, 0, 0, workerData.intervalMs);
|
|
430
|
+
let current;
|
|
431
|
+
try {
|
|
432
|
+
current = readFileSync(workerData.file, "utf8");
|
|
433
|
+
} catch {
|
|
434
|
+
break; // owner file gone entirely — can't confirm we still own it, so stop rather than guess
|
|
435
|
+
}
|
|
436
|
+
if (current !== workerData.token) break; // a DIFFERENT generation owns this path now — never touch a lock we don't own
|
|
437
|
+
try {
|
|
438
|
+
const now = new Date();
|
|
439
|
+
utimesSync(workerData.file, now, now);
|
|
440
|
+
} catch {
|
|
441
|
+
/* vanished between our read and this write (a steal landed in that
|
|
442
|
+
* gap) — next iteration's read sees the mismatch/ENOENT and stops;
|
|
443
|
+
* nothing to do for THIS tick. */
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
`, { eval: true, workerData: { file: ownerFile(dir), intervalMs: HEARTBEAT_INTERVAL_MS, token } });
|
|
447
|
+
worker.unref();
|
|
448
|
+
worker.on("error", () => { });
|
|
449
|
+
return worker;
|
|
450
|
+
}
|
|
451
|
+
/** Stop a heartbeat — best-effort, fire-and-forget (release() is synchronous
|
|
452
|
+
* and must not block waiting for worker teardown to be confirmed). Belt-and-
|
|
453
|
+
* braces alongside the worker's own generation check above (startHeartbeat's
|
|
454
|
+
* doc comment) — this is the fast, common-case path; that's the correctness
|
|
455
|
+
* backstop for the gap this alone can't close. */
|
|
456
|
+
function stopHeartbeat(worker) {
|
|
457
|
+
worker.terminate().catch(() => { });
|
|
458
|
+
}
|
|
459
|
+
/** Synchronous sleep — the lock spin has to block (no callers here are async;
|
|
460
|
+
* recordSessionRole/forgetSession are synchronous, deliberately, so a caller
|
|
461
|
+
* never has to remember to await an identity-binding call). Atomics.wait on a
|
|
462
|
+
* throwaway SharedArrayBuffer is Node's documented way to block the main
|
|
463
|
+
* thread for a bounded time without a busy CPU spin. */
|
|
464
|
+
function sleepSync(ms) {
|
|
465
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Write this acquisition's owner token inside `dir` and return it — load-
|
|
469
|
+
* bearing now (not just diagnostic): release() uses it to verify it's still
|
|
470
|
+
* acting on its OWN generation before discarding anything (see the section
|
|
471
|
+
* comment above). A write failure here means the filesystem can't even be
|
|
472
|
+
* written to right after we just mkdir'd it — fail closed rather than hand
|
|
473
|
+
* back a lock whose ownership can never be verified later.
|
|
474
|
+
*/
|
|
475
|
+
function writeOwnerToken(dir) {
|
|
476
|
+
const token = `${process.pid}@${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
477
|
+
try {
|
|
478
|
+
writeFileSync(ownerFile(dir), token);
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
throw new SessionStoreError(`could not write lock owner token in ${dir}: ${e.message}`);
|
|
482
|
+
}
|
|
483
|
+
return token;
|
|
484
|
+
}
|
|
485
|
+
function readOwnerToken(dir) {
|
|
486
|
+
try {
|
|
487
|
+
return readFileSync(ownerFile(dir), "utf8");
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Pre-commit ownership re-check (round-4 ai-review — see the section comment
|
|
495
|
+
* above for the full reasoning). Called immediately before the atomic commit
|
|
496
|
+
* that follows the critical section's mutation: if the lock's CURRENT owner
|
|
497
|
+
* token no longer matches the one captured at acquire (`token`), this holder
|
|
498
|
+
* was evicted as stale while it was still — unknown to it — running, and
|
|
499
|
+
* MUST NOT proceed to write. Throws SessionStoreError instead, which the
|
|
500
|
+
* caller's ordinary error path handles (the write simply never happened;
|
|
501
|
+
* retry the operation). A missing owner file (evicted AND the new owner has
|
|
502
|
+
* already released too) also counts as "not ours anymore" — `null` can never
|
|
503
|
+
* equal `token`.
|
|
504
|
+
*/
|
|
505
|
+
function verifyStillOwned(dir, token) {
|
|
506
|
+
if (readOwnerToken(dir) === token)
|
|
507
|
+
return;
|
|
508
|
+
throw new SessionStoreError(`session-store lock ownership lost mid-write (${dir}) — this hold was evicted as stale while still running ` +
|
|
509
|
+
`(a genuinely dead process is indistinguishable from one merely stalled past ${LOCK_STALE_MS}ms; see the lock ` +
|
|
510
|
+
`section in sessionstore.ts). Aborting before committing, to avoid clobbering the new owner's write — retry.`);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Age of the lock, in ms, judged by the owner file's mtime — the heartbeat's
|
|
514
|
+
* target, kept fresh for as long as the holder is genuinely alive (see the
|
|
515
|
+
* section comment above). Falls back to the lock DIR's own mtime only if the
|
|
516
|
+
* owner file can't be read (a sub-millisecond window right after mkdirSync,
|
|
517
|
+
* before writeOwnerToken has run, or the file vanished mid-check) — never
|
|
518
|
+
* treated as an error, just a less-precise age estimate for that instant.
|
|
519
|
+
* Null only if BOTH are unreadable (the dir itself is already gone).
|
|
520
|
+
*/
|
|
521
|
+
function lockAgeMs(dir) {
|
|
522
|
+
try {
|
|
523
|
+
return Date.now() - statSync(ownerFile(dir)).mtimeMs;
|
|
524
|
+
}
|
|
525
|
+
catch { /* fall through */ }
|
|
526
|
+
try {
|
|
527
|
+
return Date.now() - statSync(dir).mtimeMs;
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Atomically evict whatever currently lives at `dir` — the ONE primitive
|
|
535
|
+
* used by both a normal release and a steal-a-stale-lock attempt (see the
|
|
536
|
+
* section comment above for why they must share it and how the safety
|
|
537
|
+
* invariant follows from that).
|
|
538
|
+
*
|
|
539
|
+
* - `expectedToken` omitted (the STEAL path): the caller already re-verified
|
|
540
|
+
* staleness immediately before calling this, so once the rename wins,
|
|
541
|
+
* discard unconditionally — a steal doesn't care whose lock it was.
|
|
542
|
+
* - `expectedToken` given (the RELEASE path): only discard if the evicted
|
|
543
|
+
* generation's OWN owner token matches. A mismatch means this call's
|
|
544
|
+
* rename won against a DIFFERENT, newer generation that happened to
|
|
545
|
+
* occupy the same path — put it back (rename the tomb back to `dir`)
|
|
546
|
+
* instead of destroying a live owner's lock. See the residual-window note
|
|
547
|
+
* in the section comment above for the one case restoring can itself fail.
|
|
548
|
+
*
|
|
549
|
+
* Returns true iff this call's rename won the eviction race (regardless of
|
|
550
|
+
* whether the content was ultimately discarded or restored).
|
|
551
|
+
*/
|
|
552
|
+
function evictLockDir(dir, expectedToken) {
|
|
553
|
+
const tomb = `${dir}.evicted-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
554
|
+
try {
|
|
555
|
+
renameSync(dir, tomb);
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return false; // lost the race — some other release/steal already evicted this generation
|
|
559
|
+
}
|
|
560
|
+
if (expectedToken !== undefined && readOwnerToken(tomb) !== expectedToken) {
|
|
561
|
+
// We evicted a DIFFERENT, presumably-live generation by accident — try to
|
|
562
|
+
// put it back rather than discard someone else's live lock.
|
|
563
|
+
try {
|
|
564
|
+
renameSync(tomb, dir); // fails only if a third party already recreated `dir` in this sub-microsecond gap (see residual-window note above)
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* doubly-narrow race: couldn't restore — fall through and drop it.
|
|
569
|
+
* `dir` currently belongs solely to that third party, untouched by us;
|
|
570
|
+
* the invariant (never two simultaneous holders) still holds. */
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
575
|
+
}
|
|
576
|
+
catch { /* best-effort cleanup of our own, now-private tomb */ }
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
/** Acquire the session-store lock, spinning past concurrent holders and
|
|
580
|
+
* stealing a stale one, or throwing SessionStoreError if it can't be had
|
|
581
|
+
* within LOCK_TIMEOUT_MS (a stuck/crashed writer that leaves a lock younger
|
|
582
|
+
* than the stale threshold — vanishingly unlikely, but must fail loud, never
|
|
583
|
+
* hang forever or silently proceed unlocked). Returns the acquired lock's
|
|
584
|
+
* `dir`/`token` (so the caller can pre-commit-verify with them — see
|
|
585
|
+
* verifyStillOwned/withSessionLock below) alongside the release function. */
|
|
586
|
+
function acquireSessionLock() {
|
|
587
|
+
mkdirSync(join(homedir(), ".codebus"), { recursive: true });
|
|
588
|
+
const dir = lockDir();
|
|
589
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
590
|
+
for (;;) {
|
|
591
|
+
try {
|
|
592
|
+
mkdirSync(dir); // no {recursive:true} — MUST fail EEXIST when already held
|
|
593
|
+
const token = writeOwnerToken(dir);
|
|
594
|
+
const heartbeat = startHeartbeat(dir, token);
|
|
595
|
+
// Release stops the heartbeat FIRST, then verifies ownership (via
|
|
596
|
+
// `token`) before discarding anything — see evictLockDir's doc comment.
|
|
597
|
+
return { dir, token, release: () => { stopHeartbeat(heartbeat); evictLockDir(dir, token); } };
|
|
598
|
+
}
|
|
599
|
+
catch (e) {
|
|
600
|
+
if (e instanceof SessionStoreError)
|
|
601
|
+
throw e; // writeOwnerToken's own failure — fail closed, not a retry-able EEXIST
|
|
602
|
+
if (e.code !== "EEXIST") {
|
|
603
|
+
throw new SessionStoreError(`could not acquire lock dir ${dir}: ${e.message}`);
|
|
604
|
+
}
|
|
605
|
+
const age = lockAgeMs(dir); // judged by the owner file's mtime — a live holder's HEARTBEAT keeps this fresh regardless of critical-section length
|
|
606
|
+
if (age !== null && age > LOCK_STALE_MS) {
|
|
607
|
+
// Re-verify once more after a short grace before committing to a
|
|
608
|
+
// steal attempt — narrows, but (see the section comment above)
|
|
609
|
+
// cannot fully close, the false-positive window (a steal landing in
|
|
610
|
+
// the instant between two heartbeat ticks).
|
|
611
|
+
sleepSync(LOCK_STEAL_GRACE_MS);
|
|
612
|
+
const ageAfterGrace = lockAgeMs(dir);
|
|
613
|
+
if (ageAfterGrace !== null && ageAfterGrace > LOCK_STALE_MS && evictLockDir(dir)) {
|
|
614
|
+
continue; // we won the eviction race — loop back and try mkdirSync(dir) fresh
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (Date.now() > deadline) {
|
|
618
|
+
const owner = readOwnerToken(dir);
|
|
619
|
+
throw new SessionStoreError(`timed out after ${LOCK_TIMEOUT_MS}ms waiting for session-store lock (${dir})` +
|
|
620
|
+
`${owner ? ` — held by ${owner}` : ""} — a stuck writer?`);
|
|
621
|
+
}
|
|
622
|
+
sleepSync(LOCK_POLL_MS);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Run `fn` holding the session-store lock. `fn` receives `verifyOwned` — call
|
|
628
|
+
* it immediately before the critical section's own commit (see
|
|
629
|
+
* verifyStillOwned's doc comment and the round-4 section comment above): it
|
|
630
|
+
* throws SessionStoreError if this hold was evicted as stale while still
|
|
631
|
+
* running, which must abort the write rather than let it proceed.
|
|
632
|
+
* recordSessionRole/forgetSession below both call it this way.
|
|
633
|
+
*
|
|
634
|
+
* Exported for the regression suite too (round-2's steal/release race and
|
|
635
|
+
* round-4's evicted-while-stalled-commit race both need to drive the raw
|
|
636
|
+
* lock from real, separate OS processes with an artificially slow critical
|
|
637
|
+
* section — recordSessionRole's own critical section is too fast to reliably
|
|
638
|
+
* manufacture either race).
|
|
639
|
+
*/
|
|
640
|
+
export function withSessionLock(fn) {
|
|
641
|
+
const { dir, token, release } = acquireSessionLock();
|
|
642
|
+
try {
|
|
643
|
+
return fn(() => verifyStillOwned(dir, token));
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
release();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Bind THIS session (the process tree the caller runs inside, or its durable
|
|
651
|
+
* env id) to a role. Returns the session key, or null if neither an env id
|
|
652
|
+
* nor a readable ancestry was available (caller should say so — silent
|
|
653
|
+
* identity failure is the enemy). The whole read-modify-write cycle runs
|
|
654
|
+
* under the session-store lock so concurrent recordSessionRole calls (from
|
|
655
|
+
* different sessions, different processes) serialize instead of racing.
|
|
656
|
+
* `verifyOwned()` runs immediately before the commit (round-4 ai-review):
|
|
657
|
+
* see withSessionLock's doc comment — an evicted-while-stalled hold aborts
|
|
658
|
+
* here with SessionStoreError instead of writing.
|
|
659
|
+
*/
|
|
660
|
+
export function recordSessionRole(role) {
|
|
661
|
+
const key = sessionKey();
|
|
662
|
+
if (!key)
|
|
663
|
+
return null;
|
|
664
|
+
return withSessionLock((verifyOwned) => {
|
|
665
|
+
const store = pruneDead(loadSessions());
|
|
666
|
+
store.entries = store.entries.filter((e) => e.key !== key);
|
|
667
|
+
store.entries.push({ key, role, added_at: new Date().toISOString() });
|
|
668
|
+
verifyOwned();
|
|
669
|
+
saveSessions(store);
|
|
670
|
+
return key;
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Explicit forget for THIS session's entry — the escape hatch age-pruning
|
|
675
|
+
* exists alongside for durable env-keyed entries (they otherwise outlive
|
|
676
|
+
* their process by design and are never reaped by death). Returns the
|
|
677
|
+
* forgotten role, or null if this session had no entry to forget. Locked for
|
|
678
|
+
* the same reason as recordSessionRole above — it's the same read-modify-write
|
|
679
|
+
* shape over the same shared file, including the pre-commit `verifyOwned()`.
|
|
680
|
+
*/
|
|
681
|
+
export function forgetSession() {
|
|
682
|
+
const key = sessionKey();
|
|
683
|
+
if (!key)
|
|
684
|
+
return null;
|
|
685
|
+
return withSessionLock((verifyOwned) => {
|
|
686
|
+
const store = pruneDead(loadSessions());
|
|
687
|
+
const hit = store.entries.find((e) => e.key === key);
|
|
688
|
+
if (!hit)
|
|
689
|
+
return null;
|
|
690
|
+
store.entries = store.entries.filter((e) => e.key !== key);
|
|
691
|
+
verifyOwned();
|
|
692
|
+
saveSessions(store);
|
|
693
|
+
return hit.role;
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
/** True for an env-keyed (durable) entry old enough that the 30-day age
|
|
697
|
+
* policy says it's gone — the same test pruneDead applies at write time,
|
|
698
|
+
* applied here too so resolution enforces it on every read, not only when
|
|
699
|
+
* some other call happens to trigger a prune-and-save. Pid-keyed entries are
|
|
700
|
+
* never subject to this: their prune signal is process liveness, unrelated to
|
|
701
|
+
* age (pruneDead's own contract, unchanged here). */
|
|
702
|
+
function isExpiredEnvEntry(e) {
|
|
703
|
+
if (isPidKeyed(e.key))
|
|
704
|
+
return false;
|
|
705
|
+
return Date.now() - new Date(e.added_at).getTime() > ENV_KEY_MAX_AGE_MS;
|
|
706
|
+
}
|
|
707
|
+
export function resolveSession() {
|
|
708
|
+
const key = sessionKey();
|
|
709
|
+
if (!key)
|
|
710
|
+
return { status: "none" };
|
|
711
|
+
const hit = loadSessions().entries.find((e) => e.key === key);
|
|
712
|
+
if (!hit)
|
|
713
|
+
return { status: "none" };
|
|
714
|
+
// Expired-by-age is resolved as "dead", never "none": this session WAS
|
|
715
|
+
// checked in (an entry exists), so resolution must stop here loudly rather
|
|
716
|
+
// than silently falling through to a cwd binding (§2.4 precedent 3).
|
|
717
|
+
if (isExpiredEnvEntry(hit))
|
|
718
|
+
return { status: "dead", role: hit.role };
|
|
719
|
+
const entry = loadRoles().roles[hit.role];
|
|
720
|
+
if (!entry || (!entry.token && !entry.assume))
|
|
721
|
+
return { status: "dead", role: hit.role };
|
|
722
|
+
return { status: "ok", role: hit.role, entry };
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* The identity THIS session was checked into, resolved against the role
|
|
726
|
+
* store, or null (covers BOTH "never checked in" and "checked in but dead" —
|
|
727
|
+
* callers that must tell those apart use resolveSession() instead). Sits
|
|
728
|
+
* between env and the directory binding in resolveConfig's precedence: a
|
|
729
|
+
* session's own checkin beats the dir default, which is what lets N agents
|
|
730
|
+
* share one repository.
|
|
731
|
+
*/
|
|
732
|
+
export function sessionIdentity() {
|
|
733
|
+
const r = resolveSession();
|
|
734
|
+
return r.status === "ok" ? { role: r.role, entry: r.entry } : null;
|
|
735
|
+
}
|
|
736
|
+
//# sourceMappingURL=sessionstore.js.map
|