@promptctl/cc-candybar 1.17.2 → 1.17.4
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/dist/index.mjs +78 -78
- package/package.json +5 -5
- package/src/daemon/acquire.ts +150 -16
- package/src/daemon/paths.ts +11 -0
- package/src/daemon/process-fingerprint.ts +135 -0
- package/src/daemon/server.ts +42 -21
- package/src/daemon/socket-lease.ts +64 -36
- package/src/daemon/socket-ownership.ts +69 -25
- package/src/proc/launch.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.4",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.17.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.17.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.17.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.17.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.17.4",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.17.4",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.17.4",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.17.4"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { launchDetachedSync } from "../proc/launch";
|
|
4
5
|
import process from "node:process";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
socketPath,
|
|
8
|
+
spawnLockPath,
|
|
9
|
+
spawnCooldownPath,
|
|
10
|
+
daemonDir,
|
|
11
|
+
} from "./paths";
|
|
6
12
|
|
|
7
13
|
// [LAW:single-enforcer] One primitive per runtime that owns the entire
|
|
8
14
|
// "obtain a daemon" verb. Every spawn site in the Node runtime now flows
|
|
@@ -133,6 +139,28 @@ async function spawnAndWaitForReady(
|
|
|
133
139
|
outerDeadline: number,
|
|
134
140
|
reasonSuffix: string,
|
|
135
141
|
): Promise<ObtainResult> {
|
|
142
|
+
const readyDeadline = Math.min(
|
|
143
|
+
Date.now() + spawnReadyTimeoutMs,
|
|
144
|
+
outerDeadline,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// [LAW:dataflow-not-control-flow] The spawn-rate bound is consulted as data,
|
|
148
|
+
// not a mode. If a spawn was attempted within SPAWN_COOLDOWN_MS, one is
|
|
149
|
+
// already in flight — do NOT add another Node process; wait for the in-flight
|
|
150
|
+
// boot. This keeps obtainDaemon under the same global rate cap as the kick
|
|
151
|
+
// path, so the rate bound holds for EVERY spawn site [LAW:one-source-of-truth].
|
|
152
|
+
if (!claimSpawnCooldown()) {
|
|
153
|
+
// [LAW:no-silent-failure] This failure's cause is the cooldown gate, not the
|
|
154
|
+
// lock path we arrived through — so it does NOT inherit reasonSuffix (which
|
|
155
|
+
// tags lock-held vs lock-fallback *spawn* provenance). We never spawned here.
|
|
156
|
+
return (await pollUntilReady(connectTimeoutMs, readyDeadline))
|
|
157
|
+
? { kind: "attached" }
|
|
158
|
+
: {
|
|
159
|
+
kind: "failed",
|
|
160
|
+
reason: "spawn on cooldown; no daemon became ready during the wait",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
// [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>.
|
|
137
165
|
// A synchronous throw from child_process.spawn (ENOENT, invalid options)
|
|
138
166
|
// must become a typed failure, not a rejected promise.
|
|
@@ -151,20 +179,27 @@ async function spawnAndWaitForReady(
|
|
|
151
179
|
reason: `spawn returned false${reasonSuffix}`,
|
|
152
180
|
};
|
|
153
181
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
return (await pollUntilReady(connectTimeoutMs, readyDeadline))
|
|
183
|
+
? { kind: "started" }
|
|
184
|
+
: {
|
|
185
|
+
kind: "failed",
|
|
186
|
+
reason: `daemon did not bind in time${reasonSuffix}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Poll the socket until a daemon answers or the deadline elapses. Shared by the
|
|
191
|
+
// spawn path (→ "started") and the cooldown-blocked wait path (→ "attached"):
|
|
192
|
+
// both need "did a daemon come up in time", they differ only in how they label
|
|
193
|
+
// the outcome.
|
|
194
|
+
async function pollUntilReady(
|
|
195
|
+
connectTimeoutMs: number,
|
|
196
|
+
readyDeadline: number,
|
|
197
|
+
): Promise<boolean> {
|
|
158
198
|
while (Date.now() < readyDeadline) {
|
|
159
|
-
if (await canConnect(socketPath(), connectTimeoutMs))
|
|
160
|
-
return { kind: "started" };
|
|
161
|
-
}
|
|
199
|
+
if (await canConnect(socketPath(), connectTimeoutMs)) return true;
|
|
162
200
|
await sleep(20);
|
|
163
201
|
}
|
|
164
|
-
return
|
|
165
|
-
kind: "failed",
|
|
166
|
-
reason: `daemon did not bind in time${reasonSuffix}`,
|
|
167
|
-
};
|
|
202
|
+
return false;
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
// Synchronous fire-and-forget kick — used for "daemon-miss" recovery where
|
|
@@ -214,7 +249,7 @@ export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
|
|
|
214
249
|
process.stderr.write(
|
|
215
250
|
`cc-candybar: spawn-lock held ${ageMs}ms (likely crashed holder) — spawning unlocked\n`,
|
|
216
251
|
);
|
|
217
|
-
|
|
252
|
+
cooldownGatedSpawn(spawnFn);
|
|
218
253
|
}
|
|
219
254
|
return;
|
|
220
255
|
}
|
|
@@ -222,16 +257,26 @@ export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
|
|
|
222
257
|
process.stderr.write(
|
|
223
258
|
`cc-candybar: spawn-lock unavailable (${lock.reason}) — spawning unlocked\n`,
|
|
224
259
|
);
|
|
225
|
-
|
|
260
|
+
cooldownGatedSpawn(spawnFn);
|
|
226
261
|
return;
|
|
227
262
|
}
|
|
228
263
|
try {
|
|
229
|
-
|
|
264
|
+
cooldownGatedSpawn(spawnFn);
|
|
230
265
|
} finally {
|
|
231
266
|
releaseSpawnLock();
|
|
232
267
|
}
|
|
233
268
|
}
|
|
234
269
|
|
|
270
|
+
// [LAW:single-enforcer] Every kick spawn site routes through here, so the
|
|
271
|
+
// spawn-rate bound is applied at exactly one boundary — mirror of Rust's
|
|
272
|
+
// spawn_daemon_rate_limited. On cooldown we do nothing: a spawn was attempted
|
|
273
|
+
// within SPAWN_COOLDOWN_MS and is likely still booting; the kick is
|
|
274
|
+
// fire-and-forget, so "already in flight" is a complete answer.
|
|
275
|
+
function cooldownGatedSpawn(spawnFn: () => boolean): void {
|
|
276
|
+
if (!claimSpawnCooldown()) return;
|
|
277
|
+
safeSpawn(spawnFn);
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
function spawnLockAgeMs(): number | null {
|
|
236
281
|
try {
|
|
237
282
|
const st = fs.statSync(spawnLockPath());
|
|
@@ -260,6 +305,93 @@ function safeSpawn(spawnFn: () => boolean): void {
|
|
|
260
305
|
}
|
|
261
306
|
}
|
|
262
307
|
|
|
308
|
+
// ─── Spawn cooldown (shared spawn-RATE bound) ────────────────────────────────
|
|
309
|
+
//
|
|
310
|
+
// [LAW:one-source-of-truth] spawn.lock dedups spawns at one INSTANT; the
|
|
311
|
+
// cooldown bounds them over TIME. Without it, the Rust kick — which releases
|
|
312
|
+
// spawn.lock milliseconds after forking, before the 0.5-3s Node boot window —
|
|
313
|
+
// re-spawns on every render tick during an outage (spawn rate ≈ tick rate:
|
|
314
|
+
// dozens/sec, process-table exhaustion). One file's mtime records the last spawn
|
|
315
|
+
// ATTEMPT; both runtimes consult it. The constant and filename are mirrored TS↔
|
|
316
|
+
// Rust (scripts/check-protocol.mjs). Worst case with the bound: ~20 spawns/min
|
|
317
|
+
// globally, each of which exits cleanly via the sibling socket-lease defenses.
|
|
318
|
+
// Exported for the boundary unit tests (test/daemon-acquire.test.ts), which pin
|
|
319
|
+
// the window arithmetic against the exact constant the same way the Rust unit
|
|
320
|
+
// tests do — so a TS↔Rust decision divergence at a boundary is caught even
|
|
321
|
+
// though check-protocol only diffs the constant's value.
|
|
322
|
+
export const SPAWN_COOLDOWN_MS = 3_000;
|
|
323
|
+
|
|
324
|
+
// [LAW:effects-at-boundaries] The window arithmetic — the subtle part: a
|
|
325
|
+
// future-mtime garbage record (beyond the stale-lock window) must not pin the
|
|
326
|
+
// cooldown forever, while a small negative age is just ms-truncation of
|
|
327
|
+
// Date.now() against the higher-precision fs mtime and still counts as a
|
|
328
|
+
// just-recorded attempt — is a pure function of the record's age, extracted from
|
|
329
|
+
// the fs read so it is unit-tested without touching the filesystem. Mirrors the
|
|
330
|
+
// Rust cooldown_decision. `null` age (missing/unreadable file) allows (first
|
|
331
|
+
// spawn); the mtime IS the timestamp, so "unparseable timestamp" is
|
|
332
|
+
// unrepresentable by construction.
|
|
333
|
+
export type CooldownDecision =
|
|
334
|
+
| { kind: "allow" }
|
|
335
|
+
| { kind: "allow-future-garbage"; futureMs: number }
|
|
336
|
+
| { kind: "deny" };
|
|
337
|
+
|
|
338
|
+
export function cooldownDecision(ageMs: number | null): CooldownDecision {
|
|
339
|
+
if (ageMs === null) return { kind: "allow" };
|
|
340
|
+
if (ageMs < -STALE_LOCK_MS)
|
|
341
|
+
return { kind: "allow-future-garbage", futureMs: -ageMs };
|
|
342
|
+
if (ageMs < SPAWN_COOLDOWN_MS) return { kind: "deny" };
|
|
343
|
+
return { kind: "allow" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
|
|
347
|
+
// and RECORDS the attempt (updating spawn.cooldown's mtime to now) — when a
|
|
348
|
+
// spawn is permitted; false when an attempt was recorded within
|
|
349
|
+
// SPAWN_COOLDOWN_MS. Recording-on-grant (BEFORE the caller spawns) is
|
|
350
|
+
// load-bearing: a spawn that then throws or returns false still counts against
|
|
351
|
+
// the rate, so a broken binary is not retried in a tight loop. A future-mtime
|
|
352
|
+
// garbage record warns loudly and falls toward ALLOWING the spawn
|
|
353
|
+
// [LAW:no-silent-failure].
|
|
354
|
+
function claimSpawnCooldown(): boolean {
|
|
355
|
+
const path = spawnCooldownPath();
|
|
356
|
+
const decision = cooldownDecision(cooldownAgeMs(path));
|
|
357
|
+
if (decision.kind === "deny") return false;
|
|
358
|
+
if (decision.kind === "allow-future-garbage") {
|
|
359
|
+
process.stderr.write(
|
|
360
|
+
`cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
recordSpawnAttempt(path);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function cooldownAgeMs(path: string): number | null {
|
|
368
|
+
try {
|
|
369
|
+
return Date.now() - fs.statSync(path).mtimeMs;
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function recordSpawnAttempt(filePath: string): void {
|
|
376
|
+
// [LAW:composability] Self-sufficient — ensure the state dir exists rather
|
|
377
|
+
// than leaning on an ambient ensureStateDir() precondition, so any spawn site
|
|
378
|
+
// routing through claimSpawnCooldown records correctly (mirrors Rust's
|
|
379
|
+
// record_spawn_attempt). Content is human-diagnostic only; the mtime is the
|
|
380
|
+
// authority. A write failure means no cooldown recorded — worst case one extra
|
|
381
|
+
// spawn, which bind() arbitrates — but surface it loudly rather than silently
|
|
382
|
+
// un-bound the rate [LAW:no-silent-failure].
|
|
383
|
+
try {
|
|
384
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
385
|
+
fs.writeFileSync(filePath, `${process.pid} ${Date.now()}\n`, {
|
|
386
|
+
mode: 0o600,
|
|
387
|
+
});
|
|
388
|
+
} catch (e) {
|
|
389
|
+
process.stderr.write(
|
|
390
|
+
`cc-candybar: could not record spawn.cooldown: ${(e as Error).message}\n`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
263
395
|
// ─── Spawn-lock (Node side) ──────────────────────────────────────────────────
|
|
264
396
|
//
|
|
265
397
|
// O_EXLOCK / fcntl(F_SETLK) aren't reliably exposed across Node platforms, so
|
|
@@ -274,7 +406,9 @@ function safeSpawn(spawnFn: () => boolean): void {
|
|
|
274
406
|
// lock is a thundering-herd optimization, so a missed dedup just means one
|
|
275
407
|
// extra Node process eats a bind() race and exits.
|
|
276
408
|
|
|
277
|
-
|
|
409
|
+
// Exported for the cooldownDecision boundary tests. Mirrored TS↔Rust (diffed by
|
|
410
|
+
// check-protocol) since both runtimes apply it to the same spawn.cooldown file.
|
|
411
|
+
export const STALE_LOCK_MS = 10_000;
|
|
278
412
|
|
|
279
413
|
let heldLock: { fd: number; path: string } | null = null;
|
|
280
414
|
|
package/src/daemon/paths.ts
CHANGED
|
@@ -146,6 +146,17 @@ export function spawnLockPath(): string {
|
|
|
146
146
|
return path.join(stateDir(), "spawn.lock");
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// [LAW:one-source-of-truth] The spawn-RATE bound (as distinct from spawn.lock's
|
|
150
|
+
// instantaneous dedup) is anchored to one file's mtime beside spawn.lock: the
|
|
151
|
+
// time of the last daemon-spawn ATTEMPT. Both runtimes gate on the SAME file, so
|
|
152
|
+
// the filename is mirrored TS↔Rust (rust-client/src/main.rs SPAWN_COOLDOWN_FILE)
|
|
153
|
+
// and diffed by scripts/check-protocol.mjs — a drift would silently split the
|
|
154
|
+
// rate bound in two.
|
|
155
|
+
const SPAWN_COOLDOWN_FILE = "spawn.cooldown";
|
|
156
|
+
export function spawnCooldownPath(): string {
|
|
157
|
+
return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
|
|
158
|
+
}
|
|
159
|
+
|
|
149
160
|
export function logPath(): string {
|
|
150
161
|
return path.join(stateDir(), "daemon.log");
|
|
151
162
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { launchSync, type LaunchOpts, type LaunchResult } from "../proc/launch";
|
|
2
|
+
|
|
3
|
+
// ─── Process start-time fingerprint ──────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// [FRAMING:representation] A bare pid is an under-constrained identity: the
|
|
6
|
+
// kernel recycles pids, so "a process with pid 989 exists" does NOT prove "989
|
|
7
|
+
// is the same process that wrote this lease". A crashed daemon whose pid the OS
|
|
8
|
+
// later hands to an unrelated long-lived process reads `alive` on every
|
|
9
|
+
// subsequent start, so no daemon ever comes up (brandon-daemon-lifecycle-2b3.4
|
|
10
|
+
// RESIDUAL 1 — the inverse of the socket-theft storm). The kernel ALSO stamps
|
|
11
|
+
// each process with a start-time it never rewinds within that pid's life;
|
|
12
|
+
// (pid, start-time) is the pair that actually IS a process identity, so a
|
|
13
|
+
// recycled pid is provably a DIFFERENT process (different start-time).
|
|
14
|
+
//
|
|
15
|
+
// [LAW:one-source-of-truth] The authority is the kernel's process identity, not
|
|
16
|
+
// a bare pid. `ps -o lstart=` exposes the start-time on every Unix we ship to
|
|
17
|
+
// (darwin + linux). The reported token is treated as OPAQUE — compared by string
|
|
18
|
+
// equality, never parsed — so there is no date-format representation to drift
|
|
19
|
+
// [FRAMING:representation]: a producer/consumer parse mismatch is unrepresentable
|
|
20
|
+
// when neither side parses. That opaque-equality invariant holds ONLY if the
|
|
21
|
+
// GENERATOR is deterministic. `ps -o lstart=` is strftime-formatted (locale) via
|
|
22
|
+
// `localtime()` (timezone), so the SAME process renders different tokens across a
|
|
23
|
+
// locale change OR a timezone change (TZ env, DST, tzdata update). We pin BOTH on
|
|
24
|
+
// the `ps` subprocess — `LC_ALL=C` (dominates any ambient LC_TIME/LC_ALL) and
|
|
25
|
+
// `TZ=UTC` — so the token is a locale- and timezone-invariant UTC rendering of
|
|
26
|
+
// the start instant, and equality is sound.
|
|
27
|
+
|
|
28
|
+
// A read of a pid's kernel start-time. Only TWO outcomes, because nothing
|
|
29
|
+
// derivable from a `ps` exit code can SOUNDLY prove a process is dead — a
|
|
30
|
+
// non-zero exit means "no start-time to report", which conflates a genuinely
|
|
31
|
+
// absent pid with an access failure (hardened `/proc`/`hidepid`, a
|
|
32
|
+
// permission-denied read of a live process). A `gone` state produced from that
|
|
33
|
+
// would be an over-claim ([LAW:types-are-the-program]) that could false-dead a
|
|
34
|
+
// live owner and reclaim its socket. So:
|
|
35
|
+
// start — the pid is live and this is its start-time token (the ONLY
|
|
36
|
+
// thing `ps` can assert soundly: it printed a row).
|
|
37
|
+
// unavailable — `ps` reported no start-time (absent OR unreadable OR errored).
|
|
38
|
+
// Callers defer to kill(pid,0), the sound liveness test, which
|
|
39
|
+
// correctly reclaims a truly-dead pid and spares a live one.
|
|
40
|
+
// The fingerprint's unique contribution is the `start`+mismatch case (a recycled
|
|
41
|
+
// pid whose live start-time differs from the lease); everything else falls back
|
|
42
|
+
// to kill, no worse than before the fingerprint existed.
|
|
43
|
+
export type StartTimeRead =
|
|
44
|
+
| { kind: "start"; token: string }
|
|
45
|
+
| { kind: "unavailable"; detail: string };
|
|
46
|
+
|
|
47
|
+
// The subprocess boundary, injected so the parse/branch logic is exercised by
|
|
48
|
+
// input enumeration with a fake launcher while the real path runs the real `ps`.
|
|
49
|
+
export type Launcher = (opts: LaunchOpts) => LaunchResult;
|
|
50
|
+
|
|
51
|
+
// [LAW:effects-at-boundaries][LAW:single-enforcer] The sole effect (spawning
|
|
52
|
+
// `ps`) goes through the one subprocess enforcer, `launchSync`. The ONLY sound
|
|
53
|
+
// signal `ps` gives is a printed start-time row (a live pid we could read);
|
|
54
|
+
// [LAW:no-silent-failure] every other outcome — absent pid, access failure,
|
|
55
|
+
// spawn-error, timeout — is `unavailable`, deferring the alive/dead call to
|
|
56
|
+
// kill(pid,0) rather than risk declaring a live owner dead from a `ps` exit
|
|
57
|
+
// code that cannot distinguish "gone" from "cannot read".
|
|
58
|
+
export function readStartTime(
|
|
59
|
+
pid: number,
|
|
60
|
+
launch: Launcher = launchSync,
|
|
61
|
+
): StartTimeRead {
|
|
62
|
+
const res = launch({
|
|
63
|
+
bin: "ps",
|
|
64
|
+
args: ["-o", "lstart=", "-p", String(pid)],
|
|
65
|
+
category: "process-fingerprint",
|
|
66
|
+
timeoutMs: 2000,
|
|
67
|
+
// [FRAMING:representation] Pin BOTH locale and timezone so the token is
|
|
68
|
+
// deterministic across daemons — the writer and reader must render the same
|
|
69
|
+
// process identically for opaque string equality to be sound. LC_ALL=C fixes
|
|
70
|
+
// the strftime format; TZ=UTC fixes localtime(), so DST / TZ / tzdata changes
|
|
71
|
+
// can't make one live process render two different start-time strings.
|
|
72
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
73
|
+
});
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
const token = res.stdout.trim();
|
|
76
|
+
if (token.length > 0) return { kind: "start", token };
|
|
77
|
+
// Exit 0 with empty output is anomalous (a live pid always lists) — don't
|
|
78
|
+
// mint an empty-string fingerprint; defer to kill.
|
|
79
|
+
return { kind: "unavailable", detail: "ps produced no start-time" };
|
|
80
|
+
}
|
|
81
|
+
const detail =
|
|
82
|
+
res.reason === "spawn-error"
|
|
83
|
+
? (res.error ?? "ps spawn failed")
|
|
84
|
+
: `ps ${res.reason} (exit ${res.exitCode})`;
|
|
85
|
+
return { kind: "unavailable", detail };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface LivenessDeps {
|
|
89
|
+
readStartTime: (pid: number) => StartTimeRead;
|
|
90
|
+
pidAlive: (pid: number) => boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// [LAW:dataflow-not-control-flow] The liveness verdict is a pure fold over the
|
|
94
|
+
// start-time read + the lease's recorded token. Full input space:
|
|
95
|
+
// read=start + token matches lease → true (same process still alive)
|
|
96
|
+
// read=start + token differs → false (a DIFFERENT process holds the
|
|
97
|
+
// pid — a recycle, or a restarted daemon)
|
|
98
|
+
// read=unavailable → kill(pid,0) fallback (the sound
|
|
99
|
+
// alive/dead test: a truly-dead pid →
|
|
100
|
+
// false → reclaim; a live pid ps couldn't
|
|
101
|
+
// read → true → spared)
|
|
102
|
+
// lease token = null (unfingerprinted at write, e.g. a host without `ps`) →
|
|
103
|
+
// kill(pid,0) fallback for the same reason
|
|
104
|
+
//
|
|
105
|
+
// [LAW:no-silent-failure] Only the `start`+mismatch case comes from the
|
|
106
|
+
// fingerprint; every ambiguous `ps` outcome defers to kill, so the DANGEROUS
|
|
107
|
+
// direction (declaring a live daemon dead → stealing its socket, the storm this
|
|
108
|
+
// epic fights) never happens merely because `ps` could not answer. The only
|
|
109
|
+
// thing forfeited when `ps` cannot answer is detecting a recycled pid.
|
|
110
|
+
export function sameLiveProcess(
|
|
111
|
+
pid: number,
|
|
112
|
+
leaseToken: string | null,
|
|
113
|
+
deps: LivenessDeps,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (leaseToken === null) return deps.pidAlive(pid);
|
|
116
|
+
const read = deps.readStartTime(pid);
|
|
117
|
+
switch (read.kind) {
|
|
118
|
+
case "start":
|
|
119
|
+
return read.token === leaseToken;
|
|
120
|
+
case "unavailable":
|
|
121
|
+
return deps.pidAlive(pid);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Read THIS process's own start-time to stamp into its lease. `unavailable`
|
|
126
|
+
// (no `ps`) collapses to `null` — the lease records "unfingerprinted", and every
|
|
127
|
+
// reader of it falls back to kill(pid,0) via sameLiveProcess. A dead result is
|
|
128
|
+
// impossible for our own live pid; if it somehow occurs we also record null.
|
|
129
|
+
export function readOwnStartTime(
|
|
130
|
+
pid: number,
|
|
131
|
+
read: (pid: number) => StartTimeRead = readStartTime,
|
|
132
|
+
): string | null {
|
|
133
|
+
const r = read(pid);
|
|
134
|
+
return r.kind === "start" ? r.token : null;
|
|
135
|
+
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
readSocketIdentity,
|
|
23
23
|
type SocketIdentity,
|
|
24
24
|
} from "./socket-ownership";
|
|
25
|
+
import {
|
|
26
|
+
readStartTime,
|
|
27
|
+
readOwnStartTime,
|
|
28
|
+
sameLiveProcess,
|
|
29
|
+
} from "./process-fingerprint";
|
|
25
30
|
import { dlog, closeLog } from "./log";
|
|
26
31
|
import {
|
|
27
32
|
PROTOCOL_VERSION,
|
|
@@ -127,8 +132,16 @@ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
|
|
|
127
132
|
// connection. Any uncaught error exits non-zero; the next client obtains a
|
|
128
133
|
// fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
|
|
129
134
|
// obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
|
|
135
|
+
// [LAW:one-source-of-truth] Our own kernel start-time, read once at startup and
|
|
136
|
+
// stamped into our lease so a future daemon's arbitration can prove whether our
|
|
137
|
+
// pid still names THIS process or a recycled ghost (process-fingerprint.ts).
|
|
138
|
+
// null when this host cannot fingerprint (no `ps`) — readers then fall back to
|
|
139
|
+
// kill(pid,0), no worse than before the fingerprint existed.
|
|
140
|
+
let myStartTime: string | null = null;
|
|
141
|
+
|
|
130
142
|
export function runDaemon(): void {
|
|
131
143
|
fs.mkdirSync(daemonDir(), { recursive: true });
|
|
144
|
+
myStartTime = readOwnStartTime(process.pid);
|
|
132
145
|
// [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
|
|
133
146
|
// not a symlink before we bind. Without this check, a same-host attacker
|
|
134
147
|
// could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
|
|
@@ -236,7 +249,11 @@ function handleAddressInUse(server: net.Server, sockPath: string): void {
|
|
|
236
249
|
// [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
|
|
237
250
|
// through unlink + rebind below, not the re-derived global — one identity
|
|
238
251
|
// source for the whole arbitration.
|
|
239
|
-
const decision = arbitrateSocket(
|
|
252
|
+
const decision = arbitrateSocket(
|
|
253
|
+
readLease(leasePathFor(sockPath)),
|
|
254
|
+
(pid, startTime) =>
|
|
255
|
+
sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
|
|
256
|
+
);
|
|
240
257
|
if (decision.kind === "attach-and-exit") {
|
|
241
258
|
dlog("info", `EADDRINUSE: ${decision.reason} — exiting`);
|
|
242
259
|
process.exit(0);
|
|
@@ -280,20 +297,19 @@ function onListening(sockPath: string): void {
|
|
|
280
297
|
// the FD yields no path-comparable identity. stat(path), taken as close to the
|
|
281
298
|
// bind as possible, is the only path-comparable truth available.
|
|
282
299
|
//
|
|
283
|
-
// [FRAMING:representation]
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// the
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// arbitrateSocket documents for its false-alive direction.
|
|
300
|
+
// [FRAMING:representation] This inode capture still cannot close the capture
|
|
301
|
+
// race on its own: if a second daemon completed its full EADDRINUSE →
|
|
302
|
+
// read-lease → unlink → rebind cycle in the single event-loop tick between
|
|
303
|
+
// bind() and this 'listening' callback, we stat the path and capture the
|
|
304
|
+
// THIEF's inode as "ours". No race-free handle on our own socket's path
|
|
305
|
+
// identity exists (an AF_UNIX listener's fstat is a different namespace's
|
|
306
|
+
// inode), so the captured inode cannot be made trustworthy. But that is no
|
|
307
|
+
// longer the immortal orphan it was (brandon-daemon-lifecycle-2b3.4 RESIDUAL
|
|
308
|
+
// 2): the ownership self-check now ALSO requires the lease to still name us,
|
|
309
|
+
// and a real thief writes its own pid into the lease — so a displaced daemon
|
|
310
|
+
// drains within a bounded number of intervals instead of reading `owned`
|
|
311
|
+
// forever (see checkOwnership). The absent/unreadable case below still exits
|
|
312
|
+
// toward the SAFE direction without writing a lease that would stomp a thief's.
|
|
297
313
|
const boundRead = readSocketIdentity(sockPath);
|
|
298
314
|
if (boundRead.kind !== "present") {
|
|
299
315
|
dlog(
|
|
@@ -330,15 +346,20 @@ function onListening(sockPath: string): void {
|
|
|
330
346
|
// --- socket-ownership self-check ---
|
|
331
347
|
//
|
|
332
348
|
// [LAW:single-enforcer] The sole enforcer of "serving implies owning the socket
|
|
333
|
-
// path over time" (brandon-daemon-lifecycle-2b3.2).
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// SAME shutdown funnel as signals, the RSS backstop, and the watchdog
|
|
337
|
-
//
|
|
349
|
+
// path over time" (brandon-daemon-lifecycle-2b3.2). Each interval it re-reads
|
|
350
|
+
// BOTH representations a displacer can touch — the path's kernel identity (still
|
|
351
|
+
// the inode we bound?) and the lease (still names our pid?) — and drains through
|
|
352
|
+
// the SAME shutdown funnel as signals, the RSS backstop, and the watchdog on
|
|
353
|
+
// either mismatch. The lease arm closes the capture race (RESIDUAL 2): a thief
|
|
354
|
+
// that stole our socket inside the bind→listening tick, which the inode arm
|
|
355
|
+
// cannot see because we captured the thief's inode, is caught the moment the
|
|
356
|
+
// thief writes its own pid into the lease. No parallel exit path.
|
|
338
357
|
function armOwnershipWatch(sockPath: string, bound: SocketIdentity): void {
|
|
339
358
|
makeOwnershipWatch({
|
|
340
359
|
bound,
|
|
360
|
+
myPid: process.pid,
|
|
341
361
|
readIdentity: () => readSocketIdentity(sockPath),
|
|
362
|
+
readLease: () => readLease(leasePathFor(sockPath)),
|
|
342
363
|
shutdown: (code) => shutdown(code),
|
|
343
364
|
log: dlog,
|
|
344
365
|
}).arm();
|
|
@@ -418,7 +439,7 @@ function writeLeaseFile(sockPath: string): void {
|
|
|
418
439
|
pid: process.pid,
|
|
419
440
|
version: PROTOCOL_VERSION,
|
|
420
441
|
binPath: process.argv[1],
|
|
421
|
-
|
|
442
|
+
startTime: myStartTime,
|
|
422
443
|
});
|
|
423
444
|
if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
|
|
424
445
|
}
|