@promptctl/cc-candybar 1.16.0 → 1.17.1

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.16.0",
3
+ "version": "1.17.1",
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.16.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.16.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.16.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.16.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.17.1",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.17.1",
96
+ "@promptctl/cc-candybar-linux-x64": "1.17.1",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.17.1"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -185,6 +185,18 @@ export const DEFAULT_DSL_CONFIG = {
185
185
  },
186
186
  "session.id": { kind: "input", path: "session_id", default: "" },
187
187
  version: { kind: "input", path: "version", default: "" },
188
+ // [LAW:one-source-of-truth] The daemon-resolved effective theme name —
189
+ // effectiveThemeName(sessionState.theme, globals.palette), the SAME name the
190
+ // rendered basePalette is built from. A theme-picker config's trigger reads
191
+ // `{{ .theme.effective }}` to show the active theme, so the label and the
192
+ // colors trace to one resolution and cannot drift — no per-config restating
193
+ // of the initial theme (which JSON5, being inert data, cannot derive). The
194
+ // daemon always provides it; the "" default is the unreachable-absence floor.
195
+ "theme.effective": {
196
+ kind: "input",
197
+ path: "theme.effective",
198
+ default: "",
199
+ },
188
200
 
189
201
  // [LAW:one-source-of-truth] The usable terminal width for THIS render —
190
202
  // the exact post-reserve cell count FlexStrip wraps to. renderDsl injects
