@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.
- package/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- 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
|
-
}
|
package/src/daemon/paths.ts
DELETED
|
@@ -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
|
-
}
|