@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.21.0",
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.21.0",
99
- "@promptctl/cc-candybar-darwin-x64": "1.21.0",
100
- "@promptctl/cc-candybar-linux-x64": "1.21.0",
101
- "@promptctl/cc-candybar-linux-arm64": "1.21.0"
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
  }
@@ -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
- export function cooldownDecision(ageMs: number | null): CooldownDecision {
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 < SPAWN_COOLDOWN_MS) return { kind: "deny" };
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) — 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].
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 path = spawnCooldownPath();
356
- const decision = cooldownDecision(cooldownAgeMs(path));
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(path);
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
- const DEFAULT_POLL_INTERVAL_MS = 1000;
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" }