@promptctl/cc-candybar 1.17.0 → 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/dist/index.mjs +78 -78
- package/package.json +5 -5
- package/src/daemon/paths.ts +33 -9
- package/src/daemon/server.ts +127 -101
- package/src/daemon/socket-lease.ts +181 -0
- package/src/daemon/socket-ownership.ts +165 -0
|
@@ -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
|
+
}
|