@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
package/src/daemon/acquire.ts
DELETED
|
@@ -1,684 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import net from "node:net";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { launchDetachedSync } from "../proc/launch";
|
|
5
|
-
import { heapCapMb } from "./limits";
|
|
6
|
-
import process from "node:process";
|
|
7
|
-
import {
|
|
8
|
-
socketPath,
|
|
9
|
-
spawnLockPath,
|
|
10
|
-
spawnCooldownPath,
|
|
11
|
-
spawnBackoffPath,
|
|
12
|
-
daemonDir,
|
|
13
|
-
} from "./paths";
|
|
14
|
-
|
|
15
|
-
// [LAW:single-enforcer] One primitive per runtime that owns the entire
|
|
16
|
-
// "obtain a daemon" verb. Every spawn site in the Node runtime now flows
|
|
17
|
-
// through this one path; the Rust client mirrors it in its own
|
|
18
|
-
// obtain-daemon primitive. Both runtimes agree on socketPath() and
|
|
19
|
-
// spawnLockPath() *and* on the lock mechanism: open(path, O_CREAT | O_EXCL)
|
|
20
|
-
// — existence == held; release by unlinking. A Rust kick and a Node kick
|
|
21
|
-
// are mutually recognizable.
|
|
22
|
-
//
|
|
23
|
-
// [LAW:dataflow-not-control-flow] Callers do not get to choose whether to
|
|
24
|
-
// spawn. They request a daemon; this function returns one of three typed
|
|
25
|
-
// outcomes. The decision lives in the data (connect result, lock acquisition,
|
|
26
|
-
// re-check after lock).
|
|
27
|
-
|
|
28
|
-
export type ObtainResult =
|
|
29
|
-
| { kind: "attached" }
|
|
30
|
-
| { kind: "started" }
|
|
31
|
-
| { kind: "failed"; reason: string };
|
|
32
|
-
|
|
33
|
-
interface ObtainOpts {
|
|
34
|
-
// Total deadline for the entire obtain operation. After this elapses we
|
|
35
|
-
// return { kind: "failed" } even if a daemon could come up shortly. Default
|
|
36
|
-
// 2000ms — generous for cold start, tight enough to bound user-visible
|
|
37
|
-
// latency on a busted state directory.
|
|
38
|
-
totalTimeoutMs?: number;
|
|
39
|
-
// Per-connect probe timeout. AF_UNIX connect to a live listener is sub-ms;
|
|
40
|
-
// anything slower implies "no listener" in practice.
|
|
41
|
-
connectTimeoutMs?: number;
|
|
42
|
-
// How long to wait after spawning before giving up on the daemon coming up.
|
|
43
|
-
spawnReadyTimeoutMs?: number;
|
|
44
|
-
// After this much continuous contention on spawn.lock without a daemon
|
|
45
|
-
// appearing, give up on the lock and spawn anyway. The bind() inside the
|
|
46
|
-
// daemon arbitrates duplicates, so a stuck lock degrades to bind-arbitrated
|
|
47
|
-
// contention instead of a multi-second availability gap. Default
|
|
48
|
-
// totalTimeoutMs / 2.
|
|
49
|
-
lockFallbackMs?: number;
|
|
50
|
-
// Test hook: replace the actual spawn call. Returning false simulates
|
|
51
|
-
// "spawn failed"; default spawns the real daemon.
|
|
52
|
-
spawn?: () => boolean;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const DEFAULT_OPTS: Required<Omit<ObtainOpts, "spawn" | "lockFallbackMs">> = {
|
|
56
|
-
totalTimeoutMs: 2000,
|
|
57
|
-
connectTimeoutMs: 50,
|
|
58
|
-
spawnReadyTimeoutMs: 1500,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export async function obtainDaemon(
|
|
62
|
-
opts: ObtainOpts = {},
|
|
63
|
-
): Promise<ObtainResult> {
|
|
64
|
-
const settings = { ...DEFAULT_OPTS, ...opts };
|
|
65
|
-
const spawnFn = opts.spawn ?? spawnDaemonDetachedReal;
|
|
66
|
-
const deadline = Date.now() + settings.totalTimeoutMs;
|
|
67
|
-
const lockFallbackMs =
|
|
68
|
-
opts.lockFallbackMs ?? Math.floor(settings.totalTimeoutMs / 2);
|
|
69
|
-
|
|
70
|
-
// [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>
|
|
71
|
-
// — synchronous filesystem failures (read-only FS, permission denial) must
|
|
72
|
-
// become typed failure outcomes, not throws that surprise the caller.
|
|
73
|
-
const setupErr = ensureStateDir();
|
|
74
|
-
if (setupErr) return { kind: "failed", reason: setupErr };
|
|
75
|
-
|
|
76
|
-
// Fast path: is a daemon already listening?
|
|
77
|
-
if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
|
|
78
|
-
return { kind: "attached" };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// No daemon yet. Try to win the spawn-lock so we are the one to bring it up.
|
|
82
|
-
const contentionStart = Date.now();
|
|
83
|
-
while (Date.now() < deadline) {
|
|
84
|
-
const lock = tryAcquireSpawnLock();
|
|
85
|
-
if (lock.kind === "error") {
|
|
86
|
-
// [LAW:no-silent-fallbacks] Unrecoverable errors (EACCES, ENOTDIR,
|
|
87
|
-
// broken state dir) must not silently degrade into a contention loop
|
|
88
|
-
// + timeout. The caller gets an actionable reason.
|
|
89
|
-
return { kind: "failed", reason: `spawn-lock: ${lock.reason}` };
|
|
90
|
-
}
|
|
91
|
-
if (lock.kind === "held") {
|
|
92
|
-
try {
|
|
93
|
-
// Re-check: another caller may have spawned a daemon between our
|
|
94
|
-
// initial connect and our lock acquisition.
|
|
95
|
-
if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
|
|
96
|
-
return { kind: "attached" };
|
|
97
|
-
}
|
|
98
|
-
return await spawnAndWaitForReady(
|
|
99
|
-
spawnFn,
|
|
100
|
-
settings.spawnReadyTimeoutMs,
|
|
101
|
-
settings.connectTimeoutMs,
|
|
102
|
-
deadline,
|
|
103
|
-
"",
|
|
104
|
-
);
|
|
105
|
-
} finally {
|
|
106
|
-
releaseSpawnLock();
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Lock contended. Another caller is in the spawn window — brief wait,
|
|
110
|
-
// then re-check for the socket they're bringing up.
|
|
111
|
-
await sleep(20);
|
|
112
|
-
if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
|
|
113
|
-
return { kind: "attached" };
|
|
114
|
-
}
|
|
115
|
-
// [LAW:dataflow-not-control-flow] spawn.lock is an optimization; bind()
|
|
116
|
-
// is the load-bearing exclusion. If we've been contended past the
|
|
117
|
-
// fallback threshold (e.g. crashed lock holder, slow staleness reclaim),
|
|
118
|
-
// bypass the lock and let bind() inside the daemon arbitrate duplicates.
|
|
119
|
-
// Same shape as a fresh spawn — caller pays one extra Node startup cost
|
|
120
|
-
// in the worst case, which is the right trade for availability.
|
|
121
|
-
if (Date.now() - contentionStart > lockFallbackMs) {
|
|
122
|
-
return await spawnAndWaitForReady(
|
|
123
|
-
spawnFn,
|
|
124
|
-
settings.spawnReadyTimeoutMs,
|
|
125
|
-
settings.connectTimeoutMs,
|
|
126
|
-
deadline,
|
|
127
|
-
" (lock-fallback)",
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return { kind: "failed", reason: "timeout obtaining daemon" };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Spawn the daemon, then poll for it to bind. Returns the typed outcome.
|
|
136
|
-
// Shared by the lock-held path and the lock-fallback path so they don't drift.
|
|
137
|
-
async function spawnAndWaitForReady(
|
|
138
|
-
spawnFn: () => boolean,
|
|
139
|
-
spawnReadyTimeoutMs: number,
|
|
140
|
-
connectTimeoutMs: number,
|
|
141
|
-
outerDeadline: number,
|
|
142
|
-
reasonSuffix: string,
|
|
143
|
-
): Promise<ObtainResult> {
|
|
144
|
-
const readyDeadline = Math.min(
|
|
145
|
-
Date.now() + spawnReadyTimeoutMs,
|
|
146
|
-
outerDeadline,
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
// [LAW:dataflow-not-control-flow] The spawn-rate bound is consulted as data,
|
|
150
|
-
// not a mode. If a spawn was attempted within SPAWN_COOLDOWN_MS, one is
|
|
151
|
-
// already in flight — do NOT add another Node process; wait for the in-flight
|
|
152
|
-
// boot. This keeps obtainDaemon under the same global rate cap as the kick
|
|
153
|
-
// path, so the rate bound holds for EVERY spawn site [LAW:one-source-of-truth].
|
|
154
|
-
if (!claimSpawnCooldown()) {
|
|
155
|
-
// [LAW:no-silent-failure] This failure's cause is the cooldown gate, not the
|
|
156
|
-
// lock path we arrived through — so it does NOT inherit reasonSuffix (which
|
|
157
|
-
// tags lock-held vs lock-fallback *spawn* provenance). We never spawned here.
|
|
158
|
-
return (await pollUntilReady(connectTimeoutMs, readyDeadline))
|
|
159
|
-
? { kind: "attached" }
|
|
160
|
-
: {
|
|
161
|
-
kind: "failed",
|
|
162
|
-
reason: "spawn on cooldown; no daemon became ready during the wait",
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>.
|
|
167
|
-
// A synchronous throw from child_process.spawn (ENOENT, invalid options)
|
|
168
|
-
// must become a typed failure, not a rejected promise.
|
|
169
|
-
let didSpawn = false;
|
|
170
|
-
try {
|
|
171
|
-
didSpawn = spawnFn();
|
|
172
|
-
} catch (e) {
|
|
173
|
-
return {
|
|
174
|
-
kind: "failed",
|
|
175
|
-
reason: `spawn threw${reasonSuffix}: ${(e as Error).message}`,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
if (!didSpawn) {
|
|
179
|
-
return {
|
|
180
|
-
kind: "failed",
|
|
181
|
-
reason: `spawn returned false${reasonSuffix}`,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
return (await pollUntilReady(connectTimeoutMs, readyDeadline))
|
|
185
|
-
? { kind: "started" }
|
|
186
|
-
: {
|
|
187
|
-
kind: "failed",
|
|
188
|
-
reason: `daemon did not bind in time${reasonSuffix}`,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Poll the socket until a daemon answers or the deadline elapses. Shared by the
|
|
193
|
-
// spawn path (→ "started") and the cooldown-blocked wait path (→ "attached"):
|
|
194
|
-
// both need "did a daemon come up in time", they differ only in how they label
|
|
195
|
-
// the outcome.
|
|
196
|
-
async function pollUntilReady(
|
|
197
|
-
connectTimeoutMs: number,
|
|
198
|
-
readyDeadline: number,
|
|
199
|
-
): Promise<boolean> {
|
|
200
|
-
while (Date.now() < readyDeadline) {
|
|
201
|
-
if (await canConnect(socketPath(), connectTimeoutMs)) return true;
|
|
202
|
-
await sleep(20);
|
|
203
|
-
}
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Synchronous fire-and-forget kick — used for "daemon-miss" recovery where
|
|
208
|
-
// the current render is already lost and we just want to warm the daemon for
|
|
209
|
-
// the next refresh. Mirrors the Rust client's obtain_daemon_kick at the same
|
|
210
|
-
// shape: lock + spawn + release, all synchronous, no await.
|
|
211
|
-
//
|
|
212
|
-
// [LAW:one-type-per-behavior] This is the Node mirror of Rust's
|
|
213
|
-
// obtain_daemon_kick in rust-client/src/main.rs. Both runtimes use the same
|
|
214
|
-
// existence-as-lock semantics so a Rust kick and a Node kick are mutually
|
|
215
|
-
// recognizable. The bind() inside the daemon arbitrates any duplicate spawns
|
|
216
|
-
// that slip past the lock.
|
|
217
|
-
//
|
|
218
|
-
// Why synchronous: callers (src/index.ts, src/install/index.ts) call this
|
|
219
|
-
// immediately before process.exit(). An async variant would suspend on the
|
|
220
|
-
// first await and never resume — process.exit would kill the process before
|
|
221
|
-
// child_process.spawn ever runs. The lock+spawn must complete in synchronous
|
|
222
|
-
// turn for the daemon to actually start.
|
|
223
|
-
// If the spawn.lock has existed longer than this, the kick path assumes the
|
|
224
|
-
// holder crashed mid-spawn and overrides to preserve availability.
|
|
225
|
-
// Calibrated to be much larger than any legitimate hold:
|
|
226
|
-
// - A healthy kick holds for <10ms (fork + release).
|
|
227
|
-
// - obtainDaemon's lock-held path can hold up to spawnReadyTimeoutMs
|
|
228
|
-
// (1500ms default) while polling for the new daemon to bind.
|
|
229
|
-
// 2s leaves a comfortable margin above the slowest legitimate holder while
|
|
230
|
-
// still recovering from a crashed-mid-spawn holder well before STALE_LOCK_MS.
|
|
231
|
-
const KICK_CONTENDED_OVERRIDE_MS = 2_000;
|
|
232
|
-
|
|
233
|
-
export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
|
|
234
|
-
if (ensureStateDir() !== null) return;
|
|
235
|
-
const spawnFn = opts.spawn ?? spawnDaemonDetachedReal;
|
|
236
|
-
const lock = tryAcquireSpawnLock();
|
|
237
|
-
|
|
238
|
-
// [LAW:dataflow-not-control-flow] Lock outcome is data, not control flow.
|
|
239
|
-
// - "contended": typically means another caller is in the spawn window;
|
|
240
|
-
// trust them and return. BUT: if the lock has been held suspiciously
|
|
241
|
-
// long, the holder is likely crashed mid-spawn — override and spawn
|
|
242
|
-
// unlocked. bind() arbitrates any duplicates.
|
|
243
|
-
// - "error": spawn-lock unavailable (broken state dir, perms). Per the
|
|
244
|
-
// architecture, spawn.lock is an *optimization* on top of bind()'s
|
|
245
|
-
// load-bearing exclusion — a lock error should NOT make this kick a
|
|
246
|
-
// hard stop on availability. Fall through to an unlocked spawn.
|
|
247
|
-
// - "held": normal path — spawn under the lock.
|
|
248
|
-
if (lock.kind === "contended") {
|
|
249
|
-
const ageMs = spawnLockAgeMs();
|
|
250
|
-
if (ageMs !== null && ageMs > KICK_CONTENDED_OVERRIDE_MS) {
|
|
251
|
-
process.stderr.write(
|
|
252
|
-
`cc-candybar: spawn-lock held ${ageMs}ms (likely crashed holder) — spawning unlocked\n`,
|
|
253
|
-
);
|
|
254
|
-
cooldownGatedSpawn(spawnFn);
|
|
255
|
-
}
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (lock.kind === "error") {
|
|
259
|
-
process.stderr.write(
|
|
260
|
-
`cc-candybar: spawn-lock unavailable (${lock.reason}) — spawning unlocked\n`,
|
|
261
|
-
);
|
|
262
|
-
cooldownGatedSpawn(spawnFn);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
try {
|
|
266
|
-
cooldownGatedSpawn(spawnFn);
|
|
267
|
-
} finally {
|
|
268
|
-
releaseSpawnLock();
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// [LAW:single-enforcer] Every kick spawn site routes through here, so the
|
|
273
|
-
// spawn-rate bound is applied at exactly one boundary — mirror of Rust's
|
|
274
|
-
// spawn_daemon_rate_limited. On cooldown we do nothing: a spawn was attempted
|
|
275
|
-
// within SPAWN_COOLDOWN_MS and is likely still booting; the kick is
|
|
276
|
-
// fire-and-forget, so "already in flight" is a complete answer.
|
|
277
|
-
function cooldownGatedSpawn(spawnFn: () => boolean): void {
|
|
278
|
-
if (!claimSpawnCooldown()) return;
|
|
279
|
-
safeSpawn(spawnFn);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function spawnLockAgeMs(): number | null {
|
|
283
|
-
try {
|
|
284
|
-
const st = fs.statSync(spawnLockPath());
|
|
285
|
-
return Date.now() - st.mtimeMs;
|
|
286
|
-
} catch {
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// [LAW:no-defensive-null-guards] Kick path is fire-and-forget; a spawn
|
|
292
|
-
// failure here is best-effort. Swallowing prevents an uncaught throw from
|
|
293
|
-
// crashing the calling process at the wrong moment (right before its own
|
|
294
|
-
// exit). Both failure modes (throw and false-return) are logged via stderr
|
|
295
|
-
// so kick failures stay visible — silent failure is the worst outcome.
|
|
296
|
-
function safeSpawn(spawnFn: () => boolean): void {
|
|
297
|
-
try {
|
|
298
|
-
if (!spawnFn()) {
|
|
299
|
-
process.stderr.write(
|
|
300
|
-
"cc-candybar: daemon spawn returned false (unable to resolve script path?)\n",
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
} catch (e) {
|
|
304
|
-
process.stderr.write(
|
|
305
|
-
`cc-candybar: daemon spawn failed: ${(e as Error).message}\n`,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ─── Spawn cooldown (shared spawn-RATE bound) ────────────────────────────────
|
|
311
|
-
//
|
|
312
|
-
// [LAW:one-source-of-truth] spawn.lock dedups spawns at one INSTANT; the
|
|
313
|
-
// cooldown bounds them over TIME. Without it, the Rust kick — which releases
|
|
314
|
-
// spawn.lock milliseconds after forking, before the 0.5-3s Node boot window —
|
|
315
|
-
// re-spawns on every render tick during an outage (spawn rate ≈ tick rate:
|
|
316
|
-
// dozens/sec, process-table exhaustion). One file's mtime records the last spawn
|
|
317
|
-
// ATTEMPT; both runtimes consult it. The constant and filename are mirrored TS↔
|
|
318
|
-
// Rust (scripts/check-protocol.mjs). Worst case with the bound: ~20 spawns/min
|
|
319
|
-
// globally, each of which exits cleanly via the sibling socket-lease defenses.
|
|
320
|
-
// Exported for the boundary unit tests (test/daemon-acquire.test.ts), which pin
|
|
321
|
-
// the window arithmetic against the exact constant the same way the Rust unit
|
|
322
|
-
// tests do — so a TS↔Rust decision divergence at a boundary is caught even
|
|
323
|
-
// though check-protocol only diffs the constant's value.
|
|
324
|
-
export const SPAWN_COOLDOWN_MS = 3_000;
|
|
325
|
-
|
|
326
|
-
// [LAW:effects-at-boundaries] The window arithmetic — the subtle part: a
|
|
327
|
-
// future-mtime garbage record (beyond the stale-lock window) must not pin the
|
|
328
|
-
// cooldown forever, while a small negative age is just ms-truncation of
|
|
329
|
-
// Date.now() against the higher-precision fs mtime and still counts as a
|
|
330
|
-
// just-recorded attempt — is a pure function of the record's age, extracted from
|
|
331
|
-
// the fs read so it is unit-tested without touching the filesystem. Mirrors the
|
|
332
|
-
// Rust cooldown_decision. `null` age (missing/unreadable file) allows (first
|
|
333
|
-
// spawn); the mtime IS the timestamp, so "unparseable timestamp" is
|
|
334
|
-
// unrepresentable by construction.
|
|
335
|
-
export type CooldownDecision =
|
|
336
|
-
| { kind: "allow" }
|
|
337
|
-
| { kind: "allow-future-garbage"; futureMs: number }
|
|
338
|
-
| { kind: "deny" };
|
|
339
|
-
|
|
340
|
-
// [LAW:types-are-the-program] `cooldownMs` is the required window, not a
|
|
341
|
-
// captured constant — the decision is the same pure fold whether the caller
|
|
342
|
-
// is checking against the base SPAWN_COOLDOWN_MS or a backed-off window from
|
|
343
|
-
// effectiveCooldownMs(streak) below. Generalizing the threshold into a
|
|
344
|
-
// parameter is what let brandon-daemon-lifecycle-gad.3 add exponential
|
|
345
|
-
// backoff without touching this function's tested boundary arithmetic.
|
|
346
|
-
export function cooldownDecision(
|
|
347
|
-
ageMs: number | null,
|
|
348
|
-
cooldownMs: number,
|
|
349
|
-
): CooldownDecision {
|
|
350
|
-
if (ageMs === null) return { kind: "allow" };
|
|
351
|
-
if (ageMs < -STALE_LOCK_MS)
|
|
352
|
-
return { kind: "allow-future-garbage", futureMs: -ageMs };
|
|
353
|
-
if (ageMs < cooldownMs) return { kind: "deny" };
|
|
354
|
-
return { kind: "allow" };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ─── Spawn backoff (consecutive non-convergence widens the cooldown) ────────
|
|
358
|
-
//
|
|
359
|
-
// [LAW:one-source-of-truth] spawn.cooldown's mtime answers "when was a spawn
|
|
360
|
-
// last attempted"; this streak answers "how many attempts in a row have
|
|
361
|
-
// failed to converge on a live daemon" — a fact spawn.cooldown's mtime alone
|
|
362
|
-
// cannot carry (mtime is overwritten on every attempt, losing the count). One
|
|
363
|
-
// small file, one fact, read/written by both runtimes exactly like
|
|
364
|
-
// spawn.cooldown itself.
|
|
365
|
-
//
|
|
366
|
-
// [LAW:single-enforcer] The daemon is the only process that can know
|
|
367
|
-
// "convergence achieved" (it just bound the socket and is about to serve) —
|
|
368
|
-
// see resetSpawnBackoff(), called once from server.ts's onListening(). A
|
|
369
|
-
// client-side reset would need a full successful render round-trip on the
|
|
370
|
-
// hot path to detect convergence, adding fs I/O to the common case for a
|
|
371
|
-
// signal the daemon already has for free at boot.
|
|
372
|
-
//
|
|
373
|
-
// Growth is capped at SPAWN_BACKOFF_MAX_STREAK shifts so effectiveCooldownMs
|
|
374
|
-
// never has to reason about an unbounded streak (a multi-day outage would
|
|
375
|
-
// otherwise grow the stored integer without bound) and so Rust's mirrored
|
|
376
|
-
// `<<` cannot overflow. 3_000ms << 5 = 96_000ms, already past the 60s cap, so
|
|
377
|
-
// 5 is sufficient — not tuned to any particular outage length.
|
|
378
|
-
export const SPAWN_BACKOFF_CAP_MS = 60_000;
|
|
379
|
-
export const SPAWN_BACKOFF_MAX_STREAK = 5;
|
|
380
|
-
|
|
381
|
-
// [LAW:behavior-not-structure] Pure over the streak; no filesystem. Mirrors
|
|
382
|
-
// Rust's effective_cooldown_ms exactly (diffed by check-protocol for the two
|
|
383
|
-
// constants; the arithmetic itself is pinned by the boundary unit tests on
|
|
384
|
-
// both sides, matching cooldownDecision's existing pattern).
|
|
385
|
-
export function effectiveCooldownMs(streak: number): number {
|
|
386
|
-
const capped = Math.min(Math.max(streak, 0), SPAWN_BACKOFF_MAX_STREAK);
|
|
387
|
-
return Math.min(SPAWN_COOLDOWN_MS * 2 ** capped, SPAWN_BACKOFF_CAP_MS);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// [LAW:no-defensive-null-guards] Number(raw) — not parseInt — so trailing
|
|
391
|
-
// garbage ("5abc", "5.0") fails closed to NaN instead of being silently
|
|
392
|
-
// truncated to a plausible-looking integer. parseInt's truncation is exactly
|
|
393
|
-
// the bug daemonCeiling() (fork-bomb-breaker.ts, brandon-daemon-lifecycle-gad.2)
|
|
394
|
-
// fixed for the same "small integer parsed from an untrusted local file" shape;
|
|
395
|
-
// repeating parseInt here would reintroduce it. The clamp to
|
|
396
|
-
// SPAWN_BACKOFF_MAX_STREAK also bounds every value this function can ever
|
|
397
|
-
// return, so no caller — including `streak + 1` — needs its own re-clamp to
|
|
398
|
-
// stay overflow-safe (Rust's mirror clamps at the identical boundary, since a
|
|
399
|
-
// raw u32 parsed from disk has no such guarantee otherwise).
|
|
400
|
-
// Exported so the parsing-strictness contract (Number, not parseInt — see
|
|
401
|
-
// the comment above) has a direct test independent of any caller's file
|
|
402
|
-
// content, matching cooldownDecision/effectiveCooldownMs's own exported-for-
|
|
403
|
-
// testing precedent.
|
|
404
|
-
export function readBackoffStreak(filePath: string): number {
|
|
405
|
-
// [LAW:no-silent-failure] A missing or garbage streak file is NOT
|
|
406
|
-
// ambiguous the way a missing cooldown mtime is: falling back to 0 always
|
|
407
|
-
// fails toward the SAME safe direction as the rest of this module (spawn
|
|
408
|
-
// permitted at the base rate, never wedged) — matching cooldownDecision's
|
|
409
|
-
// own `ageMs === null → allow`. Never loud here; the failure mode is
|
|
410
|
-
// "one extra spawn," which bind() already arbitrates.
|
|
411
|
-
try {
|
|
412
|
-
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
413
|
-
const n = Number(raw);
|
|
414
|
-
if (!Number.isInteger(n) || n < 0) return 0;
|
|
415
|
-
return Math.min(n, SPAWN_BACKOFF_MAX_STREAK);
|
|
416
|
-
} catch {
|
|
417
|
-
return 0;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// [LAW:no-ambient-temporal-coupling] The read-then-write here (and in
|
|
422
|
-
// claimSpawnCooldown below) is not atomic across process boundaries — two
|
|
423
|
-
// client processes racing through a daemon-miss window can both read the
|
|
424
|
-
// same streak and both write the same increment, undercounting by one. This
|
|
425
|
-
// is an accepted, bounded trade, not an oversight: the ONLY failure direction
|
|
426
|
-
// is undercounting (the streak can never advance faster than reality), so a
|
|
427
|
-
// race just means backoff ramps a little slower than ideal — it can never
|
|
428
|
-
// permit MORE spawning than a race-free count would. The hard rate ceiling
|
|
429
|
-
// remains spawn.cooldown's mtime gate, which spawn.lock already serializes
|
|
430
|
-
// for the common case; this file, like spawn.lock's own documented
|
|
431
|
-
// thundering-herd tolerance, is a best-effort optimization on top of that,
|
|
432
|
-
// not a second load-bearing lock.
|
|
433
|
-
function writeBackoffStreak(filePath: string, streak: number): void {
|
|
434
|
-
try {
|
|
435
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
436
|
-
fs.writeFileSync(filePath, String(streak), { mode: 0o600 });
|
|
437
|
-
} catch (e) {
|
|
438
|
-
process.stderr.write(
|
|
439
|
-
`cc-candybar: could not record spawn.backoff: ${(e as Error).message}\n`,
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Called once by the daemon (server.ts onListening) the moment it binds the
|
|
445
|
-
// socket — the one process-wide fact that answers "did an outage just end."
|
|
446
|
-
// Deletes rather than writes "0": absence already reads as streak 0 via
|
|
447
|
-
// readBackoffStreak's catch branch, so there is no separate reset format to
|
|
448
|
-
// keep in sync with the normal write path.
|
|
449
|
-
//
|
|
450
|
-
// [LAW:no-ambient-temporal-coupling] This fires on bind, not on confirmed
|
|
451
|
-
// sustained liveness — in the vanishingly rare socket-capture race
|
|
452
|
-
// documented above armOwnershipWatch (server.ts), a daemon that only THINKS
|
|
453
|
-
// it converged resets the streak early. That daemon self-heals the same way
|
|
454
|
-
// ownership does (armOwnershipWatch drains it once the mismatch is detected);
|
|
455
|
-
// worst case is one redundant reset in an already-rare race window, not a
|
|
456
|
-
// wrong steady-state outcome.
|
|
457
|
-
export function resetSpawnBackoff(): void {
|
|
458
|
-
try {
|
|
459
|
-
fs.unlinkSync(spawnBackoffPath());
|
|
460
|
-
} catch (e) {
|
|
461
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
462
|
-
process.stderr.write(
|
|
463
|
-
`cc-candybar: could not reset spawn.backoff: ${(e as Error).message}\n`,
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
|
|
470
|
-
// and RECORDS the attempt (updating spawn.cooldown's mtime to now, advancing
|
|
471
|
-
// spawn.backoff's streak) — when a spawn is permitted; false when an attempt
|
|
472
|
-
// was recorded within the EFFECTIVE cooldown window (SPAWN_COOLDOWN_MS,
|
|
473
|
-
// widened by effectiveCooldownMs(streak) once consecutive attempts have
|
|
474
|
-
// failed to converge — see brandon-daemon-lifecycle-gad.3). Recording-on-grant
|
|
475
|
-
// (BEFORE the caller spawns) is load-bearing: a spawn that then throws or
|
|
476
|
-
// returns false still counts against the rate, so a broken binary is not
|
|
477
|
-
// retried in a tight loop. A future-mtime garbage record warns loudly and
|
|
478
|
-
// falls toward ALLOWING the spawn [LAW:no-silent-failure].
|
|
479
|
-
function claimSpawnCooldown(): boolean {
|
|
480
|
-
const cooldownPath = spawnCooldownPath();
|
|
481
|
-
const backoffPath = spawnBackoffPath();
|
|
482
|
-
const streak = readBackoffStreak(backoffPath);
|
|
483
|
-
const decision = cooldownDecision(
|
|
484
|
-
cooldownAgeMs(cooldownPath),
|
|
485
|
-
effectiveCooldownMs(streak),
|
|
486
|
-
);
|
|
487
|
-
if (decision.kind === "deny") return false;
|
|
488
|
-
if (decision.kind === "allow-future-garbage") {
|
|
489
|
-
process.stderr.write(
|
|
490
|
-
`cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
recordSpawnAttempt(cooldownPath);
|
|
494
|
-
// [LAW:dataflow-not-control-flow] Every granted spawn advances the streak
|
|
495
|
-
// by exactly one, unconditionally — the cap lives in the read side
|
|
496
|
-
// (effectiveCooldownMs) and here (Math.min), never as a skip.
|
|
497
|
-
writeBackoffStreak(
|
|
498
|
-
backoffPath,
|
|
499
|
-
Math.min(streak + 1, SPAWN_BACKOFF_MAX_STREAK),
|
|
500
|
-
);
|
|
501
|
-
return true;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function cooldownAgeMs(path: string): number | null {
|
|
505
|
-
try {
|
|
506
|
-
return Date.now() - fs.statSync(path).mtimeMs;
|
|
507
|
-
} catch {
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function recordSpawnAttempt(filePath: string): void {
|
|
513
|
-
// [LAW:composability] Self-sufficient — ensure the state dir exists rather
|
|
514
|
-
// than leaning on an ambient ensureStateDir() precondition, so any spawn site
|
|
515
|
-
// routing through claimSpawnCooldown records correctly (mirrors Rust's
|
|
516
|
-
// record_spawn_attempt). Content is human-diagnostic only; the mtime is the
|
|
517
|
-
// authority. A write failure means no cooldown recorded — worst case one extra
|
|
518
|
-
// spawn, which bind() arbitrates — but surface it loudly rather than silently
|
|
519
|
-
// un-bound the rate [LAW:no-silent-failure].
|
|
520
|
-
try {
|
|
521
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
522
|
-
fs.writeFileSync(filePath, `${process.pid} ${Date.now()}\n`, {
|
|
523
|
-
mode: 0o600,
|
|
524
|
-
});
|
|
525
|
-
} catch (e) {
|
|
526
|
-
process.stderr.write(
|
|
527
|
-
`cc-candybar: could not record spawn.cooldown: ${(e as Error).message}\n`,
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// ─── Spawn-lock (Node side) ──────────────────────────────────────────────────
|
|
533
|
-
//
|
|
534
|
-
// O_EXLOCK / fcntl(F_SETLK) aren't reliably exposed across Node platforms, so
|
|
535
|
-
// we use the simplest portable atomic primitive: open(path, "wx"). The file
|
|
536
|
-
// records owner pid + timestamp. Staleness is time-based — if the file is
|
|
537
|
-
// older than STALE_LOCK_MS we forcibly claim it. The spawn window is
|
|
538
|
-
// sub-second in practice; 10s is a wide tolerance.
|
|
539
|
-
//
|
|
540
|
-
// [LAW:no-defensive-null-guards] The staleness reclaim is not a "the holder
|
|
541
|
-
// might be dead, let me check" guard — it is the bounded-staleness policy of
|
|
542
|
-
// the lock. The bind() in the daemon is the real correctness boundary; this
|
|
543
|
-
// lock is a thundering-herd optimization, so a missed dedup just means one
|
|
544
|
-
// extra Node process eats a bind() race and exits.
|
|
545
|
-
|
|
546
|
-
// Exported for the cooldownDecision boundary tests. Mirrored TS↔Rust (diffed by
|
|
547
|
-
// check-protocol) since both runtimes apply it to the same spawn.cooldown file.
|
|
548
|
-
export const STALE_LOCK_MS = 10_000;
|
|
549
|
-
|
|
550
|
-
let heldLock: { fd: number; path: string } | null = null;
|
|
551
|
-
|
|
552
|
-
type LockOutcome =
|
|
553
|
-
| { kind: "held" }
|
|
554
|
-
| { kind: "contended" }
|
|
555
|
-
| { kind: "error"; reason: string };
|
|
556
|
-
|
|
557
|
-
function tryAcquireSpawnLock(): LockOutcome {
|
|
558
|
-
const path = spawnLockPath();
|
|
559
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
560
|
-
try {
|
|
561
|
-
const fd = fs.openSync(path, "wx", 0o600);
|
|
562
|
-
try {
|
|
563
|
-
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
564
|
-
} catch {}
|
|
565
|
-
heldLock = { fd, path };
|
|
566
|
-
return { kind: "held" };
|
|
567
|
-
} catch (e) {
|
|
568
|
-
const code = (e as NodeJS.ErrnoException).code;
|
|
569
|
-
if (code !== "EEXIST") {
|
|
570
|
-
// EACCES, ENOTDIR, ENOSPC, etc. are not contention — they are
|
|
571
|
-
// unrecoverable. Surface upward instead of pretending we lost a race.
|
|
572
|
-
return {
|
|
573
|
-
kind: "error",
|
|
574
|
-
reason: `openSync(${path}): ${code ?? (e as Error).message}`,
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
if (!isLockStale(path)) return { kind: "contended" };
|
|
579
|
-
try {
|
|
580
|
-
fs.unlinkSync(path);
|
|
581
|
-
} catch (e) {
|
|
582
|
-
// ENOENT means someone (the rightful holder, or another reclaimer)
|
|
583
|
-
// already removed the file — that's the desired post-condition, so
|
|
584
|
-
// continue to the retry. Other failures (EACCES, ENOSPC) are real.
|
|
585
|
-
const code = (e as NodeJS.ErrnoException).code;
|
|
586
|
-
if (code !== "ENOENT") {
|
|
587
|
-
return {
|
|
588
|
-
kind: "error",
|
|
589
|
-
reason: `unlink stale spawn.lock: ${(e as Error).message}`,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
return { kind: "contended" };
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function isLockStale(path: string): boolean {
|
|
598
|
-
try {
|
|
599
|
-
const st = fs.statSync(path);
|
|
600
|
-
return Date.now() - st.mtimeMs > STALE_LOCK_MS;
|
|
601
|
-
} catch {
|
|
602
|
-
return true;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function releaseSpawnLock(): void {
|
|
607
|
-
if (!heldLock) return;
|
|
608
|
-
const { fd, path } = heldLock;
|
|
609
|
-
heldLock = null;
|
|
610
|
-
try {
|
|
611
|
-
fs.closeSync(fd);
|
|
612
|
-
} catch {}
|
|
613
|
-
try {
|
|
614
|
-
fs.unlinkSync(path);
|
|
615
|
-
} catch {}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// ─── State-dir setup ────────────────────────────────────────────────────────
|
|
619
|
-
|
|
620
|
-
// Returns null on success, or a reason string on unrecoverable failure. Used
|
|
621
|
-
// by both obtainDaemon (which converts to a `failed` result) and
|
|
622
|
-
// obtainDaemonKick (which silently gives up — there is no caller to report to).
|
|
623
|
-
function ensureStateDir(): string | null {
|
|
624
|
-
try {
|
|
625
|
-
fs.mkdirSync(daemonDir(), { recursive: true });
|
|
626
|
-
return null;
|
|
627
|
-
} catch (e) {
|
|
628
|
-
return `mkdir ${daemonDir()}: ${(e as Error).message}`;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// ─── Connect probe ──────────────────────────────────────────────────────────
|
|
633
|
-
|
|
634
|
-
function canConnect(sockPath: string, timeoutMs: number): Promise<boolean> {
|
|
635
|
-
return new Promise((resolve) => {
|
|
636
|
-
const sock = net.connect(sockPath);
|
|
637
|
-
let settled = false;
|
|
638
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
639
|
-
const done = (result: boolean): void => {
|
|
640
|
-
if (settled) return;
|
|
641
|
-
settled = true;
|
|
642
|
-
if (timer) clearTimeout(timer);
|
|
643
|
-
sock.removeAllListeners();
|
|
644
|
-
sock.destroy();
|
|
645
|
-
resolve(result);
|
|
646
|
-
};
|
|
647
|
-
sock.once("connect", () => done(true));
|
|
648
|
-
sock.once("error", () => done(false));
|
|
649
|
-
timer = setTimeout(() => done(false), timeoutMs);
|
|
650
|
-
timer.unref();
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function sleep(ms: number): Promise<void> {
|
|
655
|
-
return new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// ─── Default spawn implementation ───────────────────────────────────────────
|
|
659
|
-
//
|
|
660
|
-
// [LAW:one-source-of-truth] The V8 old-space cap is derived from the daemon's
|
|
661
|
-
// RSS budget (limits.ts: heapCapMb), never a literal here — the cap must sit
|
|
662
|
-
// ABOVE the RSS backstop so the graceful path fires first, and only one owner
|
|
663
|
-
// of the budget can keep that order true. The Rust client derives the same
|
|
664
|
-
// value the same way (rust-client/src/launch.rs).
|
|
665
|
-
//
|
|
666
|
-
// [LAW:single-enforcer] Routes through src/proc/launch so daemon-spawn shows
|
|
667
|
-
// up in subprocess metering (category "daemon-spawn"). The launch primitive
|
|
668
|
-
// owns the only child_process import in this file.
|
|
669
|
-
function spawnDaemonDetachedReal(): boolean {
|
|
670
|
-
const node = process.execPath;
|
|
671
|
-
const script = process.argv[1];
|
|
672
|
-
if (!script) return false;
|
|
673
|
-
// [LAW:no-silent-fallbacks] launchDetachedSync returns the typed outcome
|
|
674
|
-
// synchronously, so the spawn-failure case (ENOENT, EACCES, EAGAIN under
|
|
675
|
-
// process-table pressure) propagates as `false` instead of being silently
|
|
676
|
-
// reported as success. The previous `void launch({detached:true})` form
|
|
677
|
-
// discarded the Promise and unconditionally returned true.
|
|
678
|
-
const result = launchDetachedSync({
|
|
679
|
-
bin: node,
|
|
680
|
-
args: [`--max-old-space-size=${heapCapMb(process.env)}`, script, "daemon"],
|
|
681
|
-
category: "daemon-spawn",
|
|
682
|
-
});
|
|
683
|
-
return result.ok;
|
|
684
|
-
}
|