@promptctl/cc-candybar 1.17.2 → 1.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,18 +12,25 @@ import process from "node:process";
12
12
  // their sockets were stolen, minting immortal orphans (see
13
13
  // brandon-daemon-lifecycle-2b3).
14
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.
15
+ // [FRAMING:representation] The lease records the owner's pid AND its kernel
16
+ // start-time (see process-fingerprint.ts). Liveness is "the SAME process is
17
+ // still alive" kill(pid,0) alone lies when a crashed daemon's pid is recycled
18
+ // to an unrelated live process (reads `alive` forever → no daemon ever comes up,
19
+ // brandon-daemon-lifecycle-2b3.4 RESIDUAL 1). Comparing the start-time makes a
20
+ // recycled pid provably a different process. Both signals are the kernel's
21
+ // load-independent truth about process identity; a connect sample is
22
+ // load-dependent hearsay and must never be the authority that destroys another
23
+ // daemon's socket.
19
24
 
20
25
  // What a lease read yielded. Distinguishing absent / unreadable / owned keeps
21
26
  // the arbitration decision a full enumeration over raw inputs (see
22
- // arbitrateSocket) rather than collapsing the ambiguous cases early.
27
+ // arbitrateSocket) rather than collapsing the ambiguous cases early. `startTime`
28
+ // is the owner's kernel start-time fingerprint, or null when the writing host
29
+ // could not fingerprint (no `ps`) — the reader then falls back to kill(pid,0).
23
30
  export type LeaseRead =
24
31
  | { kind: "absent" }
25
32
  | { kind: "unreadable"; detail: string }
26
- | { kind: "owned"; pid: number };
33
+ | { kind: "owned"; pid: number; startTime: string | null };
27
34
 
28
35
  // The EADDRINUSE arbitration outcome. The path already exists (something bound
29
36
  // it or a stale file remains); this says whether a LIVE owner holds it.
@@ -31,56 +38,61 @@ export type SocketArbitration =
31
38
  | { kind: "attach-and-exit"; reason: string }
32
39
  | { kind: "reclaim"; reason: string };
33
40
 
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.
41
+ // Diagnostic-rich lease payload. `(pid, startTime)` is the authority (process
42
+ // identity liveness); the rest is operator diagnostics carried in the same
43
+ // file so the lease subsumes the old diagnostic pidfile — one file, one identity
44
+ // root. `startTime` is the kernel start-time token (also human-readable, so it
45
+ // doubles as the "daemon started at" diagnostic the old `startedAt` gave), or
46
+ // null when this host could not fingerprint.
37
47
  export interface LeaseRecord {
38
48
  pid: number;
39
49
  version: number;
40
50
  binPath: string | undefined;
41
- startedAt: string;
51
+ startTime: string | null;
42
52
  }
43
53
 
44
54
  // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
45
55
  // 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)
56
+ // decision. Reading process identity is the sole effect and it is injected via
57
+ // `isSameLiveProcess`, so every branch is exercised by input enumeration with no
58
+ // real processes. Full input space:
59
+ // owned + same-live → attach-and-exit (the SAME live owner holds the path; we
60
+ // are a duplicate exit so the incumbent keeps serving)
61
+ // owned + not-same → reclaim (owner crashed, OR its pid was recycled to an
62
+ // unrelated process either way the socket is stale)
63
+ // absent → reclaim (no lease to consult — a pre-lease or crashed
64
+ // daemon left a stale socket; prefer availability)
65
+ // unreadable → reclaim (can't prove a live owner — same)
66
+ //
67
+ // [LAW:one-source-of-truth] `isSameLiveProcess(pid, startTime)` consults the
68
+ // kernel's process identity ((pid, start-time), see process-fingerprint.ts), not
69
+ // a bare pid. This closes RESIDUAL 1: a crashed daemon's pid recycled to a
70
+ // long-lived process now reads NOT-same (different start-time) → reclaim, so a
71
+ // daemon comes up instead of every start attaching to a ghost forever.
54
72
  //
55
73
  // Failure directions:
56
74
  // 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).
75
+ // self-check (brandon-daemon-lifecycle-2b3.2), and now far rarer: the
76
+ // start-time match will not false-negative a live owner unless the host
77
+ // cannot fingerprint at all, in which case sameLiveProcess falls back to
78
+ // kill(pid,0) the prior behavior, no worse.
70
79
  // Reclaiming on missing/unreadable is the availability-preferring direction — a
71
80
  // stale socket with no reclaimer is a hard no-service stall, strictly worse than
72
81
  // a transient double-serve that self-heals.
