@promptctl/cc-candybar 1.17.1 → 1.17.2

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.17.1",
3
+ "version": "1.17.2",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.17.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"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.17.2",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.17.2",
96
+ "@promptctl/cc-candybar-linux-x64": "1.17.2",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.17.2"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -17,6 +17,11 @@ import {
17
17
  removeLeaseIfOwned,
18
18
  writeLease,
19
19
  } from "./socket-lease";
20
+ import {
21
+ makeOwnershipWatch,
22
+ readSocketIdentity,
23
+ type SocketIdentity,
24
+ } from "./socket-ownership";
20
25
  import { dlog, closeLog } from "./log";
21
26
  import {
22
27
  PROTOCOL_VERSION,
@@ -267,12 +272,44 @@ function handleAddressInUse(server: net.Server, sockPath: string): void {
267
272
  }
268
273
 
269
274
  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.
275
+ // [LAW:no-ambient-temporal-coupling] Capture the bound socket's kernel
276
+ // identity (dev+ino) as the fingerprint the ownership self-check re-stats
277
+ // against, BEFORE claiming the lease. Captured from the PATH, not the bound
278
+ // FD: for an AF_UNIX listener, fstat(fd) reports the socket's inode in the
279
+ // socket namespace — not the filesystem-entry inode that stat(path) sees — so
280
+ // the FD yields no path-comparable identity. stat(path), taken as close to the
281
+ // bind as possible, is the only path-comparable truth available.
282
+ //
283
+ // [FRAMING:representation] Be honest about the ONE window this capture cannot
284
+ // close. It catches an absent/unreadable path (below): we could not form a
285
+ // fingerprint, so we have no proof of ownership and exit toward the SAFE
286
+ // direction (a false-displaced daemon just lets whoever holds the path serve —
287
+ // no orphan) WITHOUT writing a lease that would stomp a thief's. It does NOT
288
+ // catch a path that is present but already holds a THIEF's socket — if a second
289
+ // daemon completed its full EADDRINUSE → read-lease → unlink → rebind cycle in
290
+ // the single event-loop tick between bind() and this 'listening' callback, we
291
+ // fingerprint the thief's identity and the self-check reads `owned` forever
292
+ // (the orphan this module exists to kill). That race is irreducible at this
293
+ // layer (no race-free handle on our own socket's path-identity exists) and
294
+ // vanishingly narrow; fully closing it needs the lock-based liveness the epic
295
+ // deferred (a flock/start-time fingerprint) — the same residual sibling .1's
296
+ // arbitrateSocket documents for its false-alive direction.
297
+ const boundRead = readSocketIdentity(sockPath);
298
+ if (boundRead.kind !== "present") {
299
+ dlog(
300
+ "warn",
301
+ `no ownable socket at bind callback (${boundRead.kind}); exiting`,
302
+ );
303
+ shutdown(0);
304
+ return;
305
+ }
306
+
307
+ // [LAW:no-ambient-temporal-coupling] Claim ownership FIRST among the post-bind
308
+ // effects — before chmod or any other work — so the window between winning the
309
+ // bind and the lease naming us is as small as possible. A second daemon that
310
+ // binds in that sub-ms gap reads an absent lease and reclaims (displacing us);
311
+ // the ownership self-check below makes that self-healing, but keeping the
312
+ // window minimal keeps it vanishingly rare.
276
313
  // [LAW:one-source-of-truth] Derive the lease from the same sockPath we bound,
277
314
  // matching handleAddressInUse's read — one identity source across write + read.
278
315
  writeLeaseFile(sockPath);
@@ -287,6 +324,24 @@ function onListening(sockPath: string): void {
287
324
  );
288
325
  armBinaryWatch();
289
326
  armLimits();
327
+ armOwnershipWatch(sockPath, boundRead.identity);
328
+ }
329
+
330
+ // --- socket-ownership self-check ---
331
+ //
332
+ // [LAW:single-enforcer] The sole enforcer of "serving implies owning the socket
333
+ // path over time" (brandon-daemon-lifecycle-2b3.2). Periodically re-stats the
334
+ // path; if its kernel identity no longer matches what we bound, we were
335
+ // displaced (its socket unlinked + rebound by a reclaimer) and drain through the
336
+ // SAME shutdown funnel as signals, the RSS backstop, and the watchdog — no
337
+ // parallel exit path.
338
+ function armOwnershipWatch(sockPath: string, bound: SocketIdentity): void {
339
+ makeOwnershipWatch({
340
+ bound,
341
+ readIdentity: () => readSocketIdentity(sockPath),
342
+ shutdown: (code) => shutdown(code),
343
+ log: dlog,
344
+ }).arm();
290
345
  }
