@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,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
- }
@@ -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
- }