@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,351 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { daemonRegistryDir, ensureOwnedPrivateDir } from "./paths";
|
|
5
|
-
import {
|
|
6
|
-
readStartTime,
|
|
7
|
-
sameLiveProcess,
|
|
8
|
-
type ProcessIdentity,
|
|
9
|
-
} from "./process-fingerprint";
|
|
10
|
-
import { pidAlive } from "./parent-watchdog";
|
|
11
|
-
|
|
12
|
-
// ─── Daemon-side fork-bomb circuit breaker ───────────────────────────────────
|
|
13
|
-
//
|
|
14
|
-
// [FRAMING:representation] The 192-daemon storm (epic brandon-daemon-lifecycle-
|
|
15
|
-
// gad) happened because every existing single-instance guard (atomic bind(),
|
|
16
|
-
// the socket lease, the ownership self-check, the spawn cooldown) keys off ONE
|
|
17
|
-
// socket path — daemons on DIFFERENT sockets (test isolation's per-file
|
|
18
|
-
// CC_CANDYBAR_SOCKET) never arbitrate each other and pile up unboundedly. .1
|
|
19
|
-
// (test/helpers/daemon-pool.ts) bounds that from the SPAWNER side, but the
|
|
20
|
-
// spawner's own cleanup (afterAll, globalTeardown) fails under the exact
|
|
21
|
-
// fork-exhaustion condition it exists to prevent. This module is the
|
|
22
|
-
// load-INDEPENDENT backstop: a daemon refuses to boot past a sibling ceiling
|
|
23
|
-
// using only its own startup-time read of a shared registry — no external
|
|
24
|
-
// cleanup path required for the invariant to hold.
|
|
25
|
-
//
|
|
26
|
-
// [LAW:one-source-of-truth] The registry lives at daemonRegistryDir() (paths.ts)
|
|
27
|
-
// — a fixed, UID-anchored /tmp path that, like socketPath(), deliberately
|
|
28
|
-
// ignores XDG_STATE_HOME, so isolation overrides can't hide a daemon from the
|
|
29
|
-
// count. Every daemon that does NOT explicitly override
|
|
30
|
-
// CC_CANDYBAR_DAEMON_REGISTRY_DIR lands in the same directory.
|
|
31
|
-
//
|
|
32
|
-
// [FRAMING:representation] The production daemon is a different POPULATION
|
|
33
|
-
// than an isolated (test/dev) instance, not a smaller version of the same one:
|
|
34
|
-
// it is already bounded to exactly one by bind()'s kernel-enforced exclusion on
|
|
35
|
-
// the canonical socket path, so no ceiling can ever be its failure mode — only
|
|
36
|
-
// isolation (an explicit CC_CANDYBAR_SOCKET override) creates the "many
|
|
37
|
-
// coexisting instances" population this breaker exists to bound. Classifying by
|
|
38
|
-
// "is CC_CANDYBAR_SOCKET set" keeps the two populations from ever counting
|
|
39
|
-
// against each other: the production daemon is exempt (and so always boots,
|
|
40
|
-
// however many isolated instances are registered), and isolated instances
|
|
41
|
-
// compete only with each other over the shared ceiling.
|
|
42
|
-
//
|
|
43
|
-
// [FRAMING:representation] admitDaemon's count-then-write (read the registry,
|
|
44
|
-
// decide, write our own entry) is NOT a compare-and-swap — the same accepted
|
|
45
|
-
// tradeoff as test/helpers/daemon-pool.ts's tryClaim. Two daemons starting in
|
|
46
|
-
// the same instant can both observe the same below-ceiling count and both
|
|
47
|
-
// admit, so the ceiling is a soft bound (liveCount can briefly overshoot by
|
|
48
|
-
// the number of true simultaneous spawns), not a strict mutex. A real fix
|
|
49
|
-
// needs a cross-process lock (flock, an O_EXCL pre-registration file); skipped
|
|
50
|
-
// as disproportionate here — this is a load-independent BACKSTOP against a
|
|
51
|
-
// 192-daemon storm, not a precision gate, and the ticket's own acceptance
|
|
52
|
-
// criterion is "a small, asserted ceiling", never exact atomicity. The
|
|
53
|
-
// failure mode of the race is a brief, bounded overshoot that the next boot's
|
|
54
|
-
// stale-sweep does not even need to correct (the overshooting daemons are
|
|
55
|
-
// live, not stale) — categorically smaller than the storm this breaker
|
|
56
|
-
// exists to prevent.
|
|
57
|
-
|
|
58
|
-
export interface BootDecision {
|
|
59
|
-
allow: boolean;
|
|
60
|
-
reason: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const DEFAULT_CEILING = 16;
|
|
64
|
-
|
|
65
|
-
// [LAW:no-silent-failure] `Number(...)`, not `parseInt(...)` — parseInt
|
|
66
|
-
// truncates trailing garbage ("16o" reads as 16, silently accepting a typo
|
|
67
|
-
// that likely meant 160) instead of surfacing it. `Number` requires the
|
|
68
|
-
// WHOLE string to be numeric, so a typo becomes NaN and falls through to the
|
|
69
|
-
// default like any other garbage value.
|
|
70
|
-
export function daemonCeiling(): number {
|
|
71
|
-
const raw = Number(process.env["CC_CANDYBAR_DAEMON_CEILING"] ?? "");
|
|
72
|
-
return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_CEILING;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// [LAW:dataflow-not-control-flow] The whole decision is this one pure fold —
|
|
76
|
-
// full input space:
|
|
77
|
-
// isolated=false → allow, unconditionally (production is
|
|
78
|
-
// already singular via bind(); a
|
|
79
|
-
// ceiling here could only ever refuse
|
|
80
|
-
// the user's one real daemon, which the
|
|
81
|
-
// epic requires never happens)
|
|
82
|
-
// isolated=true, count < ceiling → allow (below the backstop)
|
|
83
|
-
// isolated=true, count >= ceiling → deny (the fork-bomb condition)
|
|
84
|
-
// [LAW:no-silent-failure] "Fails safe" is achieved by construction here, not by
|
|
85
|
-
// a guard clause: an unreadable/uncountable population reads as count=0 (see
|
|
86
|
-
// countLiveEntries), which always falls in the `allow` branch — the failure
|
|
87
|
-
// direction is never "refuse to boot", it is "undercount and allow".
|
|
88
|
-
export function decideBoot(
|
|
89
|
-
isolated: boolean,
|
|
90
|
-
liveSiblingCount: number,
|
|
91
|
-
ceiling: number,
|
|
92
|
-
): BootDecision {
|
|
93
|
-
if (!isolated) {
|
|
94
|
-
return {
|
|
95
|
-
allow: true,
|
|
96
|
-
reason:
|
|
97
|
-
"canonical production socket — exempt (bind() already caps it to one)",
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
if (liveSiblingCount >= ceiling) {
|
|
101
|
-
return {
|
|
102
|
-
allow: false,
|
|
103
|
-
reason: `${liveSiblingCount} live isolated daemons registered >= ceiling ${ceiling}`,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
allow: true,
|
|
108
|
-
reason: `${liveSiblingCount} live isolated daemons registered < ceiling ${ceiling}`,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// A registry entry read, alongside its source path so a stale one can be
|
|
113
|
-
// swept. `null` is every unreadable/corrupt/absent-pid outcome — collapsed
|
|
114
|
-
// early here (unlike readLease's richer enumeration) because the only
|
|
115
|
-
// downstream use is "count it or don't"; there is no distinct action for
|
|
116
|
-
// "unreadable" vs "absent" the way socket-lease arbitration has one.
|
|
117
|
-
export interface RegistryEntry {
|
|
118
|
-
path: string;
|
|
119
|
-
identity: ProcessIdentity;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// [LAW:no-silent-failure] Never throws: a directory that doesn't exist yet (no
|
|
123
|
-
// isolated daemon has ever registered) or is transiently unreadable both mean
|
|
124
|
-
// "no known siblings", which is the fail-open direction decideBoot expects.
|
|
125
|
-
export function listRegistryFiles(dir: string): string[] {
|
|
126
|
-
try {
|
|
127
|
-
return fs
|
|
128
|
-
.readdirSync(dir)
|
|
129
|
-
.filter((f) => f.endsWith(".json"))
|
|
130
|
-
.map((f) => path.join(dir, f));
|
|
131
|
-
} catch {
|
|
132
|
-
return [];
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Mirrors readSlot (test/helpers/daemon-pool.ts) / readLease's pid validation
|
|
137
|
-
// (socket-lease.ts): a corrupt or unreadable file is excluded from the count
|
|
138
|
-
// rather than treated as a special decision branch — see the module header for
|
|
139
|
-
// why undercounting, never overcounting, is the safe direction here.
|
|
140
|
-
export function readRegistryEntry(filePath: string): ProcessIdentity | null {
|
|
141
|
-
let raw: string;
|
|
142
|
-
try {
|
|
143
|
-
raw = fs.readFileSync(filePath, "utf8");
|
|
144
|
-
} catch {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
try {
|
|
148
|
-
const parsed = JSON.parse(raw) as Partial<ProcessIdentity> | null;
|
|
149
|
-
const pid = parsed?.pid;
|
|
150
|
-
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
const startTime =
|
|
154
|
-
typeof parsed?.startTime === "string" ? parsed.startTime : null;
|
|
155
|
-
return { pid, startTime };
|
|
156
|
-
} catch {
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// [LAW:dataflow-not-control-flow] Pure fold over already-read entries + an
|
|
162
|
-
// injected liveness predicate — full branch coverage needs no fs, no real
|
|
163
|
-
// processes: an empty list, an all-dead list, an all-live list, and a mixed
|
|
164
|
-
// list are the entire input space. `sweepStale`, kept in the same pass rather
|
|
165
|
-
// than a second read, is the effect side; the count itself never depends on
|
|
166
|
-
// whether the sweep succeeds.
|
|
167
|
-
export function countLiveEntries(
|
|
168
|
-
entries: readonly RegistryEntry[],
|
|
169
|
-
isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
|
|
170
|
-
sweepStale: (filePath: string) => void,
|
|
171
|
-
): number {
|
|
172
|
-
let count = 0;
|
|
173
|
-
for (const entry of entries) {
|
|
174
|
-
if (isSameLiveProcess(entry.identity.pid, entry.identity.startTime)) {
|
|
175
|
-
count++;
|
|
176
|
-
} else {
|
|
177
|
-
sweepStale(entry.path);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return count;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export interface BreakerDeps {
|
|
184
|
-
isolated: boolean;
|
|
185
|
-
registryDir: string;
|
|
186
|
-
ceiling: number;
|
|
187
|
-
pid: number;
|
|
188
|
-
startTime: string | null;
|
|
189
|
-
isSameLiveProcess: (pid: number, startTime: string | null) => boolean;
|
|
190
|
-
listFiles: (dir: string) => string[];
|
|
191
|
-
readEntry: (filePath: string) => ProcessIdentity | null;
|
|
192
|
-
removeFile: (filePath: string) => void;
|
|
193
|
-
writeEntry: (filePath: string, identity: ProcessIdentity) => void;
|
|
194
|
-
ensureDirSafe: (dir: string) => void;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export interface BreakerResult {
|
|
198
|
-
decision: BootDecision;
|
|
199
|
-
// The path this daemon registered at, or null when exempt/refused. Callers
|
|
200
|
-
// that boot successfully thread this into their shutdown cleanup so the slot
|
|
201
|
-
// is released promptly instead of waiting for the next boot's stale-sweep.
|
|
202
|
-
registryPath: string | null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// [LAW:effects-at-boundaries] The one place that turns the pure fold into a
|
|
206
|
-
// boot/refuse decision by reading + writing the real registry. Exempt
|
|
207
|
-
// (production) daemons never touch the registry at all — not even to read
|
|
208
|
-
// it — so a corrupt or unreadable registry can never affect the one instance
|
|
209
|
-
// the epic requires to always boot.
|
|
210
|
-
export function admitDaemon(deps: BreakerDeps): BreakerResult {
|
|
211
|
-
if (!deps.isolated) {
|
|
212
|
-
return { decision: decideBoot(false, 0, deps.ceiling), registryPath: null };
|
|
213
|
-
}
|
|
214
|
-
// [LAW:single-enforcer] `ensureDirSafe` — not a bare `ensureOwnedPrivateDir`
|
|
215
|
-
// call — because the safety boundary differs by registry: the default,
|
|
216
|
-
// UID-anchored registry sits under the same shared /tmp root the socket
|
|
217
|
-
// does and needs the two-level check `realBreakerDeps` builds for it (see
|
|
218
|
-
// its comment); an overridden registry (tests) is the caller's own
|
|
219
|
-
// directory and needs only the one-level leaf check. `admitDaemon` stays
|
|
220
|
-
// agnostic to which — it just asks the injected dependency to prove the
|
|
221
|
-
// directory is safe to use.
|
|
222
|
-
deps.ensureDirSafe(deps.registryDir);
|
|
223
|
-
const entries: RegistryEntry[] = [];
|
|
224
|
-
for (const filePath of deps.listFiles(deps.registryDir)) {
|
|
225
|
-
const identity = deps.readEntry(filePath);
|
|
226
|
-
// [LAW:no-silent-failure] Exclude any entry named with OUR OWN pid,
|
|
227
|
-
// unconditionally — no other currently-live process can ever share it
|
|
228
|
-
// (the kernel guarantees pid uniqueness among live processes), so such an
|
|
229
|
-
// entry is always either a stale pid-recycled ghost from a past
|
|
230
|
-
// incarnation, or moot (we haven't written our own entry yet). This
|
|
231
|
-
// matters specifically when `ps` is unavailable: `isSameLiveProcess`'s
|
|
232
|
-
// fallback (bare `pidAlive`) would read OUR OWN pid as alive and
|
|
233
|
-
// misclassify the ghost as a live sibling, consuming a ceiling slot and
|
|
234
|
-
// risking a spurious refusal of the one daemon that pid actually names.
|
|
235
|
-
// Excluding it here means it never reaches that ambiguous check at all.
|
|
236
|
-
if (identity !== null && identity.pid !== deps.pid) {
|
|
237
|
-
entries.push({ path: filePath, identity });
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
const liveCount = countLiveEntries(
|
|
241
|
-
entries,
|
|
242
|
-
deps.isSameLiveProcess,
|
|
243
|
-
deps.removeFile,
|
|
244
|
-
);
|
|
245
|
-
const decision = decideBoot(true, liveCount, deps.ceiling);
|
|
246
|
-
if (!decision.allow) {
|
|
247
|
-
return { decision, registryPath: null };
|
|
248
|
-
}
|
|
249
|
-
const registryPath = path.join(deps.registryDir, `pid-${deps.pid}.json`);
|
|
250
|
-
deps.writeEntry(registryPath, { pid: deps.pid, startTime: deps.startTime });
|
|
251
|
-
return { decision, registryPath };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Best-effort self-cleanup on shutdown — mirrors removeLeaseIfOwned
|
|
255
|
-
// (socket-lease.ts): only remove the entry if it still names us, so a
|
|
256
|
-
// displaced/superseded record from a different process is never deleted.
|
|
257
|
-
export function releaseRegistration(
|
|
258
|
-
registryPath: string,
|
|
259
|
-
myPid: number,
|
|
260
|
-
readEntry: (filePath: string) => ProcessIdentity | null,
|
|
261
|
-
removeFile: (filePath: string) => void,
|
|
262
|
-
): void {
|
|
263
|
-
const entry = readEntry(registryPath);
|
|
264
|
-
if (entry !== null && entry.pid === myPid) {
|
|
265
|
-
try {
|
|
266
|
-
removeFile(registryPath);
|
|
267
|
-
} catch {
|
|
268
|
-
// Best-effort; a leftover entry naming a dead pid is harmless — the
|
|
269
|
-
// next boot's sweep reclaims it.
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// `myStartTime` is threaded in rather than recomputed here so callers (only
|
|
275
|
-
// server.ts today) fingerprint themselves exactly once at startup and reuse
|
|
276
|
-
// that same read for both the registry entry and the socket lease — two
|
|
277
|
-
// independent `ps` calls could theoretically observe different processes if
|
|
278
|
-
// this pid were somehow recycled between them.
|
|
279
|
-
export function realBreakerDeps(
|
|
280
|
-
myStartTime: string | null,
|
|
281
|
-
overrides: Partial<BreakerDeps> = {},
|
|
282
|
-
): BreakerDeps {
|
|
283
|
-
return {
|
|
284
|
-
isolated: Boolean(process.env["CC_CANDYBAR_SOCKET"]),
|
|
285
|
-
registryDir: daemonRegistryDir(),
|
|
286
|
-
ceiling: daemonCeiling(),
|
|
287
|
-
pid: process.pid,
|
|
288
|
-
startTime: myStartTime,
|
|
289
|
-
isSameLiveProcess: (pid, startTime) =>
|
|
290
|
-
sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
|
|
291
|
-
listFiles: listRegistryFiles,
|
|
292
|
-
readEntry: readRegistryEntry,
|
|
293
|
-
removeFile: (filePath) => {
|
|
294
|
-
try {
|
|
295
|
-
fs.unlinkSync(filePath);
|
|
296
|
-
} catch {
|
|
297
|
-
// best-effort
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
// [LAW:one-source-of-truth] Same write-tmp-then-rename shape as
|
|
301
|
-
// socket-lease.ts's writeLease, so it gets the same cleanup: if
|
|
302
|
-
// writeFileSync succeeds but renameSync fails, best-effort unlink the tmp
|
|
303
|
-
// file (tolerating ENOENT — writeFileSync itself may have been what
|
|
304
|
-
// failed) before rethrowing, so a write failure never leaves an orphaned
|
|
305
|
-
// `.tmp` file behind (listRegistryFiles only collects `*.json`, so a
|
|
306
|
-
// stray `.tmp` would never be swept).
|
|
307
|
-
writeEntry: (filePath, identity) => {
|
|
308
|
-
const tmp = `${filePath}.${identity.pid}.tmp`;
|
|
309
|
-
try {
|
|
310
|
-
fs.writeFileSync(tmp, JSON.stringify(identity), { mode: 0o600 });
|
|
311
|
-
fs.renameSync(tmp, filePath);
|
|
312
|
-
} catch (e) {
|
|
313
|
-
try {
|
|
314
|
-
fs.unlinkSync(tmp);
|
|
315
|
-
} catch (cleanupErr) {
|
|
316
|
-
if ((cleanupErr as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
317
|
-
// Best-effort cleanup failed for a reason other than "never
|
|
318
|
-
// created" — the original error is still the one that matters,
|
|
319
|
-
// so it is not swallowed; a leaked tmp file here is a secondary
|
|
320
|
-
// symptom the next admission's stale-sweep does not reclaim
|
|
321
|
-
// (only *.json is collected), but it is not this daemon's job to
|
|
322
|
-
// retry a failing filesystem.
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
throw e;
|
|
326
|
-
}
|
|
327
|
-
},
|
|
328
|
-
// [LAW:single-enforcer] Two levels, mirroring ensureSocketParentSafe's own
|
|
329
|
-
// shape, but ONLY for the default (unoverridden) registry path: its
|
|
330
|
-
// parent is the shared UID-anchored /tmp root an attacker could pre-plant
|
|
331
|
-
// as a symlink before any daemon has ever run, and `lstatSync` only
|
|
332
|
-
// inspects a path's FINAL component — verifying the leaf alone lets a
|
|
333
|
-
// symlinked parent be silently followed by `mkdirSync({recursive:true})`,
|
|
334
|
-
// after which the freshly-created leaf looks perfectly clean (owned by
|
|
335
|
-
// us, 0700) despite living inside attacker-controlled storage. An
|
|
336
|
-
// OVERRIDDEN registry dir (CC_CANDYBAR_DAEMON_REGISTRY_DIR, tests only)
|
|
337
|
-
// has no such shared root by construction — its parent is whatever
|
|
338
|
-
// directory the caller happened to put it under (a system tmpdir on some
|
|
339
|
-
// platforms), which is not a boundary this breaker owns or should assert
|
|
340
|
-
// on; there the one-level leaf check alone is the correct, portable
|
|
341
|
-
// parity with how ensureSocketParentSafe treats an overridden
|
|
342
|
-
// CC_CANDYBAR_SOCKET (exactly one level, whatever that parent is).
|
|
343
|
-
ensureDirSafe: process.env["CC_CANDYBAR_DAEMON_REGISTRY_DIR"]
|
|
344
|
-
? ensureOwnedPrivateDir
|
|
345
|
-
: (dir: string): void => {
|
|
346
|
-
ensureOwnedPrivateDir(path.dirname(dir));
|
|
347
|
-
ensureOwnedPrivateDir(dir);
|
|
348
|
-
},
|
|
349
|
-
...overrides,
|
|
350
|
-
};
|
|
351
|
-
}
|
package/src/daemon/limits.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import v8 from "node:v8";
|
|
4
|
-
import { daemonDir } from "./paths";
|
|
5
|
-
import { dlog, type DaemonLogger } from "./log";
|
|
6
|
-
|
|
7
|
-
// [LAW:single-enforcer] One module owns "when does the daemon plan to die".
|
|
8
|
-
// Only the RSS trigger remains — idle and age limits were removed because they
|
|
9
|
-
// interrupted active sessions. The RSS limit is a true anomaly backstop; normal
|
|
10
|
-
// operation should never approach it now that transcript parsing is pruned.
|
|
11
|
-
//
|
|
12
|
-
// [LAW:one-source-of-truth] The daemon's memory budget is ONE number, read from
|
|
13
|
-
// ONE place. Two limits derive from it and their ORDER is the whole point:
|
|
14
|
-
//
|
|
15
|
-
// RSS backstop (this module) — graceful: heap snapshot, logged shutdown,
|
|
16
|
-
// clean restart on the next tick.
|
|
17
|
-
// V8 old-space cap (spawners) — hard: V8 aborts with SIGABRT below every JS
|
|
18
|
-
// handler, so no log line, no snapshot, and
|
|
19
|
-
// the next daemon finds only a stale socket.
|
|
20
|
-
//
|
|
21
|
-
// The cap sits at HEAP_CAP_OVER_RSS × the backstop, a margin wide enough that
|
|
22
|
-
// the graceful path fires first under any growth the 60 s poll can see (a
|
|
23
|
-
// burst that doubles RSS inside one poll window can still reach the hard cap).
|
|
24
|
-
// Before this the two were unrelated literals (400 MB heap in each spawner,
|
|
25
|
-
// 512 MB RSS here), and the 2026-09-03 outage found the gap: a daemon holding
|
|
26
|
-
// twenty configs' worth of duplicated helper-template ASTs (since fixed in
|
|
27
|
-
// src/dsl/render.ts compileHelpers) blew the heap in seconds, aborted
|
|
28
|
-
// silently, and crash-looped on every render tick while the backstop — a 60 s
|
|
29
|
-
// poll — never got a turn. Raising the env override raises BOTH, because both
|
|
30
|
-
// spawners derive the cap through heapCapMb below. The Rust client mirrors
|
|
31
|
-
// RSS_LIMIT_ENV, DEFAULT_RSS_LIMIT_MB, and HEAP_CAP_OVER_RSS as literals
|
|
32
|
-
// (rust-client/src/launch.rs); scripts/check-protocol.mjs fails the build on
|
|
33
|
-
// drift.
|
|
34
|
-
export const RSS_LIMIT_ENV = "CC_CANDYBAR_RSS_LIMIT_MB";
|
|
35
|
-
export const DEFAULT_RSS_LIMIT_MB = 512;
|
|
36
|
-
export const HEAP_CAP_OVER_RSS = 2;
|
|
37
|
-
|
|
38
|
-
// [LAW:parse-dont-validate] Absent → default; a positive integer → that; present
|
|
39
|
-
// but malformed → throw. Only an operator ever sets this variable, so garbage
|
|
40
|
-
// is an operator error, and `|| default` would silently run at a budget they
|
|
41
|
-
// did not ask for. [LAW:no-silent-failure]
|
|
42
|
-
//
|
|
43
|
-
// [LAW:one-source-of-truth] The grammar is ONE rule both runtimes apply
|
|
44
|
-
// verbatim — ASCII digits only, > 0, within the safe-integer range —
|
|
45
|
-
// so the spawner and the daemon it spawns accept and reject the same values
|
|
46
|
-
// (rust-client/src/launch.rs heap_cap_mb). A grammar that differed by so much
|
|
47
|
-
// as a leading `+` would let a client spawn a daemon that refuses to boot.
|
|
48
|
-
export function rssLimitMb(env: NodeJS.ProcessEnv): number {
|
|
49
|
-
const raw = env[RSS_LIMIT_ENV];
|
|
50
|
-
if (raw === undefined) return DEFAULT_RSS_LIMIT_MB;
|
|
51
|
-
const mb = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
52
|
-
if (!Number.isSafeInteger(mb) || mb <= 0) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`${RSS_LIMIT_ENV} must be a positive integer (MB), got ${JSON.stringify(raw)}`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
return mb;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// The `--max-old-space-size` value a spawner hands node for the daemon.
|
|
61
|
-
export function heapCapMb(env: NodeJS.ProcessEnv): number {
|
|
62
|
-
return rssLimitMb(env) * HEAP_CAP_OVER_RSS;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const BYTES_PER_MB = 1024 * 1024;
|
|
66
|
-
|
|
67
|
-
// The budget in the unit `process.memoryUsage().rss` reports.
|
|
68
|
-
export function rssLimitBytes(env: NodeJS.ProcessEnv): number {
|
|
69
|
-
return rssLimitMb(env) * BYTES_PER_MB;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const DEFAULT_CHECK_INTERVAL = 60 * 1000;
|
|
73
|
-
const HEAP_SNAPSHOT_KEEP = 3;
|
|
74
|
-
|
|
75
|
-
export interface LimitsDeps {
|
|
76
|
-
now: () => number;
|
|
77
|
-
// [LAW:locality-or-seam] The snapshot directory, the log sink, and the
|
|
78
|
-
// writer's identity are injected, not reached for ambiently. Without these,
|
|
79
|
-
// unit tests of checkRss compute filenames against the real daemonDir() and
|
|
80
|
-
// emit real dlog lines into the user's production daemon.log — the seam must
|
|
81
|
-
// cover every dependency or it isn't a seam.
|
|
82
|
-
pid: number;
|
|
83
|
-
snapshotDir: string;
|
|
84
|
-
log: DaemonLogger;
|
|
85
|
-
rssBytes: () => number;
|
|
86
|
-
writeHeapSnapshot: (filePath: string) => string;
|
|
87
|
-
listSnapshots: () => string[];
|
|
88
|
-
removeFile: (filePath: string) => void;
|
|
89
|
-
shutdown: (code: number) => void;
|
|
90
|
-
startedAtMs: number;
|
|
91
|
-
rssLimitBytes?: number;
|
|
92
|
-
snapshotsKeep?: number;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface LimitsHandle {
|
|
96
|
-
checkRss(): boolean;
|
|
97
|
-
describeNextRestart(): string | null;
|
|
98
|
-
arm(intervalMs?: number): { disarm(): void };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function makeLimits(deps: LimitsDeps): LimitsHandle {
|
|
102
|
-
const rssLimit = deps.rssLimitBytes ?? DEFAULT_RSS_LIMIT_MB * BYTES_PER_MB;
|
|
103
|
-
const keep = deps.snapshotsKeep ?? HEAP_SNAPSHOT_KEEP;
|
|
104
|
-
let triggered = false;
|
|
105
|
-
|
|
106
|
-
function checkRss(): boolean {
|
|
107
|
-
if (triggered) return true;
|
|
108
|
-
const rss = deps.rssBytes();
|
|
109
|
-
if (rss <= rssLimit) return false;
|
|
110
|
-
triggered = true;
|
|
111
|
-
deps.log(
|
|
112
|
-
"warn",
|
|
113
|
-
`RSS ${rss} > limit ${rssLimit}; writing heap snapshot then shutting down`,
|
|
114
|
-
);
|
|
115
|
-
try {
|
|
116
|
-
// [LAW:types-are-the-program] Uniqueness is by construction (the writer's
|
|
117
|
-
// pid), not by trusting the clock to be real and sub-ms-distinct. Two
|
|
118
|
-
// overlapping daemons hitting the wall in the same millisecond — or a
|
|
119
|
-
// frozen `now` — still produce distinct files; the timestamp stays the
|
|
120
|
-
// leading component so rotateSnapshots' newest-first ordering holds.
|
|
121
|
-
const stamp = new Date(deps.now()).toISOString().replace(/[:.]/g, "-");
|
|
122
|
-
const file = path.join(
|
|
123
|
-
deps.snapshotDir,
|
|
124
|
-
`heap-${stamp}-${deps.pid}.heapsnapshot`,
|
|
125
|
-
);
|
|
126
|
-
const written = deps.writeHeapSnapshot(file);
|
|
127
|
-
deps.log("info", `heap snapshot written: ${written}`);
|
|
128
|
-
rotateSnapshots(deps.listSnapshots(), keep, deps.removeFile);
|
|
129
|
-
} catch (e) {
|
|
130
|
-
deps.log("warn", `heap snapshot failed: ${(e as Error).message}`);
|
|
131
|
-
}
|
|
132
|
-
deps.shutdown(0);
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function describeNextRestart(): string | null {
|
|
137
|
-
const rss = deps.rssBytes();
|
|
138
|
-
if (rss > rssLimit * 0.75) {
|
|
139
|
-
return `rss ${rss} approaching limit ${rssLimit}`;
|
|
140
|
-
}
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function arm(intervalMs: number = DEFAULT_CHECK_INTERVAL): {
|
|
145
|
-
disarm(): void;
|
|
146
|
-
} {
|
|
147
|
-
const timer = setInterval(() => {
|
|
148
|
-
checkRss();
|
|
149
|
-
}, intervalMs);
|
|
150
|
-
timer.unref();
|
|
151
|
-
return {
|
|
152
|
-
disarm: () => clearInterval(timer),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return { checkRss, describeNextRestart, arm };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function rotateSnapshots(
|
|
160
|
-
files: string[],
|
|
161
|
-
keep: number,
|
|
162
|
-
remove: (p: string) => void,
|
|
163
|
-
): void {
|
|
164
|
-
// Newest-first by basename (the leading ISO timestamp is lexically ordered;
|
|
165
|
-
// the trailing -<pid> only tiebreaks same-instant writes). Sort by basename
|
|
166
|
-
// so paths with different parent dirs still order correctly when the test
|
|
167
|
-
// mock and production use different prefixes.
|
|
168
|
-
const sorted = [...files].sort((a, b) => {
|
|
169
|
-
const aBase = a.slice(a.lastIndexOf("/") + 1);
|
|
170
|
-
const bBase = b.slice(b.lastIndexOf("/") + 1);
|
|
171
|
-
return bBase.localeCompare(aBase);
|
|
172
|
-
});
|
|
173
|
-
for (const f of sorted.slice(keep)) {
|
|
174
|
-
try {
|
|
175
|
-
remove(f);
|
|
176
|
-
} catch {}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Default real-fs deps for the daemon. Test code constructs its own.
|
|
181
|
-
export function realLimitsDeps(
|
|
182
|
-
startedAtMs: number,
|
|
183
|
-
shutdown: (code: number) => void,
|
|
184
|
-
overrides: Partial<LimitsDeps> = {},
|
|
185
|
-
): LimitsDeps {
|
|
186
|
-
// [LAW:one-source-of-truth] One captured dir backs both the new-snapshot path
|
|
187
|
-
// and the listing used for rotation, so they can never read different dirs.
|
|
188
|
-
const dir = daemonDir();
|
|
189
|
-
return {
|
|
190
|
-
now: () => Date.now(),
|
|
191
|
-
pid: process.pid,
|
|
192
|
-
snapshotDir: dir,
|
|
193
|
-
log: dlog,
|
|
194
|
-
rssBytes: () => process.memoryUsage().rss,
|
|
195
|
-
writeHeapSnapshot: (file) => v8.writeHeapSnapshot(file),
|
|
196
|
-
listSnapshots: () => {
|
|
197
|
-
try {
|
|
198
|
-
return fs
|
|
199
|
-
.readdirSync(dir)
|
|
200
|
-
.filter((f) => f.startsWith("heap-") && f.endsWith(".heapsnapshot"))
|
|
201
|
-
.map((f) => path.join(dir, f));
|
|
202
|
-
} catch {
|
|
203
|
-
return [];
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
removeFile: (file) => fs.unlinkSync(file),
|
|
207
|
-
shutdown,
|
|
208
|
-
startedAtMs,
|
|
209
|
-
...overrides,
|
|
210
|
-
};
|
|
211
|
-
}
|
package/src/daemon/log.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { logPath } from "./paths";
|
|
4
|
-
|
|
5
|
-
export const MAX_BYTES = 5 * 1024 * 1024;
|
|
6
|
-
const KEEP_GENERATIONS = 3;
|
|
7
|
-
|
|
8
|
-
// [LAW:no-ambient-temporal-coupling] Every line is a synchronous append, so a
|
|
9
|
-
// line is on disk the moment the call returns — including the death line each
|
|
10
|
-
// shutdown path writes last, which an async stream dropped whenever
|
|
11
|
-
// `process.exit` outran its flush. The daemon writes a handful of short lines
|
|
12
|
-
// per second; a sync append is microseconds. No stream means nothing to flush,
|
|
13
|
-
// nothing to close, and no window in which a late writer can reopen a sink
|
|
14
|
-
// that nobody waits for. [LAW:polishing-by-subtraction]
|
|
15
|
-
let bytesWritten: number | null = null;
|
|
16
|
-
|
|
17
|
-
// Pre-load size once so rotation triggers correctly across daemon restarts.
|
|
18
|
-
function currentBytes(filePath: string): number {
|
|
19
|
-
if (bytesWritten !== null) return bytesWritten;
|
|
20
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
21
|
-
try {
|
|
22
|
-
bytesWritten = fs.statSync(filePath).size;
|
|
23
|
-
} catch {
|
|
24
|
-
bytesWritten = 0;
|
|
25
|
-
}
|
|
26
|
-
return bytesWritten;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Self-rotation: when daemon.log exceeds MAX_BYTES, shift .1→.2, .2→.3, drop
|
|
30
|
-
// the oldest, and start fresh. Daemon-internal so we don't depend on any
|
|
31
|
-
// external rotator. Cheap because rotation only runs at the rollover boundary.
|
|
32
|
-
function rotate(filePath: string): void {
|
|
33
|
-
for (let i = KEEP_GENERATIONS - 1; i >= 1; i--) {
|
|
34
|
-
const src = `${filePath}.${i}`;
|
|
35
|
-
const dst = `${filePath}.${i + 1}`;
|
|
36
|
-
try {
|
|
37
|
-
fs.renameSync(src, dst);
|
|
38
|
-
} catch {}
|
|
39
|
-
}
|
|
40
|
-
try {
|
|
41
|
-
fs.renameSync(filePath, `${filePath}.1`);
|
|
42
|
-
} catch {}
|
|
43
|
-
bytesWritten = 0;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export type LogLevel = "info" | "warn" | "error";
|
|
47
|
-
|
|
48
|
-
// [LAW:locality-or-seam] The logging capability daemon components depend on.
|
|
49
|
-
// `dlog` is the daemon's implementation (writes to daemon.log); consumers that
|
|
50
|
-
// inject a different impl (a quiet default in tests) take this shape.
|
|
51
|
-
export type DaemonLogger = (level: LogLevel, msg: string) => void;
|
|
52
|
-
|
|
53
|
-
export function dlog(level: LogLevel, msg: string): void {
|
|
54
|
-
const line = `${new Date().toISOString()} [${level}] ${msg}\n`;
|
|
55
|
-
try {
|
|
56
|
-
// [LAW:single-enforcer] Path resolution reaches os.homedir(); it lives
|
|
57
|
-
// inside the one boundary that makes dlog total.
|
|
58
|
-
const filePath = logPath();
|
|
59
|
-
const before = currentBytes(filePath);
|
|
60
|
-
fs.appendFileSync(filePath, line);
|
|
61
|
-
bytesWritten = before + Buffer.byteLength(line, "utf8");
|
|
62
|
-
if (bytesWritten >= MAX_BYTES) rotate(filePath);
|
|
63
|
-
} catch (e) {
|
|
64
|
-
// [LAW:no-silent-failure] exception: the failure IS the log sink, so it
|
|
65
|
-
// cannot be reported through the log sink. A throw here would escape the
|
|
66
|
-
// crash handlers that call dlog first (uncaughtException → dlog →
|
|
67
|
-
// shutdown), taking the clean-death path down with it. stderr carries the
|
|
68
|
-
// line and the reason — the terminal when the daemon runs by hand, and
|
|
69
|
-
// /dev/null under a detached spawn; the daemon keeps serving either way.
|
|
70
|
-
// Nulling the counter re-runs the lazy init on the next call, so a state
|
|
71
|
-
// dir removed out from under the daemon is recreated for the next line.
|
|
72
|
-
bytesWritten = null;
|
|
73
|
-
try {
|
|
74
|
-
process.stderr.write(
|
|
75
|
-
`${line}cc-candybar: daemon.log unwritable: ${(e as Error).message}\n`,
|
|
76
|
-
);
|
|
77
|
-
} catch {
|
|
78
|
-
// stderr was the last channel.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|