@promptctl/cc-candybar 1.17.3 → 1.17.5
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 +79 -79
- package/package.json +9 -5
- package/src/daemon/cache/session-usage-store.ts +195 -38
- 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/src/segments/metrics.ts +122 -36
- package/src/segments/session.ts +4 -169
- package/src/utils/claude.ts +79 -12
- package/src/utils/transcript-fs.ts +118 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.5",
|
|
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",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"test:coverage": "jest --coverage",
|
|
25
25
|
"demo": "tsx src/demo/dsl.ts",
|
|
26
26
|
"benchmark:timing": "scripts/benchmark_timing.sh",
|
|
27
|
+
"preloadtest": "pnpm build",
|
|
28
|
+
"loadtest": "node --import tsx scripts/daemon-load-harness.ts",
|
|
29
|
+
"preloadtest:gate": "pnpm build",
|
|
30
|
+
"loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --transcript-lines 5000",
|
|
27
31
|
"check:protocol": "node scripts/check-protocol.mjs",
|
|
28
32
|
"gen:schema": "tsx scripts/gen-schema.ts",
|
|
29
33
|
"check:schema": "tsx scripts/check-schema.ts",
|
|
@@ -91,10 +95,10 @@
|
|
|
91
95
|
"mobx": "^6.15.0"
|
|
92
96
|
},
|
|
93
97
|
"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.
|
|
98
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.17.5",
|
|
99
|
+
"@promptctl/cc-candybar-darwin-x64": "1.17.5",
|
|
100
|
+
"@promptctl/cc-candybar-linux-x64": "1.17.5",
|
|
101
|
+
"@promptctl/cc-candybar-linux-arm64": "1.17.5"
|
|
98
102
|
},
|
|
99
103
|
"pnpm": {
|
|
100
104
|
"supportedArchitectures": {
|
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
3
2
|
|
|
4
3
|
import {
|
|
5
|
-
SessionProvider,
|
|
6
4
|
type SessionInfo,
|
|
7
|
-
type SessionUsage,
|
|
8
5
|
type UsageInfo,
|
|
9
6
|
type TokenBreakdown,
|
|
10
7
|
} from "../../segments/session";
|
|
8
|
+
import { PricingService } from "../../segments/pricing";
|
|
11
9
|
import {
|
|
12
10
|
getClaudePaths,
|
|
13
11
|
findProjectPaths,
|
|
12
|
+
findAgentTranscripts,
|
|
13
|
+
readAppendedEntries,
|
|
14
14
|
type ClaudeHookData,
|
|
15
|
+
type ParsedEntry,
|
|
16
|
+
type TranscriptCursor,
|
|
15
17
|
} from "../../utils/claude";
|
|
16
18
|
// [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
|
|
17
19
|
// in-flight-I/O budget (gn4.2) as every other transcript scan.
|
|
18
20
|
import {
|
|
19
21
|
readdir as gatedReaddir,
|
|
20
22
|
stat as gatedStat,
|
|
23
|
+
statMtimeMs,
|
|
21
24
|
} from "../../utils/transcript-fs";
|
|
22
25
|
import { SingleFlight } from "../../utils/single-flight";
|
|
23
26
|
import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
|
|
@@ -105,6 +108,10 @@ interface DayUsage {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
interface SessionRecord {
|
|
111
|
+
// [LAW:one-source-of-truth] `files` is canonical (main transcript + agent
|
|
112
|
+
// sidechains, each its own incremental FileFold); `sessionInfo`/`days` are
|
|
113
|
+
// derived from mergeFolds and re-synced on every ingest — the single writer.
|
|
114
|
+
files: Map<string, FileFold>;
|
|
108
115
|
sessionInfo: SessionInfo;
|
|
109
116
|
days: Map<string, DayUsage>;
|
|
110
117
|
transcriptMtime: number;
|
|
@@ -152,33 +159,119 @@ function seedCutoffMs(): number {
|
|
|
152
159
|
return d.getTime();
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
function emptyBreakdown(): TokenBreakdown {
|
|
163
|
+
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// [LAW:one-source-of-truth] The incremental fold of ONE append-only transcript
|
|
167
|
+
// file (a session's main transcript or one agent sidechain). `cursor` marks the
|
|
168
|
+
// bytes already folded; the sums run over every usage-bearing entry seen so far.
|
|
169
|
+
// The session aggregate is the MERGE of its files' folds (mergeFolds), so a file
|
|
170
|
+
// rewritten by /compact re-folds in isolation without disturbing the others.
|
|
171
|
+
interface FileFold {
|
|
172
|
+
cursor: TranscriptCursor;
|
|
173
|
+
entries: number; // usage-bearing entries folded (0 ⇒ empty session, all-null)
|
|
174
|
+
cost: number;
|
|
175
|
+
breakdown: TokenBreakdown;
|
|
176
|
+
days: Map<string, DayUsage>; // keys >= seed cutoff only (pruned on fold)
|
|
162
177
|
}
|
|
163
178
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
179
|
+
// [LAW:effects-at-boundaries] Pure fold of NEW entries onto a prior file fold.
|
|
180
|
+
// Returns a FRESH FileFold (prior is never mutated) so two concurrent refolds
|
|
181
|
+
// sharing one prior snapshot can't corrupt each other. `reset` (file rewritten)
|
|
182
|
+
// discards the prior sums and folds `entries` — the whole new file — from zero.
|
|
183
|
+
//
|
|
184
|
+
// [LAW:one-source-of-truth] Session cost and day cost use the SAME priced value:
|
|
185
|
+
// an entry lacking costUSD is priced once (PricingService) and that figure feeds
|
|
186
|
+
// BOTH the session total and its day bucket. This matches the old path exactly —
|
|
187
|
+
// it mutated entry.costUSD to the priced cost before bucketByDay read it — so
|
|
188
|
+
// `today` is not silently under-counted for un-costed entries.
|
|
189
|
+
async function foldFile(
|
|
190
|
+
prior: FileFold | undefined,
|
|
191
|
+
reset: boolean,
|
|
192
|
+
entries: readonly ParsedEntry[],
|
|
193
|
+
cursor: TranscriptCursor,
|
|
194
|
+
): Promise<FileFold> {
|
|
195
|
+
// dayKey strings sort lexically == chronologically, so "keep recent" is a
|
|
196
|
+
// string comparison against yesterday's key.
|
|
167
197
|
const keep = dayKey(new Date(seedCutoffMs()));
|
|
198
|
+
const base = prior && !reset ? prior : undefined;
|
|
199
|
+
let count = base?.entries ?? 0;
|
|
200
|
+
let cost = base?.cost ?? 0;
|
|
201
|
+
const breakdown = base ? { ...base.breakdown } : emptyBreakdown();
|
|
168
202
|
const days = new Map<string, DayUsage>();
|
|
169
|
-
|
|
203
|
+
if (base) {
|
|
204
|
+
for (const [k, v] of base.days) if (k >= keep) days.set(k, { ...v });
|
|
205
|
+
}
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const u = entry.message?.usage;
|
|
208
|
+
if (!u) continue;
|
|
209
|
+
count++;
|
|
210
|
+
const priced =
|
|
211
|
+
entry.costUSD ?? (await PricingService.calculateCostForEntry(entry.raw));
|
|
212
|
+
cost += priced;
|
|
213
|
+
breakdown.input += u.input_tokens || 0;
|
|
214
|
+
breakdown.output += u.output_tokens || 0;
|
|
215
|
+
breakdown.cacheCreation += u.cache_creation_input_tokens || 0;
|
|
216
|
+
breakdown.cacheRead += u.cache_read_input_tokens || 0;
|
|
170
217
|
const key = dayKey(new Date(entry.timestamp));
|
|
171
218
|
if (key < keep) continue;
|
|
172
219
|
const d = days.get(key) ?? { ...EMPTY_DAY };
|
|
173
|
-
|
|
174
|
-
|
|
220
|
+
// [LAW:one-source-of-truth] Day cost uses the PRICED value, same as session
|
|
221
|
+
// cost — the old path mutated entry.costUSD to the priced cost before
|
|
222
|
+
// bucketByDay read it, so an un-costed entry contributed its priced value to
|
|
223
|
+
// `today`, not 0. Using `priced` here preserves that (session and day agree).
|
|
224
|
+
d.cost += priced;
|
|
175
225
|
d.input += u.input_tokens || 0;
|
|
176
226
|
d.output += u.output_tokens || 0;
|
|
177
227
|
d.cacheCreation += u.cache_creation_input_tokens || 0;
|
|
178
228
|
d.cacheRead += u.cache_read_input_tokens || 0;
|
|
179
229
|
days.set(key, d);
|
|
180
230
|
}
|
|
181
|
-
return days;
|
|
231
|
+
return { cursor, entries: count, cost, breakdown, days };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// [LAW:effects-at-boundaries] Pure merge of a session's file folds into the
|
|
235
|
+
// derived record shape the read path serves. `entries === 0` across all files is
|
|
236
|
+
// the empty session (all-null SessionInfo, whose fields the payload drops) — the
|
|
237
|
+
// same distinction the old `entries.length === 0` arm carried.
|
|
238
|
+
function mergeFolds(files: ReadonlyMap<string, FileFold>): {
|
|
239
|
+
sessionInfo: SessionInfo;
|
|
240
|
+
days: Map<string, DayUsage>;
|
|
241
|
+
} {
|
|
242
|
+
let count = 0;
|
|
243
|
+
let cost = 0;
|
|
244
|
+
const bd = emptyBreakdown();
|
|
245
|
+
const days = new Map<string, DayUsage>();
|
|
246
|
+
for (const f of files.values()) {
|
|
247
|
+
count += f.entries;
|
|
248
|
+
cost += f.cost;
|
|
249
|
+
bd.input += f.breakdown.input;
|
|
250
|
+
bd.output += f.breakdown.output;
|
|
251
|
+
bd.cacheCreation += f.breakdown.cacheCreation;
|
|
252
|
+
bd.cacheRead += f.breakdown.cacheRead;
|
|
253
|
+
for (const [k, v] of f.days) {
|
|
254
|
+
const d = days.get(k) ?? { ...EMPTY_DAY };
|
|
255
|
+
d.cost += v.cost;
|
|
256
|
+
d.input += v.input;
|
|
257
|
+
d.output += v.output;
|
|
258
|
+
d.cacheCreation += v.cacheCreation;
|
|
259
|
+
d.cacheRead += v.cacheRead;
|
|
260
|
+
days.set(k, d);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (count === 0) return { sessionInfo: EMPTY_SESSION_INFO, days };
|
|
264
|
+
const tokens = bd.input + bd.output + bd.cacheCreation + bd.cacheRead;
|
|
265
|
+
return {
|
|
266
|
+
sessionInfo: {
|
|
267
|
+
cost,
|
|
268
|
+
calculatedCost: cost,
|
|
269
|
+
officialCost: null,
|
|
270
|
+
tokens,
|
|
271
|
+
tokenBreakdown: bd,
|
|
272
|
+
},
|
|
273
|
+
days,
|
|
274
|
+
};
|
|
182
275
|
}
|
|
183
276
|
|
|
184
277
|
// Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
|
|
@@ -201,7 +294,6 @@ async function mapPool<T>(
|
|
|
201
294
|
}
|
|
202
295
|
|
|
203
296
|
export class SessionUsageStore {
|
|
204
|
-
private readonly sessions = new SessionProvider();
|
|
205
297
|
private readonly entries = new Map<string, SessionRecord>();
|
|
206
298
|
// [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
|
|
207
299
|
// (session, observed mtime) onto one parse; cleared on settle (a coalescer,
|
|
@@ -424,15 +516,49 @@ export class SessionUsageStore {
|
|
|
424
516
|
if (!transcriptPath) return existing ? ok(existing) : ABSENT;
|
|
425
517
|
|
|
426
518
|
this.misses++;
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
//
|
|
519
|
+
// [LAW:no-ambient-temporal-coupling] The flight thunk snapshots the prior
|
|
520
|
+
// record when it RUNS (first caller); coalesced callers share that one
|
|
521
|
+
// refold. Two concurrent DIFFERENT-mtime refolds each read the same prior and
|
|
522
|
+
// last-writer-wins. This is safe — and specifically NOT a double-count —
|
|
523
|
+
// because `refold` writes the byte cursor and the fold as ONE atomic pair
|
|
524
|
+
// (both from a single refold result), and the fold always covers exactly
|
|
525
|
+
// [0, cursor). A losing writer's fold is discarded WITH its cursor, not left
|
|
526
|
+
// beside a stale one. So even when last-writer-wins rewinds the stored cursor,
|
|
527
|
+
// it rewinds the paired fold with it; the next refold folds [cursor, end) onto
|
|
528
|
+
// a fold that lacks those bytes — each byte folded once. `foldFile` is pure
|
|
529
|
+
// (fresh FileFold, prior never mutated), so the shared prior can't corrupt.
|
|
530
|
+
// Pinned by the concurrency test in test/transcript-incremental.test.ts.
|
|
531
|
+
// The prior is read when the thunk RUNS. If evictIfNeeded dropped this
|
|
532
|
+
// session between thunk creation and run, prior is undefined and refold does
|
|
533
|
+
// one whole-file re-fold — still correct, just non-incremental that once.
|
|
534
|
+
// (Eviction only fires after a successful write, so this can bite only on a
|
|
535
|
+
// first miss under cap pressure.)
|
|
536
|
+
// [LAW:no-silent-failure] ingest MUST stay total — every caller reads
|
|
537
|
+
// `.kind`, so a rejected promise (e.g. PricingService.calculateCostForEntry
|
|
538
|
+
// throwing on a bad rate table or network hiccup inside foldFile) would
|
|
539
|
+
// escape the Outcome contract entirely. The deleted getSessionUsageFromPath
|
|
540
|
+
// wrapped this; keep that guarantee by mapping any throw to `failed`.
|
|
541
|
+
let outcome: Outcome<{
|
|
542
|
+
files: Map<string, FileFold>;
|
|
543
|
+
sessionInfo: SessionInfo;
|
|
544
|
+
days: Map<string, DayUsage>;
|
|
545
|
+
mainMtime: number;
|
|
546
|
+
}>;
|
|
547
|
+
try {
|
|
548
|
+
outcome = await this.flight.run(`${sessionId}:${mtime}`, () =>
|
|
549
|
+
this.refold(sessionId, transcriptPath, this.entries.get(sessionId)),
|
|
550
|
+
);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
return failed(
|
|
553
|
+
`usage refold (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
if (outcome.kind !== "ok") return outcome;
|
|
433
557
|
const record: SessionRecord = {
|
|
434
|
-
|
|
435
|
-
|
|
558
|
+
files: outcome.value.files,
|
|
559
|
+
sessionInfo: outcome.value.sessionInfo,
|
|
560
|
+
days: outcome.value.days,
|
|
561
|
+
transcriptMtime: outcome.value.mainMtime,
|
|
436
562
|
transcriptPath,
|
|
437
563
|
lastSeenAt: Date.now(),
|
|
438
564
|
};
|
|
@@ -442,23 +568,54 @@ export class SessionUsageStore {
|
|
|
442
568
|
return ok(record);
|
|
443
569
|
}
|
|
444
570
|
|
|
445
|
-
|
|
571
|
+
// [LAW:dataflow-not-control-flow] Re-fold ONLY the bytes appended since the
|
|
572
|
+
// prior record — the transcript (and each agent sidechain) is append-only, so
|
|
573
|
+
// per-render work is O(new bytes), not O(file). The whole-file case (cold
|
|
574
|
+
// record, or a first-ever read) is the same path with an empty prior: the
|
|
575
|
+
// reader yields the whole file from offset 0. A `failed` read on any file
|
|
576
|
+
// fails the fold (the boundary logs it); an `absent` file (not yet written /
|
|
577
|
+
// agent removed) keeps its prior fold and contributes nothing new.
|
|
578
|
+
private async refold(
|
|
446
579
|
sessionId: string,
|
|
447
580
|
transcriptPath: string,
|
|
581
|
+
prior: SessionRecord | undefined,
|
|
448
582
|
): Promise<
|
|
449
|
-
Outcome<{
|
|
583
|
+
Outcome<{
|
|
584
|
+
files: Map<string, FileFold>;
|
|
585
|
+
sessionInfo: SessionInfo;
|
|
586
|
+
days: Map<string, DayUsage>;
|
|
587
|
+
mainMtime: number;
|
|
588
|
+
}>
|
|
450
589
|
> {
|
|
451
|
-
|
|
590
|
+
// File set for this fold: the main transcript plus the session's current
|
|
591
|
+
// agent sidechains. Files no longer in the set (a removed sidechain) drop
|
|
592
|
+
// out of `newFiles`, so a rewritten/renamed file cannot linger in the merge.
|
|
593
|
+
// Pass the prior file set so already-verified agent sidechains skip their
|
|
594
|
+
// first-line re-read — per-render agent discovery is O(new sidechains).
|
|
595
|
+
const agentPaths = await findAgentTranscripts(
|
|
452
596
|
sessionId,
|
|
453
|
-
transcriptPath,
|
|
597
|
+
dirname(transcriptPath),
|
|
598
|
+
prior === undefined ? undefined : new Set(prior.files.keys()),
|
|
454
599
|
);
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
600
|
+
const newFiles = new Map<string, FileFold>();
|
|
601
|
+
let mainMtime = 0;
|
|
602
|
+
for (const filePath of [transcriptPath, ...agentPaths]) {
|
|
603
|
+
const priorFold = prior?.files.get(filePath);
|
|
604
|
+
const read = await readAppendedEntries(filePath, priorFold?.cursor);
|
|
605
|
+
if (read.kind === "failed") return read;
|
|
606
|
+
if (read.kind === "absent") {
|
|
607
|
+
// The file has no bytes yet (fresh main) or vanished (removed agent
|
|
608
|
+
// sidechain). Carry a prior fold forward; otherwise it contributes
|
|
609
|
+
// nothing. The main transcript's absence leaves mainMtime 0, so the next
|
|
610
|
+
// render's mtime gate re-attempts rather than caching an empty read.
|
|
611
|
+
if (priorFold) newFiles.set(filePath, priorFold);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const { entries, cursor, reset } = read.value;
|
|
615
|
+
newFiles.set(filePath, await foldFile(priorFold, reset, entries, cursor));
|
|
616
|
+
if (filePath === transcriptPath) mainMtime = cursor.mtimeMs;
|
|
617
|
+
}
|
|
618
|
+
return ok({ files: newFiles, ...mergeFolds(newFiles), mainMtime });
|
|
462
619
|
}
|
|
463
620
|
|
|
464
621
|
private ensureSeeded(day: string): Promise<void> {
|
|
@@ -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
|
}
|