@@ -95,19 +95,43 @@ export function ensureSocketParentSafe(sockPath: string): void {
95
95
  // If a stale socket file is a symlink, refuse — an attacker who briefly
96
96
  // had write access to a previously-permissive dir could have planted a
97
97
  // symlink even after we tighten perms.
98
- try {
99
- const sst = fs.lstatSync(sockPath);
100
- if (sst.isSymbolicLink()) {
101
- throw new Error(`socket path is a symlink: ${sockPath}`);
98
+ //
99
+ // [LAW:single-enforcer] The lease (`${sockPath}.lease`) gets the SAME gate:
100
+ // it is now load-bearing (ownership authority — readLease follows a symlink
101
+ // and a planted `lease /dev/null` would read `absent`/`unreadable`, forcing
102
+ // a false reclaim that unlinks a live daemon's socket). One enforcer certifies
103
+ // every path we bind/read/write under this parent, so the socket and its lease
104
+ // can never diverge on "is this a symlink".
105
+ for (const p of [sockPath, leasePathFor(sockPath)]) {
106
+ try {
107
+ if (fs.lstatSync(p).isSymbolicLink()) {
108
+ throw new Error(`path is a symlink: ${p}`);
109
+ }
110
+ } catch (e) {
111
+ const code = (e as NodeJS.ErrnoException).code;
112
+ if (code !== "ENOENT") throw e;
102
113
  }
103
- } catch (e) {
104
- const code = (e as NodeJS.ErrnoException).code;
105
- if (code !== "ENOENT") throw e;
106
114
  }
107
115
  }
108
116
 
109
- export function pidPath(): string {
110
- return path.join(stateDir(), "pid");
117
+ // [LAW:one-source-of-truth] The socket-ownership lease is DERIVED FROM the
118
+ // socket path, so it shares the socket's identity root. The old diagnostic
119
+ // pidfile lived under $XDG_STATE_HOME while the socket lived under /tmp — two
120
+ // identity roots for one instance, and CC_CANDYBAR_SOCKET isolated only one of
121
+ // them. Anchoring the lease to socketPath() means test/dev isolation via
122
+ // CC_CANDYBAR_SOCKET isolates the lease too. The lease subsumes the old
123
+ // diagnostic pidfile: it carries the owner pid (the reclaim authority) plus the
124
+ // same diagnostic fields.
125
+ // [LAW:one-source-of-truth] The socket→lease derivation lives here alone, so
126
+ // leasePath() (the runtime authority path) and ensureSocketParentSafe's symlink
127
+ // gate agree by construction. Takes the socket path explicitly so the safety
128
+ // check certifies the exact path it was handed, not a re-derived one.
129
+ export function leasePathFor(sockPath: string): string {
130
+ return `${sockPath}.lease`;
131
+ }
132
+
133
+ export function leasePath(): string {
134
+ return leasePathFor(socketPath());
111
135
  }
112
136
 
113
137
  export function sessionStatePath(): string {
@@ -33,7 +33,6 @@ import type { ContextProvider } from "../segments/context.js";
33
33
  import type { MetricsProvider } from "../segments/metrics.js";
34
34
  import type { TmuxService } from "../segments/tmux.js";
35
35
  import type { GitDataProvider } from "./cache/git.js";
36
- import type { SessionStateRW } from "./session-state.js";
37
36
 
38
37
  // ─── Augmented payload shape ─────────────────────────────────────────────────
39
38
 
@@ -54,7 +53,16 @@ export interface RenderPayload extends ClaudeHookData {
54
53
 
55
54
  readonly git?: GitPayload;
56
55
  readonly tmux?: { readonly session: string };
57
- readonly theme?: string;
56
+ // [LAW:one-source-of-truth] The daemon-resolved effective theme name —
57
+ // effectiveThemeName(sessionState.theme, globals.palette). The SAME value the
58
+ // rendered basePalette is built from, surfaced so a trigger label can display
59
+ // the active theme WITHOUT the config restating it (the label and the colors
60
+ // trace to one resolution and cannot drift).
61
+ // [LAW:types-are-the-program] REQUIRED, not optional: the daemon resolves it
62
+ // every render and buildRenderPayload includes it unconditionally, so the
63
+ // domain truth is "always present". A `?` here would let a callsite believe it
64
+ // could be undefined and guard defensively against an impossibility.
65
+ readonly theme: { readonly effective: string };
58
66
 
59
67
  // Usage-family. Each provider returns null when it has no data (no
60
68
  // transcript yet, no rate-limit window active, etc.); we drop the field
@@ -206,7 +214,6 @@ export interface RenderPayloadDeps {
206
214
  readonly contextProvider: ContextProvider;
207
215
  readonly metricsProvider: MetricsProvider;
208
216
  readonly tmuxService: TmuxService;
209
- readonly sessionState: SessionStateRW;
210
217
  // [LAW:single-enforcer] The log capability for every provider lane:
211
218
  // buildRenderPayload is the ONE place lane failures are logged, so the
212
219
  // providers' interiors never log and never double-log.
@@ -568,6 +575,13 @@ export async function buildRenderPayload(
568
575
  // registration; passing it in (rather than recomputing per render) keeps
569
576
  // the hot path free of the BFS + extractTemplateRefs cost.
570
577
  neededInputPaths: ReadonlySet<string>,
578
+ // [LAW:one-source-of-truth] The effective theme name, resolved ONCE by the
579
+ // daemon (effectiveThemeName(sessionState.theme, globals.palette)) and used
580
+ // for BOTH the rendered basePalette and this payload field — so a trigger
581
+ // label reading `.theme.effective` can never disagree with the colors. Passed
582
+ // in (not re-resolved here) because the daemon already computes it for the
583
+ // palette; this is that same value, threaded to the sole payload assembler.
584
+ effectiveTheme: string,
571
585
  ): Promise<RenderPayload> {
572
586
  const wants = (prefix: string): boolean =>
573
587
  anyPathStartsWith(neededInputPaths, prefix);
@@ -718,15 +732,6 @@ export async function buildRenderPayload(
718
732
  }
719
733
  : undefined;
720
734
 
721
- // [LAW:one-source-of-truth] The theme variable surfaces the session's
722
- // resolved theme so the toolbar/tray DSL templates can encode it into
723
- // cc-candybar:// URLs without re-resolving. SessionState owns the value;
724
- // we mirror it onto the payload, not redeclare it.
725
- const themeRaw = wants("theme")
726
- ? deps.sessionState.get(hookData.session_id, "theme")
727
- : undefined;
728
- const theme = typeof themeRaw === "string" ? themeRaw : undefined;
729
-
730
735
  // home is always available — it's a single env-var read, no I/O cost.
731
736
  // Letting the gate skip it would add a branch with no win.
732
737
  // [LAW:single-enforcer] All path-shaped payload fields are normalized
@@ -778,7 +783,11 @@ export async function buildRenderPayload(
778
783
  ...(home !== undefined && { home }),
779
784
  ...(gitProjection.git !== undefined && { git: gitProjection.git }),
780
785
  ...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
781
- ...(theme !== undefined && { theme }),
786
+ // [LAW:one-source-of-truth] Always present the daemon resolves the
787
+ // effective theme every render (for basePalette), and this is that value.
788
+ // No `wants` gate: it costs nothing (a string already in hand) and a
789
+ // config that reads `.theme.effective` must always find it.
790
+ theme: { effective: effectiveTheme },
782
791
  ...(sessionPayload !== undefined && { session: sessionPayload }),
783
792
  ...(todayPayload !== undefined && { today: todayPayload }),
784
793
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
@@ -6,10 +6,17 @@ import { parseArgs } from "node:util";
6
6
  import {
7
7
  daemonDir,
8
8
  ensureSocketParentSafe,
9
- pidPath,
9
+ leasePath,
10
+ leasePathFor,
10
11
  socketPath,
11
12
  sessionStatePath,
12
13
  } from "./paths";
14
+ import {
15
+ arbitrateSocket,
16
+ readLease,
17
+ removeLeaseIfOwned,
18
+ writeLease,
19
+ } from "./socket-lease";
13
20
  import { dlog, closeLog } from "./log";
14
21
  import {
15
22
  PROTOCOL_VERSION,
@@ -184,6 +191,13 @@ function bindOrAttachAndExit(
184
191
  retried: boolean,
185
192
  ): void {
186
193
  server.removeAllListeners("error");
194
+ // [LAW:no-ambient-temporal-coupling] server.listen(path, cb) registers cb as
195
+ // a ONE-TIME 'listening' listener. A first listen that fails EADDRINUSE never
196
+ // fires 'listening', so its callback stays pending; the reclaim retry adds a
197
+ // second. Without clearing the stale one, a successful rebind fires BOTH and
198
+ // onListening runs twice — double-arming the RSS backstop + watchers. Clear
199
+ // pending 'listening' listeners so exactly one onListening fires per bind.
200
+ server.removeAllListeners("listening");
187
201
  server.once("error", (err) => {
188
202
  const code = (err as NodeJS.ErrnoException).code;
189
203
  if (code !== "EADDRINUSE") {
@@ -198,36 +212,40 @@ function bindOrAttachAndExit(
198
212
  process.exit(0);
199
213
  return;
200
214
  }
201
- void handleAddressInUse(server, sockPath);
215
+ handleAddressInUse(server, sockPath);
202
216
  });
203
217
  server.listen(sockPath, () => onListening(sockPath));
204
218
  }
205
219
 
206
- async function handleAddressInUse(
207
- server: net.Server,
208
- sockPath: string,
209
- ): Promise<void> {
210
- // EADDRINUSE: either a live daemon (we are a duplicate exit), or a stale
211
- // socket file from a crashed prior daemon (unlink + rebind).
212
- const alive = await isSocketAlive(sockPath);
213
- if (alive) {
214
- dlog("info", "another daemon is listening on socket exiting");
215
- process.exit(0);
216
- }
217
- // Race-window guard: between our first `isSocketAlive` returning false and
218
- // our `unlinkSync` running, another concurrent recoverer could unlink+bind
219
- // the path. Without re-checking, our unlink would remove their *live*
220
- // socket, leaving two daemons (one orphaned-but-listening, one freshly
221
- // bound). Re-check immediately before unlink. If a live listener appeared
222
- // between checks, exit instead of stomping on it.
223
- if (await isSocketAlive(sockPath)) {
224
- dlog(
225
- "info",
226
- "race: another daemon claimed the socket during recovery — exiting",
227
- );
220
+ // [LAW:one-source-of-truth] EADDRINUSE arbitration consults the socket-derived
221
+ // pid lease, NEVER a connect probe. The path already exists (a live daemon, or
222
+ // a stale file from a crashed one); the lease's owner pid + kill(pid,0) decides
223
+ // which. This is fully synchronous — reading the lease and testing liveness are
224
+ // both sync so there is no await gap for a concurrent recoverer to race
225
+ // through (the old async probe had two such gaps and a hand-rolled re-check).
226
+ //
227
+ // [LAW:effects-at-boundaries] The decision is the pure arbitrateSocket fold
228
+ // over the lease read + injected pidAlive; the kill / unlink / rebind effects
229
+ // are performed here at the edge.
230
+ function handleAddressInUse(server: net.Server, sockPath: string): void {
231
+ // [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
232
+ // through unlink + rebind below, not the re-derived global — one identity
233
+ // source for the whole arbitration.
234
+ const decision = arbitrateSocket(readLease(leasePathFor(sockPath)), pidAlive);
235
+ if (decision.kind === "attach-and-exit") {
236
+ dlog("info", `EADDRINUSE: ${decision.reason} exiting`);
228
237
  process.exit(0);
238
+ // [LAW:no-ambient-temporal-coupling] process.exit() halts synchronously, so
239
+ // the reclaim below is already unreachable — but the explicit return makes
240
+ // that structural, matching the sibling `retried` branch, so no future
241
+ // refactor of the exit path can accidentally fall through to unlinking a
242
+ // live daemon's socket.
243
+ return;
229
244
  }
230
- dlog("warn", "stale socket from crashed daemon — unlinking and rebinding");
245
+ dlog(
246
+ "warn",
247
+ `EADDRINUSE: ${decision.reason} — unlinking stale socket and rebinding`,
248
+ );
231
249
  // [LAW:no-defensive-null-guards] If unlink fails (permissions, read-only
232
250
  // FS), the retry will hit EADDRINUSE again, exit 0, and leave the system
233
251
  // in the worst state: no daemon + stale socket blocking future starts.
@@ -248,61 +266,21 @@ async function handleAddressInUse(
248
266
  bindOrAttachAndExit(server, sockPath, /* retried */ true);
249
267
  }
250
268
 
251
- // [LAW:no-defensive-null-guards] Three-state outcome distinguishes
252
- // "definitely no listener" from "probably alive but slow." Callers that
253
- // might destroy state on "no listener" (the stale-socket unlink path) must
254
- // treat "unknown" as alive to avoid stomping on a slow live daemon.
255
- type SocketAliveness = "alive" | "dead" | "unknown";
256
-
257
- function probeSocket(sockPath: string): Promise<SocketAliveness> {
258
- return new Promise((resolve) => {
259
- const sock = net.connect(sockPath);
260
- let settled = false;
261
- let timer: ReturnType<typeof setTimeout> | null = null;
262
- const done = (result: SocketAliveness): void => {
263
- if (settled) return;
264
- settled = true;
265
- if (timer) clearTimeout(timer);
266
- sock.removeAllListeners();
267
- sock.destroy();
268
- resolve(result);
269
- };
270
- sock.once("connect", () => done("alive"));
271
- sock.once("error", (err) => {
272
- // Only these error codes definitively mean "no listener at this path":
273
- // ECONNREFUSED — socket file present, kernel rejected connect
274
- // ENOENT — socket file absent
275
- // ENOTSOCK — path exists but isn't a socket
276
- // Anything else (EPERM, EACCES, EAGAIN, …) is ambiguous — assume the
277
- // daemon is alive to avoid destructive false negatives.
278
- const code = (err as NodeJS.ErrnoException).code;
279
- const dead =
280
- code === "ECONNREFUSED" || code === "ENOENT" || code === "ENOTSOCK";
281
- done(dead ? "dead" : "unknown");
282
- });
283
- // 50ms is generous; localhost AF_UNIX connect is sub-ms when a listener
284
- // exists. Timeout means "we couldn't tell" — treat as unknown so the
285
- // stale-socket recovery path doesn't unlink a live (but slow) daemon's
286
- // socket.
287
- timer = setTimeout(() => done("unknown"), 50);
288
- timer.unref();
289
- });
290
- }
291
-
292
- // Convenience: callers that just want "is something listening" treat
293
- // "unknown" as alive (conservative — used by the EADDRINUSE attach branch).
294
- async function isSocketAlive(sockPath: string): Promise<boolean> {
295
- const result = await probeSocket(sockPath);
296
- return result !== "dead";
297
- }
298
-
299
269
  function onListening(sockPath: string): void {
270
+ // [LAW:no-ambient-temporal-coupling] Claim ownership FIRST — before chmod or
271
+ // any other post-bind work — so the window between winning the bind and the
272
+ // lease naming us is as small as possible. A second daemon that binds in that
273
+ // sub-ms gap reads an absent lease and reclaims (displacing us); the
274
+ // ownership self-check (brandon-daemon-lifecycle-2b3.2) makes that
275
+ // self-healing, but keeping the window minimal keeps it vanishingly rare.
276
+ // [LAW:one-source-of-truth] Derive the lease from the same sockPath we bound,
277
+ // matching handleAddressInUse's read — one identity source across write + read.
278
+ writeLeaseFile(sockPath);
300
279
  try {
301
280
  fs.chmodSync(sockPath, 0o600);
302
281
  } catch (e) {
303
282
  dlog("warn", `chmod socket failed: ${(e as Error).message}`);
304
283
  }
305
- writePidfileDiagnostic();
306
284
  dlog(
307
285
  "info",
308
286
  `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
@@ -365,40 +343,29 @@ function armLimits(): void {
365
343
  limits.arm();
366
344
  }
367
345
 
368
- // --- diagnostic pidfile ---
346
+ // --- socket-ownership lease ---
369
347
  //
370
- // [LAW:one-source-of-truth] The pidfile is *diagnostic only*. It records who
371
- // the running daemon is so `daemon-stats` can report it; it plays no role in
372
- // exclusion. Exclusion is the atomic bind() in bindOrAttachAndExit().
348
+ // [LAW:one-source-of-truth] The lease is the authority for socket ownership —
349
+ // its owner pid + kill(pid,0) is what the next daemon's EADDRINUSE arbitration
350
+ // consults (handleAddressInUse). Exclusion RIGHT NOW is still the atomic bind()
351
+ // in bindOrAttachAndExit(); the lease answers the separate question "may I
352
+ // destroy this existing path" over time. It also carries the same diagnostic
353
+ // fields the old pidfile did, so one file serves both roles.
373
354
  //
374
- // Overwrite-on-write (no EEXIST check). If a stale pidfile exists from a
375
- // crashed prior daemon, we replace it. The bind() above already proved no
376
- // other daemon is alive.
355
+ // Overwrite-on-write (no EEXIST check). We only reach onListening after winning
356
+ // the bind, so whatever stale lease a dead owner left is ours to replace. Write
357
+ // failure is non-fatal: the lease is best-effort ownership signalling on top of
358
+ // bind()'s hard exclusion; a missing lease degrades a future arbitration to
359
+ // "reclaim" (unlink + rebind), never to a wrong attach.
377
360
 
378
- function writePidfileDiagnostic(): void {
379
- const payload = JSON.stringify({
361
+ function writeLeaseFile(sockPath: string): void {
362
+ const reason = writeLease(leasePathFor(sockPath), {
380
363
  pid: process.pid,
381
364
  version: PROTOCOL_VERSION,
382
365
  binPath: process.argv[1],
383
366
  startedAt: new Date().toISOString(),
384
367
  });
385
- try {
386
- fs.writeFileSync(pidPath(), payload, { mode: 0o600 });
387
- // [LAW:single-enforcer] writeFileSync's `mode` only applies when the file
388
- // is created. If a stale pidfile from a prior run was left with broader
389
- // permissions, the write above won't tighten them — chmod explicitly so
390
- // 0600 is the invariant regardless of prior state.
391
- fs.chmodSync(pidPath(), 0o600);
392
- } catch (e) {
393
- // Diagnostic only — failure does not block the daemon from serving.
394
- dlog("warn", `pidfile write failed: ${(e as Error).message}`);
395
- }
396
- }
397
-
398
- function removePidfileDiagnostic(): void {
399
- try {
400
- fs.unlinkSync(pidPath());
401
- } catch {}
368
+ if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
402
369
  }
403
370
 
404
371
  let inFlight = 0;
@@ -465,7 +432,11 @@ function shutdown(code: number): void {
465
432
  } catch (e) {
466
433
  dlog("warn", `sessionState flush failed: ${(e as Error).message}`);
467
434
  }
468
- removePidfileDiagnostic();
435
+ // [LAW:one-source-of-truth] Remove the lease only if it still names us. A
436
+ // displaced daemon (socket stolen, thief wrote its own lease) must not delete
437
+ // the live owner's lease on its way out, or the next EADDRINUSE would read
438
+ // `absent` and reclaim the thief's live socket — cascading the theft.
439
+ removeLeaseIfOwned(leasePath(), process.pid);
469
440
  closeLog();
470
441
  process.exit(code);
471
442
  }
@@ -745,24 +716,30 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
745
716
  // No special-case branches — same composition every render.
746
717
  let body = "";
747
718
  if (entry.state !== null) {
719
+ // [LAW:one-source-of-truth] Resolve the effective theme ONCE — the
720
+ // session's chosen theme (SessionState) over the config default. This
721
+ // single name drives BOTH the payload's `theme.effective` field (the
722
+ // trigger label reads it) AND the rendered basePalette below, so a label
723
+ // and the colors can never disagree. Resolved here, before the payload
724
+ // build, so it can be threaded into the sole payload assembler.
725
+ const effectiveTheme = effectiveThemeName(
726
+ sessionState.get(req.hookData.session_id, "theme"),
727
+ entry.state.config.globals.palette,
728
+ );
748
729
  const payload = await buildRenderPayload(
749
730
  req.hookData,
750
731
  payloadDeps,
751
732
  req.cwd,
752
733
  entry.state.neededInputPaths,
734
+ effectiveTheme,
753
735
  );
754
736
  // [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
755
- // is derived per render from the effective theme — the session's chosen
756
- // theme (SessionState) over the config default so a theme click
757
- // recolors the whole bar on the next render. Not frozen on the cache
758
- // entry (one entry serves many sessions). resolverForThemeName memoizes,
759
- // so the per-render cost is one Map lookup once the theme is warm.
760
- const basePalette = resolverForThemeName(
761
- effectiveThemeName(
762
- sessionState.get(req.hookData.session_id, "theme"),
763
- entry.state.config.globals.palette,
764
- ),
765
- );
737
+ // is derived from the same effective theme resolved above so a theme
738
+ // click recolors the whole bar on the next render. Not frozen on the
739
+ // cache entry (one entry serves many sessions). resolverForThemeName
740
+ // memoizes, so the per-render cost is one Map lookup once the theme is
741
+ // warm.
742
+ const basePalette = resolverForThemeName(effectiveTheme);
766
743
  // [LAW:one-type-per-behavior][LAW:dataflow-not-control-flow] The powerline
767
744
  // cap/separator SHAPE, resolved per render the exact way the theme is: the
768
745
  // session's clicked style (SessionState) over the config default over the
@@ -1139,7 +1116,6 @@ const payloadDeps = {
1139
1116
  contextProvider,
1140
1117
  metricsProvider,
1141
1118
  tmuxService,
1142
- sessionState,
1143
1119
  // [LAW:single-enforcer] buildRenderPayload is the one log site for the
1144
1120
  // outcome-carrying provider lanes (git, cache).
1145
1121
  log: dlog,
@@ -0,0 +1,181 @@
1
+ import fs from "node:fs";
2
+ import process from "node:process";
3
+
4
+ // ─── Socket ownership lease ──────────────────────────────────────────────────
5
+ //
6
+ // [LAW:one-source-of-truth] The authority for "who owns this socket path" is a
7
+ // lease file DERIVED FROM the socket path (one lease per socket identity), not a
8
+ // connect probe. The prior probe (connect + classify the error code) was a
9
+ // representation lie on macOS/BSD: connect(2) on a LIVE AF_UNIX listener whose
10
+ // accept backlog is full returns ECONNREFUSED — indistinguishable from a dead
11
+ // socket — so under load the probe judged healthy-but-slow daemons dead and
12
+ // their sockets were stolen, minting immortal orphans (see
13
+ // brandon-daemon-lifecycle-2b3).
14
+ //
15
+ // [FRAMING:representation] The lease records the owner's pid; liveness is
16
+ // kill(pid, 0) — the kernel's process table, which is load-independent truth
17
+ // about process existence. A connect sample is load-dependent hearsay and must
18
+ // never be the authority that destroys another daemon's socket.
19
+
20
+ // What a lease read yielded. Distinguishing absent / unreadable / owned keeps
21
+ // the arbitration decision a full enumeration over raw inputs (see
22
+ // arbitrateSocket) rather than collapsing the ambiguous cases early.
23
+ export type LeaseRead =
24
+ | { kind: "absent" }
25
+ | { kind: "unreadable"; detail: string }
26
+ | { kind: "owned"; pid: number };
27
+
28
+ // The EADDRINUSE arbitration outcome. The path already exists (something bound
29
+ // it or a stale file remains); this says whether a LIVE owner holds it.
30
+ export type SocketArbitration =
31
+ | { kind: "attach-and-exit"; reason: string }
32
+ | { kind: "reclaim"; reason: string };
33
+
34
+ // Diagnostic-rich lease payload. `pid` is the authority (liveness); the rest is
35
+ // operator diagnostics carried in the same file so the lease subsumes the old
36
+ // diagnostic pidfile — one file, one identity root.
37
+ export interface LeaseRecord {
38
+ pid: number;
39
+ version: number;
40
+ binPath: string | undefined;
41
+ startedAt: string;
42
+ }
43
+
44
+ // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
45
+ // arbitration is a pure fold: (raw lease read, injected liveness predicate) →
46
+ // decision. kill(2) is the sole effect and it is injected, so every branch is
47
+ // exercised by input enumeration with no real processes. Full input space:
48
+ // owned + alive → attach-and-exit (a live owner holds the path; we are a
49
+ // duplicate — exit so the incumbent keeps serving)
50
+ // owned + dead → reclaim (owner crashed; the socket is stale)
51
+ // absent → reclaim (no lease to consult — a pre-lease or crashed
52
+ // daemon left a stale socket; prefer availability)
53
+ // unreadable → reclaim (can't prove a live owner — same)
54
+ //
55
+ // Failure directions:
56
+ // false-dead (steal a live socket) — made self-healing by the ownership
57
+ // self-check (brandon-daemon-lifecycle-2b3.2).
58
+ // false-alive (the lease pid is alive but is NOT this daemon — a crashed
59
+ // daemon's pid recycled to an unrelated process) — we attach-and-exit
60
+ // without serving. Short-lived recycle self-corrects next tick; a LONG-LIVED
61
+ // recycle reads alive on every start, so no daemon comes up until the stale
62
+ // lease is cleared. Fully closing this needs a pid-identity fingerprint
63
+ // (process start-time, or an fd/flock held for the daemon's lifetime) — the
64
+ // lock-based liveness the epic deferred. connect() can't substitute: it
65
+ // can't tell a busy-live daemon with a full accept backlog (ECONNREFUSED —
66
+ // the exact storm this replaces) from a recycled non-daemon pid. It is
67
+ // strictly rarer than that storm (needs an unclean death skipping lease
68
+ // cleanup AND a pid recycle to a long-lived process within the respawn
69
+ // window).
70
+ // Reclaiming on missing/unreadable is the availability-preferring direction — a
71
+ // stale socket with no reclaimer is a hard no-service stall, strictly worse than
72
+ // a transient double-serve that self-heals.
73
+ export function arbitrateSocket(
74
+ read: LeaseRead,
75
+ isAlive: (pid: number) => boolean,
76
+ ): SocketArbitration {
77
+ if (read.kind === "owned") {
78
+ return isAlive(read.pid)
79
+ ? {
80
+ kind: "attach-and-exit",
81
+ reason: `live owner pid=${read.pid} holds the socket`,
82
+ }
83
+ : { kind: "reclaim", reason: `owner pid=${read.pid} is gone (ESRCH)` };
84
+ }
85
+ if (read.kind === "absent") {
86
+ return {
87
+ kind: "reclaim",
88
+ reason: "no lease — stale socket, no live owner",
89
+ };
90
+ }
91
+ return {
92
+ kind: "reclaim",
93
+ reason: `unreadable lease (${read.detail}) — cannot prove a live owner`,
94
+ };
95
+ }
96
+
97
+ // [LAW:no-silent-failure] Every non-happy path becomes a typed `unreadable`
98
+ // with the reason inline, never a swallowed default that pretends the lease said
99
+ // something. ENOENT is the one benign case → `absent`.
100
+ export function readLease(leasePath: string): LeaseRead {
101
+ let raw: string;
102
+ try {
103
+ raw = fs.readFileSync(leasePath, "utf8");
104
+ } catch (e) {
105
+ const code = (e as NodeJS.ErrnoException).code;
106
+ if (code === "ENOENT") return { kind: "absent" };
107
+ return {
108
+ kind: "unreadable",
109
+ detail: `read failed: ${(e as Error).message}`,
110
+ };
111
+ }
112
+ let parsed: unknown;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ } catch (e) {
116
+ return { kind: "unreadable", detail: `bad JSON: ${(e as Error).message}` };
117
+ }
118
+ const pid = (parsed as { pid?: unknown } | null)?.pid;
119
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
120
+ return {
121
+ kind: "unreadable",
122
+ detail: `no valid pid (got ${JSON.stringify(pid)})`,
123
+ };
124
+ }
125
+ return { kind: "owned", pid };
126
+ }
127
+
128
+ // Write our lease after we win the bind. Overwrite-on-write: whoever holds the
129
+ // socket right now replaces whatever stale lease a dead owner left.
130
+ //
131
+ // [LAW:no-ambient-temporal-coupling] Atomic publish: write a uniquely-named temp
132
+ // sibling then rename onto the lease path. POSIX rename within a directory is
133
+ // atomic, so a concurrent reader (a second daemon's EADDRINUSE arbitration) sees
134
+ // either the old lease or the complete new one — never a truncated/empty file
135
+ // mid-write, which would read as `unreadable` and force a false reclaim of this
136
+ // daemon's live socket. The unique temp name means it is always freshly created,
137
+ // so its 0600 mode always applies (0600 has no group/world bits for any umask to
138
+ // need re-tightening) and rename carries that mode across. Returns null on
139
+ // success, a reason on failure.
140
+ export function writeLease(
141
+ leasePath: string,
142
+ record: LeaseRecord,
143
+ ): string | null {
144
+ const tmp = `${leasePath}.${process.pid}.tmp`;
145
+ try {
146
+ fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
147
+ fs.renameSync(tmp, leasePath);
148
+ return null;
149
+ } catch (e) {
150
+ const reason = (e as Error).message;
151
+ // [LAW:no-silent-failure] Best-effort temp cleanup, but don't swallow a real
152
+ // failure: ENOENT means the temp was never created (write failed first) —
153
+ // benign; any other unlink error means the temp WAS created and now leaks,
154
+ // so append it to the reason rather than hiding it behind the primary error.
155
+ try {
156
+ fs.unlinkSync(tmp);
157
+ } catch (cleanupErr) {
158
+ if ((cleanupErr as NodeJS.ErrnoException).code !== "ENOENT") {
159
+ return `${reason}; also failed to remove temp ${tmp}: ${(cleanupErr as Error).message}`;
160
+ }
161
+ }
162
+ return reason;
163
+ }
164
+ }
165
+
166
+ // [LAW:one-source-of-truth] Only the owner mutates its lease. A daemon that was
167
+ // displaced (its socket stolen, the thief wrote a new lease) must NOT delete the
168
+ // current owner's lease on its way out — that would make the next EADDRINUSE
169
+ // read `absent` and reclaim the live thief's socket, cascading the theft. So
170
+ // remove only when the lease still names us.
171
+ export function removeLeaseIfOwned(leasePath: string, myPid: number): void {
172
+ const read = readLease(leasePath);
173
+ if (read.kind === "owned" && read.pid === myPid) {
174
+ try {
175
+ fs.unlinkSync(leasePath);
176
+ } catch {
177
+ // Best-effort cleanup; a leftover lease naming a dead pid is harmless
178
+ // (the next daemon reads it, kill(pid,0)→ESRCH, reclaims).
179
+ }
180
+ }
181
+ }