@promptctl/cc-candybar 1.17.3 → 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.
- package/dist/index.mjs +70 -70
- package/package.json +5 -5
- package/src/daemon/process-fingerprint.ts +135 -0
- package/src/daemon/server.ts +42 -21
- package/src/daemon/socket-lease.ts +64 -36
- package/src/daemon/socket-ownership.ts +69 -25
- package/src/proc/launch.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.4",
|
|
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.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.17.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.17.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.17.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.17.4",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.17.4",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.17.4",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.17.4"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { launchSync, type LaunchOpts, type LaunchResult } from "../proc/launch";
|
|
2
|
+
|
|
3
|
+
// ─── Process start-time fingerprint ──────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// [FRAMING:representation] A bare pid is an under-constrained identity: the
|
|
6
|
+
// kernel recycles pids, so "a process with pid 989 exists" does NOT prove "989
|
|
7
|
+
// is the same process that wrote this lease". A crashed daemon whose pid the OS
|
|
8
|
+
// later hands to an unrelated long-lived process reads `alive` on every
|
|
9
|
+
// subsequent start, so no daemon ever comes up (brandon-daemon-lifecycle-2b3.4
|
|
10
|
+
// RESIDUAL 1 — the inverse of the socket-theft storm). The kernel ALSO stamps
|
|
11
|
+
// each process with a start-time it never rewinds within that pid's life;
|
|
12
|
+
// (pid, start-time) is the pair that actually IS a process identity, so a
|
|
13
|
+
// recycled pid is provably a DIFFERENT process (different start-time).
|
|
14
|
+
//
|
|
15
|
+
// [LAW:one-source-of-truth] The authority is the kernel's process identity, not
|
|
16
|
+
// a bare pid. `ps -o lstart=` exposes the start-time on every Unix we ship to
|
|
17
|
+
// (darwin + linux). The reported token is treated as OPAQUE — compared by string
|
|
18
|
+
// equality, never parsed — so there is no date-format representation to drift
|
|
19
|
+
// [FRAMING:representation]: a producer/consumer parse mismatch is unrepresentable
|
|
20
|
+
// when neither side parses. That opaque-equality invariant holds ONLY if the
|
|
21
|
+
// GENERATOR is deterministic. `ps -o lstart=` is strftime-formatted (locale) via
|
|
22
|
+
// `localtime()` (timezone), so the SAME process renders different tokens across a
|
|
23
|
+
// locale change OR a timezone change (TZ env, DST, tzdata update). We pin BOTH on
|
|
24
|
+
// the `ps` subprocess — `LC_ALL=C` (dominates any ambient LC_TIME/LC_ALL) and
|
|
25
|
+
// `TZ=UTC` — so the token is a locale- and timezone-invariant UTC rendering of
|
|
26
|
+
// the start instant, and equality is sound.
|
|
27
|
+
|
|
28
|
+
// A read of a pid's kernel start-time. Only TWO outcomes, because nothing
|
|
29
|
+
// derivable from a `ps` exit code can SOUNDLY prove a process is dead — a
|
|
30
|
+
// non-zero exit means "no start-time to report", which conflates a genuinely
|
|
31
|
+
// absent pid with an access failure (hardened `/proc`/`hidepid`, a
|
|
32
|
+
// permission-denied read of a live process). A `gone` state produced from that
|
|
33
|
+
// would be an over-claim ([LAW:types-are-the-program]) that could false-dead a
|
|
34
|
+
// live owner and reclaim its socket. So:
|
|
35
|
+
// start — the pid is live and this is its start-time token (the ONLY
|
|
36
|
+
// thing `ps` can assert soundly: it printed a row).
|
|
37
|
+
// unavailable — `ps` reported no start-time (absent OR unreadable OR errored).
|
|
38
|
+
// Callers defer to kill(pid,0), the sound liveness test, which
|
|
39
|
+
// correctly reclaims a truly-dead pid and spares a live one.
|
|
40
|
+
// The fingerprint's unique contribution is the `start`+mismatch case (a recycled
|
|
41
|
+
// pid whose live start-time differs from the lease); everything else falls back
|
|
42
|
+
// to kill, no worse than before the fingerprint existed.
|
|
43
|
+
export type StartTimeRead =
|
|
44
|
+
| { kind: "start"; token: string }
|
|
45
|
+
| { kind: "unavailable"; detail: string };
|
|
46
|
+
|
|
47
|
+
// The subprocess boundary, injected so the parse/branch logic is exercised by
|
|
48
|
+
// input enumeration with a fake launcher while the real path runs the real `ps`.
|
|
49
|
+
export type Launcher = (opts: LaunchOpts) => LaunchResult;
|
|
50
|
+
|
|
51
|
+
// [LAW:effects-at-boundaries][LAW:single-enforcer] The sole effect (spawning
|
|
52
|
+
// `ps`) goes through the one subprocess enforcer, `launchSync`. The ONLY sound
|
|
53
|
+
// signal `ps` gives is a printed start-time row (a live pid we could read);
|
|
54
|
+
// [LAW:no-silent-failure] every other outcome — absent pid, access failure,
|
|
55
|
+
// spawn-error, timeout — is `unavailable`, deferring the alive/dead call to
|
|
56
|
+
// kill(pid,0) rather than risk declaring a live owner dead from a `ps` exit
|
|
57
|
+
// code that cannot distinguish "gone" from "cannot read".
|
|
58
|
+
export function readStartTime(
|
|
59
|
+
pid: number,
|
|
60
|
+
launch: Launcher = launchSync,
|
|
61
|
+
): StartTimeRead {
|
|
62
|
+
const res = launch({
|
|
63
|
+
bin: "ps",
|
|
64
|
+
args: ["-o", "lstart=", "-p", String(pid)],
|
|
65
|
+
category: "process-fingerprint",
|
|
66
|
+
timeoutMs: 2000,
|
|
67
|
+
// [FRAMING:representation] Pin BOTH locale and timezone so the token is
|
|
68
|
+
// deterministic across daemons — the writer and reader must render the same
|
|
69
|
+
// process identically for opaque string equality to be sound. LC_ALL=C fixes
|
|
70
|
+
// the strftime format; TZ=UTC fixes localtime(), so DST / TZ / tzdata changes
|
|
71
|
+
// can't make one live process render two different start-time strings.
|
|
72
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
73
|
+
});
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
const token = res.stdout.trim();
|
|
76
|
+
if (token.length > 0) return { kind: "start", token };
|
|
77
|
+
// Exit 0 with empty output is anomalous (a live pid always lists) — don't
|
|
78
|
+
// mint an empty-string fingerprint; defer to kill.
|
|
79
|
+
return { kind: "unavailable", detail: "ps produced no start-time" };
|
|
80
|
+
}
|
|
81
|
+
const detail =
|
|
82
|
+
res.reason === "spawn-error"
|
|
83
|
+
? (res.error ?? "ps spawn failed")
|
|
84
|
+
: `ps ${res.reason} (exit ${res.exitCode})`;
|
|
85
|
+
return { kind: "unavailable", detail };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface LivenessDeps {
|
|
89
|
+
readStartTime: (pid: number) => StartTimeRead;
|
|
90
|
+
pidAlive: (pid: number) => boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// [LAW:dataflow-not-control-flow] The liveness verdict is a pure fold over the
|
|
94
|
+
// start-time read + the lease's recorded token. Full input space:
|
|
95
|
+
// read=start + token matches lease → true (same process still alive)
|
|
96
|
+
// read=start + token differs → false (a DIFFERENT process holds the
|
|
97
|
+
// pid — a recycle, or a restarted daemon)
|
|
98
|
+
// read=unavailable → kill(pid,0) fallback (the sound
|
|
99
|
+
// alive/dead test: a truly-dead pid →
|
|
100
|
+
// false → reclaim; a live pid ps couldn't
|
|
101
|
+
// read → true → spared)
|
|
102
|
+
// lease token = null (unfingerprinted at write, e.g. a host without `ps`) →
|
|
103
|
+
// kill(pid,0) fallback for the same reason
|
|
104
|
+
//
|
|
105
|
+
// [LAW:no-silent-failure] Only the `start`+mismatch case comes from the
|
|
106
|
+
// fingerprint; every ambiguous `ps` outcome defers to kill, so the DANGEROUS
|
|
107
|
+
// direction (declaring a live daemon dead → stealing its socket, the storm this
|
|
108
|
+
// epic fights) never happens merely because `ps` could not answer. The only
|
|
109
|
+
// thing forfeited when `ps` cannot answer is detecting a recycled pid.
|
|
110
|
+
export function sameLiveProcess(
|
|
111
|
+
pid: number,
|
|
112
|
+
leaseToken: string | null,
|
|
113
|
+
deps: LivenessDeps,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (leaseToken === null) return deps.pidAlive(pid);
|
|
116
|
+
const read = deps.readStartTime(pid);
|
|
117
|
+
switch (read.kind) {
|
|
118
|
+
case "start":
|
|
119
|
+
return read.token === leaseToken;
|
|
120
|
+
case "unavailable":
|
|
121
|
+
return deps.pidAlive(pid);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Read THIS process's own start-time to stamp into its lease. `unavailable`
|
|
126
|
+
// (no `ps`) collapses to `null` — the lease records "unfingerprinted", and every
|
|
127
|
+
// reader of it falls back to kill(pid,0) via sameLiveProcess. A dead result is
|
|
128
|
+
// impossible for our own live pid; if it somehow occurs we also record null.
|
|
129
|
+
export function readOwnStartTime(
|
|
130
|
+
pid: number,
|
|
131
|
+
read: (pid: number) => StartTimeRead = readStartTime,
|
|
132
|
+
): string | null {
|
|
133
|
+
const r = read(pid);
|
|
134
|
+
return r.kind === "start" ? r.token : null;
|
|
135
|
+
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
readSocketIdentity,
|
|
23
23
|
type SocketIdentity,
|
|
24
24
|
} from "./socket-ownership";
|
|
25
|
+
import {
|
|
26
|
+
readStartTime,
|
|
27
|
+
readOwnStartTime,
|
|
28
|
+
sameLiveProcess,
|
|
29
|
+
} from "./process-fingerprint";
|
|
25
30
|
import { dlog, closeLog } from "./log";
|
|
26
31
|
import {
|
|
27
32
|
PROTOCOL_VERSION,
|
|
@@ -127,8 +132,16 @@ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
|
|
|
127
132
|
// connection. Any uncaught error exits non-zero; the next client obtains a
|
|
128
133
|
// fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
|
|
129
134
|
// obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
|
|
135
|
+
// [LAW:one-source-of-truth] Our own kernel start-time, read once at startup and
|
|
136
|
+
// stamped into our lease so a future daemon's arbitration can prove whether our
|
|
137
|
+
// pid still names THIS process or a recycled ghost (process-fingerprint.ts).
|
|
138
|
+
// null when this host cannot fingerprint (no `ps`) — readers then fall back to
|
|
139
|
+
// kill(pid,0), no worse than before the fingerprint existed.
|
|
140
|
+
let myStartTime: string | null = null;
|
|
141
|
+
|
|
130
142
|
export function runDaemon(): void {
|
|
131
143
|
fs.mkdirSync(daemonDir(), { recursive: true });
|
|
144
|
+
myStartTime = readOwnStartTime(process.pid);
|
|
132
145
|
// [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
|
|
133
146
|
// not a symlink before we bind. Without this check, a same-host attacker
|
|
134
147
|
// could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
|
|
@@ -236,7 +249,11 @@ function handleAddressInUse(server: net.Server, sockPath: string): void {
|
|
|
236
249
|
// [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
|
|
237
250
|
// through unlink + rebind below, not the re-derived global — one identity
|
|
238
251
|
// source for the whole arbitration.
|
|
239
|
-
const decision = arbitrateSocket(
|
|
252
|
+
const decision = arbitrateSocket(
|
|
253
|
+
readLease(leasePathFor(sockPath)),
|
|
254
|
+
(pid, startTime) =>
|
|
255
|
+
sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
|
|
256
|
+
);
|
|
240
257
|
if (decision.kind === "attach-and-exit") {
|
|
241
258
|
dlog("info", `EADDRINUSE: ${decision.reason} — exiting`);
|
|
242
259
|
process.exit(0);
|
|
@@ -280,20 +297,19 @@ function onListening(sockPath: string): void {
|
|
|
280
297
|
// the FD yields no path-comparable identity. stat(path), taken as close to the
|
|
281
298
|
// bind as possible, is the only path-comparable truth available.
|
|
282
299
|
//
|
|
283
|
-
// [FRAMING:representation]
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// the
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// arbitrateSocket documents for its false-alive direction.
|
|
300
|
+
// [FRAMING:representation] This inode capture still cannot close the capture
|
|
301
|
+
// race on its own: if a second daemon completed its full EADDRINUSE →
|
|
302
|
+
// read-lease → unlink → rebind cycle in the single event-loop tick between
|
|
303
|
+
// bind() and this 'listening' callback, we stat the path and capture the
|
|
304
|
+
// THIEF's inode as "ours". No race-free handle on our own socket's path
|
|
305
|
+
// identity exists (an AF_UNIX listener's fstat is a different namespace's
|
|
306
|
+
// inode), so the captured inode cannot be made trustworthy. But that is no
|
|
307
|
+
// longer the immortal orphan it was (brandon-daemon-lifecycle-2b3.4 RESIDUAL
|
|
308
|
+
// 2): the ownership self-check now ALSO requires the lease to still name us,
|
|
309
|
+
// and a real thief writes its own pid into the lease — so a displaced daemon
|
|
310
|
+
// drains within a bounded number of intervals instead of reading `owned`
|
|
311
|
+
// forever (see checkOwnership). The absent/unreadable case below still exits
|
|
312
|
+
// toward the SAFE direction without writing a lease that would stomp a thief's.
|
|
297
313
|
const boundRead = readSocketIdentity(sockPath);
|
|
298
314
|
if (boundRead.kind !== "present") {
|
|
299
315
|
dlog(
|
|
@@ -330,15 +346,20 @@ function onListening(sockPath: string): void {
|
|
|
330
346
|
// --- socket-ownership self-check ---
|
|
331
347
|
//
|
|
332
348
|
// [LAW:single-enforcer] The sole enforcer of "serving implies owning the socket
|
|
333
|
-
// path over time" (brandon-daemon-lifecycle-2b3.2).
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// SAME shutdown funnel as signals, the RSS backstop, and the watchdog
|
|
337
|
-
//
|
|
349
|
+
// path over time" (brandon-daemon-lifecycle-2b3.2). Each interval it re-reads
|
|
350
|
+
// BOTH representations a displacer can touch — the path's kernel identity (still
|
|
351
|
+
// the inode we bound?) and the lease (still names our pid?) — and drains through
|
|
352
|
+
// the SAME shutdown funnel as signals, the RSS backstop, and the watchdog on
|
|
353
|
+
// either mismatch. The lease arm closes the capture race (RESIDUAL 2): a thief
|
|
354
|
+
// that stole our socket inside the bind→listening tick, which the inode arm
|
|
355
|
+
// cannot see because we captured the thief's inode, is caught the moment the
|
|
356
|
+
// thief writes its own pid into the lease. No parallel exit path.
|
|
338
357
|
function armOwnershipWatch(sockPath: string, bound: SocketIdentity): void {
|
|
339
358
|
makeOwnershipWatch({
|
|
340
359
|
bound,
|
|
360
|
+
myPid: process.pid,
|
|
341
361
|
readIdentity: () => readSocketIdentity(sockPath),
|
|
362
|
+
readLease: () => readLease(leasePathFor(sockPath)),
|
|
342
363
|
shutdown: (code) => shutdown(code),
|
|
343
364
|
log: dlog,
|
|
344
365
|
}).arm();
|
|
@@ -418,7 +439,7 @@ function writeLeaseFile(sockPath: string): void {
|
|
|
418
439
|
pid: process.pid,
|
|
419
440
|
version: PROTOCOL_VERSION,
|
|
420
441
|
binPath: process.argv[1],
|
|
421
|
-
|
|
442
|
+
startTime: myStartTime,
|
|
422
443
|
});
|
|
423
444
|
if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
|
|
424
445
|
}
|
|
@@ -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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 (
|
|
35
|
-
//
|
|
36
|
-
// diagnostic pidfile — one file, one identity
|
|
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
|
-
|
|
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.
|
|
47
|
-
// exercised by input enumeration with no
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
|
|
84
|
+
isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
|
|
76
85
|
): SocketArbitration {
|
|
77
86
|
if (read.kind === "owned") {
|
|
78
|
-
return
|
|
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
|
-
: {
|
|
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
|
|
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
|
-
|
|
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
|
|
68
|
-
// every branch is exercised by input enumeration.
|
|
69
|
-
//
|
|
70
|
-
// present +
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// serve and the next client respawns; a false `owned` is the
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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: `
|
|
113
|
+
reason: `lease not held (${lease.kind}) — no longer the socket owner`,
|
|
92
114
|
};
|
|
93
115
|
}
|
|
94
|
-
if (
|
|
116
|
+
if (lease.pid !== myPid) {
|
|
95
117
|
return {
|
|
96
118
|
kind: "displaced",
|
|
97
|
-
reason:
|
|
119
|
+
reason: `lease reassigned (names pid=${lease.pid}, we are pid=${myPid}) — a reclaimer overwrote it`,
|
|
98
120
|
};
|
|
99
121
|
}
|
|
100
|
-
return {
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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(
|
package/src/proc/launch.ts
CHANGED
|
@@ -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];
|