73
82
  export function arbitrateSocket(
74
83
  read: LeaseRead,
75
- isAlive: (pid: number) => boolean,
84
+ isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
76
85
  ): SocketArbitration {
77
86
  if (read.kind === "owned") {
78
- return isAlive(read.pid)
87
+ return isSameLiveProcess(read.pid, read.startTime)
79
88
  ? {
80
89
  kind: "attach-and-exit",
81
90
  reason: `live owner pid=${read.pid} holds the socket`,
82
91
  }
83
- : { kind: "reclaim", reason: `owner pid=${read.pid} is gone (ESRCH)` };
92
+ : {
93
+ kind: "reclaim",
94
+ reason: `owner pid=${read.pid} is gone or recycled`,
95
+ };
84
96
  }
85
97
  if (read.kind === "absent") {
86
98
  return {
@@ -115,14 +127,30 @@ export function readLease(leasePath: string): LeaseRead {
115
127
  } catch (e) {
116
128
  return { kind: "unreadable", detail: `bad JSON: ${(e as Error).message}` };
117
129
  }
118
- const pid = (parsed as { pid?: unknown } | null)?.pid;
130
+ const record = parsed as { pid?: unknown; startTime?: unknown } | null;
131
+ const pid = record?.pid;
119
132
  if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
120
133
  return {
121
134
  kind: "unreadable",
122
135
  detail: `no valid pid (got ${JSON.stringify(pid)})`,
123
136
  };
124
137
  }
125
- return { kind: "owned", pid };
138
+ // startTime is the process fingerprint. A present value must be a string;
139
+ // absent/null means "unfingerprinted" (an older lease, or a host without
140
+ // `ps`) and the reader falls back to kill(pid,0). A present-but-non-string is
141
+ // malformed → unreadable (never silently coerce a lie into a fingerprint).
142
+ const rawStartTime = record?.startTime;
143
+ if (
144
+ rawStartTime !== undefined &&
145
+ rawStartTime !== null &&
146
+ typeof rawStartTime !== "string"
147
+ ) {
148
+ return {
149
+ kind: "unreadable",
150
+ detail: `invalid startTime (got ${JSON.stringify(rawStartTime)})`,
151
+ };
152
+ }
153
+ return { kind: "owned", pid, startTime: rawStartTime ?? null };
126
154
  }
127
155
 
128
156
  // Write our lease after we win the bind. Overwrite-on-write: whoever holds the
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { type DaemonLogger } from "./log";
3
+ import { type LeaseRead } from "./socket-lease";
3
4
 
4
5
  // ─── Socket ownership self-check ─────────────────────────────────────────────
5
6
  //
@@ -21,6 +22,21 @@ import { type DaemonLogger } from "./log";
21
22
  // I bound" is the exact, load-independent test for continued ownership — unlike
22
23
  // a connect probe, which the sibling .1 removed precisely because it lied under
23
24
  // load.
25
+ //
26
+ // [FRAMING:representation] The inode alone leaves ONE hole (RESIDUAL 2, the
27
+ // capture race): if a thief completes unlink + rebind inside the sub-millisecond
28
+ // window between our bind() and our 'listening' callback, we stat the path and
29
+ // capture the THIEF's inode as "ours" — so the inode check reads `owned` forever
30
+ // against the wrong identity. We cannot make that captured inode trustworthy (an
31
+ // AF_UNIX listener's fstat reports a different namespace's inode than stat(path),
32
+ // so there is no race-free "the inode I truly bound"). But a real thief, being a
33
+ // daemon, writes its OWN pid into the lease. So ownership requires a SECOND
34
+ // condition: the lease must still name us. A displacer must touch one of the two
35
+ // representations — steal the socket (inode changes) or overwrite the lease (pid
36
+ // changes) — so requiring BOTH drains any orphan within a bounded number of
37
+ // intervals. No live process can share our live pid, so the lease pid alone
38
+ // distinguishes "the lease still names me" (the start-time fingerprint is only
39
+ // needed by the sibling arbitration, which reasons about a possibly-DEAD pid).
24
40
 
25
41
  // The kernel identity of the bound socket file. (dev, ino) is the unique
26
42
  // identity of a filesystem entry; ino alone can be reused across devices.
@@ -64,43 +80,64 @@ export function readSocketIdentity(sockPath: string): IdentityRead {
64
80
  }
65
81
 
66
82
  // [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 ownershipfail toward exit)
83
+ // (identity captured at bind, current identity read, my pid, current lease read)
84
+ // → decision. No effects, so every branch is exercised by input enumeration.
85
+ // Ownership is the CONJUNCTION of two conditions; either failing displaced:
86
+ // inode: present + same identity as bound (we still hold the path we bound)
87
+ // lease: owned + pid == mine (the lease still names us)
88
+ // Inode failures: different inode / absent / unreadable a reclaimer displaced
89
+ // us (or we cannot prove otherwise). Lease failures: a thief overwrote the
90
+ // lease with its own pid, or the lease vanished/broke — either way we no
91
+ // longer hold the authority.
73
92
  //
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.
93
+ // [FRAMING:representation] Only "still holding my bound socket AND still named by
94
+ // the lease" proves ownership; anything short is `displaced`. This is the
95
+ // strongest-true theorem for "serving implies owning", checked against BOTH
96
+ // mutable representations a displacer can touch. The failure direction is
97
+ // deliberately safe a false `displaced` (exit while actually owning) just lets
98
+ // the real owner serve and the next client respawns; a false `owned` is the
99
+ // immortal orphan we are killing. Always err toward exit.
81
100
  export function checkOwnership(
82
101
  bound: SocketIdentity,
83
102
  now: IdentityRead,
103
+ myPid: number,
104
+ lease: LeaseRead,
84
105
  ): OwnershipCheck {
85
- if (now.kind === "present") {
86
- if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
87
- return { kind: "owned" };
88
- }
106
+ const inodeFailure = inodeDisplacement(bound, now);
107
+ if (inodeFailure !== null) {
108
+ return { kind: "displaced", reason: inodeFailure };
109
+ }
110
+ if (lease.kind !== "owned") {
89
111
  return {
90
112
  kind: "displaced",
91
- reason: `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`,
113
+ reason: `lease not held (${lease.kind}) no longer the socket owner`,
92
114
  };
93
115
  }
94
- if (now.kind === "absent") {
116
+ if (lease.pid !== myPid) {
95
117
  return {
96
118
  kind: "displaced",
97
- reason: "socket path gone (ENOENT) — unlinked by a reclaimer",
119
+ reason: `lease reassigned (names pid=${lease.pid}, we are pid=${myPid}) — a reclaimer overwrote it`,
98
120
  };
99
121
  }
100
- return {
101
- kind: "displaced",
102
- reason: `socket path unreadable (${now.detail}) — cannot prove ownership`,
103
- };
122
+ return { kind: "owned" };
123
+ }
124
+
125
+ // Returns the displacement reason if the current path identity no longer matches
126
+ // what we bound, or null if the inode still proves ownership.
127
+ function inodeDisplacement(
128
+ bound: SocketIdentity,
129
+ now: IdentityRead,
130
+ ): string | null {
131
+ if (now.kind === "present") {
132
+ if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
133
+ return null;
134
+ }
135
+ return `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`;
136
+ }
137
+ if (now.kind === "absent") {
138
+ return "socket path gone (ENOENT) — unlinked by a reclaimer";
139
+ }
140
+ return `socket path unreadable (${now.detail}) — cannot prove ownership`;
104
141
  }
105
142
 
106
143
  // [LAW:locality-or-seam] The identity read, the shutdown funnel, and the log
@@ -110,7 +147,9 @@ export function checkOwnership(
110
147
  // `() => readSocketIdentity(sockPath)` and the daemon's single shutdown().
111
148
  export interface OwnershipWatchDeps {
112
149
  bound: SocketIdentity;
150
+ myPid: number;
113
151
  readIdentity: () => IdentityRead;
152
+ readLease: () => LeaseRead;
114
153
  shutdown: (code: number) => void;
115
154
  log: DaemonLogger;
116
155
  intervalMs?: number;
@@ -132,7 +171,12 @@ export function makeOwnershipWatch(
132
171
  let displaced = false;
133
172
 
134
173
  function check(): OwnershipCheck {
135
- const decision = checkOwnership(deps.bound, deps.readIdentity());
174
+ const decision = checkOwnership(
175
+ deps.bound,
176
+ deps.readIdentity(),
177
+ deps.myPid,
178
+ deps.readLease(),
179
+ );
136
180
  if (decision.kind === "displaced" && !displaced) {
137
181
  displaced = true;
138
182
  deps.log(
@@ -45,6 +45,10 @@ export const LAUNCH_CATEGORIES = [
45
45
  "install.pbcopy",
46
46
  "install.open",
47
47
  "daemon-spawn",
48
+ // Process start-time fingerprint (`ps -o lstart=`) for socket-lease liveness
49
+ // (process-fingerprint.ts). Spawned only at daemon start + EADDRINUSE
50
+ // arbitration — never per render — so it needs no rate limit.
51
+ "process-fingerprint",
48
52
  ] as const;
49
53
 
50
54
  export type LaunchCategory = (typeof LAUNCH_CATEGORIES)[number];