@crouter/api 0.3.387 → 0.3.389
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/api/__tests__/integration/client.test.js +97 -0
- package/dist/api/client.d.ts +7 -0
- package/dist/api/client.js +40 -21
- package/dist/api/dto/config.d.ts +11 -1
- package/dist/core/asset-root.d.ts +7 -0
- package/dist/core/asset-root.js +18 -0
- package/dist/core/canvas/boot-id.d.ts +6 -0
- package/dist/core/canvas/boot-id.js +26 -0
- package/dist/core/canvas/paths.d.ts +72 -0
- package/dist/core/canvas/paths.js +163 -0
- package/dist/core/canvas/pid.d.ts +391 -0
- package/dist/core/canvas/pid.js +948 -0
- package/dist/core/command-plugins/bundle.d.ts +149 -0
- package/dist/core/command-plugins/bundle.js +588 -0
- package/dist/core/command-plugins/endpoint.d.ts +24 -0
- package/dist/core/command-plugins/endpoint.js +51 -0
- package/dist/core/config.d.ts +233 -0
- package/dist/core/config.js +1120 -0
- package/dist/core/env-name.d.ts +6 -0
- package/dist/core/env-name.js +9 -0
- package/dist/core/errors.d.ts +38 -0
- package/dist/core/errors.js +90 -0
- package/dist/core/events/emit.d.ts +6 -0
- package/dist/core/events/emit.js +42 -0
- package/dist/core/events/envelope.d.ts +2 -0
- package/dist/core/events/envelope.js +84 -0
- package/dist/core/events/errors.d.ts +4 -0
- package/dist/core/events/errors.js +69 -0
- package/dist/core/events/operation-id.d.ts +4 -0
- package/dist/core/events/operation-id.js +24 -0
- package/dist/core/events/serialize.d.ts +4 -0
- package/dist/core/events/serialize.js +199 -0
- package/dist/core/events/source.d.ts +16 -0
- package/dist/core/events/source.js +31 -0
- package/dist/core/events/types.d.ts +68 -0
- package/dist/core/events/types.js +11 -0
- package/dist/core/exclusive-lock.d.ts +34 -0
- package/dist/core/exclusive-lock.js +197 -0
- package/dist/core/fs-utils.d.ts +44 -0
- package/dist/core/fs-utils.js +208 -0
- package/dist/core/help.d.ts +309 -0
- package/dist/core/help.js +406 -0
- package/dist/core/human/page-catalog.d.ts +57 -0
- package/dist/core/human/page-catalog.js +172 -0
- package/dist/core/installed-plugins.d.ts +2 -0
- package/dist/core/installed-plugins.js +79 -0
- package/dist/core/io.d.ts +122 -0
- package/dist/core/io.js +373 -0
- package/dist/core/keybindings/attach-control.d.ts +49 -0
- package/dist/core/keybindings/attach-control.js +42 -0
- package/dist/core/keybindings/catalog.d.ts +18 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/types.d.ts +42 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/layout.d.ts +26 -0
- package/dist/core/layout.js +94 -0
- package/dist/core/locked-file.d.ts +27 -0
- package/dist/core/locked-file.js +118 -0
- package/dist/core/log.d.ts +9 -0
- package/dist/core/log.js +89 -0
- package/dist/core/manifest.d.ts +5 -0
- package/dist/core/manifest.js +15 -0
- package/dist/core/plugin-env.d.ts +8 -0
- package/dist/core/plugin-env.js +31 -0
- package/dist/core/plugin-extensions.d.ts +29 -0
- package/dist/core/plugin-extensions.js +191 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/preview-result-path.d.ts +4 -0
- package/dist/core/preview-result-path.js +26 -0
- package/dist/core/profiles/env-store.d.ts +22 -0
- package/dist/core/profiles/env-store.js +163 -0
- package/dist/core/profiles/fuzzy-match.d.ts +19 -0
- package/dist/core/profiles/fuzzy-match.js +92 -0
- package/dist/core/profiles/manifest.d.ts +120 -0
- package/dist/core/profiles/manifest.js +529 -0
- package/dist/core/rate-limit-scope.d.ts +25 -0
- package/dist/core/rate-limit-scope.js +64 -0
- package/dist/core/render.d.ts +12 -0
- package/dist/core/render.js +138 -0
- package/dist/core/resolver.d.ts +14 -0
- package/dist/core/resolver.js +111 -0
- package/dist/core/runtime/branded-host.d.ts +25 -0
- package/dist/core/runtime/branded-host.js +264 -0
- package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
- package/dist/core/runtime/broker/daemon-ops.js +177 -0
- package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
- package/dist/core/runtime/broker/signal-stream.js +149 -0
- package/dist/core/scope.d.ts +32 -0
- package/dist/core/scope.js +184 -0
- package/dist/core/scoped-state/db.d.ts +17 -0
- package/dist/core/scoped-state/db.js +247 -0
- package/dist/core/scoped-state/migrate.d.ts +8 -0
- package/dist/core/scoped-state/migrate.js +187 -0
- package/dist/core/scoped-state/paths.d.ts +9 -0
- package/dist/core/scoped-state/paths.js +27 -0
- package/dist/core/scoped-state/profiles.d.ts +27 -0
- package/dist/core/scoped-state/profiles.js +93 -0
- package/dist/core/scoped-state/providers.d.ts +24 -0
- package/dist/core/scoped-state/providers.js +19 -0
- package/dist/core/scoped-state/schema.d.ts +6 -0
- package/dist/core/scoped-state/schema.js +43 -0
- package/dist/core/scoped-state/settings.d.ts +28 -0
- package/dist/core/scoped-state/settings.js +83 -0
- package/dist/core/spaces/open-beneath.d.ts +71 -0
- package/dist/core/spaces/open-beneath.js +581 -0
- package/dist/core/sqlite-statements.d.ts +4 -0
- package/dist/core/sqlite-statements.js +17 -0
- package/dist/core/subscription-state.d.ts +121 -0
- package/dist/core/subscription-state.js +287 -0
- package/dist/core/user-settings.d.ts +377 -0
- package/dist/core/user-settings.js +458 -0
- package/dist/daemon/broker-signals/bus.d.ts +30 -0
- package/dist/daemon/broker-signals/bus.js +87 -0
- package/dist/daemon/manage.d.ts +176 -0
- package/dist/daemon/manage.js +664 -0
- package/dist/daemon/pidfile.d.ts +8 -0
- package/dist/daemon/pidfile.js +37 -0
- package/dist/daemon/startup-policy.d.ts +1 -0
- package/dist/daemon/startup-policy.js +1 -0
- package/dist/native/linux.d.ts +29 -0
- package/dist/native/linux.js +20 -0
- package/dist/shared/env.d.ts +116 -0
- package/dist/shared/env.js +271 -0
- package/dist/shared/inbox-entry-body.d.ts +22 -0
- package/dist/shared/inbox-entry-body.js +116 -0
- package/dist/shared/working-activity.d.ts +9 -0
- package/dist/shared/working-activity.js +27 -0
- package/dist/types.d.ts +562 -0
- package/dist/types.js +186 -0
- package/package.json +1 -1
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
// Daemon management helpers — importable without the full command tree.
|
|
2
|
+
//
|
|
3
|
+
// spawnDaemon() is the low-level spawn call shared by `crtr sys daemon start` and
|
|
4
|
+
// ensureDaemon(). ensureDaemon() is the silent "start if not running" front-
|
|
5
|
+
// door helper called by the canvas runtime before spawning child nodes.
|
|
6
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, openSync, closeSync, fstatSync, readSync } from 'node:fs';
|
|
11
|
+
import { crtrHome } from '../core/canvas/paths.js';
|
|
12
|
+
import { listProcessTable } from '../core/canvas/pid.js';
|
|
13
|
+
import { bindDaemonEventSource, eventSource } from '../core/events/source.js';
|
|
14
|
+
import { hostExecPath } from '../core/runtime/branded-host.js';
|
|
15
|
+
// Pulled from the lean db-free pidfile module (NOT crtrd.js, whose module graph
|
|
16
|
+
// reaches openDb) so the CLI daemon front door stays canvas.db-free (plan B-0).
|
|
17
|
+
import { isDaemonRunning, readPidfile, isPidAlive } from './pidfile.js';
|
|
18
|
+
import { DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
|
|
19
|
+
import { exclusiveLockOwnerPid } from '../core/exclusive-lock.js';
|
|
20
|
+
import { onDiskMigrationLockPath } from '../core/canvas/paths.js';
|
|
21
|
+
import { envNoDaemonAutostart } from '../shared/env.js';
|
|
22
|
+
import { waitForDaemonAvailability } from '../api/index.js';
|
|
23
|
+
import { localClient } from '../api/node-transport.js';
|
|
24
|
+
// Daemon env sanitization
|
|
25
|
+
/** Env keys that must NEVER reach the daemon process. Restarting crtrd is
|
|
26
|
+
* overwhelmingly done from inside an agent node's own bash tool (`crtr sys
|
|
27
|
+
* daemon stop && start`), which inherits that node's FULL env — its identity
|
|
28
|
+
* (`CRTR_NODE_ID`/`CRTR_KIND`/…, the `nodeEnv()` shape in `core/runtime/
|
|
29
|
+
* nodes.ts`), its front-door recursion-guard flag, and any pi-engine
|
|
30
|
+
* resolution seam a prior test/dev session left exported. The daemon is a
|
|
31
|
+
* singleton supervisor, never "a node" itself, so none of this belongs in its
|
|
32
|
+
* env regardless of whether today's code happens to read it — and at least one
|
|
33
|
+
* of these IS actively read: `host.ts`'s broker-engine resolution falls back to
|
|
34
|
+
* `envBrokerEngine()`'s raw `CRTR_BROKER_ENGINE` read verbatim (nodeEnv() never
|
|
35
|
+
* sets that key, so nothing overrides it per child launch), so a daemon that inherits a
|
|
36
|
+
* stale/dev override throws inside `headlessBrokerHost.launch()` on EVERY
|
|
37
|
+
* relaunch it ever attempts, for its whole lifetime — the 2026-07-06 diagnosis
|
|
38
|
+
* root cause behind 19 nodes killed with "failed to relaunch and is now dead". */
|
|
39
|
+
export const DAEMON_ENV_STRIP_KEYS = [
|
|
40
|
+
// Node identity (nodeEnv() shape) — meaningless for a process supervising
|
|
41
|
+
// many nodes rather than being one.
|
|
42
|
+
'CRTR_NODE_ID',
|
|
43
|
+
'CRTR_KIND',
|
|
44
|
+
'CRTR_MODE',
|
|
45
|
+
'CRTR_LIFECYCLE',
|
|
46
|
+
'CRTR_NODE_CWD',
|
|
47
|
+
'CRTR_CONTEXT_DIR',
|
|
48
|
+
'CRTR_CYCLES',
|
|
49
|
+
'CRTR_PROFILE_ID',
|
|
50
|
+
'CRTR_PARENT_NODE_ID',
|
|
51
|
+
// Recursion-guard flag — only meaningful inside a pi engine process.
|
|
52
|
+
'CRTR_FRONT_DOOR',
|
|
53
|
+
// Ambient tmux identity of whatever shell first autostarted the daemon. The
|
|
54
|
+
// daemon is a headless supervisor with no pane of its own, but `spawnNode`
|
|
55
|
+
// reads `currentTmux()` for a --root's foreground viewer placement; a stale
|
|
56
|
+
// inherited $TMUX makes that resolve to an arbitrary "current" pane, so a
|
|
57
|
+
// front-door root (created through crtrd) opens a SECOND viewer window and
|
|
58
|
+
// focuses it — stealing focus from the front door's own inline attach in the
|
|
59
|
+
// user's pane.
|
|
60
|
+
// Strip them so the daemon is truly paneless and leaves root-viewer placement
|
|
61
|
+
// to the front door. Explicit `-t <target>` tmux ops (focus/window mgmt) are
|
|
62
|
+
// unaffected — the tmux CLI reaches the server via its default socket.
|
|
63
|
+
'TMUX',
|
|
64
|
+
'TMUX_PANE',
|
|
65
|
+
// Engine-resolution seams NOT overridden per child launch — the proven and
|
|
66
|
+
// suspected poison vectors.
|
|
67
|
+
'CRTR_BROKER_ENGINE',
|
|
68
|
+
'CRTR_PI_BINARY',
|
|
69
|
+
// Generic Node.js env poisoning (a stray dev/debug flag from the restarting
|
|
70
|
+
// shell).
|
|
71
|
+
'NODE_OPTIONS',
|
|
72
|
+
];
|
|
73
|
+
/** A copy of `process.env` with every `DAEMON_ENV_STRIP_KEYS` entry removed.
|
|
74
|
+
* Deliberate global config the user actually wants the daemon to see —
|
|
75
|
+
* `CRTR_HOME`, `CRTR_SUBTREE`, `CRTR_LOG`, `CRTR_DEBUG`, etc. — passes through
|
|
76
|
+
* untouched; only the node-identity/engine-poisoning surface is stripped. */
|
|
77
|
+
export function sanitizedDaemonEnv() {
|
|
78
|
+
const env = { ...process.env };
|
|
79
|
+
for (const key of DAEMON_ENV_STRIP_KEYS)
|
|
80
|
+
delete env[key];
|
|
81
|
+
return env;
|
|
82
|
+
}
|
|
83
|
+
// Entry point resolution
|
|
84
|
+
/** Locate the package or generation root from a regular build, the standalone
|
|
85
|
+
* attach bundle, or a tsx source checkout. */
|
|
86
|
+
function generationRoot() {
|
|
87
|
+
for (let current = dirname(fileURLToPath(import.meta.url));; current = dirname(current)) {
|
|
88
|
+
if (existsSync(join(current, 'bin', 'runtime-generation-manifest.mjs')))
|
|
89
|
+
return current;
|
|
90
|
+
const parent = dirname(current);
|
|
91
|
+
if (parent === current)
|
|
92
|
+
throw new Error(`cannot locate the crouter generation root from ${import.meta.url}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Resolve crtrd's launch argv through the same selector as `bin/crtrd`. The
|
|
96
|
+
* selector may choose an ABI-compatible sibling of `runtime/selected`, but
|
|
97
|
+
* never the caller's pinned generation. With no selection, a source checkout
|
|
98
|
+
* launches through tsx; every other selection failure remains fatal. */
|
|
99
|
+
async function resolveDaemonLaunch() {
|
|
100
|
+
const root = generationRoot();
|
|
101
|
+
const manifestUrl = pathToFileURL(join(root, 'bin', 'runtime-generation-manifest.mjs')).href;
|
|
102
|
+
const { runtimeHome } = (await import(manifestUrl));
|
|
103
|
+
try {
|
|
104
|
+
lstatSync(join(runtimeHome(), 'selected'));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code !== 'ENOENT')
|
|
108
|
+
throw error;
|
|
109
|
+
const tsxLoader = pathToFileURL(createRequire(import.meta.url).resolve('tsx/esm')).href;
|
|
110
|
+
// `crtr-src` maps the package's `#` subpath imports to src/*.ts; without it
|
|
111
|
+
// a source-checkout launch would silently resolve them into a stale dist/.
|
|
112
|
+
return ['--conditions', 'crtr-src', '--import', tsxLoader, join(root, 'src', 'daemon', 'crtrd-cli.ts')];
|
|
113
|
+
}
|
|
114
|
+
const selectorUrl = pathToFileURL(join(root, 'bin', 'runtime-selector.mjs')).href;
|
|
115
|
+
const { tryResolveRuntime } = (await import(selectorUrl));
|
|
116
|
+
const result = tryResolveRuntime('daemon');
|
|
117
|
+
if (!result.ok) {
|
|
118
|
+
throw new Error(`cannot resolve the selected runtime generation's daemon entry: ${result.error}`);
|
|
119
|
+
}
|
|
120
|
+
return [result.runtime.entryPath];
|
|
121
|
+
}
|
|
122
|
+
// Stray-daemon sweep
|
|
123
|
+
/** A crtrd declares its owning canvas in argv, so ownership is an exact match
|
|
124
|
+
* on this process's canvas home. Untagged daemons retain their own
|
|
125
|
+
* supervision and are never counted or killed here. */
|
|
126
|
+
export function daemonCommandDeclaresCanvasHome(command, home) {
|
|
127
|
+
return command.includes(` --canvas-home ${home} `) || command.endsWith(` --canvas-home ${home}`);
|
|
128
|
+
}
|
|
129
|
+
/** Every crtrd process on this host that declares ownership of this process's
|
|
130
|
+
* canvas home, by pid. The `daemon/crtrd` argv segment distinguishes daemons
|
|
131
|
+
* from brokers. `ps` is best-effort: failures return no matches, and the
|
|
132
|
+
* current process is always excluded. */
|
|
133
|
+
export function findDaemonPids() {
|
|
134
|
+
const processes = listProcessTable();
|
|
135
|
+
if (processes === null)
|
|
136
|
+
return [];
|
|
137
|
+
const home = crtrHome();
|
|
138
|
+
const pids = [];
|
|
139
|
+
for (const { pid, command } of processes) {
|
|
140
|
+
if (!command.includes('daemon/crtrd'))
|
|
141
|
+
continue;
|
|
142
|
+
if (!daemonCommandDeclaresCanvasHome(command, home))
|
|
143
|
+
continue; // another install/canvas owns it
|
|
144
|
+
if (pid !== process.pid)
|
|
145
|
+
pids.push(pid);
|
|
146
|
+
}
|
|
147
|
+
return pids;
|
|
148
|
+
}
|
|
149
|
+
/** SIGTERM (then SIGKILL after a short grace) every crtrd daemon process EXCEPT
|
|
150
|
+
* `keepPid`. This is the backstop the pidfile alone cannot provide: a daemon
|
|
151
|
+
* that survived a prior stop (ignored SIGTERM, or released its ownership lock
|
|
152
|
+
* without exiting) keeps running a supervise loop — reviving brokers and
|
|
153
|
+
* contending for canvas state — while never appearing in the pidfile again, so
|
|
154
|
+
* `stop`'s single recorded-pid signal can never reach it. Returns the pids it
|
|
155
|
+
* reaped. Best-effort throughout: a pid that dies between enumeration and signal
|
|
156
|
+
* just no-ops. */
|
|
157
|
+
export function sweepStrayDaemons(keepPid) {
|
|
158
|
+
const strays = findDaemonPids().filter((pid) => pid !== keepPid);
|
|
159
|
+
for (const pid of strays) {
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, 'SIGTERM');
|
|
162
|
+
}
|
|
163
|
+
catch { /* already gone */ }
|
|
164
|
+
}
|
|
165
|
+
if (strays.length === 0)
|
|
166
|
+
return strays;
|
|
167
|
+
// Give SIGTERM handlers a beat, then SIGKILL any that ignored it — a stray
|
|
168
|
+
// daemon that hung its shutdown is exactly the failure mode this exists for.
|
|
169
|
+
const deadline = Date.now() + 2_000;
|
|
170
|
+
while (Date.now() < deadline) {
|
|
171
|
+
if (!strays.some((pid) => isPidAlive(pid)))
|
|
172
|
+
break;
|
|
173
|
+
execFileSync('sleep', ['0.1']);
|
|
174
|
+
}
|
|
175
|
+
for (const pid of strays) {
|
|
176
|
+
if (isPidAlive(pid)) {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, 'SIGKILL');
|
|
179
|
+
}
|
|
180
|
+
catch { /* already gone */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return strays;
|
|
184
|
+
}
|
|
185
|
+
// daemon launch — low-level spawn/exec
|
|
186
|
+
/** Replace PID 1 with the selected daemon generation. Docker keeps a container
|
|
187
|
+
* alive only while PID 1 lives, so a handover there must preserve its pid. */
|
|
188
|
+
export async function execDaemon() {
|
|
189
|
+
const execve = process.execve;
|
|
190
|
+
if (execve === undefined) {
|
|
191
|
+
throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);
|
|
192
|
+
}
|
|
193
|
+
const executable = hostExecPath();
|
|
194
|
+
const launchArgs = await resolveDaemonLaunch();
|
|
195
|
+
return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
|
|
196
|
+
}
|
|
197
|
+
// Shutdown stays short even though startup may include corpus migration.
|
|
198
|
+
const DAEMON_SHUTDOWN_WINDOW_MS = 20_000;
|
|
199
|
+
const DAEMON_VERIFY_POLL_MS = 10;
|
|
200
|
+
/** A status/start readiness probe must not hang behind a daemon that accepts a
|
|
201
|
+
* Unix connection but never answers it. */
|
|
202
|
+
const DAEMON_HEALTH_PROBE_TIMEOUT_MS = 1_000;
|
|
203
|
+
/** Make one `/healthz` observation without client-side recovery. The caller owns the availability window, so this probe cannot extend it. */
|
|
204
|
+
export async function probeDaemonHealth(timeoutMs = DAEMON_HEALTH_PROBE_TIMEOUT_MS) {
|
|
205
|
+
const probeTimeoutMs = Math.min(timeoutMs, DAEMON_HEALTH_PROBE_TIMEOUT_MS);
|
|
206
|
+
return localClient({ autostart: false, timeoutMs: probeTimeoutMs }).probeHealthz(probeTimeoutMs);
|
|
207
|
+
}
|
|
208
|
+
export function probeErrorMessage(error) {
|
|
209
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
210
|
+
if (typeof error !== 'object' || error === null)
|
|
211
|
+
return message;
|
|
212
|
+
const direct = error.code;
|
|
213
|
+
const cause = error.cause;
|
|
214
|
+
const code = typeof direct === 'string' ? direct : cause?.code;
|
|
215
|
+
return typeof code === 'string' && !message.includes(code) ? `${message}: ${code}` : message;
|
|
216
|
+
}
|
|
217
|
+
/** True only when one mandatory Unix API socket probe answers the existing ready health contract. */
|
|
218
|
+
export async function probeDaemonServing(timeoutMs = DAEMON_HEALTH_PROBE_TIMEOUT_MS) {
|
|
219
|
+
const health = await probeDaemonHealth(timeoutMs);
|
|
220
|
+
if (health.startup_blocked !== undefined) {
|
|
221
|
+
const pid = readPidfile() ?? process.pid;
|
|
222
|
+
throw new DaemonStartupBlockedError(pid, health.startup_blocked);
|
|
223
|
+
}
|
|
224
|
+
if (!health.ok)
|
|
225
|
+
throw new Error('crtrd health check reported not ready');
|
|
226
|
+
}
|
|
227
|
+
/** True only when one mandatory Unix API socket probe answers `/healthz`.
|
|
228
|
+
* A pidfile identifies an owner; it is never readiness. */
|
|
229
|
+
export async function isDaemonServing() {
|
|
230
|
+
try {
|
|
231
|
+
await probeDaemonServing();
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function sleepMs(ms) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
setTimeout(resolve, ms);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** The spawned daemon process died before it ever became ready — a startup
|
|
244
|
+
* failure, not a progressing migration (a live daemon never raises this).
|
|
245
|
+
* `stderrTail` is what that run wrote to crtrd.err, so the CLI can say why. */
|
|
246
|
+
export class DaemonExitedBeforeReadyError extends Error {
|
|
247
|
+
stderrTail;
|
|
248
|
+
constructor(message, stderrTail) {
|
|
249
|
+
super(stderrTail === '' ? message : `${message}\n${stderrTail}`);
|
|
250
|
+
this.name = 'DaemonExitedBeforeReadyError';
|
|
251
|
+
this.stderrTail = stderrTail;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** A live daemon has bound its repair socket but cannot serve normal work
|
|
255
|
+
* until its corpus migration succeeds. */
|
|
256
|
+
export class DaemonStartupBlockedError extends Error {
|
|
257
|
+
constructor(pid, message) {
|
|
258
|
+
super(`daemon ${pid} is running but normal startup is blocked: ${message}`);
|
|
259
|
+
this.name = 'DaemonStartupBlockedError';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** A preparation request cannot succeed unless the live daemon explicitly reports prepared. */
|
|
263
|
+
export class DaemonNotPreparedError extends Error {
|
|
264
|
+
constructor(pid, startupPhase) {
|
|
265
|
+
super(startupPhase === undefined
|
|
266
|
+
? `daemon ${pid} is serving but did not report the prepared startup phase`
|
|
267
|
+
: `daemon ${pid} reported startup phase ${startupPhase}, not prepared`);
|
|
268
|
+
this.name = 'DaemonNotPreparedError';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Tiny bounded post-spawn guard: wait for the daemon pidfile and live pid to
|
|
272
|
+
* appear before reporting success. This catches the "started:true but not yet
|
|
273
|
+
* plausibly alive" race without turning startup into a retry loop.
|
|
274
|
+
*
|
|
275
|
+
* One deadline, owner-first polling. A signal or non-zero child exit fails
|
|
276
|
+
* immediately. A clean code-0 exit is treated only as a possible ownership-
|
|
277
|
+
* claim loser (never readiness on its own): polling continues on the SAME
|
|
278
|
+
* deadline for a different live pidfile owner — the winner may publish its
|
|
279
|
+
* pidfile deliberately late — and fails at that deadline if none appears. No
|
|
280
|
+
* deadline extension and no retry spawn.
|
|
281
|
+
*
|
|
282
|
+
* Returns null when the spawned pid owns the pidfile; returns a different live
|
|
283
|
+
* daemon pid when startup lost the race to an already-running daemon. */
|
|
284
|
+
export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_STARTUP_WINDOW_MS, deps = {}, targetPhase = 'ready') {
|
|
285
|
+
const read = deps.readPidfile ?? readPidfile;
|
|
286
|
+
const alive = deps.isPidAlive ?? isPidAlive;
|
|
287
|
+
const exited = deps.childExited;
|
|
288
|
+
const serving = deps.isDaemonServing;
|
|
289
|
+
const probeServing = deps.probeDaemonServing ?? (async (remainingMs) => {
|
|
290
|
+
if (serving !== undefined) {
|
|
291
|
+
if (await serving())
|
|
292
|
+
return;
|
|
293
|
+
throw new Error('crtrd health check reported not ready');
|
|
294
|
+
}
|
|
295
|
+
await probeDaemonServing(remainingMs);
|
|
296
|
+
});
|
|
297
|
+
const probeHealth = deps.probeDaemonHealth ?? probeDaemonHealth;
|
|
298
|
+
const sleep = deps.sleepMs ?? sleepMs;
|
|
299
|
+
const now = deps.now ?? Date.now;
|
|
300
|
+
let owner = null;
|
|
301
|
+
let terminalError = null;
|
|
302
|
+
await waitForDaemonAvailability({
|
|
303
|
+
windowMs: timeoutMs,
|
|
304
|
+
pollIntervalMs: DAEMON_VERIFY_POLL_MS,
|
|
305
|
+
now,
|
|
306
|
+
sleep,
|
|
307
|
+
retry: (error) => error !== terminalError,
|
|
308
|
+
probe: async (remainingMs) => {
|
|
309
|
+
const candidate = read();
|
|
310
|
+
if (candidate === null || !alive(candidate)) {
|
|
311
|
+
const exitState = exited?.();
|
|
312
|
+
if (exitState !== null && exitState !== undefined) {
|
|
313
|
+
if (exitState.signal !== null)
|
|
314
|
+
terminalError = new DaemonExitedBeforeReadyError(`daemon ${pid} exited before becoming ready (by ${exitState.signal})`, deps.childStderrTail?.() ?? '');
|
|
315
|
+
else if (exitState.code !== 0)
|
|
316
|
+
terminalError = new DaemonExitedBeforeReadyError(`daemon ${pid} exited before becoming ready (with exit code ${exitState.code ?? '?'})`, deps.childStderrTail?.() ?? '');
|
|
317
|
+
if (terminalError !== null)
|
|
318
|
+
throw terminalError;
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`daemon ${pid} has no live pidfile owner`);
|
|
321
|
+
}
|
|
322
|
+
if (targetPhase === 'ready') {
|
|
323
|
+
try {
|
|
324
|
+
await probeServing(remainingMs);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof DaemonStartupBlockedError)
|
|
328
|
+
terminalError = error;
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
owner = candidate;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const health = await probeHealth(remainingMs);
|
|
335
|
+
if (health.startup_phase === 'prepared') {
|
|
336
|
+
owner = candidate;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (health.startup_phase === 'blocked') {
|
|
340
|
+
terminalError = new DaemonStartupBlockedError(candidate, health.startup_blocked ?? 'on-disk migration is blocked');
|
|
341
|
+
throw terminalError;
|
|
342
|
+
}
|
|
343
|
+
if (health.startup_phase === 'ready') {
|
|
344
|
+
terminalError = new DaemonNotPreparedError(candidate, health.startup_phase);
|
|
345
|
+
throw terminalError;
|
|
346
|
+
}
|
|
347
|
+
throw new Error(`daemon ${candidate} is ${health.startup_phase ?? 'not reporting a startup phase'}; waiting for prepared`);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
return owner === pid ? null : owner;
|
|
351
|
+
}
|
|
352
|
+
/** Wait until a signaled daemon has exited and relinquished its pidfile. This
|
|
353
|
+
* makes a following start safe to claim the singleton rather than racing its
|
|
354
|
+
* previous owner. */
|
|
355
|
+
export async function waitForDaemonExit(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_MS, deps = {}) {
|
|
356
|
+
const read = deps.readPidfile ?? readPidfile;
|
|
357
|
+
const alive = deps.isPidAlive ?? isPidAlive;
|
|
358
|
+
const sleep = deps.sleepMs ?? sleepMs;
|
|
359
|
+
const now = deps.now ?? Date.now;
|
|
360
|
+
const deadline = now() + timeoutMs;
|
|
361
|
+
while (now() <= deadline) {
|
|
362
|
+
if (!alive(pid) && read() !== pid)
|
|
363
|
+
return;
|
|
364
|
+
await sleep(DAEMON_VERIFY_POLL_MS);
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`daemon ${pid} did not exit and release its pidfile within ${timeoutMs}ms`);
|
|
367
|
+
}
|
|
368
|
+
/** How long a SIGKILL'd daemon gets to actually leave the process table before
|
|
369
|
+
* `stopDaemonProcess` gives up. A killed process is reaped by the kernel, so
|
|
370
|
+
* this only absorbs scheduling latency (or an uninterruptible wait). */
|
|
371
|
+
const DAEMON_KILL_WINDOW_MS = 2_000;
|
|
372
|
+
/** How long a failed-to-start daemon gets to go down before the reap gives up.
|
|
373
|
+
* Short on purpose: nothing is waiting on this process any more. */
|
|
374
|
+
const DAEMON_REAP_WINDOW_MS = 5_000;
|
|
375
|
+
/** Stop the RECORDED daemon: SIGTERM, and if it does not exit within the wait
|
|
376
|
+
* window, escalate to SIGKILL exactly as `sweepStrayDaemons` does for a stray.
|
|
377
|
+
*
|
|
378
|
+
* The escalation is the point. Without it — SIGTERM, wait, throw on timeout,
|
|
379
|
+
* never reaching the stray sweep — the one daemon `stop` is actually aimed at
|
|
380
|
+
* would be the only crtrd process exempt from the SIGKILL backstop, and a
|
|
381
|
+
* daemon whose shutdown hangs (or outruns the window) becomes permanently
|
|
382
|
+
* unstoppable from the CLI: every retry re-sends a SIGTERM it is already
|
|
383
|
+
* ignoring, and `start` keeps refusing as "already running".
|
|
384
|
+
*
|
|
385
|
+
* A SIGKILL'd daemon runs no exit handler, so it leaves its pidfile behind
|
|
386
|
+
* pointing at a dead pid. That is a stale record, not an owner: `readPidfile`
|
|
387
|
+
* callers gate on `isPidAlive`, and the next `start` overwrites it. So this
|
|
388
|
+
* waits on process death alone after a kill, never on pidfile release.
|
|
389
|
+
*
|
|
390
|
+
* Returns how the daemon went down, for a truthful report. */
|
|
391
|
+
export async function stopDaemonProcess(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_MS, deps = {}) {
|
|
392
|
+
const alive = deps.isPidAlive ?? isPidAlive;
|
|
393
|
+
const sleep = deps.sleepMs ?? sleepMs;
|
|
394
|
+
const now = deps.now ?? Date.now;
|
|
395
|
+
try {
|
|
396
|
+
process.kill(pid, 'SIGTERM');
|
|
397
|
+
}
|
|
398
|
+
catch { /* already gone */ }
|
|
399
|
+
try {
|
|
400
|
+
await waitForDaemonExit(pid, timeoutMs, deps);
|
|
401
|
+
return 'terminated';
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Ignored or hung SIGTERM — the exact failure the stray sweep already kills for.
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
process.kill(pid, 'SIGKILL');
|
|
408
|
+
}
|
|
409
|
+
catch { /* already gone */ }
|
|
410
|
+
const deadline = now() + DAEMON_KILL_WINDOW_MS;
|
|
411
|
+
while (now() <= deadline) {
|
|
412
|
+
if (!alive(pid))
|
|
413
|
+
return 'killed';
|
|
414
|
+
await sleep(DAEMON_VERIFY_POLL_MS);
|
|
415
|
+
}
|
|
416
|
+
throw new Error(`daemon ${pid} survived both SIGTERM and SIGKILL`);
|
|
417
|
+
}
|
|
418
|
+
/** The last `maxBytes` of a log from byte `start` onward, trimmed; '' when it
|
|
419
|
+
* cannot be read. Bounded so a runaway log can never flood an error message. */
|
|
420
|
+
function readLogTailFrom(path, start, maxBytes = 2_048) {
|
|
421
|
+
let fd;
|
|
422
|
+
try {
|
|
423
|
+
fd = openSync(path, 'r');
|
|
424
|
+
const { size } = fstatSync(fd);
|
|
425
|
+
const from = Math.max(start, size - maxBytes);
|
|
426
|
+
const length = size - from;
|
|
427
|
+
if (length <= 0)
|
|
428
|
+
return '';
|
|
429
|
+
const buffer = Buffer.alloc(length);
|
|
430
|
+
readSync(fd, buffer, 0, length, from);
|
|
431
|
+
return buffer.toString('utf8').trim();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return '';
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
if (fd !== undefined) {
|
|
438
|
+
try {
|
|
439
|
+
closeSync(fd);
|
|
440
|
+
}
|
|
441
|
+
catch { /* best-effort */ }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Spawn crtrd detached. Returns immediately; the child outlives this process.
|
|
446
|
+
*
|
|
447
|
+
* If the daemon is already running, returns {started:false, existing_pid}.
|
|
448
|
+
* If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
|
|
449
|
+
export async function spawnDaemon(opts = {}) {
|
|
450
|
+
if (process.platform === 'linux' && existsSync('/run/crtr/launcher.sock') && !isPidAlive(readPidfile() ?? -1)) {
|
|
451
|
+
throw new Error('crtrd is managed by the root launcher; it cannot self-daemonize');
|
|
452
|
+
}
|
|
453
|
+
const targetPhase = opts.targetPhase ?? 'ready';
|
|
454
|
+
const recordedPid = readPidfile();
|
|
455
|
+
if (recordedPid !== null && isPidAlive(recordedPid)) {
|
|
456
|
+
try {
|
|
457
|
+
const health = await probeDaemonHealth();
|
|
458
|
+
if (targetPhase === 'prepared') {
|
|
459
|
+
if (health.startup_phase === 'prepared') {
|
|
460
|
+
return { started: false, existing_pid: recordedPid, startup_phase: health.startup_phase };
|
|
461
|
+
}
|
|
462
|
+
throw new DaemonNotPreparedError(recordedPid, health.startup_phase);
|
|
463
|
+
}
|
|
464
|
+
if (health.ok) {
|
|
465
|
+
return {
|
|
466
|
+
started: false,
|
|
467
|
+
existing_pid: recordedPid,
|
|
468
|
+
...(health.startup_phase === undefined ? {} : { startup_phase: health.startup_phase }),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
started: false,
|
|
473
|
+
existing_pid: recordedPid,
|
|
474
|
+
running: true,
|
|
475
|
+
serving: false,
|
|
476
|
+
probe_error: health.startup_blocked ?? 'crtrd health check reported not ready',
|
|
477
|
+
...(health.startup_phase === undefined ? {} : { startup_phase: health.startup_phase }),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
if (error instanceof DaemonNotPreparedError)
|
|
482
|
+
throw error;
|
|
483
|
+
return {
|
|
484
|
+
started: false,
|
|
485
|
+
existing_pid: recordedPid,
|
|
486
|
+
running: true,
|
|
487
|
+
serving: false,
|
|
488
|
+
probe_error: probeErrorMessage(error),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Ensure the canvas home directory exists so the daemon can write its pidfile.
|
|
493
|
+
mkdirSync(crtrHome(), { recursive: true });
|
|
494
|
+
// Launch from the crouter-branded host binary so the daemon shows "crouter"
|
|
495
|
+
// in macOS Full Disk Access, not "node" (see branded-host). NOTE: when crtrd
|
|
496
|
+
// is launchd-owned, the plist's ProgramArguments must point at the branded
|
|
497
|
+
// binary too — this path only covers a manual `crtr sys daemon start`.
|
|
498
|
+
const launchArgs = await resolveDaemonLaunch();
|
|
499
|
+
// Route stdout+stderr to an append-mode file instead of discarding them
|
|
500
|
+
// (stdio:'ignore'). This fd is RAW RESIDUE only — third-party/runtime
|
|
501
|
+
// output the daemon doesn't control, plus the narrow set of authorized raw
|
|
502
|
+
// `[crtrd] …` bootstrap/fatal lines that by construction predate or escape
|
|
503
|
+
// canonical event emission (the pre-bind losing-claim line and a cleanup-
|
|
504
|
+
// fatal write in crtrd.ts). Without this fd all of it is discarded for any
|
|
505
|
+
// manually-started daemon, the common case (only a launchd-owned daemon has a
|
|
506
|
+
// logging plist). Every other daemon diagnostic — including per-node
|
|
507
|
+
// supervise/relaunch failures — is a canonical `emitEvent` write, not raw
|
|
508
|
+
// stderr; this file is not where those live. Mirrors host.ts's broker.log
|
|
509
|
+
// pattern: one fd, both streams, append so a restart keeps history.
|
|
510
|
+
const errLogPath = join(crtrHome(), 'crtrd.err');
|
|
511
|
+
const errFd = openSync(errLogPath, 'a');
|
|
512
|
+
// crtrd.err is append-only across restarts; remember where THIS run starts so
|
|
513
|
+
// a failure can quote only what this run wrote.
|
|
514
|
+
const errLogStart = (() => { try {
|
|
515
|
+
return fstatSync(errFd).size;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return 0;
|
|
519
|
+
} })();
|
|
520
|
+
if (eventSource() === undefined)
|
|
521
|
+
bindDaemonEventSource();
|
|
522
|
+
// `--canvas-home` declares this daemon's canvas ownership in argv so
|
|
523
|
+
// findDaemonPids()/sweepStrayDaemons (above) can match its own daemon
|
|
524
|
+
// exactly instead of scanning the whole machine. crtrd-cli.ts's argv
|
|
525
|
+
// parsing only looks for `--tcp`, so this extra pair of tokens is inert
|
|
526
|
+
// there — it exists purely for `ps` visibility.
|
|
527
|
+
const child = spawn(hostExecPath(), [...launchArgs, '--canvas-home', crtrHome(), ...(targetPhase === 'prepared' ? ['--prepare'] : [])], {
|
|
528
|
+
detached: true,
|
|
529
|
+
stdio: ['ignore', errFd, errFd],
|
|
530
|
+
env: sanitizedDaemonEnv(),
|
|
531
|
+
});
|
|
532
|
+
// The child holds its own dup of the fd; release the parent's copy so the
|
|
533
|
+
// launching process never leaks it.
|
|
534
|
+
closeSync(errFd);
|
|
535
|
+
const pid = child.pid;
|
|
536
|
+
if (pid === undefined) {
|
|
537
|
+
throw new Error('daemon spawn did not return a pid');
|
|
538
|
+
}
|
|
539
|
+
let exitState = null;
|
|
540
|
+
child.once('exit', (code, signal) => {
|
|
541
|
+
exitState = { code, signal };
|
|
542
|
+
});
|
|
543
|
+
child.unref();
|
|
544
|
+
// Reap on failure. verifyDaemonStartup gives up at its deadline but the child
|
|
545
|
+
// it was watching is still running — and a daemon that failed to become ready
|
|
546
|
+
// is typically parked deep in startup (blocked on the migration lock, whose
|
|
547
|
+
// own window is DAEMON_STARTUP_WINDOW_MS), so it lingers long after the
|
|
548
|
+
// spawner walked away. Leaving it is what let repeated autostarts ACCUMULATE
|
|
549
|
+
// instead of merely repeating: each attempt added a resident process that
|
|
550
|
+
// outlived the attempt, and the machine went down under the pile rather than
|
|
551
|
+
// under any single failure.
|
|
552
|
+
let existingPid;
|
|
553
|
+
try {
|
|
554
|
+
existingPid = await verifyDaemonStartup(pid, DAEMON_STARTUP_WINDOW_MS, {
|
|
555
|
+
childExited: () => exitState,
|
|
556
|
+
childStderrTail: () => readLogTailFrom(errLogPath, errLogStart),
|
|
557
|
+
}, targetPhase);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
// Reap ONLY a child that is not doing the work. A child holding the on-disk
|
|
561
|
+
// migration lock is mid-corpus-rewrite: the reap escalates to SIGKILL, and
|
|
562
|
+
// killing a migration partway through is far worse than the resident
|
|
563
|
+
// process the reap exists to avoid. Startup verification giving up is a
|
|
564
|
+
// statement about our patience, not proof the child is stuck — a large
|
|
565
|
+
// corpus can legitimately outlast the window, and that daemon should be
|
|
566
|
+
// allowed to finish and serve.
|
|
567
|
+
const childOwnsMigrationLock = exclusiveLockOwnerPid(onDiskMigrationLockPath()) === pid;
|
|
568
|
+
if (exitState === null && !childOwnsMigrationLock && !(error instanceof DaemonStartupBlockedError)) {
|
|
569
|
+
try {
|
|
570
|
+
await stopDaemonProcess(pid, DAEMON_REAP_WINDOW_MS);
|
|
571
|
+
}
|
|
572
|
+
catch { /* best effort */ }
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
if (existingPid !== null) {
|
|
577
|
+
return { started: false, existing_pid: existingPid, startup_phase: targetPhase };
|
|
578
|
+
}
|
|
579
|
+
return { started: true, pid, startup_phase: targetPhase };
|
|
580
|
+
}
|
|
581
|
+
// ensureDaemon — fire-and-forget front-door helper
|
|
582
|
+
/** Whether autostart is suppressed for this invocation. `--no-autostart` or
|
|
583
|
+
* `CRTR_NO_DAEMON_AUTOSTART=1` forces fail-loud on a cold socket (spec §7.1) —
|
|
584
|
+
* for environments that manage crtrd externally and want the diagnostic, and
|
|
585
|
+
* for test lanes, which point `CRTR_HOME` at a throwaway directory: an
|
|
586
|
+
* autostarted daemon there is detached and resident, so it outlives both the
|
|
587
|
+
* test process and the deleted temp home with nothing left to stop it.
|
|
588
|
+
*
|
|
589
|
+
* This is the ONE gate. `ensureDaemon` below is the only path that spawns a
|
|
590
|
+
* daemon implicitly, so the check belongs here rather than at each caller —
|
|
591
|
+
* `cliClient()` reads it too, but the in-process spawn/recycle/promote callers
|
|
592
|
+
* reach `ensureDaemon` without going through the CLI client at all. An
|
|
593
|
+
* explicit `crtr sys daemon start` calls `spawnDaemon` directly and is
|
|
594
|
+
* deliberately unaffected. */
|
|
595
|
+
export function autostartDisabled() {
|
|
596
|
+
return envNoDaemonAutostart() || process.argv.includes('--no-autostart');
|
|
597
|
+
}
|
|
598
|
+
// Autostart rate limiting. `ensureDaemon` is the one implicit spawn path and is
|
|
599
|
+
// reached on every cold socket — including each redial of a long-lived attach
|
|
600
|
+
// client. Unguarded that is an open loop: a daemon that cannot start is retried
|
|
601
|
+
// forever, by every client, with nothing tracking that the last attempt failed
|
|
602
|
+
// for a reason the next one will hit too.
|
|
603
|
+
const AUTOSTART_BACKOFF_BASE_MS = 5_000;
|
|
604
|
+
const AUTOSTART_BACKOFF_MAX_MS = 60_000;
|
|
605
|
+
let autostartInFlight = false;
|
|
606
|
+
let autostartStartupFailure;
|
|
607
|
+
let autostartFailures = 0;
|
|
608
|
+
let autostartNextAttemptAt = 0;
|
|
609
|
+
function autostartBackoffMs(failures) {
|
|
610
|
+
return Math.min(AUTOSTART_BACKOFF_BASE_MS * 2 ** (failures - 1), AUTOSTART_BACKOFF_MAX_MS);
|
|
611
|
+
}
|
|
612
|
+
/** Reset in-process spawn backoff after a daemon is observed alive. */
|
|
613
|
+
export function resetDaemonAutostart() {
|
|
614
|
+
autostartStartupFailure = undefined;
|
|
615
|
+
autostartFailures = 0;
|
|
616
|
+
autostartNextAttemptAt = 0;
|
|
617
|
+
}
|
|
618
|
+
/** Why the daemon this process autostarted died before becoming ready, or
|
|
619
|
+
* undefined while it is starting, running, or exited cleanly (a clean exit may
|
|
620
|
+
* be a lost singleton race, so only a non-zero/signal exit counts). A client
|
|
621
|
+
* waiting for that daemon polls this so a startup failure ends the wait now
|
|
622
|
+
* rather than at the end of the startup window. */
|
|
623
|
+
export function daemonAutostartStartupFailure() {
|
|
624
|
+
return autostartStartupFailure;
|
|
625
|
+
}
|
|
626
|
+
/** Start the daemon if it is not already running. No-op if already up, if
|
|
627
|
+
* autostart is suppressed for this invocation, while an attempt is in flight,
|
|
628
|
+
* or while backing off from a recent failure.
|
|
629
|
+
* Silently swallows spawn errors (the canvas still works without the daemon;
|
|
630
|
+
* nodes just won't be auto-revived). */
|
|
631
|
+
export function ensureDaemon(deps = {}) {
|
|
632
|
+
const running = deps.isDaemonRunning ?? isDaemonRunning;
|
|
633
|
+
const spawn = deps.spawnDaemon ?? spawnDaemon;
|
|
634
|
+
const now = deps.now ?? Date.now;
|
|
635
|
+
if (autostartDisabled())
|
|
636
|
+
return;
|
|
637
|
+
if (autostartInFlight)
|
|
638
|
+
return;
|
|
639
|
+
if (now() < autostartNextAttemptAt)
|
|
640
|
+
return;
|
|
641
|
+
if (running()) {
|
|
642
|
+
resetDaemonAutostart();
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// A failure belongs to the attempt that produced it: this attempt starts with
|
|
646
|
+
// none, so a client waiting on it is never ended by the previous child's death.
|
|
647
|
+
autostartStartupFailure = undefined;
|
|
648
|
+
autostartInFlight = true;
|
|
649
|
+
void spawn().then(() => {
|
|
650
|
+
autostartStartupFailure = undefined;
|
|
651
|
+
autostartFailures = 0;
|
|
652
|
+
autostartNextAttemptAt = 0;
|
|
653
|
+
}, (error) => {
|
|
654
|
+
if (error instanceof DaemonExitedBeforeReadyError) {
|
|
655
|
+
autostartStartupFailure = error.message;
|
|
656
|
+
}
|
|
657
|
+
// Intentionally silent — a missing dist/daemon/crtrd-cli.js (dev mode,
|
|
658
|
+
// pre-build) must not break the calling command.
|
|
659
|
+
autostartFailures += 1;
|
|
660
|
+
autostartNextAttemptAt = now() + autostartBackoffMs(autostartFailures);
|
|
661
|
+
}).finally(() => {
|
|
662
|
+
autostartInFlight = false;
|
|
663
|
+
});
|
|
664
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isPidAlive } from '../core/canvas/pid.js';
|
|
2
|
+
export { isPidAlive };
|
|
3
|
+
/** Absolute path to crtrd's pidfile. `CRTR_PIDFILE` overrides for tests. */
|
|
4
|
+
export declare function pidfilePath(): string;
|
|
5
|
+
/** Read the pid stored in the pidfile, or null if absent / malformed. */
|
|
6
|
+
export declare function readPidfile(): number | null;
|
|
7
|
+
/** True when a crtrd process is already running (pidfile exists + pid alive). */
|
|
8
|
+
export declare function isDaemonRunning(): boolean;
|