@promptctl/cc-candybar 1.21.0 → 1.23.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/README.md +13 -9
- package/bin/cc-candybar +18 -6
- package/dist/index.mjs +89 -86
- package/package.json +6 -22
- package/src/daemon/acquire.ts +148 -12
- package/src/daemon/fork-bomb-breaker.ts +351 -0
- package/src/daemon/parent-watchdog.ts +8 -1
- package/src/daemon/paths.ts +47 -8
- package/src/daemon/process-fingerprint.ts +11 -0
- package/src/daemon/server.ts +73 -18
- package/src/daemon/socket-lease.ts +4 -4
- package/src/index.ts +8 -6
- package/src/install/index.ts +207 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
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",
|
|
@@ -31,8 +31,6 @@
|
|
|
31
31
|
"check:protocol": "node scripts/check-protocol.mjs",
|
|
32
32
|
"gen:schema": "tsx scripts/gen-schema.ts",
|
|
33
33
|
"check:schema": "tsx scripts/check-schema.ts",
|
|
34
|
-
"prepack": "node scripts/write-bin-placeholder.mjs",
|
|
35
|
-
"postinstall": "node scripts/postinstall.mjs",
|
|
36
34
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run check:protocol && npm run check:schema && npm run build"
|
|
37
35
|
},
|
|
38
36
|
"keywords": [
|
|
@@ -86,30 +84,16 @@
|
|
|
86
84
|
"ts-jest": "^29.4.1",
|
|
87
85
|
"tsdown": "^0.21.4",
|
|
88
86
|
"tsx": "^4.21.0",
|
|
89
|
-
"typescript": "^5.0.0"
|
|
90
|
-
},
|
|
91
|
-
"dependencies": {
|
|
87
|
+
"typescript": "^5.0.0",
|
|
92
88
|
"@promptctl/go-template-js": "^0.7.0",
|
|
93
89
|
"@promptctl/rich-js": "^0.6.0",
|
|
94
90
|
"json5": "^2.2.3",
|
|
95
91
|
"mobx": "^6.15.0"
|
|
96
92
|
},
|
|
97
93
|
"optionalDependencies": {
|
|
98
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
102
|
-
},
|
|
103
|
-
"pnpm": {
|
|
104
|
-
"supportedArchitectures": {
|
|
105
|
-
"os": [
|
|
106
|
-
"darwin",
|
|
107
|
-
"linux"
|
|
108
|
-
],
|
|
109
|
-
"cpu": [
|
|
110
|
-
"x64",
|
|
111
|
-
"arm64"
|
|
112
|
-
]
|
|
113
|
-
}
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.23.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.23.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.23.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.23.0"
|
|
114
98
|
}
|
|
115
99
|
}
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
socketPath,
|
|
8
8
|
spawnLockPath,
|
|
9
9
|
spawnCooldownPath,
|
|
10
|
+
spawnBackoffPath,
|
|
10
11
|
daemonDir,
|
|
11
12
|
} from "./paths";
|
|
12
13
|
|
|
@@ -335,32 +336,167 @@ export type CooldownDecision =
|
|
|
335
336
|
| { kind: "allow-future-garbage"; futureMs: number }
|
|
336
337
|
| { kind: "deny" };
|
|
337
338
|
|
|
338
|
-
|
|
339
|
+
// [LAW:types-are-the-program] `cooldownMs` is the required window, not a
|
|
340
|
+
// captured constant — the decision is the same pure fold whether the caller
|
|
341
|
+
// is checking against the base SPAWN_COOLDOWN_MS or a backed-off window from
|
|
342
|
+
// effectiveCooldownMs(streak) below. Generalizing the threshold into a
|
|
343
|
+
// parameter is what let brandon-daemon-lifecycle-gad.3 add exponential
|
|
344
|
+
// backoff without touching this function's tested boundary arithmetic.
|
|
345
|
+
export function cooldownDecision(
|
|
346
|
+
ageMs: number | null,
|
|
347
|
+
cooldownMs: number,
|
|
348
|
+
): CooldownDecision {
|
|
339
349
|
if (ageMs === null) return { kind: "allow" };
|
|
340
350
|
if (ageMs < -STALE_LOCK_MS)
|
|
341
351
|
return { kind: "allow-future-garbage", futureMs: -ageMs };
|
|
342
|
-
if (ageMs <
|
|
352
|
+
if (ageMs < cooldownMs) return { kind: "deny" };
|
|
343
353
|
return { kind: "allow" };
|
|
344
354
|
}
|
|
345
355
|
|
|
356
|
+
// ─── Spawn backoff (consecutive non-convergence widens the cooldown) ────────
|
|
357
|
+
//
|
|
358
|
+
// [LAW:one-source-of-truth] spawn.cooldown's mtime answers "when was a spawn
|
|
359
|
+
// last attempted"; this streak answers "how many attempts in a row have
|
|
360
|
+
// failed to converge on a live daemon" — a fact spawn.cooldown's mtime alone
|
|
361
|
+
// cannot carry (mtime is overwritten on every attempt, losing the count). One
|
|
362
|
+
// small file, one fact, read/written by both runtimes exactly like
|
|
363
|
+
// spawn.cooldown itself.
|
|
364
|
+
//
|
|
365
|
+
// [LAW:single-enforcer] The daemon is the only process that can know
|
|
366
|
+
// "convergence achieved" (it just bound the socket and is about to serve) —
|
|
367
|
+
// see resetSpawnBackoff(), called once from server.ts's onListening(). A
|
|
368
|
+
// client-side reset would need a full successful render round-trip on the
|
|
369
|
+
// hot path to detect convergence, adding fs I/O to the common case for a
|
|
370
|
+
// signal the daemon already has for free at boot.
|
|
371
|
+
//
|
|
372
|
+
// Growth is capped at SPAWN_BACKOFF_MAX_STREAK shifts so effectiveCooldownMs
|
|
373
|
+
// never has to reason about an unbounded streak (a multi-day outage would
|
|
374
|
+
// otherwise grow the stored integer without bound) and so Rust's mirrored
|
|
375
|
+
// `<<` cannot overflow. 3_000ms << 5 = 96_000ms, already past the 60s cap, so
|
|
376
|
+
// 5 is sufficient — not tuned to any particular outage length.
|
|
377
|
+
export const SPAWN_BACKOFF_CAP_MS = 60_000;
|
|
378
|
+
export const SPAWN_BACKOFF_MAX_STREAK = 5;
|
|
379
|
+
|
|
380
|
+
// [LAW:behavior-not-structure] Pure over the streak; no filesystem. Mirrors
|
|
381
|
+
// Rust's effective_cooldown_ms exactly (diffed by check-protocol for the two
|
|
382
|
+
// constants; the arithmetic itself is pinned by the boundary unit tests on
|
|
383
|
+
// both sides, matching cooldownDecision's existing pattern).
|
|
384
|
+
export function effectiveCooldownMs(streak: number): number {
|
|
385
|
+
const capped = Math.min(Math.max(streak, 0), SPAWN_BACKOFF_MAX_STREAK);
|
|
386
|
+
return Math.min(SPAWN_COOLDOWN_MS * 2 ** capped, SPAWN_BACKOFF_CAP_MS);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// [LAW:no-defensive-null-guards] Number(raw) — not parseInt — so trailing
|
|
390
|
+
// garbage ("5abc", "5.0") fails closed to NaN instead of being silently
|
|
391
|
+
// truncated to a plausible-looking integer. parseInt's truncation is exactly
|
|
392
|
+
// the bug daemonCeiling() (fork-bomb-breaker.ts, brandon-daemon-lifecycle-gad.2)
|
|
393
|
+
// fixed for the same "small integer parsed from an untrusted local file" shape;
|
|
394
|
+
// repeating parseInt here would reintroduce it. The clamp to
|
|
395
|
+
// SPAWN_BACKOFF_MAX_STREAK also bounds every value this function can ever
|
|
396
|
+
// return, so no caller — including `streak + 1` — needs its own re-clamp to
|
|
397
|
+
// stay overflow-safe (Rust's mirror clamps at the identical boundary, since a
|
|
398
|
+
// raw u32 parsed from disk has no such guarantee otherwise).
|
|
399
|
+
// Exported so the parsing-strictness contract (Number, not parseInt — see
|
|
400
|
+
// the comment above) has a direct test independent of any caller's file
|
|
401
|
+
// content, matching cooldownDecision/effectiveCooldownMs's own exported-for-
|
|
402
|
+
// testing precedent.
|
|
403
|
+
export function readBackoffStreak(filePath: string): number {
|
|
404
|
+
// [LAW:no-silent-failure] A missing or garbage streak file is NOT
|
|
405
|
+
// ambiguous the way a missing cooldown mtime is: falling back to 0 always
|
|
406
|
+
// fails toward the SAME safe direction as the rest of this module (spawn
|
|
407
|
+
// permitted at the base rate, never wedged) — matching cooldownDecision's
|
|
408
|
+
// own `ageMs === null → allow`. Never loud here; the failure mode is
|
|
409
|
+
// "one extra spawn," which bind() already arbitrates.
|
|
410
|
+
try {
|
|
411
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
412
|
+
const n = Number(raw);
|
|
413
|
+
if (!Number.isInteger(n) || n < 0) return 0;
|
|
414
|
+
return Math.min(n, SPAWN_BACKOFF_MAX_STREAK);
|
|
415
|
+
} catch {
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// [LAW:no-ambient-temporal-coupling] The read-then-write here (and in
|
|
421
|
+
// claimSpawnCooldown below) is not atomic across process boundaries — two
|
|
422
|
+
// client processes racing through a daemon-miss window can both read the
|
|
423
|
+
// same streak and both write the same increment, undercounting by one. This
|
|
424
|
+
// is an accepted, bounded trade, not an oversight: the ONLY failure direction
|
|
425
|
+
// is undercounting (the streak can never advance faster than reality), so a
|
|
426
|
+
// race just means backoff ramps a little slower than ideal — it can never
|
|
427
|
+
// permit MORE spawning than a race-free count would. The hard rate ceiling
|
|
428
|
+
// remains spawn.cooldown's mtime gate, which spawn.lock already serializes
|
|
429
|
+
// for the common case; this file, like spawn.lock's own documented
|
|
430
|
+
// thundering-herd tolerance, is a best-effort optimization on top of that,
|
|
431
|
+
// not a second load-bearing lock.
|
|
432
|
+
function writeBackoffStreak(filePath: string, streak: number): void {
|
|
433
|
+
try {
|
|
434
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
435
|
+
fs.writeFileSync(filePath, String(streak), { mode: 0o600 });
|
|
436
|
+
} catch (e) {
|
|
437
|
+
process.stderr.write(
|
|
438
|
+
`cc-candybar: could not record spawn.backoff: ${(e as Error).message}\n`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Called once by the daemon (server.ts onListening) the moment it binds the
|
|
444
|
+
// socket — the one process-wide fact that answers "did an outage just end."
|
|
445
|
+
// Deletes rather than writes "0": absence already reads as streak 0 via
|
|
446
|
+
// readBackoffStreak's catch branch, so there is no separate reset format to
|
|
447
|
+
// keep in sync with the normal write path.
|
|
448
|
+
//
|
|
449
|
+
// [LAW:no-ambient-temporal-coupling] This fires on bind, not on confirmed
|
|
450
|
+
// sustained liveness — in the vanishingly rare socket-capture race
|
|
451
|
+
// documented above armOwnershipWatch (server.ts), a daemon that only THINKS
|
|
452
|
+
// it converged resets the streak early. That daemon self-heals the same way
|
|
453
|
+
// ownership does (armOwnershipWatch drains it once the mismatch is detected);
|
|
454
|
+
// worst case is one redundant reset in an already-rare race window, not a
|
|
455
|
+
// wrong steady-state outcome.
|
|
456
|
+
export function resetSpawnBackoff(): void {
|
|
457
|
+
try {
|
|
458
|
+
fs.unlinkSync(spawnBackoffPath());
|
|
459
|
+
} catch (e) {
|
|
460
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
461
|
+
process.stderr.write(
|
|
462
|
+
`cc-candybar: could not reset spawn.backoff: ${(e as Error).message}\n`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
346
468
|
// [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
|
|
347
|
-
// and RECORDS the attempt (updating spawn.cooldown's mtime to now
|
|
348
|
-
// spawn is permitted; false when an attempt
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
469
|
+
// and RECORDS the attempt (updating spawn.cooldown's mtime to now, advancing
|
|
470
|
+
// spawn.backoff's streak) — when a spawn is permitted; false when an attempt
|
|
471
|
+
// was recorded within the EFFECTIVE cooldown window (SPAWN_COOLDOWN_MS,
|
|
472
|
+
// widened by effectiveCooldownMs(streak) once consecutive attempts have
|
|
473
|
+
// failed to converge — see brandon-daemon-lifecycle-gad.3). Recording-on-grant
|
|
474
|
+
// (BEFORE the caller spawns) is load-bearing: a spawn that then throws or
|
|
475
|
+
// returns false still counts against the rate, so a broken binary is not
|
|
476
|
+
// retried in a tight loop. A future-mtime garbage record warns loudly and
|
|
477
|
+
// falls toward ALLOWING the spawn [LAW:no-silent-failure].
|
|
354
478
|
function claimSpawnCooldown(): boolean {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
479
|
+
const cooldownPath = spawnCooldownPath();
|
|
480
|
+
const backoffPath = spawnBackoffPath();
|
|
481
|
+
const streak = readBackoffStreak(backoffPath);
|
|
482
|
+
const decision = cooldownDecision(
|
|
483
|
+
cooldownAgeMs(cooldownPath),
|
|
484
|
+
effectiveCooldownMs(streak),
|
|
485
|
+
);
|
|
357
486
|
if (decision.kind === "deny") return false;
|
|
358
487
|
if (decision.kind === "allow-future-garbage") {
|
|
359
488
|
process.stderr.write(
|
|
360
489
|
`cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
|
|
361
490
|
);
|
|
362
491
|
}
|
|
363
|
-
recordSpawnAttempt(
|
|
492
|
+
recordSpawnAttempt(cooldownPath);
|
|
493
|
+
// [LAW:dataflow-not-control-flow] Every granted spawn advances the streak
|
|
494
|
+
// by exactly one, unconditionally — the cap lives in the read side
|
|
495
|
+
// (effectiveCooldownMs) and here (Math.min), never as a skip.
|
|
496
|
+
writeBackoffStreak(
|
|
497
|
+
backoffPath,
|
|
498
|
+
Math.min(streak + 1, SPAWN_BACKOFF_MAX_STREAK),
|
|
499
|
+
);
|
|
364
500
|
return true;
|
|
365
501
|
}
|
|
366
502
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { daemonRegistryDir, ensureOwnedPrivateDir } from "./paths";
|
|
5
|
+
import {
|
|
6
|
+
readStartTime,
|
|
7
|
+
sameLiveProcess,
|
|
8
|
+
type ProcessIdentity,
|
|
9
|
+
} from "./process-fingerprint";
|
|
10
|
+
import { pidAlive } from "./parent-watchdog";
|
|
11
|
+
|
|
12
|
+
// ─── Daemon-side fork-bomb circuit breaker ───────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// [FRAMING:representation] The 192-daemon storm (epic brandon-daemon-lifecycle-
|
|
15
|
+
// gad) happened because every existing single-instance guard (atomic bind(),
|
|
16
|
+
// the socket lease, the ownership self-check, the spawn cooldown) keys off ONE
|
|
17
|
+
// socket path — daemons on DIFFERENT sockets (test isolation's per-file
|
|
18
|
+
// CC_CANDYBAR_SOCKET) never arbitrate each other and pile up unboundedly. .1
|
|
19
|
+
// (test/helpers/daemon-pool.ts) bounds that from the SPAWNER side, but the
|
|
20
|
+
// spawner's own cleanup (afterAll, globalTeardown) fails under the exact
|
|
21
|
+
// fork-exhaustion condition it exists to prevent. This module is the
|
|
22
|
+
// load-INDEPENDENT backstop: a daemon refuses to boot past a sibling ceiling
|
|
23
|
+
// using only its own startup-time read of a shared registry — no external
|
|
24
|
+
// cleanup path required for the invariant to hold.
|
|
25
|
+
//
|
|
26
|
+
// [LAW:one-source-of-truth] The registry lives at daemonRegistryDir() (paths.ts)
|
|
27
|
+
// — a fixed, UID-anchored /tmp path that, like socketPath(), deliberately
|
|
28
|
+
// ignores XDG_STATE_HOME, so isolation overrides can't hide a daemon from the
|
|
29
|
+
// count. Every daemon that does NOT explicitly override
|
|
30
|
+
// CC_CANDYBAR_DAEMON_REGISTRY_DIR lands in the same directory.
|
|
31
|
+
//
|
|
32
|
+
// [FRAMING:representation] The production daemon is a different POPULATION
|
|
33
|
+
// than an isolated (test/dev) instance, not a smaller version of the same one:
|
|
34
|
+
// it is already bounded to exactly one by bind()'s kernel-enforced exclusion on
|
|
35
|
+
// the canonical socket path, so no ceiling can ever be its failure mode — only
|
|
36
|
+
// isolation (an explicit CC_CANDYBAR_SOCKET override) creates the "many
|
|
37
|
+
// coexisting instances" population this breaker exists to bound. Classifying by
|
|
38
|
+
// "is CC_CANDYBAR_SOCKET set" keeps the two populations from ever counting
|
|
39
|
+
// against each other: the production daemon is exempt (and so always boots,
|
|
40
|
+
// however many isolated instances are registered), and isolated instances
|
|
41
|
+
// compete only with each other over the shared ceiling.
|
|
42
|
+
//
|
|
43
|
+
// [FRAMING:representation] admitDaemon's count-then-write (read the registry,
|
|
44
|
+
// decide, write our own entry) is NOT a compare-and-swap — the same accepted
|
|
45
|
+
// tradeoff as test/helpers/daemon-pool.ts's tryClaim. Two daemons starting in
|
|
46
|
+
// the same instant can both observe the same below-ceiling count and both
|
|
47
|
+
// admit, so the ceiling is a soft bound (liveCount can briefly overshoot by
|
|
48
|
+
// the number of true simultaneous spawns), not a strict mutex. A real fix
|
|
49
|
+
// needs a cross-process lock (flock, an O_EXCL pre-registration file); skipped
|
|
50
|
+
// as disproportionate here — this is a load-independent BACKSTOP against a
|
|
51
|
+
// 192-daemon storm, not a precision gate, and the ticket's own acceptance
|
|
52
|
+
// criterion is "a small, asserted ceiling", never exact atomicity. The
|
|
53
|
+
// failure mode of the race is a brief, bounded overshoot that the next boot's
|
|
54
|
+
// stale-sweep does not even need to correct (the overshooting daemons are
|
|
55
|
+
// live, not stale) — categorically smaller than the storm this breaker
|
|
56
|
+
// exists to prevent.
|
|
57
|
+
|
|
58
|
+
export interface BootDecision {
|
|
59
|
+
allow: boolean;
|
|
60
|
+
reason: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_CEILING = 16;
|
|
64
|
+
|
|
65
|
+
// [LAW:no-silent-failure] `Number(...)`, not `parseInt(...)` — parseInt
|
|
66
|
+
// truncates trailing garbage ("16o" reads as 16, silently accepting a typo
|
|
67
|
+
// that likely meant 160) instead of surfacing it. `Number` requires the
|
|
68
|
+
// WHOLE string to be numeric, so a typo becomes NaN and falls through to the
|
|
69
|
+
// default like any other garbage value.
|
|
70
|
+
export function daemonCeiling(): number {
|
|
71
|
+
const raw = Number(process.env["CC_CANDYBAR_DAEMON_CEILING"] ?? "");
|
|
72
|
+
return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_CEILING;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// [LAW:dataflow-not-control-flow] The whole decision is this one pure fold —
|
|
76
|
+
// full input space:
|
|
77
|
+
// isolated=false → allow, unconditionally (production is
|
|
78
|
+
// already singular via bind(); a
|
|
79
|
+
// ceiling here could only ever refuse
|
|
80
|
+
// the user's one real daemon, which the
|
|
81
|
+
// epic requires never happens)
|
|
82
|
+
// isolated=true, count < ceiling → allow (below the backstop)
|
|
83
|
+
// isolated=true, count >= ceiling → deny (the fork-bomb condition)
|
|
84
|
+
// [LAW:no-silent-failure] "Fails safe" is achieved by construction here, not by
|
|
85
|
+
// a guard clause: an unreadable/uncountable population reads as count=0 (see
|
|
86
|
+
// countLiveEntries), which always falls in the `allow` branch — the failure
|
|
87
|
+
// direction is never "refuse to boot", it is "undercount and allow".
|
|
88
|
+
export function decideBoot(
|
|
89
|
+
isolated: boolean,
|
|
90
|
+
liveSiblingCount: number,
|
|
91
|
+
ceiling: number,
|
|
92
|
+
): BootDecision {
|
|
93
|
+
if (!isolated) {
|
|
94
|
+
return {
|
|
95
|
+
allow: true,
|
|
96
|
+
reason:
|
|
97
|
+
"canonical production socket — exempt (bind() already caps it to one)",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (liveSiblingCount >= ceiling) {
|
|
101
|
+
return {
|
|
102
|
+
allow: false,
|
|
103
|
+
reason: `${liveSiblingCount} live isolated daemons registered >= ceiling ${ceiling}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
allow: true,
|
|
108
|
+
reason: `${liveSiblingCount} live isolated daemons registered < ceiling ${ceiling}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A registry entry read, alongside its source path so a stale one can be
|
|
113
|
+
// swept. `null` is every unreadable/corrupt/absent-pid outcome — collapsed
|
|
114
|
+
// early here (unlike readLease's richer enumeration) because the only
|
|
115
|
+
// downstream use is "count it or don't"; there is no distinct action for
|
|
116
|
+
// "unreadable" vs "absent" the way socket-lease arbitration has one.
|
|
117
|
+
export interface RegistryEntry {
|
|
118
|
+
path: string;
|
|
119
|
+
identity: ProcessIdentity;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// [LAW:no-silent-failure] Never throws: a directory that doesn't exist yet (no
|
|
123
|
+
// isolated daemon has ever registered) or is transiently unreadable both mean
|
|
124
|
+
// "no known siblings", which is the fail-open direction decideBoot expects.
|
|
125
|
+
export function listRegistryFiles(dir: string): string[] {
|
|
126
|
+
try {
|
|
127
|
+
return fs
|
|
128
|
+
.readdirSync(dir)
|
|
129
|
+
.filter((f) => f.endsWith(".json"))
|
|
130
|
+
.map((f) => path.join(dir, f));
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Mirrors readSlot (test/helpers/daemon-pool.ts) / readLease's pid validation
|
|
137
|
+
// (socket-lease.ts): a corrupt or unreadable file is excluded from the count
|
|
138
|
+
// rather than treated as a special decision branch — see the module header for
|
|
139
|
+
// why undercounting, never overcounting, is the safe direction here.
|
|
140
|
+
export function readRegistryEntry(filePath: string): ProcessIdentity | null {
|
|
141
|
+
let raw: string;
|
|
142
|
+
try {
|
|
143
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(raw) as Partial<ProcessIdentity> | null;
|
|
149
|
+
const pid = parsed?.pid;
|
|
150
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const startTime =
|
|
154
|
+
typeof parsed?.startTime === "string" ? parsed.startTime : null;
|
|
155
|
+
return { pid, startTime };
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// [LAW:dataflow-not-control-flow] Pure fold over already-read entries + an
|
|
162
|
+
// injected liveness predicate — full branch coverage needs no fs, no real
|
|
163
|
+
// processes: an empty list, an all-dead list, an all-live list, and a mixed
|
|
164
|
+
// list are the entire input space. `sweepStale`, kept in the same pass rather
|
|
165
|
+
// than a second read, is the effect side; the count itself never depends on
|
|
166
|
+
// whether the sweep succeeds.
|
|
167
|
+
export function countLiveEntries(
|
|
168
|
+
entries: readonly RegistryEntry[],
|
|
169
|
+
isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
|
|
170
|
+
sweepStale: (filePath: string) => void,
|
|
171
|
+
): number {
|
|
172
|
+
let count = 0;
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (isSameLiveProcess(entry.identity.pid, entry.identity.startTime)) {
|
|
175
|
+
count++;
|
|
176
|
+
} else {
|
|
177
|
+
sweepStale(entry.path);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return count;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface BreakerDeps {
|
|
184
|
+
isolated: boolean;
|
|
185
|
+
registryDir: string;
|
|
186
|
+
ceiling: number;
|
|
187
|
+
pid: number;
|
|
188
|
+
startTime: string | null;
|
|
189
|
+
isSameLiveProcess: (pid: number, startTime: string | null) => boolean;
|
|
190
|
+
listFiles: (dir: string) => string[];
|
|
191
|
+
readEntry: (filePath: string) => ProcessIdentity | null;
|
|
192
|
+
removeFile: (filePath: string) => void;
|
|
193
|
+
writeEntry: (filePath: string, identity: ProcessIdentity) => void;
|
|
194
|
+
ensureDirSafe: (dir: string) => void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface BreakerResult {
|
|
198
|
+
decision: BootDecision;
|
|
199
|
+
// The path this daemon registered at, or null when exempt/refused. Callers
|
|
200
|
+
// that boot successfully thread this into their shutdown cleanup so the slot
|
|
201
|
+
// is released promptly instead of waiting for the next boot's stale-sweep.
|
|
202
|
+
registryPath: string | null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// [LAW:effects-at-boundaries] The one place that turns the pure fold into a
|
|
206
|
+
// boot/refuse decision by reading + writing the real registry. Exempt
|
|
207
|
+
// (production) daemons never touch the registry at all — not even to read
|
|
208
|
+
// it — so a corrupt or unreadable registry can never affect the one instance
|
|
209
|
+
// the epic requires to always boot.
|
|
210
|
+
export function admitDaemon(deps: BreakerDeps): BreakerResult {
|
|
211
|
+
if (!deps.isolated) {
|
|
212
|
+
return { decision: decideBoot(false, 0, deps.ceiling), registryPath: null };
|
|
213
|
+
}
|
|
214
|
+
// [LAW:single-enforcer] `ensureDirSafe` — not a bare `ensureOwnedPrivateDir`
|
|
215
|
+
// call — because the safety boundary differs by registry: the default,
|
|
216
|
+
// UID-anchored registry sits under the same shared /tmp root the socket
|
|
217
|
+
// does and needs the two-level check `realBreakerDeps` builds for it (see
|
|
218
|
+
// its comment); an overridden registry (tests) is the caller's own
|
|
219
|
+
// directory and needs only the one-level leaf check. `admitDaemon` stays
|
|
220
|
+
// agnostic to which — it just asks the injected dependency to prove the
|
|
221
|
+
// directory is safe to use.
|
|
222
|
+
deps.ensureDirSafe(deps.registryDir);
|
|
223
|
+
const entries: RegistryEntry[] = [];
|
|
224
|
+
for (const filePath of deps.listFiles(deps.registryDir)) {
|
|
225
|
+
const identity = deps.readEntry(filePath);
|
|
226
|
+
// [LAW:no-silent-failure] Exclude any entry named with OUR OWN pid,
|
|
227
|
+
// unconditionally — no other currently-live process can ever share it
|
|
228
|
+
// (the kernel guarantees pid uniqueness among live processes), so such an
|
|
229
|
+
// entry is always either a stale pid-recycled ghost from a past
|
|
230
|
+
// incarnation, or moot (we haven't written our own entry yet). This
|
|
231
|
+
// matters specifically when `ps` is unavailable: `isSameLiveProcess`'s
|
|
232
|
+
// fallback (bare `pidAlive`) would read OUR OWN pid as alive and
|
|
233
|
+
// misclassify the ghost as a live sibling, consuming a ceiling slot and
|
|
234
|
+
// risking a spurious refusal of the one daemon that pid actually names.
|
|
235
|
+
// Excluding it here means it never reaches that ambiguous check at all.
|
|
236
|
+
if (identity !== null && identity.pid !== deps.pid) {
|
|
237
|
+
entries.push({ path: filePath, identity });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const liveCount = countLiveEntries(
|
|
241
|
+
entries,
|
|
242
|
+
deps.isSameLiveProcess,
|
|
243
|
+
deps.removeFile,
|
|
244
|
+
);
|
|
245
|
+
const decision = decideBoot(true, liveCount, deps.ceiling);
|
|
246
|
+
if (!decision.allow) {
|
|
247
|
+
return { decision, registryPath: null };
|
|
248
|
+
}
|
|
249
|
+
const registryPath = path.join(deps.registryDir, `pid-${deps.pid}.json`);
|
|
250
|
+
deps.writeEntry(registryPath, { pid: deps.pid, startTime: deps.startTime });
|
|
251
|
+
return { decision, registryPath };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Best-effort self-cleanup on shutdown — mirrors removeLeaseIfOwned
|
|
255
|
+
// (socket-lease.ts): only remove the entry if it still names us, so a
|
|
256
|
+
// displaced/superseded record from a different process is never deleted.
|
|
257
|
+
export function releaseRegistration(
|
|
258
|
+
registryPath: string,
|
|
259
|
+
myPid: number,
|
|
260
|
+
readEntry: (filePath: string) => ProcessIdentity | null,
|
|
261
|
+
removeFile: (filePath: string) => void,
|
|
262
|
+
): void {
|
|
263
|
+
const entry = readEntry(registryPath);
|
|
264
|
+
if (entry !== null && entry.pid === myPid) {
|
|
265
|
+
try {
|
|
266
|
+
removeFile(registryPath);
|
|
267
|
+
} catch {
|
|
268
|
+
// Best-effort; a leftover entry naming a dead pid is harmless — the
|
|
269
|
+
// next boot's sweep reclaims it.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// `myStartTime` is threaded in rather than recomputed here so callers (only
|
|
275
|
+
// server.ts today) fingerprint themselves exactly once at startup and reuse
|
|
276
|
+
// that same read for both the registry entry and the socket lease — two
|
|
277
|
+
// independent `ps` calls could theoretically observe different processes if
|
|
278
|
+
// this pid were somehow recycled between them.
|
|
279
|
+
export function realBreakerDeps(
|
|
280
|
+
myStartTime: string | null,
|
|
281
|
+
overrides: Partial<BreakerDeps> = {},
|
|
282
|
+
): BreakerDeps {
|
|
283
|
+
return {
|
|
284
|
+
isolated: Boolean(process.env["CC_CANDYBAR_SOCKET"]),
|
|
285
|
+
registryDir: daemonRegistryDir(),
|
|
286
|
+
ceiling: daemonCeiling(),
|
|
287
|
+
pid: process.pid,
|
|
288
|
+
startTime: myStartTime,
|
|
289
|
+
isSameLiveProcess: (pid, startTime) =>
|
|
290
|
+
sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
|
|
291
|
+
listFiles: listRegistryFiles,
|
|
292
|
+
readEntry: readRegistryEntry,
|
|
293
|
+
removeFile: (filePath) => {
|
|
294
|
+
try {
|
|
295
|
+
fs.unlinkSync(filePath);
|
|
296
|
+
} catch {
|
|
297
|
+
// best-effort
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
// [LAW:one-source-of-truth] Same write-tmp-then-rename shape as
|
|
301
|
+
// socket-lease.ts's writeLease, so it gets the same cleanup: if
|
|
302
|
+
// writeFileSync succeeds but renameSync fails, best-effort unlink the tmp
|
|
303
|
+
// file (tolerating ENOENT — writeFileSync itself may have been what
|
|
304
|
+
// failed) before rethrowing, so a write failure never leaves an orphaned
|
|
305
|
+
// `.tmp` file behind (listRegistryFiles only collects `*.json`, so a
|
|
306
|
+
// stray `.tmp` would never be swept).
|
|
307
|
+
writeEntry: (filePath, identity) => {
|
|
308
|
+
const tmp = `${filePath}.${identity.pid}.tmp`;
|
|
309
|
+
try {
|
|
310
|
+
fs.writeFileSync(tmp, JSON.stringify(identity), { mode: 0o600 });
|
|
311
|
+
fs.renameSync(tmp, filePath);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
try {
|
|
314
|
+
fs.unlinkSync(tmp);
|
|
315
|
+
} catch (cleanupErr) {
|
|
316
|
+
if ((cleanupErr as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
317
|
+
// Best-effort cleanup failed for a reason other than "never
|
|
318
|
+
// created" — the original error is still the one that matters,
|
|
319
|
+
// so it is not swallowed; a leaked tmp file here is a secondary
|
|
320
|
+
// symptom the next admission's stale-sweep does not reclaim
|
|
321
|
+
// (only *.json is collected), but it is not this daemon's job to
|
|
322
|
+
// retry a failing filesystem.
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
throw e;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
// [LAW:single-enforcer] Two levels, mirroring ensureSocketParentSafe's own
|
|
329
|
+
// shape, but ONLY for the default (unoverridden) registry path: its
|
|
330
|
+
// parent is the shared UID-anchored /tmp root an attacker could pre-plant
|
|
331
|
+
// as a symlink before any daemon has ever run, and `lstatSync` only
|
|
332
|
+
// inspects a path's FINAL component — verifying the leaf alone lets a
|
|
333
|
+
// symlinked parent be silently followed by `mkdirSync({recursive:true})`,
|
|
334
|
+
// after which the freshly-created leaf looks perfectly clean (owned by
|
|
335
|
+
// us, 0700) despite living inside attacker-controlled storage. An
|
|
336
|
+
// OVERRIDDEN registry dir (CC_CANDYBAR_DAEMON_REGISTRY_DIR, tests only)
|
|
337
|
+
// has no such shared root by construction — its parent is whatever
|
|
338
|
+
// directory the caller happened to put it under (a system tmpdir on some
|
|
339
|
+
// platforms), which is not a boundary this breaker owns or should assert
|
|
340
|
+
// on; there the one-level leaf check alone is the correct, portable
|
|
341
|
+
// parity with how ensureSocketParentSafe treats an overridden
|
|
342
|
+
// CC_CANDYBAR_SOCKET (exactly one level, whatever that parent is).
|
|
343
|
+
ensureDirSafe: process.env["CC_CANDYBAR_DAEMON_REGISTRY_DIR"]
|
|
344
|
+
? ensureOwnedPrivateDir
|
|
345
|
+
: (dir: string): void => {
|
|
346
|
+
ensureOwnedPrivateDir(path.dirname(dir));
|
|
347
|
+
ensureOwnedPrivateDir(dir);
|
|
348
|
+
},
|
|
349
|
+
...overrides,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -20,7 +20,14 @@ import process from "node:process";
|
|
|
20
20
|
|
|
21
21
|
export const PARENT_PID_ENV = "CC_CANDYBAR_PARENT_PID";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// [LAW:verifiable-goals] Only ever consulted for an ANCHORED (test) daemon —
|
|
24
|
+
// "outlives-nobody" (the production daemon) arms no timer at all, so
|
|
25
|
+
// tightening this is invisible to production. A dead-runner orphan can live
|
|
26
|
+
// at most one poll tick before the watchdog trips; 250ms (down from 1000ms,
|
|
27
|
+
// brandon-daemon-lifecycle-gad.1) shrinks that window fourfold without
|
|
28
|
+
// meaningfully increasing CPU — polling a single `kill(pid,0)` 4x/sec is
|
|
29
|
+
// negligible next to the daemon work it guards.
|
|
30
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
24
31
|
|
|
25
32
|
export type LivenessAnchor =
|
|
26
33
|
| { kind: "outlives-nobody" }
|