@promptctl/cc-candybar 1.42.1 → 1.43.0

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.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,87 +0,0 @@
1
- import process from "node:process";
2
-
3
- // [LAW:single-enforcer] One owner of the invariant "I must not outlive the
4
- // process that spawned me." A *production* daemon is spawned detached and is
5
- // SUPPOSED to outlive its spawner — the render-tick client exits, leaving a
6
- // warm daemon — so it is anchored to nobody and this watchdog never fires. A
7
- // daemon spawned by a *transient* process (the Jest worker) must die WITH it:
8
- // on abnormal exit (SIGKILL, worker crash, suite timeout) the OS reparents the
9
- // orphan to init and it survives forever. That orphan-to-init survival is the
10
- // test-daemon leak. The spawner publishes its pid in the environment and every
11
- // descendant inherits it, so even a detached grand-child daemon stays anchored
12
- // to the original runner.
13
- //
14
- // [LAW:dataflow-not-control-flow] The watchdog runs the same poll every tick;
15
- // whether it ever trips lives in the anchor VALUE (a pid to outlive, or
16
- // nobody), derived once from the environment — never in a branch wrapped around
17
- // the spawn path. Like the RSS backstop in `limits.ts`, it calls `onOrphaned`
18
- // (the lifecycle `shutdown`) rather than exiting itself, so every daemon-death
19
- // path funnels through the one enforcer.
20
-
21
- export const PARENT_PID_ENV = "CC_CANDYBAR_PARENT_PID";
22
-
23
- // [LAW:verifiable-goals] Only ever consulted for an ANCHORED (test) daemon —
24
- // "outlives-nobody" (the production daemon) arms no timer at all, so
25
- // tightening this is invisible to production. A dead-runner orphan can live
26
- // at most one poll tick before the watchdog trips; 250ms (down from 1000ms,
27
- // brandon-daemon-lifecycle-gad.1) shrinks that window fourfold without
28
- // meaningfully increasing CPU — polling a single `kill(pid,0)` 4x/sec is
29
- // negligible next to the daemon work it guards.
30
- const DEFAULT_POLL_INTERVAL_MS = 250;
31
-
32
- export type LivenessAnchor =
33
- | { kind: "outlives-nobody" }
34
- | { kind: "anchored"; pid: number };
35
-
36
- // [LAW:no-silent-fallbacks] Three inputs, three outcomes, no overlap: absent →
37
- // production (outlive nobody); a positive integer → anchor to it; present but
38
- // malformed → throw. Only the test harness ever sets this variable, so a
39
- // malformed value is a harness bug; silently degrading to "outlives-nobody"
40
- // would re-open the very leak this module closes.
41
- export function anchorFromEnv(env: NodeJS.ProcessEnv): LivenessAnchor {
42
- const raw = env[PARENT_PID_ENV];
43
- if (raw === undefined) return { kind: "outlives-nobody" };
44
- const pid = Number.parseInt(raw, 10);
45
- if (!Number.isInteger(pid) || pid <= 0) {
46
- throw new Error(
47
- `${PARENT_PID_ENV} must be a positive integer pid, got ${JSON.stringify(raw)}`,
48
- );
49
- }
50
- return { kind: "anchored", pid };
51
- }
52
-
53
- export interface ParentWatchdogDeps {
54
- anchor: LivenessAnchor;
55
- isAlive: (pid: number) => boolean;
56
- onOrphaned: (reason: string) => void;
57
- intervalMs?: number;
58
- }
59
-
60
- export function armParentWatchdog(deps: ParentWatchdogDeps): {
61
- disarm(): void;
62
- } {
63
- // An unanchored daemon has nothing to poll — arming a perpetual no-op timer on
64
- // the user's always-running daemon would be pure waste. Returning an inert
65
- // handle is the consequence of the data, not a special case in the spawn path.
66
- if (deps.anchor.kind === "outlives-nobody") return { disarm: () => {} };
67
-
68
- const { pid } = deps.anchor;
69
- const timer = setInterval(() => {
70
- if (!deps.isAlive(pid)) deps.onOrphaned(`spawner pid ${pid} gone`);
71
- }, deps.intervalMs ?? DEFAULT_POLL_INTERVAL_MS);
72
- timer.unref();
73
- return { disarm: () => clearInterval(timer) };
74
- }
75
-
76
- export function pidAlive(pid: number): boolean {
77
- try {
78
- process.kill(pid, 0);
79
- return true;
80
- } catch (e) {
81
- // ESRCH: the process is gone — orphaned, trip the watchdog. EPERM: a live
82
- // process we don't own (the pid was reused by another user) — treat as
83
- // alive so a reused pid can never make us shut down a daemon whose real
84
- // spawner is still running.
85
- return (e as NodeJS.ErrnoException).code === "EPERM";
86
- }
87
- }
@@ -1,211 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
-
5
- // XDG Base Directory split:
6
- // - daemon runtime (pid, log, heap snapshots, spawn.lock) → $XDG_STATE_HOME/cc-candybar
7
- // - filesystem caches (git, usage, last-render) → $XDG_CACHE_HOME/cc-candybar
8
- //
9
- // Both default per the XDG spec ($HOME/.local/state and $HOME/.cache). Empty
10
- // env vars fall through to the defaults. The two roots are kept separate so
11
- // users can `rm -rf` either one without taking the other down.
12
- //
13
- // The socket path is NOT derived from XDG_STATE_HOME — see socketPath() below.
14
- // The Rust client mirrors both path families in rust-client/src/main.rs; both
15
- // must agree or the client can't find the daemon's socket.
16
-
17
- function xdgEnv(name: string): string | undefined {
18
- const v = process.env[name];
19
- return v && v.length > 0 ? v : undefined;
20
- }
21
-
22
- export function stateDir(): string {
23
- const base =
24
- xdgEnv("XDG_STATE_HOME") ?? path.join(os.homedir(), ".local", "state");
25
- return path.join(base, "cc-candybar");
26
- }
27
-
28
- export function cacheDir(): string {
29
- const base = xdgEnv("XDG_CACHE_HOME") ?? path.join(os.homedir(), ".cache");
30
- return path.join(base, "cc-candybar");
31
- }
32
-
33
- export function configDir(): string {
34
- const base = xdgEnv("XDG_CONFIG_HOME") ?? path.join(os.homedir(), ".config");
35
- return path.join(base, "cc-candybar");
36
- }
37
-
38
- // `daemonDir` kept as the canonical name for the runtime root so existing
39
- // callers (limits.ts, server.ts) don't need to learn a new term. It now
40
- // resolves under $XDG_STATE_HOME/cc-candybar instead of ~/.claude/powerline.
41
- export function daemonDir(): string {
42
- return stateDir();
43
- }
44
-
45
- // [LAW:one-source-of-truth] The socket IS the daemon's identity — same as
46
- // tmux's /tmp/tmux-<uid>/default model. UID is kernel identity: immutable,
47
- // not overridable by any env var. /tmp is guaranteed on every Unix host and
48
- // is cleared on reboot, which is fine — the daemon doesn't survive reboots.
49
- // CC_CANDYBAR_SOCKET is the only explicit override for intentional isolation
50
- // (tests, dev, multiple intentional instances).
51
- export function socketPath(): string {
52
- const override = process.env.CC_CANDYBAR_SOCKET;
53
- if (override) return override;
54
- const uid = os.userInfo().uid;
55
- return path.join("/tmp", `cc-candybar-${uid}`, "socket");
56
- }
57
-
58
- // [LAW:single-enforcer] The daemon is the sole creator of the socket parent
59
- // directory. If we enforce "this dir is uid==me + mode 0700 + not a symlink"
60
- // at bind time, then by induction every successful bind happened under a
61
- // trusted parent — and any client reaching the socket via the canonical path
62
- // reached one our daemon owns. A foreign-uid or world-writable squat triggers
63
- // a refusal, turning a silent-MITM attempt into a visible daemon failure (the
64
- // client sees no response, the user sees the last cached render).
65
- //
66
- // Throws on any unsafe state; callers are expected to let the daemon exit.
67
- // [LAW:no-silent-fallbacks] do NOT auto-rmdir + recreate — a wrong-owner dir
68
- // is hostile state, not a recoverable error.
69
- // [LAW:one-source-of-truth] The owner/mode/symlink verification a private
70
- // per-uid directory needs is declared once here — both the socket parent
71
- // (below) and the fork-bomb breaker's daemon registry dir
72
- // (daemonRegistryDir(), fork-bomb-breaker.ts) sit under the same untrusted
73
- // shared /tmp root and must reject the identical attack (a pre-created
74
- // world-writable dir, a planted symlink), so they share one enforcer instead
75
- // of two copies that could silently drift apart on what "safe" means.
76
- export function ensureOwnedPrivateDir(dir: string): void {
77
- // mkdir with mode 0o700; harmless if already exists (mode is not applied
78
- // post-hoc — we verify it next).
79
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
80
-
81
- const st = fs.lstatSync(dir);
82
- if (st.isSymbolicLink()) {
83
- throw new Error(`directory is a symlink: ${dir}`);
84
- }
85
- if (!st.isDirectory()) {
86
- throw new Error(`not a directory: ${dir}`);
87
- }
88
- const myUid = os.userInfo().uid;
89
- // getuid is undefined on Windows; we don't ship there, but guard cheaply.
90
- if (typeof myUid === "number" && st.uid !== myUid) {
91
- throw new Error(
92
- `directory is not owned by uid ${myUid}: ${dir} (owner uid=${st.uid})`,
93
- );
94
- }
95
- // Reject any group/world bits — only the owner may traverse.
96
- if ((st.mode & 0o077) !== 0) {
97
- throw new Error(
98
- `directory has unsafe permissions: ${dir} (mode=${(st.mode & 0o777).toString(8)}, expected 0700)`,
99
- );
100
- }
101
- }
102
-
103
- export function ensureSocketParentSafe(sockPath: string): void {
104
- const parent = path.dirname(sockPath);
105
- ensureOwnedPrivateDir(parent);
106
- // If a stale socket file is a symlink, refuse — an attacker who briefly
107
- // had write access to a previously-permissive dir could have planted a
108
- // symlink even after we tighten perms.
109
- //
110
- // [LAW:single-enforcer] The lease (`${sockPath}.lease`) gets the SAME gate:
111
- // it is now load-bearing (ownership authority — readLease follows a symlink
112
- // and a planted `lease → /dev/null` would read `absent`/`unreadable`, forcing
113
- // a false reclaim that unlinks a live daemon's socket). One enforcer certifies
114
- // every path we bind/read/write under this parent, so the socket and its lease
115
- // can never diverge on "is this a symlink".
116
- for (const p of [sockPath, leasePathFor(sockPath)]) {
117
- try {
118
- if (fs.lstatSync(p).isSymbolicLink()) {
119
- throw new Error(`path is a symlink: ${p}`);
120
- }
121
- } catch (e) {
122
- const code = (e as NodeJS.ErrnoException).code;
123
- if (code !== "ENOENT") throw e;
124
- }
125
- }
126
- }
127
-
128
- // [LAW:one-source-of-truth] The socket-ownership lease is DERIVED FROM the
129
- // socket path, so it shares the socket's identity root. The old diagnostic
130
- // pidfile lived under $XDG_STATE_HOME while the socket lived under /tmp — two
131
- // identity roots for one instance, and CC_CANDYBAR_SOCKET isolated only one of
132
- // them. Anchoring the lease to socketPath() means test/dev isolation via
133
- // CC_CANDYBAR_SOCKET isolates the lease too. The lease subsumes the old
134
- // diagnostic pidfile: it carries the owner pid (the reclaim authority) plus the
135
- // same diagnostic fields.
136
- // [LAW:one-source-of-truth] The socket→lease derivation lives here alone, so
137
- // leasePath() (the runtime authority path) and ensureSocketParentSafe's symlink
138
- // gate agree by construction. Takes the socket path explicitly so the safety
139
- // check certifies the exact path it was handed, not a re-derived one.
140
- export function leasePathFor(sockPath: string): string {
141
- return `${sockPath}.lease`;
142
- }
143
-
144
- export function leasePath(): string {
145
- return leasePathFor(socketPath());
146
- }
147
-
148
- export function sessionStatePath(): string {
149
- return path.join(stateDir(), "session-state.json");
150
- }
151
-
152
- // [LAW:one-source-of-truth] The daemon-owned overrides layer for persistent
153
- // config writes (candybar-config-engine-71o.2): a click that mutates the
154
- // bundled/user-file DEFAULT (as opposed to `set`, which mutates per-session
155
- // state) lands here — never in the hand-authored config file itself. Sibling
156
- // of sessionStatePath(): same root, same single-writer daemon, same test
157
- // isolation via XDG_STATE_HOME/CC_CANDYBAR_SOCKET-derived overrides.
158
- export function configOverridesPath(): string {
159
- return path.join(stateDir(), "config-overrides.json");
160
- }
161
-
162
- // [LAW:one-source-of-truth] The fork-bomb breaker's daemon-population registry
163
- // (fork-bomb-breaker.ts) shares socketPath()'s UID-anchored /tmp root and, like
164
- // it, deliberately ignores XDG_STATE_HOME — the very isolation
165
- // `CC_CANDYBAR_SOCKET`/`XDG_STATE_HOME` overrides grant a test daemon is the
166
- // thing this registry exists to see THROUGH, so every daemon on this machine
167
- // (production and every isolated instance) that does not explicitly override
168
- // this path lands in the same directory and is counted together.
169
- // `CC_CANDYBAR_DAEMON_REGISTRY_DIR` is the explicit override, used only by
170
- // tests of the breaker itself so they don't contend over the machine's real
171
- // shared registry.
172
- export function daemonRegistryDir(): string {
173
- const override = process.env.CC_CANDYBAR_DAEMON_REGISTRY_DIR;
174
- if (override) return override;
175
- const uid = os.userInfo().uid;
176
- return path.join("/tmp", `cc-candybar-${uid}`, "daemons");
177
- }
178
-
179
- // [LAW:single-enforcer] Caller-side spawn dedup. Held by a client *only* during
180
- // the spawn window — never for the daemon's lifetime. The actual one-daemon
181
- // invariant is enforced by atomic bind() on socketPath() inside the daemon.
182
- // This file is a thundering-herd optimization, not the load-bearing lock.
183
- export function spawnLockPath(): string {
184
- return path.join(stateDir(), "spawn.lock");
185
- }
186
-
187
- // [LAW:one-source-of-truth] The spawn-RATE bound (as distinct from spawn.lock's
188
- // instantaneous dedup) is anchored to one file's mtime beside spawn.lock: the
189
- // time of the last daemon-spawn ATTEMPT. Both runtimes gate on the SAME file, so
190
- // the filename is mirrored TS↔Rust (rust-client/src/main.rs SPAWN_COOLDOWN_FILE)
191
- // and diffed by scripts/check-protocol.mjs — a drift would silently split the
192
- // rate bound in two.
193
- const SPAWN_COOLDOWN_FILE = "spawn.cooldown";
194
- export function spawnCooldownPath(): string {
195
- return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
196
- }
197
-
198
- // [LAW:one-source-of-truth] Sibling of spawn.cooldown: that file's mtime
199
- // answers "when was a spawn last attempted"; this file's content answers
200
- // "how many attempts in a row have failed to converge on a live daemon" —
201
- // the consecutive-non-convergence streak that widens the cooldown window
202
- // (see effectiveCooldownMs in acquire.ts). Same filename mirrored TS↔Rust,
203
- // diffed by scripts/check-protocol.mjs.
204
- const SPAWN_BACKOFF_FILE = "spawn.backoff";
205
- export function spawnBackoffPath(): string {
206
- return path.join(stateDir(), SPAWN_BACKOFF_FILE);
207
- }
208
-
209
- export function logPath(): string {
210
- return path.join(stateDir(), "daemon.log");
211
- }
@@ -1,146 +0,0 @@
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
- // [LAW:one-source-of-truth] The (pid, start-time) pair IS a process identity
29
- // (see the file header) — every owner-of-a-resource record in this codebase
30
- // (a socket lease, a test-pool slot) names its owner with exactly this shape.
31
- // Declared once here, the module that owns the process-identity concept, so
32
- // a future field addition to "what identifies a process" can't drift between
33
- // independent copies.
34
- export interface ProcessIdentity {
35
- pid: number;
36
- startTime: string | null;
37
- }
38
-
39
- // A read of a pid's kernel start-time. Only TWO outcomes, because nothing
40
- // derivable from a `ps` exit code can SOUNDLY prove a process is dead — a
41
- // non-zero exit means "no start-time to report", which conflates a genuinely
42
- // absent pid with an access failure (hardened `/proc`/`hidepid`, a
43
- // permission-denied read of a live process). A `gone` state produced from that
44
- // would be an over-claim ([LAW:types-are-the-program]) that could false-dead a
45
- // live owner and reclaim its socket. So:
46
- // start — the pid is live and this is its start-time token (the ONLY
47
- // thing `ps` can assert soundly: it printed a row).
48
- // unavailable — `ps` reported no start-time (absent OR unreadable OR errored).
49
- // Callers defer to kill(pid,0), the sound liveness test, which
50
- // correctly reclaims a truly-dead pid and spares a live one.
51
- // The fingerprint's unique contribution is the `start`+mismatch case (a recycled
52
- // pid whose live start-time differs from the lease); everything else falls back
53
- // to kill, no worse than before the fingerprint existed.
54
- export type StartTimeRead =
55
- | { kind: "start"; token: string }
56
- | { kind: "unavailable"; detail: string };
57
-
58
- // The subprocess boundary, injected so the parse/branch logic is exercised by
59
- // input enumeration with a fake launcher while the real path runs the real `ps`.
60
- export type Launcher = (opts: LaunchOpts) => LaunchResult;
61
-
62
- // [LAW:effects-at-boundaries][LAW:single-enforcer] The sole effect (spawning
63
- // `ps`) goes through the one subprocess enforcer, `launchSync`. The ONLY sound
64
- // signal `ps` gives is a printed start-time row (a live pid we could read);
65
- // [LAW:no-silent-failure] every other outcome — absent pid, access failure,
66
- // spawn-error, timeout — is `unavailable`, deferring the alive/dead call to
67
- // kill(pid,0) rather than risk declaring a live owner dead from a `ps` exit
68
- // code that cannot distinguish "gone" from "cannot read".
69
- export function readStartTime(
70
- pid: number,
71
- launch: Launcher = launchSync,
72
- ): StartTimeRead {
73
- const res = launch({
74
- bin: "ps",
75
- args: ["-o", "lstart=", "-p", String(pid)],
76
- category: "process-fingerprint",
77
- timeoutMs: 2000,
78
- // [FRAMING:representation] Pin BOTH locale and timezone so the token is
79
- // deterministic across daemons — the writer and reader must render the same
80
- // process identically for opaque string equality to be sound. LC_ALL=C fixes
81
- // the strftime format; TZ=UTC fixes localtime(), so DST / TZ / tzdata changes
82
- // can't make one live process render two different start-time strings.
83
- env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
84
- });
85
- if (res.ok) {
86
- const token = res.stdout.trim();
87
- if (token.length > 0) return { kind: "start", token };
88
- // Exit 0 with empty output is anomalous (a live pid always lists) — don't
89
- // mint an empty-string fingerprint; defer to kill.
90
- return { kind: "unavailable", detail: "ps produced no start-time" };
91
- }
92
- const detail =
93
- res.reason === "spawn-error"
94
- ? (res.error ?? "ps spawn failed")
95
- : `ps ${res.reason} (exit ${res.exitCode})`;
96
- return { kind: "unavailable", detail };
97
- }
98
-
99
- export interface LivenessDeps {
100
- readStartTime: (pid: number) => StartTimeRead;
101
- pidAlive: (pid: number) => boolean;
102
- }
103
-
104
- // [LAW:dataflow-not-control-flow] The liveness verdict is a pure fold over the
105
- // start-time read + the lease's recorded token. Full input space:
106
- // read=start + token matches lease → true (same process still alive)
107
- // read=start + token differs → false (a DIFFERENT process holds the
108
- // pid — a recycle, or a restarted daemon)
109
- // read=unavailable → kill(pid,0) fallback (the sound
110
- // alive/dead test: a truly-dead pid →
111
- // false → reclaim; a live pid ps couldn't
112
- // read → true → spared)
113
- // lease token = null (unfingerprinted at write, e.g. a host without `ps`) →
114
- // kill(pid,0) fallback for the same reason
115
- //
116
- // [LAW:no-silent-failure] Only the `start`+mismatch case comes from the
117
- // fingerprint; every ambiguous `ps` outcome defers to kill, so the DANGEROUS
118
- // direction (declaring a live daemon dead → stealing its socket, the storm this
119
- // epic fights) never happens merely because `ps` could not answer. The only
120
- // thing forfeited when `ps` cannot answer is detecting a recycled pid.
121
- export function sameLiveProcess(
122
- pid: number,
123
- leaseToken: string | null,
124
- deps: LivenessDeps,
125
- ): boolean {
126
- if (leaseToken === null) return deps.pidAlive(pid);
127
- const read = deps.readStartTime(pid);
128
- switch (read.kind) {
129
- case "start":
130
- return read.token === leaseToken;
131
- case "unavailable":
132
- return deps.pidAlive(pid);
133
- }
134
- }
135
-
136
- // Read THIS process's own start-time to stamp into its lease. `unavailable`
137
- // (no `ps`) collapses to `null` — the lease records "unfingerprinted", and every
138
- // reader of it falls back to kill(pid,0) via sameLiveProcess. A dead result is
139
- // impossible for our own live pid; if it somehow occurs we also record null.
140
- export function readOwnStartTime(
141
- pid: number,
142
- read: (pid: number) => StartTimeRead = readStartTime,
143
- ): string | null {
144
- const r = read(pid);
145
- return r.kind === "start" ? r.token : null;
146
- }