@promptctl/cc-candybar 1.17.1 → 1.17.3
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 +80 -80
- package/package.json +5 -5
- package/src/daemon/acquire.ts +150 -16
- package/src/daemon/paths.ts +11 -0
- package/src/daemon/server.ts +61 -6
- package/src/daemon/socket-ownership.ts +165 -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.3",
|
|
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.3",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.17.3",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.17.3",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.17.3"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { launchDetachedSync } from "../proc/launch";
|
|
4
5
|
import process from "node:process";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
socketPath,
|
|
8
|
+
spawnLockPath,
|
|
9
|
+
spawnCooldownPath,
|
|
10
|
+
daemonDir,
|
|
11
|
+
} from "./paths";
|
|
6
12
|
|
|
7
13
|
// [LAW:single-enforcer] One primitive per runtime that owns the entire
|
|
8
14
|
// "obtain a daemon" verb. Every spawn site in the Node runtime now flows
|
|
@@ -133,6 +139,28 @@ async function spawnAndWaitForReady(
|
|
|
133
139
|
outerDeadline: number,
|
|
134
140
|
reasonSuffix: string,
|
|
135
141
|
): Promise<ObtainResult> {
|
|
142
|
+
const readyDeadline = Math.min(
|
|
143
|
+
Date.now() + spawnReadyTimeoutMs,
|
|
144
|
+
outerDeadline,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// [LAW:dataflow-not-control-flow] The spawn-rate bound is consulted as data,
|
|
148
|
+
// not a mode. If a spawn was attempted within SPAWN_COOLDOWN_MS, one is
|
|
149
|
+
// already in flight — do NOT add another Node process; wait for the in-flight
|
|
150
|
+
// boot. This keeps obtainDaemon under the same global rate cap as the kick
|
|
151
|
+
// path, so the rate bound holds for EVERY spawn site [LAW:one-source-of-truth].
|
|
152
|
+
if (!claimSpawnCooldown()) {
|
|
153
|
+
// [LAW:no-silent-failure] This failure's cause is the cooldown gate, not the
|
|
154
|
+
// lock path we arrived through — so it does NOT inherit reasonSuffix (which
|
|
155
|
+
// tags lock-held vs lock-fallback *spawn* provenance). We never spawned here.
|
|
156
|
+
return (await pollUntilReady(connectTimeoutMs, readyDeadline))
|
|
157
|
+
? { kind: "attached" }
|
|
158
|
+
: {
|
|
159
|
+
kind: "failed",
|
|
160
|
+
reason: "spawn on cooldown; no daemon became ready during the wait",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
// [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>.
|
|
137
165
|
// A synchronous throw from child_process.spawn (ENOENT, invalid options)
|
|
138
166
|
// must become a typed failure, not a rejected promise.
|
|
@@ -151,20 +179,27 @@ async function spawnAndWaitForReady(
|
|
|
151
179
|
reason: `spawn returned false${reasonSuffix}`,
|
|
152
180
|
};
|
|
153
181
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
return (await pollUntilReady(connectTimeoutMs, readyDeadline))
|
|
183
|
+
? { kind: "started" }
|
|
184
|
+
: {
|
|
185
|
+
kind: "failed",
|
|
186
|
+
reason: `daemon did not bind in time${reasonSuffix}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Poll the socket until a daemon answers or the deadline elapses. Shared by the
|
|
191
|
+
// spawn path (→ "started") and the cooldown-blocked wait path (→ "attached"):
|
|
192
|
+
// both need "did a daemon come up in time", they differ only in how they label
|
|
193
|
+
// the outcome.
|
|
194
|
+
async function pollUntilReady(
|
|
195
|
+
connectTimeoutMs: number,
|
|
196
|
+
readyDeadline: number,
|
|
197
|
+
): Promise<boolean> {
|
|
158
198
|
while (Date.now() < readyDeadline) {
|
|
159
|
-
if (await canConnect(socketPath(), connectTimeoutMs))
|
|
160
|
-
return { kind: "started" };
|
|
161
|
-
}
|
|
199
|
+
if (await canConnect(socketPath(), connectTimeoutMs)) return true;
|
|
162
200
|
await sleep(20);
|
|
163
201
|
}
|
|
164
|
-
return
|
|
165
|
-
kind: "failed",
|
|
166
|
-
reason: `daemon did not bind in time${reasonSuffix}`,
|
|
167
|
-
};
|
|
202
|
+
return false;
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
// Synchronous fire-and-forget kick — used for "daemon-miss" recovery where
|
|
@@ -214,7 +249,7 @@ export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
|
|
|
214
249
|
process.stderr.write(
|
|
215
250
|
`cc-candybar: spawn-lock held ${ageMs}ms (likely crashed holder) — spawning unlocked\n`,
|
|
216
251
|
);
|
|
217
|
-
|
|
252
|
+
cooldownGatedSpawn(spawnFn);
|
|
218
253
|
}
|
|
219
254
|
return;
|
|
220
255
|
}
|
|
@@ -222,16 +257,26 @@ export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
|
|
|
222
257
|
process.stderr.write(
|
|
223
258
|
`cc-candybar: spawn-lock unavailable (${lock.reason}) — spawning unlocked\n`,
|
|
224
259
|
);
|
|
225
|
-
|
|
260
|
+
cooldownGatedSpawn(spawnFn);
|
|
226
261
|
return;
|
|
227
262
|
}
|
|
228
263
|
try {
|
|
229
|
-
|
|
264
|
+
cooldownGatedSpawn(spawnFn);
|
|
230
265
|
} finally {
|
|
231
266
|
releaseSpawnLock();
|
|
232
267
|
}
|
|
233
268
|
}
|
|
234
269
|
|
|
270
|
+
// [LAW:single-enforcer] Every kick spawn site routes through here, so the
|
|
271
|
+
// spawn-rate bound is applied at exactly one boundary — mirror of Rust's
|
|
272
|
+
// spawn_daemon_rate_limited. On cooldown we do nothing: a spawn was attempted
|
|
273
|
+
// within SPAWN_COOLDOWN_MS and is likely still booting; the kick is
|
|
274
|
+
// fire-and-forget, so "already in flight" is a complete answer.
|
|
275
|
+
function cooldownGatedSpawn(spawnFn: () => boolean): void {
|
|
276
|
+
if (!claimSpawnCooldown()) return;
|
|
277
|
+
safeSpawn(spawnFn);
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
function spawnLockAgeMs(): number | null {
|
|
236
281
|
try {
|
|
237
282
|
const st = fs.statSync(spawnLockPath());
|
|
@@ -260,6 +305,93 @@ function safeSpawn(spawnFn: () => boolean): void {
|
|
|
260
305
|
}
|
|
261
306
|
}
|
|
262
307
|
|
|
308
|
+
// ─── Spawn cooldown (shared spawn-RATE bound) ────────────────────────────────
|
|
309
|
+
//
|
|
310
|
+
// [LAW:one-source-of-truth] spawn.lock dedups spawns at one INSTANT; the
|
|
311
|
+
// cooldown bounds them over TIME. Without it, the Rust kick — which releases
|
|
312
|
+
// spawn.lock milliseconds after forking, before the 0.5-3s Node boot window —
|
|
313
|
+
// re-spawns on every render tick during an outage (spawn rate ≈ tick rate:
|
|
314
|
+
// dozens/sec, process-table exhaustion). One file's mtime records the last spawn
|
|
315
|
+
// ATTEMPT; both runtimes consult it. The constant and filename are mirrored TS↔
|
|
316
|
+
// Rust (scripts/check-protocol.mjs). Worst case with the bound: ~20 spawns/min
|
|
317
|
+
// globally, each of which exits cleanly via the sibling socket-lease defenses.
|
|
318
|
+
// Exported for the boundary unit tests (test/daemon-acquire.test.ts), which pin
|
|
319
|
+
// the window arithmetic against the exact constant the same way the Rust unit
|
|
320
|
+
// tests do — so a TS↔Rust decision divergence at a boundary is caught even
|
|
321
|
+
// though check-protocol only diffs the constant's value.
|
|
322
|
+
export const SPAWN_COOLDOWN_MS = 3_000;
|
|
323
|
+
|
|
324
|
+
// [LAW:effects-at-boundaries] The window arithmetic — the subtle part: a
|
|
325
|
+
// future-mtime garbage record (beyond the stale-lock window) must not pin the
|
|
326
|
+
// cooldown forever, while a small negative age is just ms-truncation of
|
|
327
|
+
// Date.now() against the higher-precision fs mtime and still counts as a
|
|
328
|
+
// just-recorded attempt — is a pure function of the record's age, extracted from
|
|
329
|
+
// the fs read so it is unit-tested without touching the filesystem. Mirrors the
|
|
330
|
+
// Rust cooldown_decision. `null` age (missing/unreadable file) allows (first
|
|
331
|
+
// spawn); the mtime IS the timestamp, so "unparseable timestamp" is
|
|
332
|
+
// unrepresentable by construction.
|
|
333
|
+
export type CooldownDecision =
|
|
334
|
+
| { kind: "allow" }
|
|
335
|
+
| { kind: "allow-future-garbage"; futureMs: number }
|
|
336
|
+
| { kind: "deny" };
|
|
337
|
+
|
|
338
|
+
export function cooldownDecision(ageMs: number | null): CooldownDecision {
|
|
339
|
+
if (ageMs === null) return { kind: "allow" };
|
|
340
|
+
if (ageMs < -STALE_LOCK_MS)
|
|
341
|
+
return { kind: "allow-future-garbage", futureMs: -ageMs };
|
|
342
|
+
if (ageMs < SPAWN_COOLDOWN_MS) return { kind: "deny" };
|
|
343
|
+
return { kind: "allow" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
|
|
347
|
+
// and RECORDS the attempt (updating spawn.cooldown's mtime to now) — when a
|
|
348
|
+
// spawn is permitted; false when an attempt was recorded within
|
|
349
|
+
// SPAWN_COOLDOWN_MS. Recording-on-grant (BEFORE the caller spawns) is
|
|
350
|
+
// load-bearing: a spawn that then throws or returns false still counts against
|
|
351
|
+
// the rate, so a broken binary is not retried in a tight loop. A future-mtime
|
|
352
|
+
// garbage record warns loudly and falls toward ALLOWING the spawn
|
|
353
|
+
// [LAW:no-silent-failure].
|
|
354
|
+
function claimSpawnCooldown(): boolean {
|
|
355
|
+
const path = spawnCooldownPath();
|
|
356
|
+
const decision = cooldownDecision(cooldownAgeMs(path));
|
|
357
|
+
if (decision.kind === "deny") return false;
|
|
358
|
+
if (decision.kind === "allow-future-garbage") {
|
|
359
|
+
process.stderr.write(
|
|
360
|
+
`cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
recordSpawnAttempt(path);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function cooldownAgeMs(path: string): number | null {
|
|
368
|
+
try {
|
|
369
|
+
return Date.now() - fs.statSync(path).mtimeMs;
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function recordSpawnAttempt(filePath: string): void {
|
|
376
|
+
// [LAW:composability] Self-sufficient — ensure the state dir exists rather
|
|
377
|
+
// than leaning on an ambient ensureStateDir() precondition, so any spawn site
|
|
378
|
+
// routing through claimSpawnCooldown records correctly (mirrors Rust's
|
|
379
|
+
// record_spawn_attempt). Content is human-diagnostic only; the mtime is the
|
|
380
|
+
// authority. A write failure means no cooldown recorded — worst case one extra
|
|
381
|
+
// spawn, which bind() arbitrates — but surface it loudly rather than silently
|
|
382
|
+
// un-bound the rate [LAW:no-silent-failure].
|
|
383
|
+
try {
|
|
384
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
385
|
+
fs.writeFileSync(filePath, `${process.pid} ${Date.now()}\n`, {
|
|
386
|
+
mode: 0o600,
|
|
387
|
+
});
|
|
388
|
+
} catch (e) {
|
|
389
|
+
process.stderr.write(
|
|
390
|
+
`cc-candybar: could not record spawn.cooldown: ${(e as Error).message}\n`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
263
395
|
// ─── Spawn-lock (Node side) ──────────────────────────────────────────────────
|
|
264
396
|
//
|
|
265
397
|
// O_EXLOCK / fcntl(F_SETLK) aren't reliably exposed across Node platforms, so
|
|
@@ -274,7 +406,9 @@ function safeSpawn(spawnFn: () => boolean): void {
|
|
|
274
406
|
// lock is a thundering-herd optimization, so a missed dedup just means one
|
|
275
407
|
// extra Node process eats a bind() race and exits.
|
|
276
408
|
|
|
277
|
-
|
|
409
|
+
// Exported for the cooldownDecision boundary tests. Mirrored TS↔Rust (diffed by
|
|
410
|
+
// check-protocol) since both runtimes apply it to the same spawn.cooldown file.
|
|
411
|
+
export const STALE_LOCK_MS = 10_000;
|
|
278
412
|
|
|
279
413
|
let heldLock: { fd: number; path: string } | null = null;
|
|
280
414
|
|
package/src/daemon/paths.ts
CHANGED
|
@@ -146,6 +146,17 @@ export function spawnLockPath(): string {
|
|
|
146
146
|
return path.join(stateDir(), "spawn.lock");
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// [LAW:one-source-of-truth] The spawn-RATE bound (as distinct from spawn.lock's
|
|
150
|
+
// instantaneous dedup) is anchored to one file's mtime beside spawn.lock: the
|
|
151
|
+
// time of the last daemon-spawn ATTEMPT. Both runtimes gate on the SAME file, so
|
|
152
|
+
// the filename is mirrored TS↔Rust (rust-client/src/main.rs SPAWN_COOLDOWN_FILE)
|
|
153
|
+
// and diffed by scripts/check-protocol.mjs — a drift would silently split the
|
|
154
|
+
// rate bound in two.
|
|
155
|
+
const SPAWN_COOLDOWN_FILE = "spawn.cooldown";
|
|
156
|
+
export function spawnCooldownPath(): string {
|
|
157
|
+
return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
|
|
158
|
+
}
|
|
159
|
+
|
|
149
160
|
export function logPath(): string {
|
|
150
161
|
return path.join(stateDir(), "daemon.log");
|
|
151
162
|
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -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]
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
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
|
+
}
|