@promptctl/cc-candybar 1.17.0 → 1.17.1
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/paths.ts +33 -9
- package/src/daemon/server.ts +72 -101
- package/src/daemon/socket-lease.ts +181 -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.1",
|
|
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.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"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
package/src/daemon/paths.ts
CHANGED
|
@@ -95,19 +95,43 @@ export function ensureSocketParentSafe(sockPath: string): void {
|
|
|
95
95
|
// If a stale socket file is a symlink, refuse — an attacker who briefly
|
|
96
96
|
// had write access to a previously-permissive dir could have planted a
|
|
97
97
|
// symlink even after we tighten perms.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
//
|
|
99
|
+
// [LAW:single-enforcer] The lease (`${sockPath}.lease`) gets the SAME gate:
|
|
100
|
+
// it is now load-bearing (ownership authority — readLease follows a symlink
|
|
101
|
+
// and a planted `lease → /dev/null` would read `absent`/`unreadable`, forcing
|
|
102
|
+
// a false reclaim that unlinks a live daemon's socket). One enforcer certifies
|
|
103
|
+
// every path we bind/read/write under this parent, so the socket and its lease
|
|
104
|
+
// can never diverge on "is this a symlink".
|
|
105
|
+
for (const p of [sockPath, leasePathFor(sockPath)]) {
|
|
106
|
+
try {
|
|
107
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
108
|
+
throw new Error(`path is a symlink: ${p}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
112
|
+
if (code !== "ENOENT") throw e;
|
|
102
113
|
}
|
|
103
|
-
} catch (e) {
|
|
104
|
-
const code = (e as NodeJS.ErrnoException).code;
|
|
105
|
-
if (code !== "ENOENT") throw e;
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
// [LAW:one-source-of-truth] The socket-ownership lease is DERIVED FROM the
|
|
118
|
+
// socket path, so it shares the socket's identity root. The old diagnostic
|
|
119
|
+
// pidfile lived under $XDG_STATE_HOME while the socket lived under /tmp — two
|
|
120
|
+
// identity roots for one instance, and CC_CANDYBAR_SOCKET isolated only one of
|
|
121
|
+
// them. Anchoring the lease to socketPath() means test/dev isolation via
|
|
122
|
+
// CC_CANDYBAR_SOCKET isolates the lease too. The lease subsumes the old
|
|
123
|
+
// diagnostic pidfile: it carries the owner pid (the reclaim authority) plus the
|
|
124
|
+
// same diagnostic fields.
|
|
125
|
+
// [LAW:one-source-of-truth] The socket→lease derivation lives here alone, so
|
|
126
|
+
// leasePath() (the runtime authority path) and ensureSocketParentSafe's symlink
|
|
127
|
+
// gate agree by construction. Takes the socket path explicitly so the safety
|
|
128
|
+
// check certifies the exact path it was handed, not a re-derived one.
|
|
129
|
+
export function leasePathFor(sockPath: string): string {
|
|
130
|
+
return `${sockPath}.lease`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function leasePath(): string {
|
|
134
|
+
return leasePathFor(socketPath());
|
|
111
135
|
}
|
|
112
136
|
|
|
113
137
|
export function sessionStatePath(): string {
|
package/src/daemon/server.ts
CHANGED
|
@@ -6,10 +6,17 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import {
|
|
7
7
|
daemonDir,
|
|
8
8
|
ensureSocketParentSafe,
|
|
9
|
-
|
|
9
|
+
leasePath,
|
|
10
|
+
leasePathFor,
|
|
10
11
|
socketPath,
|
|
11
12
|
sessionStatePath,
|
|
12
13
|
} from "./paths";
|
|
14
|
+
import {
|
|
15
|
+
arbitrateSocket,
|
|
16
|
+
readLease,
|
|
17
|
+
removeLeaseIfOwned,
|
|
18
|
+
writeLease,
|
|
19
|
+
} from "./socket-lease";
|
|
13
20
|
import { dlog, closeLog } from "./log";
|
|
14
21
|
import {
|
|
15
22
|
PROTOCOL_VERSION,
|
|
@@ -184,6 +191,13 @@ function bindOrAttachAndExit(
|
|
|
184
191
|
retried: boolean,
|
|
185
192
|
): void {
|
|
186
193
|
server.removeAllListeners("error");
|
|
194
|
+
// [LAW:no-ambient-temporal-coupling] server.listen(path, cb) registers cb as
|
|
195
|
+
// a ONE-TIME 'listening' listener. A first listen that fails EADDRINUSE never
|
|
196
|
+
// fires 'listening', so its callback stays pending; the reclaim retry adds a
|
|
197
|
+
// second. Without clearing the stale one, a successful rebind fires BOTH and
|
|
198
|
+
// onListening runs twice — double-arming the RSS backstop + watchers. Clear
|
|
199
|
+
// pending 'listening' listeners so exactly one onListening fires per bind.
|
|
200
|
+
server.removeAllListeners("listening");
|
|
187
201
|
server.once("error", (err) => {
|
|
188
202
|
const code = (err as NodeJS.ErrnoException).code;
|
|
189
203
|
if (code !== "EADDRINUSE") {
|
|
@@ -198,36 +212,40 @@ function bindOrAttachAndExit(
|
|
|
198
212
|
process.exit(0);
|
|
199
213
|
return;
|
|
200
214
|
}
|
|
201
|
-
|
|
215
|
+
handleAddressInUse(server, sockPath);
|
|
202
216
|
});
|
|
203
217
|
server.listen(sockPath, () => onListening(sockPath));
|
|
204
218
|
}
|
|
205
219
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (await isSocketAlive(sockPath)) {
|
|
224
|
-
dlog(
|
|
225
|
-
"info",
|
|
226
|
-
"race: another daemon claimed the socket during recovery — exiting",
|
|
227
|
-
);
|
|
220
|
+
// [LAW:one-source-of-truth] EADDRINUSE arbitration consults the socket-derived
|
|
221
|
+
// pid lease, NEVER a connect probe. The path already exists (a live daemon, or
|
|
222
|
+
// a stale file from a crashed one); the lease's owner pid + kill(pid,0) decides
|
|
223
|
+
// which. This is fully synchronous — reading the lease and testing liveness are
|
|
224
|
+
// both sync — so there is no await gap for a concurrent recoverer to race
|
|
225
|
+
// through (the old async probe had two such gaps and a hand-rolled re-check).
|
|
226
|
+
//
|
|
227
|
+
// [LAW:effects-at-boundaries] The decision is the pure arbitrateSocket fold
|
|
228
|
+
// over the lease read + injected pidAlive; the kill / unlink / rebind effects
|
|
229
|
+
// are performed here at the edge.
|
|
230
|
+
function handleAddressInUse(server: net.Server, sockPath: string): void {
|
|
231
|
+
// [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
|
|
232
|
+
// through unlink + rebind below, not the re-derived global — one identity
|
|
233
|
+
// source for the whole arbitration.
|
|
234
|
+
const decision = arbitrateSocket(readLease(leasePathFor(sockPath)), pidAlive);
|
|
235
|
+
if (decision.kind === "attach-and-exit") {
|
|
236
|
+
dlog("info", `EADDRINUSE: ${decision.reason} — exiting`);
|
|
228
237
|
process.exit(0);
|
|
238
|
+
// [LAW:no-ambient-temporal-coupling] process.exit() halts synchronously, so
|
|
239
|
+
// the reclaim below is already unreachable — but the explicit return makes
|
|
240
|
+
// that structural, matching the sibling `retried` branch, so no future
|
|
241
|
+
// refactor of the exit path can accidentally fall through to unlinking a
|
|
242
|
+
// live daemon's socket.
|
|
243
|
+
return;
|
|
229
244
|
}
|
|
230
|
-
dlog(
|
|
245
|
+
dlog(
|
|
246
|
+
"warn",
|
|
247
|
+
`EADDRINUSE: ${decision.reason} — unlinking stale socket and rebinding`,
|
|
248
|
+
);
|
|
231
249
|
// [LAW:no-defensive-null-guards] If unlink fails (permissions, read-only
|
|
232
250
|
// FS), the retry will hit EADDRINUSE again, exit 0, and leave the system
|
|
233
251
|
// in the worst state: no daemon + stale socket blocking future starts.
|
|
@@ -248,61 +266,21 @@ async function handleAddressInUse(
|
|
|
248
266
|
bindOrAttachAndExit(server, sockPath, /* retried */ true);
|
|
249
267
|
}
|
|
250
268
|
|
|
251
|
-
// [LAW:no-defensive-null-guards] Three-state outcome distinguishes
|
|
252
|
-
// "definitely no listener" from "probably alive but slow." Callers that
|
|
253
|
-
// might destroy state on "no listener" (the stale-socket unlink path) must
|
|
254
|
-
// treat "unknown" as alive to avoid stomping on a slow live daemon.
|
|
255
|
-
type SocketAliveness = "alive" | "dead" | "unknown";
|
|
256
|
-
|
|
257
|
-
function probeSocket(sockPath: string): Promise<SocketAliveness> {
|
|
258
|
-
return new Promise((resolve) => {
|
|
259
|
-
const sock = net.connect(sockPath);
|
|
260
|
-
let settled = false;
|
|
261
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
262
|
-
const done = (result: SocketAliveness): void => {
|
|
263
|
-
if (settled) return;
|
|
264
|
-
settled = true;
|
|
265
|
-
if (timer) clearTimeout(timer);
|
|
266
|
-
sock.removeAllListeners();
|
|
267
|
-
sock.destroy();
|
|
268
|
-
resolve(result);
|
|
269
|
-
};
|
|
270
|
-
sock.once("connect", () => done("alive"));
|
|
271
|
-
sock.once("error", (err) => {
|
|
272
|
-
// Only these error codes definitively mean "no listener at this path":
|
|
273
|
-
// ECONNREFUSED — socket file present, kernel rejected connect
|
|
274
|
-
// ENOENT — socket file absent
|
|
275
|
-
// ENOTSOCK — path exists but isn't a socket
|
|
276
|
-
// Anything else (EPERM, EACCES, EAGAIN, …) is ambiguous — assume the
|
|
277
|
-
// daemon is alive to avoid destructive false negatives.
|
|
278
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
279
|
-
const dead =
|
|
280
|
-
code === "ECONNREFUSED" || code === "ENOENT" || code === "ENOTSOCK";
|
|
281
|
-
done(dead ? "dead" : "unknown");
|
|
282
|
-
});
|
|
283
|
-
// 50ms is generous; localhost AF_UNIX connect is sub-ms when a listener
|
|
284
|
-
// exists. Timeout means "we couldn't tell" — treat as unknown so the
|
|
285
|
-
// stale-socket recovery path doesn't unlink a live (but slow) daemon's
|
|
286
|
-
// socket.
|
|
287
|
-
timer = setTimeout(() => done("unknown"), 50);
|
|
288
|
-
timer.unref();
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Convenience: callers that just want "is something listening" treat
|
|
293
|
-
// "unknown" as alive (conservative — used by the EADDRINUSE attach branch).
|
|
294
|
-
async function isSocketAlive(sockPath: string): Promise<boolean> {
|
|
295
|
-
const result = await probeSocket(sockPath);
|
|
296
|
-
return result !== "dead";
|
|
297
|
-
}
|
|
298
|
-
|
|
299
269
|
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.
|
|
276
|
+
// [LAW:one-source-of-truth] Derive the lease from the same sockPath we bound,
|
|
277
|
+
// matching handleAddressInUse's read — one identity source across write + read.
|
|
278
|
+
writeLeaseFile(sockPath);
|
|
300
279
|
try {
|
|
301
280
|
fs.chmodSync(sockPath, 0o600);
|
|
302
281
|
} catch (e) {
|
|
303
282
|
dlog("warn", `chmod socket failed: ${(e as Error).message}`);
|
|
304
283
|
}
|
|
305
|
-
writePidfileDiagnostic();
|
|
306
284
|
dlog(
|
|
307
285
|
"info",
|
|
308
286
|
`daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
|
|
@@ -365,40 +343,29 @@ function armLimits(): void {
|
|
|
365
343
|
limits.arm();
|
|
366
344
|
}
|
|
367
345
|
|
|
368
|
-
// ---
|
|
346
|
+
// --- socket-ownership lease ---
|
|
369
347
|
//
|
|
370
|
-
// [LAW:one-source-of-truth] The
|
|
371
|
-
//
|
|
372
|
-
//
|
|
348
|
+
// [LAW:one-source-of-truth] The lease is the authority for socket ownership —
|
|
349
|
+
// its owner pid + kill(pid,0) is what the next daemon's EADDRINUSE arbitration
|
|
350
|
+
// consults (handleAddressInUse). Exclusion RIGHT NOW is still the atomic bind()
|
|
351
|
+
// in bindOrAttachAndExit(); the lease answers the separate question "may I
|
|
352
|
+
// destroy this existing path" over time. It also carries the same diagnostic
|
|
353
|
+
// fields the old pidfile did, so one file serves both roles.
|
|
373
354
|
//
|
|
374
|
-
// Overwrite-on-write (no EEXIST check).
|
|
375
|
-
//
|
|
376
|
-
//
|
|
355
|
+
// Overwrite-on-write (no EEXIST check). We only reach onListening after winning
|
|
356
|
+
// the bind, so whatever stale lease a dead owner left is ours to replace. Write
|
|
357
|
+
// failure is non-fatal: the lease is best-effort ownership signalling on top of
|
|
358
|
+
// bind()'s hard exclusion; a missing lease degrades a future arbitration to
|
|
359
|
+
// "reclaim" (unlink + rebind), never to a wrong attach.
|
|
377
360
|
|
|
378
|
-
function
|
|
379
|
-
const
|
|
361
|
+
function writeLeaseFile(sockPath: string): void {
|
|
362
|
+
const reason = writeLease(leasePathFor(sockPath), {
|
|
380
363
|
pid: process.pid,
|
|
381
364
|
version: PROTOCOL_VERSION,
|
|
382
365
|
binPath: process.argv[1],
|
|
383
366
|
startedAt: new Date().toISOString(),
|
|
384
367
|
});
|
|
385
|
-
|
|
386
|
-
fs.writeFileSync(pidPath(), payload, { mode: 0o600 });
|
|
387
|
-
// [LAW:single-enforcer] writeFileSync's `mode` only applies when the file
|
|
388
|
-
// is created. If a stale pidfile from a prior run was left with broader
|
|
389
|
-
// permissions, the write above won't tighten them — chmod explicitly so
|
|
390
|
-
// 0600 is the invariant regardless of prior state.
|
|
391
|
-
fs.chmodSync(pidPath(), 0o600);
|
|
392
|
-
} catch (e) {
|
|
393
|
-
// Diagnostic only — failure does not block the daemon from serving.
|
|
394
|
-
dlog("warn", `pidfile write failed: ${(e as Error).message}`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function removePidfileDiagnostic(): void {
|
|
399
|
-
try {
|
|
400
|
-
fs.unlinkSync(pidPath());
|
|
401
|
-
} catch {}
|
|
368
|
+
if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
|
|
402
369
|
}
|
|
403
370
|
|
|
404
371
|
let inFlight = 0;
|
|
@@ -465,7 +432,11 @@ function shutdown(code: number): void {
|
|
|
465
432
|
} catch (e) {
|
|
466
433
|
dlog("warn", `sessionState flush failed: ${(e as Error).message}`);
|
|
467
434
|
}
|
|
468
|
-
|
|
435
|
+
// [LAW:one-source-of-truth] Remove the lease only if it still names us. A
|
|
436
|
+
// displaced daemon (socket stolen, thief wrote its own lease) must not delete
|
|
437
|
+
// the live owner's lease on its way out, or the next EADDRINUSE would read
|
|
438
|
+
// `absent` and reclaim the thief's live socket — cascading the theft.
|
|
439
|
+
removeLeaseIfOwned(leasePath(), process.pid);
|
|
469
440
|
closeLog();
|
|
470
441
|
process.exit(code);
|
|
471
442
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
// ─── Socket ownership lease ──────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// [LAW:one-source-of-truth] The authority for "who owns this socket path" is a
|
|
7
|
+
// lease file DERIVED FROM the socket path (one lease per socket identity), not a
|
|
8
|
+
// connect probe. The prior probe (connect + classify the error code) was a
|
|
9
|
+
// representation lie on macOS/BSD: connect(2) on a LIVE AF_UNIX listener whose
|
|
10
|
+
// accept backlog is full returns ECONNREFUSED — indistinguishable from a dead
|
|
11
|
+
// socket — so under load the probe judged healthy-but-slow daemons dead and
|
|
12
|
+
// their sockets were stolen, minting immortal orphans (see
|
|
13
|
+
// brandon-daemon-lifecycle-2b3).
|
|
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.
|
|
19
|
+
|
|
20
|
+
// What a lease read yielded. Distinguishing absent / unreadable / owned keeps
|
|
21
|
+
// the arbitration decision a full enumeration over raw inputs (see
|
|
22
|
+
// arbitrateSocket) rather than collapsing the ambiguous cases early.
|
|
23
|
+
export type LeaseRead =
|
|
24
|
+
| { kind: "absent" }
|
|
25
|
+
| { kind: "unreadable"; detail: string }
|
|
26
|
+
| { kind: "owned"; pid: number };
|
|
27
|
+
|
|
28
|
+
// The EADDRINUSE arbitration outcome. The path already exists (something bound
|
|
29
|
+
// it or a stale file remains); this says whether a LIVE owner holds it.
|
|
30
|
+
export type SocketArbitration =
|
|
31
|
+
| { kind: "attach-and-exit"; reason: string }
|
|
32
|
+
| { kind: "reclaim"; reason: string };
|
|
33
|
+
|
|
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.
|
|
37
|
+
export interface LeaseRecord {
|
|
38
|
+
pid: number;
|
|
39
|
+
version: number;
|
|
40
|
+
binPath: string | undefined;
|
|
41
|
+
startedAt: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
|
|
45
|
+
// 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)
|
|
54
|
+
//
|
|
55
|
+
// Failure directions:
|
|
56
|
+
// 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).
|
|
70
|
+
// Reclaiming on missing/unreadable is the availability-preferring direction — a
|
|
71
|
+
// stale socket with no reclaimer is a hard no-service stall, strictly worse than
|
|
72
|
+
// a transient double-serve that self-heals.
|
|
73
|
+
export function arbitrateSocket(
|
|
74
|
+
read: LeaseRead,
|
|
75
|
+
isAlive: (pid: number) => boolean,
|
|
76
|
+
): SocketArbitration {
|
|
77
|
+
if (read.kind === "owned") {
|
|
78
|
+
return isAlive(read.pid)
|
|
79
|
+
? {
|
|
80
|
+
kind: "attach-and-exit",
|
|
81
|
+
reason: `live owner pid=${read.pid} holds the socket`,
|
|
82
|
+
}
|
|
83
|
+
: { kind: "reclaim", reason: `owner pid=${read.pid} is gone (ESRCH)` };
|
|
84
|
+
}
|
|
85
|
+
if (read.kind === "absent") {
|
|
86
|
+
return {
|
|
87
|
+
kind: "reclaim",
|
|
88
|
+
reason: "no lease — stale socket, no live owner",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
kind: "reclaim",
|
|
93
|
+
reason: `unreadable lease (${read.detail}) — cannot prove a live owner`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// [LAW:no-silent-failure] Every non-happy path becomes a typed `unreadable`
|
|
98
|
+
// with the reason inline, never a swallowed default that pretends the lease said
|
|
99
|
+
// something. ENOENT is the one benign case → `absent`.
|
|
100
|
+
export function readLease(leasePath: string): LeaseRead {
|
|
101
|
+
let raw: string;
|
|
102
|
+
try {
|
|
103
|
+
raw = fs.readFileSync(leasePath, "utf8");
|
|
104
|
+
} catch (e) {
|
|
105
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
106
|
+
if (code === "ENOENT") return { kind: "absent" };
|
|
107
|
+
return {
|
|
108
|
+
kind: "unreadable",
|
|
109
|
+
detail: `read failed: ${(e as Error).message}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { kind: "unreadable", detail: `bad JSON: ${(e as Error).message}` };
|
|
117
|
+
}
|
|
118
|
+
const pid = (parsed as { pid?: unknown } | null)?.pid;
|
|
119
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "unreadable",
|
|
122
|
+
detail: `no valid pid (got ${JSON.stringify(pid)})`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { kind: "owned", pid };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Write our lease after we win the bind. Overwrite-on-write: whoever holds the
|
|
129
|
+
// socket right now replaces whatever stale lease a dead owner left.
|
|
130
|
+
//
|
|
131
|
+
// [LAW:no-ambient-temporal-coupling] Atomic publish: write a uniquely-named temp
|
|
132
|
+
// sibling then rename onto the lease path. POSIX rename within a directory is
|
|
133
|
+
// atomic, so a concurrent reader (a second daemon's EADDRINUSE arbitration) sees
|
|
134
|
+
// either the old lease or the complete new one — never a truncated/empty file
|
|
135
|
+
// mid-write, which would read as `unreadable` and force a false reclaim of this
|
|
136
|
+
// daemon's live socket. The unique temp name means it is always freshly created,
|
|
137
|
+
// so its 0600 mode always applies (0600 has no group/world bits for any umask to
|
|
138
|
+
// need re-tightening) and rename carries that mode across. Returns null on
|
|
139
|
+
// success, a reason on failure.
|
|
140
|
+
export function writeLease(
|
|
141
|
+
leasePath: string,
|
|
142
|
+
record: LeaseRecord,
|
|
143
|
+
): string | null {
|
|
144
|
+
const tmp = `${leasePath}.${process.pid}.tmp`;
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
147
|
+
fs.renameSync(tmp, leasePath);
|
|
148
|
+
return null;
|
|
149
|
+
} catch (e) {
|
|
150
|
+
const reason = (e as Error).message;
|
|
151
|
+
// [LAW:no-silent-failure] Best-effort temp cleanup, but don't swallow a real
|
|
152
|
+
// failure: ENOENT means the temp was never created (write failed first) —
|
|
153
|
+
// benign; any other unlink error means the temp WAS created and now leaks,
|
|
154
|
+
// so append it to the reason rather than hiding it behind the primary error.
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(tmp);
|
|
157
|
+
} catch (cleanupErr) {
|
|
158
|
+
if ((cleanupErr as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
159
|
+
return `${reason}; also failed to remove temp ${tmp}: ${(cleanupErr as Error).message}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return reason;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// [LAW:one-source-of-truth] Only the owner mutates its lease. A daemon that was
|
|
167
|
+
// displaced (its socket stolen, the thief wrote a new lease) must NOT delete the
|
|
168
|
+
// current owner's lease on its way out — that would make the next EADDRINUSE
|
|
169
|
+
// read `absent` and reclaim the live thief's socket, cascading the theft. So
|
|
170
|
+
// remove only when the lease still names us.
|
|
171
|
+
export function removeLeaseIfOwned(leasePath: string, myPid: number): void {
|
|
172
|
+
const read = readLease(leasePath);
|
|
173
|
+
if (read.kind === "owned" && read.pid === myPid) {
|
|
174
|
+
try {
|
|
175
|
+
fs.unlinkSync(leasePath);
|
|
176
|
+
} catch {
|
|
177
|
+
// Best-effort cleanup; a leftover lease naming a dead pid is harmless
|
|
178
|
+
// (the next daemon reads it, kill(pid,0)→ESRCH, reclaims).
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|