@reticlehq/server 2.5.0 → 2.7.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/SKILL.md +226 -26
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +59 -6
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +74 -14
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/cli/cli-parse.d.ts +5 -9
- package/dist/cli/cli-parse.js +7 -23
- package/dist/cli/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +44 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +170 -19
- package/dist/daemon/daemon-resilience.d.ts +24 -0
- package/dist/daemon/daemon-resilience.js +105 -17
- package/dist/daemon/daemon.js +26 -1
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-shutdown.d.ts +13 -5
- package/dist/daemon/idle-shutdown.js +14 -6
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/events/predicate-eval.d.ts +17 -3
- package/dist/events/predicate-eval.js +183 -96
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +68 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +35 -17
- package/dist/flows/flow-replay.d.ts +1 -1
- package/dist/flows/flow-replay.js +19 -2
- package/dist/flows/flow-step-runners.js +2 -3
- package/dist/flows/flow-tools.js +10 -14
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/honesty/blind-spots.d.ts +19 -0
- package/dist/honesty/blind-spots.js +33 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +42 -3
- package/dist/http-server.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +36 -3
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +28 -5
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +14 -0
- package/dist/init/detect.js +23 -0
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/node-io.js +8 -2
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +55 -3
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +93 -12
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +169 -23
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/mcp/mcp-outage.d.ts +1 -0
- package/dist/mcp/mcp-outage.js +8 -1
- package/dist/mcp/mcp-proxy.d.ts +11 -1
- package/dist/mcp/mcp-proxy.js +39 -9
- package/dist/mcp/mcp.js +64 -2
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +11 -4
- package/dist/session/no-session-diagnosis.js +95 -14
- package/dist/session/no-session-watch.d.ts +3 -1
- package/dist/session/no-session-watch.js +10 -2
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +12 -0
- package/dist/session/session-manager.js +83 -3
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +20 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +25 -7
- package/dist/telemetry/daemon-telemetry.js +32 -13
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/feedback-cli.d.ts +7 -2
- package/dist/telemetry/feedback-cli.js +49 -5
- package/dist/telemetry/feedback-context.d.ts +1 -6
- package/dist/telemetry/feedback-context.js +64 -4
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +4 -4
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +45 -3
- package/dist/telemetry/session-metrics.js +247 -9
- package/dist/telemetry/telemetry.d.ts +21 -1
- package/dist/telemetry/telemetry.js +34 -9
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +63 -14
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +22 -3
- package/dist/tools/browser-tools.js +8 -2
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +26 -0
- package/dist/tools/dynamic-tools.js +6 -1
- package/dist/tools/error-recovery.d.ts +14 -0
- package/dist/tools/error-recovery.js +89 -8
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +76 -7
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +15 -1
- package/dist/tools/navigate-result.js +17 -10
- package/dist/tools/observe-tools.js +22 -4
- package/dist/tools/real-input-attempt.js +10 -5
- package/dist/tools/tool-kit.d.ts +60 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +13 -0
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +15 -3
- package/docs/benchmarks.md +1 -1
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +17 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/local-registry.md +1 -1
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +1 -1
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +15 -3
- package/docs/telemetry.md +16 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
package/dist/daemon/daemon.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, statSync, renameSync, } from 'node:fs';
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, statSync, renameSync, writeSync, } from 'node:fs';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { daemonRegistryFileName, ReticleEnv } from '@reticlehq/core';
|
|
6
6
|
import { log } from '../log.js';
|
|
7
|
+
import { uncleanPredecessor } from './unclean-predecessor.js';
|
|
7
8
|
/** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
|
|
8
9
|
export const STATE_DIR_ENV = ReticleEnv.STATE_DIR;
|
|
9
10
|
/**
|
|
@@ -233,6 +234,8 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
233
234
|
// O_EXCL spawn-lock: only the FIRST racer to create the pidfile spawns. A concurrent second gets
|
|
234
235
|
// EEXIST — if a LIVE daemon owns the port it skips (no duplicate detached daemon, no clobbered pid);
|
|
235
236
|
// a stale pidfile from a crashed daemon is reclaimed. Returns false when it did not spawn.
|
|
237
|
+
/** Set when this start reclaimed a pidfile whose owner is gone — see unclean-predecessor.ts. */
|
|
238
|
+
let orphanPid = null;
|
|
236
239
|
let lockFd;
|
|
237
240
|
try {
|
|
238
241
|
lockFd = deps.openFile(pidFilePath, 'wx');
|
|
@@ -241,6 +244,10 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
241
244
|
const existing = readPid(port, deps.home);
|
|
242
245
|
if (existing !== null && deps.pidAlive(existing))
|
|
243
246
|
return false;
|
|
247
|
+
// This branch IS the crash detection — its own comment has always said so ("a stale pidfile from
|
|
248
|
+
// a crashed daemon is reclaimed"), it just never said it out loud. Captured before the unlink,
|
|
249
|
+
// because after it there is no trace left that the previous daemon died at all.
|
|
250
|
+
orphanPid = uncleanPredecessor(existing, false, process.pid);
|
|
244
251
|
try {
|
|
245
252
|
unlinkSync(pidFilePath);
|
|
246
253
|
lockFd = deps.openFile(pidFilePath, 'wx');
|
|
@@ -267,6 +274,24 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
267
274
|
}
|
|
268
275
|
return false;
|
|
269
276
|
}
|
|
277
|
+
// Into the daemon log itself, not the parent's stdout: `doctor` points the reader at this file, and
|
|
278
|
+
// a death recorded anywhere else is a death they will not find. Written through the fd the child is
|
|
279
|
+
// about to inherit, so it lands immediately before that child's first line — exactly where a reader
|
|
280
|
+
// comparing two `mcp_daemon_started` entries is already looking.
|
|
281
|
+
if (null !== orphanPid) {
|
|
282
|
+
try {
|
|
283
|
+
writeSync(logFd, `${JSON.stringify({
|
|
284
|
+
// Same convention as log(): the clock is read at the I/O boundary, not injected.
|
|
285
|
+
t: new Date().toISOString(),
|
|
286
|
+
event: 'reticle_daemon_previous_died_unclean',
|
|
287
|
+
port,
|
|
288
|
+
pid: orphanPid,
|
|
289
|
+
})}\n`);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// A log we cannot write is not a reason to refuse to start the daemon.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
270
295
|
let child;
|
|
271
296
|
try {
|
|
272
297
|
child = deps.spawnChild(nodeExec, [scriptPath, ...args], {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular beat, so SILENCE in the daemon log becomes evidence.
|
|
3
|
+
*
|
|
4
|
+
* `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
|
|
5
|
+
* crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
|
|
6
|
+
* thread — leaves no trace at all, and the last line before its port goes dark is whatever it
|
|
7
|
+
* happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
|
|
8
|
+
* violent death are the same shape to anyone reading the file.
|
|
9
|
+
*
|
|
10
|
+
* Measured cost of that: a correct SvelteKit install was written up as an install failure, because
|
|
11
|
+
* the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
|
|
12
|
+
* report; the daemon was not.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, and the second is what makes the first worth having:
|
|
15
|
+
* - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
|
|
16
|
+
* - classifyDaemonLife reads a log back and says which way the daemon ended.
|
|
17
|
+
*
|
|
18
|
+
* A heartbeat nobody interprets is just log volume.
|
|
19
|
+
*/
|
|
20
|
+
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
|
+
export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
|
|
22
|
+
/** Written by installExitTrace when the process exits through Node. */
|
|
23
|
+
export declare const DAEMON_EXIT_EVENT = "reticle_daemon_exiting";
|
|
24
|
+
/** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
|
|
25
|
+
export declare const DAEMON_SIGNAL_EVENT = "reticle_daemon_signalled";
|
|
26
|
+
/**
|
|
27
|
+
* How often a live daemon says so.
|
|
28
|
+
*
|
|
29
|
+
* The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
|
|
30
|
+
* that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
|
|
31
|
+
* to be legible against a connect window, which is measured in seconds.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DAEMON_HEARTBEAT_MS = 30000;
|
|
34
|
+
/**
|
|
35
|
+
* The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
|
|
36
|
+
* rather than throwing, and 0 or negative is not a legal cadence — it would spin.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveHeartbeatMs(raw: string | undefined): number;
|
|
39
|
+
interface HeartbeatFacts {
|
|
40
|
+
/** Browser sessions connected right now. */
|
|
41
|
+
sessions: number;
|
|
42
|
+
/**
|
|
43
|
+
* Has this daemon EVER served a tool call? A lifetime fact, not a window count.
|
|
44
|
+
*
|
|
45
|
+
* It is the difference between a death that cost somebody work and one that cost nothing — the
|
|
46
|
+
* same distinction `isUselessDaemon` already draws. A windowed count would reset under the reader
|
|
47
|
+
* and make a long-lived daemon look freshly idle.
|
|
48
|
+
*/
|
|
49
|
+
served: boolean;
|
|
50
|
+
}
|
|
51
|
+
interface DaemonHeartbeatOptions {
|
|
52
|
+
log: (event: string, fields: Record<string, unknown>) => void;
|
|
53
|
+
/** Read at each beat, never cached — the point is to describe the daemon NOW. */
|
|
54
|
+
facts: () => HeartbeatFacts;
|
|
55
|
+
intervalMs?: number;
|
|
56
|
+
/** Injected, per the repo rule: never call Date.now() inside logic. */
|
|
57
|
+
clock?: () => number;
|
|
58
|
+
}
|
|
59
|
+
export declare class DaemonHeartbeat {
|
|
60
|
+
#private;
|
|
61
|
+
constructor(opts: DaemonHeartbeatOptions);
|
|
62
|
+
/**
|
|
63
|
+
* One beat. Exposed so a test can drive it with an injected clock rather than waiting.
|
|
64
|
+
*
|
|
65
|
+
* Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
|
|
66
|
+
* it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
|
|
67
|
+
* ambiguity this exists to remove.
|
|
68
|
+
*/
|
|
69
|
+
beat(): void;
|
|
70
|
+
start(): void;
|
|
71
|
+
stop(): void;
|
|
72
|
+
}
|
|
73
|
+
/** How a daemon's life ended, as far as its own log can say. */
|
|
74
|
+
export declare const DaemonEnd: {
|
|
75
|
+
/** Still beating within tolerance. */
|
|
76
|
+
readonly ALIVE: "alive";
|
|
77
|
+
/** Logged its own exit, with no signal before it. */
|
|
78
|
+
readonly CLEAN: "clean";
|
|
79
|
+
/** A signal arrived — the cause an exit line with `code: 0` does not carry. */
|
|
80
|
+
readonly SIGNALLED: "signalled";
|
|
81
|
+
/** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
|
|
82
|
+
readonly DIED_SILENTLY: "died_silently";
|
|
83
|
+
/** No beat was ever emitted, so this log cannot answer the question. */
|
|
84
|
+
readonly UNKNOWN: "unknown";
|
|
85
|
+
};
|
|
86
|
+
export type DaemonEnd = (typeof DaemonEnd)[keyof typeof DaemonEnd];
|
|
87
|
+
/**
|
|
88
|
+
* One parsed line of a daemon log. The index signature is not laziness: these lines carry whatever
|
|
89
|
+
* their event needed (`code`, `port`, `pid`, `reason`, …), and a reader that refused unknown keys
|
|
90
|
+
* would reject the very file it exists to read.
|
|
91
|
+
*/
|
|
92
|
+
interface LogLine {
|
|
93
|
+
t: number;
|
|
94
|
+
event: string;
|
|
95
|
+
signal?: unknown;
|
|
96
|
+
everyMs?: unknown;
|
|
97
|
+
[field: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
export interface DaemonLife {
|
|
100
|
+
end: DaemonEnd;
|
|
101
|
+
/** When the last beat landed, or undefined if there was never one. */
|
|
102
|
+
lastBeatMs?: number;
|
|
103
|
+
/** How long the log has been silent, when that is the finding. */
|
|
104
|
+
silentForMs?: number;
|
|
105
|
+
signal?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read a daemon log back and say how it ended.
|
|
109
|
+
*
|
|
110
|
+
* UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
|
|
111
|
+
* existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
|
|
112
|
+
* signal we never sent is the same confident-wrong this module replaces.
|
|
113
|
+
*/
|
|
114
|
+
export declare function classifyDaemonLife(lines: readonly LogLine[], nowMs: number): DaemonLife;
|
|
115
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular beat, so SILENCE in the daemon log becomes evidence.
|
|
3
|
+
*
|
|
4
|
+
* `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
|
|
5
|
+
* crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
|
|
6
|
+
* thread — leaves no trace at all, and the last line before its port goes dark is whatever it
|
|
7
|
+
* happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
|
|
8
|
+
* violent death are the same shape to anyone reading the file.
|
|
9
|
+
*
|
|
10
|
+
* Measured cost of that: a correct SvelteKit install was written up as an install failure, because
|
|
11
|
+
* the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
|
|
12
|
+
* report; the daemon was not.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, and the second is what makes the first worth having:
|
|
15
|
+
* - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
|
|
16
|
+
* - classifyDaemonLife reads a log back and says which way the daemon ended.
|
|
17
|
+
*
|
|
18
|
+
* A heartbeat nobody interprets is just log volume.
|
|
19
|
+
*/
|
|
20
|
+
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
|
+
export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
|
|
22
|
+
/** Written by installExitTrace when the process exits through Node. */
|
|
23
|
+
export const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
|
|
24
|
+
/** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
|
|
25
|
+
export const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
|
|
26
|
+
/**
|
|
27
|
+
* How often a live daemon says so.
|
|
28
|
+
*
|
|
29
|
+
* The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
|
|
30
|
+
* that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
|
|
31
|
+
* to be legible against a connect window, which is measured in seconds.
|
|
32
|
+
*/
|
|
33
|
+
export const DAEMON_HEARTBEAT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* How many beats may be missed before a reader calls it dead.
|
|
36
|
+
*
|
|
37
|
+
* Timers slip under load — the battery boots three servers and a browser — and a reader that cries
|
|
38
|
+
* death on one late beat is a reader people learn to ignore. Two intervals plus a margin.
|
|
39
|
+
*/
|
|
40
|
+
const MISSED_BEATS_TOLERATED = 2;
|
|
41
|
+
/**
|
|
42
|
+
* The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
|
|
43
|
+
* rather than throwing, and 0 or negative is not a legal cadence — it would spin.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveHeartbeatMs(raw) {
|
|
46
|
+
if (raw === undefined || '' === raw.trim())
|
|
47
|
+
return DAEMON_HEARTBEAT_MS;
|
|
48
|
+
const n = Number(raw);
|
|
49
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
50
|
+
return DAEMON_HEARTBEAT_MS;
|
|
51
|
+
return Math.floor(n);
|
|
52
|
+
}
|
|
53
|
+
export class DaemonHeartbeat {
|
|
54
|
+
#log;
|
|
55
|
+
#facts;
|
|
56
|
+
#intervalMs;
|
|
57
|
+
#clock;
|
|
58
|
+
#startedAt;
|
|
59
|
+
#timer;
|
|
60
|
+
constructor(opts) {
|
|
61
|
+
this.#log = opts.log;
|
|
62
|
+
this.#facts = opts.facts;
|
|
63
|
+
this.#intervalMs = opts.intervalMs ?? DAEMON_HEARTBEAT_MS;
|
|
64
|
+
this.#clock = opts.clock ?? (() => Date.now());
|
|
65
|
+
this.#startedAt = this.#clock();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One beat. Exposed so a test can drive it with an injected clock rather than waiting.
|
|
69
|
+
*
|
|
70
|
+
* Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
|
|
71
|
+
* it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
|
|
72
|
+
* ambiguity this exists to remove.
|
|
73
|
+
*/
|
|
74
|
+
beat() {
|
|
75
|
+
const facts = this.#facts();
|
|
76
|
+
this.#log(DAEMON_HEARTBEAT_EVENT, {
|
|
77
|
+
uptimeMs: this.#clock() - this.#startedAt,
|
|
78
|
+
// The cadence travels WITH the beat so a reader never hard-codes it. Hard-coding is how the
|
|
79
|
+
// reader and the emitter drift apart the first time the interval is tuned.
|
|
80
|
+
everyMs: this.#intervalMs,
|
|
81
|
+
sessions: facts.sessions,
|
|
82
|
+
served: facts.served,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
start() {
|
|
86
|
+
if (this.#timer !== undefined)
|
|
87
|
+
return;
|
|
88
|
+
this.#timer = setInterval(() => this.beat(), this.#intervalMs);
|
|
89
|
+
// Never the reason a process stays alive — this is a diagnostic, not a workload.
|
|
90
|
+
this.#timer.unref();
|
|
91
|
+
}
|
|
92
|
+
stop() {
|
|
93
|
+
if (this.#timer === undefined)
|
|
94
|
+
return;
|
|
95
|
+
clearInterval(this.#timer);
|
|
96
|
+
this.#timer = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** How a daemon's life ended, as far as its own log can say. */
|
|
100
|
+
export const DaemonEnd = {
|
|
101
|
+
/** Still beating within tolerance. */
|
|
102
|
+
ALIVE: 'alive',
|
|
103
|
+
/** Logged its own exit, with no signal before it. */
|
|
104
|
+
CLEAN: 'clean',
|
|
105
|
+
/** A signal arrived — the cause an exit line with `code: 0` does not carry. */
|
|
106
|
+
SIGNALLED: 'signalled',
|
|
107
|
+
/** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
|
|
108
|
+
DIED_SILENTLY: 'died_silently',
|
|
109
|
+
/** No beat was ever emitted, so this log cannot answer the question. */
|
|
110
|
+
UNKNOWN: 'unknown',
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Read a daemon log back and say how it ended.
|
|
114
|
+
*
|
|
115
|
+
* UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
|
|
116
|
+
* existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
|
|
117
|
+
* signal we never sent is the same confident-wrong this module replaces.
|
|
118
|
+
*/
|
|
119
|
+
export function classifyDaemonLife(lines, nowMs) {
|
|
120
|
+
const beats = lines.filter((l) => l.event === DAEMON_HEARTBEAT_EVENT);
|
|
121
|
+
const exit = lines.findLast((l) => l.event === DAEMON_EXIT_EVENT);
|
|
122
|
+
const signal = lines.findLast((l) => l.event === DAEMON_SIGNAL_EVENT);
|
|
123
|
+
if (exit !== undefined) {
|
|
124
|
+
// A signal is the CAUSE; the exit line only says it happened. Reporting `clean` for a SIGTERMed
|
|
125
|
+
// daemon is how "shut down tidily" and "the bridge every app needs is gone" became one sentence.
|
|
126
|
+
return signal === undefined
|
|
127
|
+
? { end: DaemonEnd.CLEAN }
|
|
128
|
+
: {
|
|
129
|
+
end: DaemonEnd.SIGNALLED,
|
|
130
|
+
...('string' === typeof signal.signal ? { signal: signal.signal } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const last = beats.at(-1);
|
|
134
|
+
if (last === undefined)
|
|
135
|
+
return { end: DaemonEnd.UNKNOWN };
|
|
136
|
+
const everyMs = 'number' === typeof last.everyMs ? last.everyMs : DAEMON_HEARTBEAT_MS;
|
|
137
|
+
const silentForMs = nowMs - last.t;
|
|
138
|
+
return silentForMs > everyMs * MISSED_BEATS_TOLERATED
|
|
139
|
+
? { end: DaemonEnd.DIED_SILENTLY, lastBeatMs: last.t, silentForMs }
|
|
140
|
+
: { end: DaemonEnd.ALIVE, lastBeatMs: last.t };
|
|
141
|
+
}
|
|
@@ -35,12 +35,20 @@ export declare class IdleShutdown {
|
|
|
35
35
|
stop(): void;
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
* `0`
|
|
38
|
+
* The idle re-check cadence, from the environment. Same shape as the grace resolver, with one
|
|
39
|
+
* deliberate difference: `0` is not a legal cadence here (it would spin), so `<= 0` falls back to
|
|
40
|
+
* the default rather than meaning anything.
|
|
40
41
|
*/
|
|
42
|
+
export declare function resolveIdleCheckMs(raw: string | undefined): number;
|
|
41
43
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
+
* Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of
|
|
45
|
+
* milliseconds, `0` to disable. Anything missing or invalid falls back to the default. Pure.
|
|
46
|
+
*
|
|
47
|
+
* The disable is NOT implemented here — 0 is passed through as 0, and `IdleShutdown` treats
|
|
48
|
+
* `graceMs <= 0` as "never self-shut-down" in both `start()` and `check()`. Said out loud because
|
|
49
|
+
* this docblock had drifted onto the function above it, leaving this one undocumented next to a
|
|
50
|
+
* bare `Math.floor`: a reviewer driving 2.5.0 read it as a documented option with no branch
|
|
51
|
+
* implementing it and filed the gate that depends on it as inverted. The behaviour was correct in
|
|
52
|
+
* every build; only the code was readable as broken.
|
|
44
53
|
*/
|
|
45
|
-
export declare function resolveIdleCheckMs(raw: string | undefined): number;
|
|
46
54
|
export declare function resolveIdleShutdownMs(raw: string | undefined): number;
|
|
@@ -65,12 +65,9 @@ export class IdleShutdown {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
* `0`
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The idle re-check cadence, from the environment. Same shape as the grace resolver: a bad value
|
|
73
|
-
* falls back rather than throwing, and 0/negative is not a legal cadence (it would spin).
|
|
68
|
+
* The idle re-check cadence, from the environment. Same shape as the grace resolver, with one
|
|
69
|
+
* deliberate difference: `0` is not a legal cadence here (it would spin), so `<= 0` falls back to
|
|
70
|
+
* the default rather than meaning anything.
|
|
74
71
|
*/
|
|
75
72
|
export function resolveIdleCheckMs(raw) {
|
|
76
73
|
if (raw === undefined || '' === raw.trim())
|
|
@@ -80,6 +77,17 @@ export function resolveIdleCheckMs(raw) {
|
|
|
80
77
|
return SESSION_LIFECYCLE.DAEMON_IDLE_CHECK_MS;
|
|
81
78
|
return Math.floor(n);
|
|
82
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the idle-shutdown grace from `RETICLE_IDLE_SHUTDOWN_MS`: a non-negative integer of
|
|
82
|
+
* milliseconds, `0` to disable. Anything missing or invalid falls back to the default. Pure.
|
|
83
|
+
*
|
|
84
|
+
* The disable is NOT implemented here — 0 is passed through as 0, and `IdleShutdown` treats
|
|
85
|
+
* `graceMs <= 0` as "never self-shut-down" in both `start()` and `check()`. Said out loud because
|
|
86
|
+
* this docblock had drifted onto the function above it, leaving this one undocumented next to a
|
|
87
|
+
* bare `Math.floor`: a reviewer driving 2.5.0 read it as a documented option with no branch
|
|
88
|
+
* implementing it and filed the gate that depends on it as inverted. The behaviour was correct in
|
|
89
|
+
* every build; only the code was readable as broken.
|
|
90
|
+
*/
|
|
83
91
|
export function resolveIdleShutdownMs(raw) {
|
|
84
92
|
if (raw === undefined || '' === raw.trim())
|
|
85
93
|
return SESSION_LIFECYCLE.DAEMON_IDLE_SHUTDOWN_MS;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
|
|
3
|
+
*
|
|
4
|
+
* The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
|
|
5
|
+
* `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
|
|
6
|
+
* default bridge URL says `localhost`. So on the platform that is most of our installs, and the
|
|
7
|
+
* browser that is most of that, the documented default cannot connect — and the error text names the
|
|
8
|
+
* daemon, containers and WSL, none of which is the cause.
|
|
9
|
+
*
|
|
10
|
+
* This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
|
|
11
|
+
* WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
|
|
12
|
+
* second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
|
|
13
|
+
* only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
|
|
16
|
+
* leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
|
|
17
|
+
* precondition for serving.
|
|
18
|
+
*/
|
|
19
|
+
/** IPv6 loopback. The other half of what `localhost` can mean. */
|
|
20
|
+
export declare const IPV6_LOOPBACK = "::1";
|
|
21
|
+
export interface LoopbackAlias {
|
|
22
|
+
/** False when the alias could not be bound — the daemon still serves IPv4 as before. */
|
|
23
|
+
opened: boolean;
|
|
24
|
+
close: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
|
|
28
|
+
*
|
|
29
|
+
* Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
|
|
30
|
+
* trust holds — the alias does not widen who may connect, only which address they may name.
|
|
31
|
+
*/
|
|
32
|
+
export declare function openLoopbackAlias(port: number): Promise<LoopbackAlias>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
|
|
3
|
+
*
|
|
4
|
+
* The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
|
|
5
|
+
* `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
|
|
6
|
+
* default bridge URL says `localhost`. So on the platform that is most of our installs, and the
|
|
7
|
+
* browser that is most of that, the documented default cannot connect — and the error text names the
|
|
8
|
+
* daemon, containers and WSL, none of which is the cause.
|
|
9
|
+
*
|
|
10
|
+
* This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
|
|
11
|
+
* WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
|
|
12
|
+
* second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
|
|
13
|
+
* only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
|
|
16
|
+
* leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
|
|
17
|
+
* precondition for serving.
|
|
18
|
+
*/
|
|
19
|
+
import net from 'node:net';
|
|
20
|
+
import { LOOPBACK_HOST } from '@reticlehq/core';
|
|
21
|
+
/** IPv6 loopback. The other half of what `localhost` can mean. */
|
|
22
|
+
export const IPV6_LOOPBACK = '::1';
|
|
23
|
+
/**
|
|
24
|
+
* Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
|
|
25
|
+
*
|
|
26
|
+
* Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
|
|
27
|
+
* trust holds — the alias does not widen who may connect, only which address they may name.
|
|
28
|
+
*/
|
|
29
|
+
export function openLoopbackAlias(port) {
|
|
30
|
+
/** Every socket this alias has open, so shutdown can end them instead of waiting them out. */
|
|
31
|
+
const live = new Set();
|
|
32
|
+
const server = net.createServer((inbound) => {
|
|
33
|
+
const outbound = net.connect(port, LOOPBACK_HOST);
|
|
34
|
+
live.add(inbound).add(outbound);
|
|
35
|
+
const drop = (socket) => {
|
|
36
|
+
live.delete(socket);
|
|
37
|
+
};
|
|
38
|
+
inbound.on('close', () => drop(inbound));
|
|
39
|
+
outbound.on('close', () => drop(outbound));
|
|
40
|
+
// A dead peer on either side must tear down the pair, not surface as an unhandled 'error'
|
|
41
|
+
// event — which in Node terminates the daemon this alias exists to make reachable.
|
|
42
|
+
inbound.on('error', () => outbound.destroy());
|
|
43
|
+
outbound.on('error', () => inbound.destroy());
|
|
44
|
+
inbound.pipe(outbound);
|
|
45
|
+
outbound.pipe(inbound);
|
|
46
|
+
});
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
/**
|
|
49
|
+
* Stop listening and END EVERYTHING, rather than draining.
|
|
50
|
+
*
|
|
51
|
+
* `server.close()` fires its callback only once every existing connection has gone, so a single
|
|
52
|
+
* held-open socket — a keep-alive, an SSE stream, exactly what this daemon serves — means the
|
|
53
|
+
* callback never fires. The daemon's shutdown chain awaits this, so an un-drained alias silently
|
|
54
|
+
* became a daemon that ignores SIGTERM: `reticle stop` timed out, the process stayed alive, and
|
|
55
|
+
* the port stayed held. Caught by the heartbeat spec, which is the one gate that watches a
|
|
56
|
+
* daemon actually die.
|
|
57
|
+
*
|
|
58
|
+
* A best-effort side listener must never be able to hold up the shutdown of the thing it exists
|
|
59
|
+
* to make reachable. We are exiting; ending these sockets is the correct outcome, not a
|
|
60
|
+
* compromise.
|
|
61
|
+
*/
|
|
62
|
+
const close = () => new Promise((done) => {
|
|
63
|
+
server.close(() => done());
|
|
64
|
+
for (const socket of live)
|
|
65
|
+
socket.destroy();
|
|
66
|
+
live.clear();
|
|
67
|
+
});
|
|
68
|
+
server.once('error', () => {
|
|
69
|
+
// Nothing was bound, so there is nothing to close — but the caller still gets a close it can
|
|
70
|
+
// call unconditionally, rather than a shape it has to branch on.
|
|
71
|
+
resolve({ opened: false, close: () => Promise.resolve() });
|
|
72
|
+
});
|
|
73
|
+
server.listen(port, IPV6_LOOPBACK, () => {
|
|
74
|
+
// Never the reason the process stays up: if the daemon is otherwise done, this must not be
|
|
75
|
+
// what keeps the event loop alive.
|
|
76
|
+
server.unref();
|
|
77
|
+
resolve({ opened: true, close });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is actually on the bridge port — three states, because two produced a lie.
|
|
3
|
+
*
|
|
4
|
+
* `isRunning` reads the pid file and never the port. So "a stranger holds 4400" and "nothing holds
|
|
5
|
+
* 4400" both answer `running: false`, and every surface built on it inherits that:
|
|
6
|
+
*
|
|
7
|
+
* $ reticle serve
|
|
8
|
+
* {"event":"reticle_daemon_spawned","port":4400} <- exit 0
|
|
9
|
+
* $ reticle status
|
|
10
|
+
* {"event":"reticle_status","port":4400,"running":false}
|
|
11
|
+
* $ tail -1 ~/.reticle/daemon-4400.log
|
|
12
|
+
* {"event":"reticle_daemon_start_failed","error":"listen EADDRINUSE: ... 127.0.0.1:4400"}
|
|
13
|
+
*
|
|
14
|
+
* The daemon knew exactly what was wrong and wrote it where nobody looks. The true sentence — *port
|
|
15
|
+
* 4400 is held by another process* — was never said by anything a person sees.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately probe-based rather than lsof-based: two questions (does TCP connect, does `/status`
|
|
18
|
+
* answer) separate all three states with no platform-specific code, and they work on Windows, where
|
|
19
|
+
* every `lsof` recipe in this repo does not. Naming the holding process is a nicer message and a
|
|
20
|
+
* strictly harder problem; it is not needed to stop lying.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PortPresence: {
|
|
23
|
+
/** A Reticle daemon answered `/status`. The only usable state. */
|
|
24
|
+
readonly DAEMON: "daemon";
|
|
25
|
+
/**
|
|
26
|
+
* Something accepts connections here and does not serve Reticle's status endpoint.
|
|
27
|
+
*
|
|
28
|
+
* A foreign process, or one of our own daemons wedged mid-start — and those are the same fact for
|
|
29
|
+
* every caller: this port cannot be bound and cannot be used.
|
|
30
|
+
*/
|
|
31
|
+
readonly FOREIGN: "foreign";
|
|
32
|
+
/** Nothing is listening. */
|
|
33
|
+
readonly FREE: "free";
|
|
34
|
+
};
|
|
35
|
+
export type PortPresence = (typeof PortPresence)[keyof typeof PortPresence];
|
|
36
|
+
interface PortFacts {
|
|
37
|
+
/** A TCP connection to the port was accepted. */
|
|
38
|
+
tcpOpen: boolean;
|
|
39
|
+
/** An HTTP GET of `/status` returned a Reticle status payload. */
|
|
40
|
+
statusAnswered: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure. The probes live at the call sites (`probeDaemon`, `fetchStatus`) so this stays testable
|
|
44
|
+
* without a socket, and so a probe that starts lying is a separate failure from a rule that does.
|
|
45
|
+
*/
|
|
46
|
+
export declare function classifyPort(facts: PortFacts): PortPresence;
|
|
47
|
+
/** True only for a daemon that answered. Anything else must not be treated as "running". */
|
|
48
|
+
export declare function presenceIsUsable(presence: PortPresence): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Ask the port both questions and classify the answers.
|
|
51
|
+
*
|
|
52
|
+
* The TCP probe is first and cheap; `/status` is only asked when something accepted, which keeps the
|
|
53
|
+
* common "nothing there" case to one failed connect. Impure by necessity — the rule above is where
|
|
54
|
+
* the behaviour lives, and it is tested without a socket.
|
|
55
|
+
*/
|
|
56
|
+
export declare function probePresence(port: number, probes: {
|
|
57
|
+
tcpOpen: (port: number) => Promise<boolean>;
|
|
58
|
+
status: (port: number) => Promise<unknown>;
|
|
59
|
+
}): Promise<PortPresence>;
|
|
60
|
+
/**
|
|
61
|
+
* Who is holding the port, when we can tell.
|
|
62
|
+
*
|
|
63
|
+
* `FOREIGN` covers two situations that need the same answer to "can I bind this?" and OPPOSITE
|
|
64
|
+
* answers to "what do I do?": a stranger's process, and one of our own daemons wedged. Passing
|
|
65
|
+
* these lets the sentence tell them apart when — and only when — the recorded pid for the port
|
|
66
|
+
* matches the process actually holding it.
|
|
67
|
+
*/
|
|
68
|
+
export interface PortHolder {
|
|
69
|
+
/** The pid we recorded for this port, from the daemon pid file. `null` when there is none. */
|
|
70
|
+
ourPid: number | null;
|
|
71
|
+
/** The pid actually holding the port, when the caller could determine it. */
|
|
72
|
+
holderPid: number | undefined;
|
|
73
|
+
}
|
|
74
|
+
/** One sentence a human can act on, for each state. Exhaustive by switch, so a new state must add one. */
|
|
75
|
+
export declare function describePresence(presence: PortPresence, port: number, holder?: PortHolder): string;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is actually on the bridge port — three states, because two produced a lie.
|
|
3
|
+
*
|
|
4
|
+
* `isRunning` reads the pid file and never the port. So "a stranger holds 4400" and "nothing holds
|
|
5
|
+
* 4400" both answer `running: false`, and every surface built on it inherits that:
|
|
6
|
+
*
|
|
7
|
+
* $ reticle serve
|
|
8
|
+
* {"event":"reticle_daemon_spawned","port":4400} <- exit 0
|
|
9
|
+
* $ reticle status
|
|
10
|
+
* {"event":"reticle_status","port":4400,"running":false}
|
|
11
|
+
* $ tail -1 ~/.reticle/daemon-4400.log
|
|
12
|
+
* {"event":"reticle_daemon_start_failed","error":"listen EADDRINUSE: ... 127.0.0.1:4400"}
|
|
13
|
+
*
|
|
14
|
+
* The daemon knew exactly what was wrong and wrote it where nobody looks. The true sentence — *port
|
|
15
|
+
* 4400 is held by another process* — was never said by anything a person sees.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately probe-based rather than lsof-based: two questions (does TCP connect, does `/status`
|
|
18
|
+
* answer) separate all three states with no platform-specific code, and they work on Windows, where
|
|
19
|
+
* every `lsof` recipe in this repo does not. Naming the holding process is a nicer message and a
|
|
20
|
+
* strictly harder problem; it is not needed to stop lying.
|
|
21
|
+
*/
|
|
22
|
+
export const PortPresence = {
|
|
23
|
+
/** A Reticle daemon answered `/status`. The only usable state. */
|
|
24
|
+
DAEMON: 'daemon',
|
|
25
|
+
/**
|
|
26
|
+
* Something accepts connections here and does not serve Reticle's status endpoint.
|
|
27
|
+
*
|
|
28
|
+
* A foreign process, or one of our own daemons wedged mid-start — and those are the same fact for
|
|
29
|
+
* every caller: this port cannot be bound and cannot be used.
|
|
30
|
+
*/
|
|
31
|
+
FOREIGN: 'foreign',
|
|
32
|
+
/** Nothing is listening. */
|
|
33
|
+
FREE: 'free',
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Pure. The probes live at the call sites (`probeDaemon`, `fetchStatus`) so this stays testable
|
|
37
|
+
* without a socket, and so a probe that starts lying is a separate failure from a rule that does.
|
|
38
|
+
*/
|
|
39
|
+
export function classifyPort(facts) {
|
|
40
|
+
if (facts.statusAnswered) {
|
|
41
|
+
if (!facts.tcpOpen) {
|
|
42
|
+
// Not reachable from a real pair of probes. Folding it into DAEMON would mean a broken TCP
|
|
43
|
+
// probe reports a healthy daemon — the exact shape of confident-wrong this module exists to
|
|
44
|
+
// remove — so it is an error rather than a guess.
|
|
45
|
+
throw new Error('a port cannot answer /status without accepting a TCP connection; one of the probes is wrong');
|
|
46
|
+
}
|
|
47
|
+
return PortPresence.DAEMON;
|
|
48
|
+
}
|
|
49
|
+
return facts.tcpOpen ? PortPresence.FOREIGN : PortPresence.FREE;
|
|
50
|
+
}
|
|
51
|
+
/** True only for a daemon that answered. Anything else must not be treated as "running". */
|
|
52
|
+
export function presenceIsUsable(presence) {
|
|
53
|
+
return presence === PortPresence.DAEMON;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Ask the port both questions and classify the answers.
|
|
57
|
+
*
|
|
58
|
+
* The TCP probe is first and cheap; `/status` is only asked when something accepted, which keeps the
|
|
59
|
+
* common "nothing there" case to one failed connect. Impure by necessity — the rule above is where
|
|
60
|
+
* the behaviour lives, and it is tested without a socket.
|
|
61
|
+
*/
|
|
62
|
+
export async function probePresence(port, probes) {
|
|
63
|
+
const tcpOpen = await probes.tcpOpen(port);
|
|
64
|
+
if (!tcpOpen)
|
|
65
|
+
return PortPresence.FREE;
|
|
66
|
+
const status = await probes.status(port);
|
|
67
|
+
return classifyPort({ tcpOpen, statusAnswered: status !== undefined });
|
|
68
|
+
}
|
|
69
|
+
/** One sentence a human can act on, for each state. Exhaustive by switch, so a new state must add one. */
|
|
70
|
+
export function describePresence(presence, port, holder) {
|
|
71
|
+
switch (presence) {
|
|
72
|
+
case PortPresence.DAEMON:
|
|
73
|
+
return `a Reticle daemon is serving :${String(port)}`;
|
|
74
|
+
case PortPresence.FOREIGN:
|
|
75
|
+
// Our own daemon, wedged: it accepts TCP and never answers `/status`, so it classifies as
|
|
76
|
+
// FOREIGN — correct for binding, and a lie in prose. Found by SIGSTOPping a daemon: doctor
|
|
77
|
+
// said "which is not a Reticle daemon" about a pid its own pid file named, and sent the
|
|
78
|
+
// reader hunting a stranger while never mentioning the fix that works.
|
|
79
|
+
if (holder?.ourPid !== null &&
|
|
80
|
+
holder?.ourPid !== undefined &&
|
|
81
|
+
holder.ourPid === holder.holderPid) {
|
|
82
|
+
return (`port ${String(port)} is held by YOUR Reticle daemon (pid ${String(holder.ourPid)}), ` +
|
|
83
|
+
'and it is not responding — it is running but wedged, so nothing can use it and nothing ' +
|
|
84
|
+
'else can bind the port. Stop it (`reticle stop`) and the next tool call starts a fresh ' +
|
|
85
|
+
'one.');
|
|
86
|
+
}
|
|
87
|
+
return (`port ${String(port)} is held by another process that is not a Reticle daemon — ` +
|
|
88
|
+
'a daemon cannot bind it. Stop that process, or run Reticle on a different port ' +
|
|
89
|
+
'(`--port`), then retry.');
|
|
90
|
+
case PortPresence.FREE:
|
|
91
|
+
return `nothing is listening on :${String(port)} — no daemon is running`;
|
|
92
|
+
}
|
|
93
|
+
}
|