291
346
 
292
347
  // --- binary-mtime self-restart ---
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import { type DaemonLogger } from "./log";
3
+
4
+ // ─── Socket ownership self-check ─────────────────────────────────────────────
5
+ //
6
+ // [LAW:single-enforcer] "Serving implies owning the socket path" is a temporal
7
+ // invariant every daemon component assumes and none enforced. The socket-theft
8
+ // storm (brandon-daemon-lifecycle-2b3) proved it false: a daemon whose socket
9
+ // was unlinked + rebound by a reclaimer kept running forever (no idle shutdown,
10
+ // no watchdog in production, RSS backstop only), holding ~400MB each — 28
11
+ // daemons bound in one hour with ZERO voluntary exits. This module is that
12
+ // invariant's single enforcer: a displaced daemon exits within one check
13
+ // interval, making ANY displacement self-healing regardless of which bug caused
14
+ // it.
15
+ //
16
+ // [FRAMING:representation] Ownership of a filesystem entry IS its kernel
17
+ // identity: (st_dev, st_ino). We never unlink + rebind our OWN socket during our
18
+ // lifetime, so that identity is stable for as long as we hold the path. A
19
+ // reclaimer that displaces us unlinks the path and binds a fresh socket → a NEW
20
+ // inode (or, briefly, no file at all). So "the identity now equals the identity
21
+ // I bound" is the exact, load-independent test for continued ownership — unlike
22
+ // a connect probe, which the sibling .1 removed precisely because it lied under
23
+ // load.
24
+
25
+ // The kernel identity of the bound socket file. (dev, ino) is the unique
26
+ // identity of a filesystem entry; ino alone can be reused across devices.
27
+ export interface SocketIdentity {
28
+ dev: number;
29
+ ino: number;
30
+ }
31
+
32
+ // What re-reading the socket path's identity yielded. Distinguishing
33
+ // present / absent / unreadable keeps the ownership decision a full enumeration
34
+ // over raw inputs (see checkOwnership) rather than collapsing the ambiguous
35
+ // cases early — the same shape as socket-lease.ts's LeaseRead.
36
+ export type IdentityRead =
37
+ | { kind: "present"; identity: SocketIdentity }
38
+ | { kind: "absent" }
39
+ | { kind: "unreadable"; detail: string };
40
+
41
+ // The self-check outcome. `owned` is the ONLY proof-of-ownership state; every
42
+ // other input maps to `displaced` (fail toward exit).
43
+ export type OwnershipCheck =
44
+ | { kind: "owned" }
45
+ | { kind: "displaced"; reason: string };
46
+
47
+ export const DEFAULT_OWNERSHIP_CHECK_INTERVAL_MS = 5000;
48
+
49
+ // [LAW:no-silent-failure] The sole effect (statSync) is lifted to a typed
50
+ // boundary read: ENOENT → `absent` (the path was unlinked, not yet rebound),
51
+ // any other error → `unreadable` with the reason inline, never a swallowed
52
+ // default. statSync (not lstatSync) follows a symlink deliberately — it reads
53
+ // the identity of whatever a CLIENT connecting to the path would actually reach,
54
+ // which is exactly what "do I still own what clients hit" asks.
55
+ export function readSocketIdentity(sockPath: string): IdentityRead {
56
+ try {
57
+ const st = fs.statSync(sockPath);
58
+ return { kind: "present", identity: { dev: st.dev, ino: st.ino } };
59
+ } catch (e) {
60
+ const code = (e as NodeJS.ErrnoException).code;
61
+ if (code === "ENOENT") return { kind: "absent" };
62
+ return { kind: "unreadable", detail: (e as Error).message };
63
+ }
64
+ }
65
+
66
+ // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] A pure fold:
67
+ // (identity captured at bind, current identity read) → decision. No effects, so
68
+ // every branch is exercised by input enumeration. Full input space:
69
+ // present + same identity → owned (we still hold the path we bound)
70
+ // present + different → displaced (a reclaimer unlinked + rebound; new inode)
71
+ // absent (ENOENT) → displaced (path unlinked, reclaimer not yet rebound)
72
+ // unreadable → displaced (cannot PROVE ownership → fail toward exit)
73
+ //
74
+ // [FRAMING:representation] Only `present + same identity` proves ownership;
75
+ // everything else is `displaced`. This is the strongest-true theorem: serving
76
+ // must IMPLY owning, so anything short of positive proof drains and exits. The
77
+ // failure direction is deliberately safe — a false `displaced` (exit while
78
+ // actually owning, e.g. a transient stat error) just lets the real owner/thief
79
+ // serve and the next client respawns; a false `owned` is the immortal orphan we
80
+ // are killing. Always err toward exit.
81
+ export function checkOwnership(
82
+ bound: SocketIdentity,
83
+ now: IdentityRead,
84
+ ): OwnershipCheck {
85
+ if (now.kind === "present") {
86
+ if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
87
+ return { kind: "owned" };
88
+ }
89
+ return {
90
+ kind: "displaced",
91
+ reason: `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`,
92
+ };
93
+ }
94
+ if (now.kind === "absent") {
95
+ return {
96
+ kind: "displaced",
97
+ reason: "socket path gone (ENOENT) — unlinked by a reclaimer",
98
+ };
99
+ }
100
+ return {
101
+ kind: "displaced",
102
+ reason: `socket path unreadable (${now.detail}) — cannot prove ownership`,
103
+ };
104
+ }
105
+
106
+ // [LAW:locality-or-seam] The identity read, the shutdown funnel, and the log
107
+ // sink are injected, not reached for ambiently — so a unit test drives every
108
+ // branch (swap the inode, delete the path) against a fake shutdown with no real
109
+ // daemon, mirroring makeLimits. The real wiring injects
110
+ // `() => readSocketIdentity(sockPath)` and the daemon's single shutdown().
111
+ export interface OwnershipWatchDeps {
112
+ bound: SocketIdentity;
113
+ readIdentity: () => IdentityRead;
114
+ shutdown: (code: number) => void;
115
+ log: DaemonLogger;
116
+ intervalMs?: number;
117
+ }
118
+
119
+ export interface OwnershipWatchHandle {
120
+ // Run one check now; funnels through shutdown(0) on the first `displaced`.
121
+ // Returns the decision so callers/tests can assert without timers.
122
+ check(): OwnershipCheck;
123
+ arm(intervalMs?: number): { disarm(): void };
124
+ }
125
+
126
+ export function makeOwnershipWatch(
127
+ deps: OwnershipWatchDeps,
128
+ ): OwnershipWatchHandle {
129
+ // [LAW:single-enforcer] Latch like limits' `triggered`: displacement funnels
130
+ // shutdown exactly once (one log line, one shutdown call), even though
131
+ // shutdown() is itself idempotent. Serving implies owning through ONE exit.
132
+ let displaced = false;
133
+
134
+ function check(): OwnershipCheck {
135
+ const decision = checkOwnership(deps.bound, deps.readIdentity());
136
+ if (decision.kind === "displaced" && !displaced) {
137
+ displaced = true;
138
+ deps.log(
139
+ "warn",
140
+ `ownership self-check: ${decision.reason}; shutting down`,
141
+ );
142
+ deps.shutdown(0);
143
+ }
144
+ return decision;
145
+ }
146
+
147
+ function arm(
148
+ intervalMs: number = deps.intervalMs ?? DEFAULT_OWNERSHIP_CHECK_INTERVAL_MS,
149
+ ): { disarm(): void } {
150
+ const timer = setInterval(() => {
151
+ // [LAW:no-ambient-temporal-coupling] Self-disarm once displaced: the watch
152
+ // has funneled its single shutdown, so stop polling — no zombie statSync
153
+ // fires during the shutdown window (a hung shutdown reaches its SIGKILL
154
+ // backstop at 500ms). The timer's whole lifecycle lives here in arm(),
155
+ // mirroring armBinaryWatch which clears its interval before shutting down.
156
+ if (check().kind === "displaced") clearInterval(timer);
157
+ }, intervalMs);
158
+ // unref so the check never keeps the process alive on its own — it only ever
159
+ // hastens an exit, never delays one.
160
+ timer.unref();
161
+ return { disarm: () => clearInterval(timer) };
162
+ }
163
+
164
+ return { check, arm };
165
+ }