@north-light/crouter-api 0.3.302 → 0.3.304
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 +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/package.json +1 -1
|
@@ -1,30 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
// used to throw a bare `daemon_unavailable` message that discarded the actual
|
|
3
|
-
// startup failure (the tail of crtrd.log). `coldStartTimeoutMessage` is the
|
|
4
|
-
// pure composition `handleColdSocket` uses on timeout; this proves an injected
|
|
5
|
-
// diagnostic is appended (not dropped) and that a missing/empty diagnostic
|
|
6
|
-
// still falls back to the original bare message unchanged. `safeColdStartDiagnostic`
|
|
7
|
-
// is the guard `handleColdSocket` applies before that composition; this proves a
|
|
8
|
-
// THROWING hook is treated as absent (per the documented `CrtrClientOptions`
|
|
9
|
-
// contract) instead of propagating and replacing the typed `daemon_unavailable`
|
|
10
|
-
// error.
|
|
1
|
+
// Client availability recovery regressions.
|
|
11
2
|
import { test } from 'node:test';
|
|
12
3
|
import assert from 'node:assert/strict';
|
|
13
4
|
import { createServer } from 'node:http';
|
|
14
5
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
15
6
|
import { tmpdir } from 'node:os';
|
|
16
7
|
import { join } from 'node:path';
|
|
17
|
-
import {
|
|
8
|
+
import { CrtrClient, safeColdStartDiagnostic } from '../../client.js';
|
|
18
9
|
import { ApiError } from '../../errors.js';
|
|
19
|
-
test('coldStartTimeoutMessage appends a present diagnostic to the base message', () => {
|
|
20
|
-
const msg = coldStartTimeoutMessage('crtrd.log (tail):\nError: bind EADDRINUSE');
|
|
21
|
-
assert.match(msg, /crtrd did not start/);
|
|
22
|
-
assert.match(msg, /crtrd\.log \(tail\):\nError: bind EADDRINUSE/);
|
|
23
|
-
});
|
|
24
|
-
test('coldStartTimeoutMessage falls back to the bare message when no diagnostic is available', () => {
|
|
25
|
-
assert.equal(coldStartTimeoutMessage(undefined), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.log.');
|
|
26
|
-
assert.equal(coldStartTimeoutMessage(''), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.log.');
|
|
27
|
-
});
|
|
28
10
|
function startDelayedHealthzServer(socketPath, delayMs) {
|
|
29
11
|
const server = createServer((_req, res) => {
|
|
30
12
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -33,11 +15,7 @@ function startDelayedHealthzServer(socketPath, delayMs) {
|
|
|
33
15
|
const timer = setTimeout(() => server.listen(socketPath), delayMs);
|
|
34
16
|
return { server, cancel: () => clearTimeout(timer) };
|
|
35
17
|
}
|
|
36
|
-
//
|
|
37
|
-
// hands over to a successor — a request crossing that window dies with a hang-up
|
|
38
|
-
// (ECONNRESET), not a refused connect. That used to surface as
|
|
39
|
-
// `daemon_unavailable: socket hang up` telling the caller to run
|
|
40
|
-
// `crtr sys daemon start` against a daemon that was already coming back up.
|
|
18
|
+
// A dropped response is distinct from a refused connect. Its cause is unknown, so reads may wait and retry but mutations never replay.
|
|
41
19
|
function startHandoverServer(socketPath) {
|
|
42
20
|
let dropped = false;
|
|
43
21
|
const server = createServer((req, res) => {
|
|
@@ -68,7 +46,7 @@ test('a hang-up mid-request rides out the daemon handover and replays the idempo
|
|
|
68
46
|
rmSync(dir, { recursive: true, force: true });
|
|
69
47
|
}
|
|
70
48
|
});
|
|
71
|
-
test('
|
|
49
|
+
test('an interrupted mutation is not replayed or labelled a daemon handover', async () => {
|
|
72
50
|
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
|
|
73
51
|
const socketPath = join(dir, 'crtrd.sock');
|
|
74
52
|
const { server, ready } = startHandoverServer(socketPath);
|
|
@@ -78,7 +56,7 @@ test('a hang-up mid-mutation reports daemon_restarting, not daemon_unavailable',
|
|
|
78
56
|
await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
|
|
79
57
|
// Replaying is unsafe (the daemon may have applied it), so this one is the
|
|
80
58
|
// caller's call — but it must not be reported as a daemon that is down.
|
|
81
|
-
(err) => err instanceof ApiError && err.code === '
|
|
59
|
+
(err) => err instanceof ApiError && err.code === 'daemon_request_interrupted');
|
|
82
60
|
}
|
|
83
61
|
finally {
|
|
84
62
|
server.close();
|
|
@@ -127,24 +105,47 @@ test('cliClient-style cold start fails loud when the injected poll window is sho
|
|
|
127
105
|
rmSync(dir, { recursive: true, force: true });
|
|
128
106
|
}
|
|
129
107
|
});
|
|
130
|
-
test('
|
|
108
|
+
test('a disabled-autostart client waits for an externally managed listener without invoking its spawn hook', async () => {
|
|
131
109
|
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
|
|
132
110
|
const socketPath = join(dir, 'crtrd.sock');
|
|
133
|
-
const { server, cancel } = startDelayedHealthzServer(socketPath,
|
|
111
|
+
const { server, cancel } = startDelayedHealthzServer(socketPath, 100);
|
|
112
|
+
let spawnAttempts = 0;
|
|
113
|
+
try {
|
|
114
|
+
const client = new CrtrClient({
|
|
115
|
+
socketPath,
|
|
116
|
+
autostart: false,
|
|
117
|
+
onColdSocket: async () => { spawnAttempts += 1; },
|
|
118
|
+
coldStartPollWindowMs: 1_000,
|
|
119
|
+
});
|
|
120
|
+
assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true });
|
|
121
|
+
assert.equal(spawnAttempts, 0, 'disabled autostart only waits; it never invokes the spawn hook');
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
cancel();
|
|
125
|
+
server.close();
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
test('availability expiry keeps the final transport failure inside one wall-clock budget', async () => {
|
|
130
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
|
|
131
|
+
const socketPath = join(dir, 'crtrd.sock');
|
|
132
|
+
const server = createServer((_req, _res) => { });
|
|
133
|
+
let listenTimer;
|
|
134
134
|
try {
|
|
135
135
|
const client = new CrtrClient({
|
|
136
136
|
socketPath,
|
|
137
137
|
autostart: true,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
},
|
|
141
|
-
coldStartPollWindowMs: 2_000, // long enough to see the 300ms-delayed daemon come up
|
|
138
|
+
timeoutMs: 5_000,
|
|
139
|
+
coldStartPollWindowMs: 250,
|
|
140
|
+
onColdSocket: async () => { listenTimer = setTimeout(() => server.listen(socketPath), 10); },
|
|
142
141
|
});
|
|
143
|
-
const
|
|
144
|
-
assert.
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
await assert.rejects(() => client.healthz(), (error) => error instanceof ApiError && error.code === 'request_timeout' && error.message.includes('request timed out'));
|
|
144
|
+
assert.ok(Date.now() - started < 500, 'a probe cannot extend the 250ms availability budget');
|
|
145
145
|
}
|
|
146
146
|
finally {
|
|
147
|
-
|
|
147
|
+
if (listenTimer !== undefined)
|
|
148
|
+
clearTimeout(listenTimer);
|
|
148
149
|
server.close();
|
|
149
150
|
rmSync(dir, { recursive: true, force: true });
|
|
150
151
|
}
|
package/dist/api/client.d.ts
CHANGED
|
@@ -47,28 +47,25 @@ export interface CrtrClientOptions {
|
|
|
47
47
|
* return synchronously and cheaply — it runs on the failure path, not the
|
|
48
48
|
* happy path. A thrown/undefined result is treated as "no diagnostic". */
|
|
49
49
|
coldStartDiagnostic?: () => string | undefined;
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* window actually governs "did the daemon start":
|
|
54
|
-
* `onColdSocket` is fire-and-forget, so this poll is the ONLY deadline that
|
|
55
|
-
* determines whether the CLI reports success. A caller whose `onColdSocket`
|
|
56
|
-
* hook triggers a differently-windowed startup verifier (e.g. the CLI's
|
|
57
|
-
* `ensureDaemon`/`verifyDaemonStartup`) must pass that same window here so
|
|
58
|
-
* a slow-but-valid cold start cannot pass the authoritative verifier while
|
|
59
|
-
* this poll times out first. */
|
|
50
|
+
/** Strict wall-clock window (ms) for local API availability after a cold
|
|
51
|
+
* socket or interrupted response. Each probe is capped to the remaining
|
|
52
|
+
* budget. Defaults to `HEALTHZ_POLL_WINDOW_MS`. */
|
|
60
53
|
coldStartPollWindowMs?: number;
|
|
61
|
-
/** Injected check for a
|
|
62
|
-
*
|
|
63
|
-
* time. Consulted on each `/healthz` poll; a returned string ends the wait
|
|
64
|
-
* immediately and becomes the error message.
|
|
65
|
-
*
|
|
66
|
-
* Without this the caller waits out the whole cold-start window to report
|
|
67
|
-
* "not reachable", even when the reason it is unreachable — and the repair
|
|
68
|
-
* for it — were both known before the first poll. Must be synchronous and
|
|
69
|
-
* cheap; it runs on every poll tick. */
|
|
54
|
+
/** Injected check for a standing startup block. After an attempted spawn,
|
|
55
|
+
* its result supplements the observed transport failure with the known repair. */
|
|
70
56
|
coldStartAbort?: () => string | null;
|
|
71
57
|
}
|
|
58
|
+
/** One strict wall-clock availability window shared by local API clients and
|
|
59
|
+
* daemon management. Each probe gets only the budget remaining at its start. */
|
|
60
|
+
export declare function waitForDaemonAvailability({ windowMs, probe, initialError, pollIntervalMs, retry, now, sleep, }: {
|
|
61
|
+
windowMs: number;
|
|
62
|
+
probe: (timeoutMs: number) => Promise<void>;
|
|
63
|
+
initialError?: unknown;
|
|
64
|
+
pollIntervalMs?: number;
|
|
65
|
+
retry?: (error: unknown) => boolean;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
sleep?: (ms: number) => Promise<void> | void;
|
|
68
|
+
}): Promise<void>;
|
|
72
69
|
export declare class CrtrClient {
|
|
73
70
|
private readonly socketPath?;
|
|
74
71
|
private readonly baseUrl?;
|
|
@@ -79,7 +76,7 @@ export declare class CrtrClient {
|
|
|
79
76
|
private readonly coldStartDiagnostic?;
|
|
80
77
|
private readonly coldStartPollWindowMs;
|
|
81
78
|
private readonly coldStartAbort?;
|
|
82
|
-
/** Guards against
|
|
79
|
+
/** Guards against invoking the daemon-start hook more than once per client. */
|
|
83
80
|
private coldStartAttempted;
|
|
84
81
|
constructor(opts: CrtrClientOptions);
|
|
85
82
|
/** Construct a client bound to the default local socket with autostart on. Pass
|
|
@@ -87,6 +84,9 @@ export declare class CrtrClient {
|
|
|
87
84
|
* socket fails loud with `daemon_unavailable`. */
|
|
88
85
|
static forLocalSocket(opts?: Omit<CrtrClientOptions, 'socketPath' | 'baseUrl'>): CrtrClient;
|
|
89
86
|
healthz(): Promise<HealthDTO>;
|
|
87
|
+
/** One `/healthz` observation without cold-socket recovery. Availability
|
|
88
|
+
* waiters own retry policy and pass their remaining wall-clock budget here. */
|
|
89
|
+
probeHealthz(timeoutMs: number): Promise<HealthDTO>;
|
|
90
90
|
status(): Promise<StatusDTO>;
|
|
91
91
|
/** Ask the daemon to replace itself with a successor running the currently
|
|
92
92
|
* selected runtime generation. Answers before the handover starts, so a
|
|
@@ -314,8 +314,8 @@ export declare class CrtrClient {
|
|
|
314
314
|
/** Raw request for routes not yet method-wrapped. Applies the same
|
|
315
315
|
* autostart + error-mapping semantics. */
|
|
316
316
|
request<T>(method: string, path: string, body?: unknown): Promise<T>;
|
|
317
|
-
/** The unparsed request:
|
|
318
|
-
*
|
|
317
|
+
/** The unparsed request: cold-socket and interrupted-response recovery, no
|
|
318
|
+
* JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
|
|
319
319
|
private send;
|
|
320
320
|
private nodePath;
|
|
321
321
|
/** Validate a human interaction id before route construction. Reply-bearing
|
|
@@ -348,39 +348,16 @@ export declare class CrtrClient {
|
|
|
348
348
|
private transport;
|
|
349
349
|
private isColdSocketError;
|
|
350
350
|
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
351
|
-
*
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
*
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* never `daemon_unavailable` ("start the daemon" is the wrong advice for a
|
|
362
|
-
* daemon that is mid-handover). */
|
|
363
|
-
private rideOutHandover;
|
|
364
|
-
/** Poll `/healthz` until the successor daemon answers, bounded by the
|
|
365
|
-
* cold-start window. Tolerates both the pre-listen gap (cold socket) and a
|
|
366
|
-
* second hang-up from a server still tearing down. */
|
|
367
|
-
private awaitHandover;
|
|
368
|
-
/** Trigger the injected daemon-start hook, poll `/healthz`, then let the caller
|
|
369
|
-
* retry once. Fail loud with `daemon_unavailable` when autostart is off, no
|
|
370
|
-
* hook is wired, or the daemon never becomes reachable. */
|
|
351
|
+
* pipe). It establishes only that the response was interrupted, not why. */
|
|
352
|
+
private isInterruptedSocketError;
|
|
353
|
+
/** Wait for the local API after an interrupted response. GET/HEAD can retry
|
|
354
|
+
* because they are idempotent. A mutation may already have been applied, so
|
|
355
|
+
* it never replays. */
|
|
356
|
+
private rideOutInterruptedRequest;
|
|
357
|
+
private awaitAvailability;
|
|
358
|
+
/** Optionally start the daemon, then observe availability before retrying the
|
|
359
|
+
* request. Waiting is independent from permission to spawn: externally
|
|
360
|
+
* managed daemons still get the same bounded readiness window. */
|
|
371
361
|
private handleColdSocket;
|
|
372
362
|
}
|
|
373
|
-
/** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
|
|
374
|
-
* appending the injected diagnostic when one is present instead of discarding
|
|
375
|
-
* the real startup failure behind a bare message. Exported (not
|
|
376
|
-
* from the package's public `index.ts` surface, which is dependency-light by
|
|
377
|
-
* design) purely so the regression test can assert the composition without
|
|
378
|
-
* waiting out the real poll window. */
|
|
379
|
-
export declare function coldStartTimeoutMessage(diagnostic: string | undefined): string;
|
|
380
|
-
/** Invoke the injected `coldStartDiagnostic` hook, treating a THROW the same
|
|
381
|
-
* as an absent/undefined result — the contract `CrtrClientOptions` documents
|
|
382
|
-
* ("a thrown/undefined result is treated as 'no diagnostic'"). Without this,
|
|
383
|
-
* a broken hook would propagate and replace the typed `daemon_unavailable`
|
|
384
|
-
* error the caller is entitled to. Exported alongside `coldStartTimeoutMessage`
|
|
385
|
-
* for the same direct-unit-test reason. */
|
|
386
363
|
export declare function safeColdStartDiagnostic(hook: (() => string | undefined) | undefined): string | undefined;
|
package/dist/api/client.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// (`node:http`, `node:https`, `node:os`, `node:path`) plus `src/api/*`. Never
|
|
5
5
|
// `core/*` / `node:sqlite` / TUI. The daemon-spawn logic lives in `core`/
|
|
6
6
|
// `daemon`, which this module may not import — so autostart is delegated to an
|
|
7
|
-
// injectable `onColdSocket` hook the CLI wires in (spec §7.1).
|
|
8
|
-
//
|
|
7
|
+
// injectable `onColdSocket` hook the CLI wires in (spec §7.1). A cold socket
|
|
8
|
+
// always gets one bounded availability observation; the hook only permits spawning.
|
|
9
9
|
//
|
|
10
10
|
// TRANSPORT (spec O-1): `node:http`/`node:https` `request()` — NO `undici`.
|
|
11
11
|
// unix socket via `{ socketPath }`; TCP/remote via a parsed `baseUrl`.
|
|
@@ -39,6 +39,30 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
39
39
|
* `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
|
|
40
40
|
const HEALTHZ_POLL_WINDOW_MS = 10_000;
|
|
41
41
|
const HEALTHZ_POLL_INTERVAL_MS = 200;
|
|
42
|
+
/** One strict wall-clock availability window shared by local API clients and
|
|
43
|
+
* daemon management. Each probe gets only the budget remaining at its start. */
|
|
44
|
+
export async function waitForDaemonAvailability({ windowMs, probe, initialError, pollIntervalMs = HEALTHZ_POLL_INTERVAL_MS, retry = () => true, now = Date.now, sleep = sleepMs, }) {
|
|
45
|
+
const deadline = now() + windowMs;
|
|
46
|
+
let lastError = initialError;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const remaining = deadline - now();
|
|
49
|
+
if (remaining <= 0)
|
|
50
|
+
throw lastError ?? new Error('crtrd availability window expired without a probe failure');
|
|
51
|
+
try {
|
|
52
|
+
await probe(remaining);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
lastError = error;
|
|
57
|
+
if (!retry(error))
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const afterProbe = deadline - now();
|
|
61
|
+
if (afterProbe <= 0)
|
|
62
|
+
throw lastError;
|
|
63
|
+
await sleep(Math.min(pollIntervalMs, afterProbe));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
export class CrtrClient {
|
|
43
67
|
socketPath;
|
|
44
68
|
baseUrl;
|
|
@@ -49,7 +73,7 @@ export class CrtrClient {
|
|
|
49
73
|
coldStartDiagnostic;
|
|
50
74
|
coldStartPollWindowMs;
|
|
51
75
|
coldStartAbort;
|
|
52
|
-
/** Guards against
|
|
76
|
+
/** Guards against invoking the daemon-start hook more than once per client. */
|
|
53
77
|
coldStartAttempted = false;
|
|
54
78
|
constructor(opts) {
|
|
55
79
|
const hasSocket = opts.socketPath !== undefined && opts.socketPath !== '';
|
|
@@ -82,6 +106,11 @@ export class CrtrClient {
|
|
|
82
106
|
healthz() {
|
|
83
107
|
return this.request('GET', routes.healthz());
|
|
84
108
|
}
|
|
109
|
+
/** One `/healthz` observation without cold-socket recovery. Availability
|
|
110
|
+
* waiters own retry policy and pass their remaining wall-clock budget here. */
|
|
111
|
+
async probeHealthz(timeoutMs) {
|
|
112
|
+
return parse(await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs));
|
|
113
|
+
}
|
|
85
114
|
status() {
|
|
86
115
|
return this.request('GET', routes.status());
|
|
87
116
|
}
|
|
@@ -583,19 +612,24 @@ export class CrtrClient {
|
|
|
583
612
|
async request(method, path, body) {
|
|
584
613
|
return parse(await this.send(method, path, body));
|
|
585
614
|
}
|
|
586
|
-
/** The unparsed request:
|
|
587
|
-
*
|
|
615
|
+
/** The unparsed request: cold-socket and interrupted-response recovery, no
|
|
616
|
+
* JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
|
|
588
617
|
async send(method, path, body, extraHeaders) {
|
|
589
618
|
try {
|
|
590
619
|
return await this.transport(method, path, body, extraHeaders);
|
|
591
620
|
}
|
|
592
621
|
catch (err) {
|
|
593
622
|
if (this.isColdSocketError(err)) {
|
|
594
|
-
|
|
595
|
-
|
|
623
|
+
try {
|
|
624
|
+
await this.handleColdSocket(err);
|
|
625
|
+
return await this.transport(method, path, body, extraHeaders);
|
|
626
|
+
}
|
|
627
|
+
catch (recoveryError) {
|
|
628
|
+
throw toTransportApiError(recoveryError);
|
|
629
|
+
}
|
|
596
630
|
}
|
|
597
|
-
if (this.
|
|
598
|
-
return await this.
|
|
631
|
+
if (this.isInterruptedSocketError(err))
|
|
632
|
+
return await this.rideOutInterruptedRequest(method, path, body, extraHeaders);
|
|
599
633
|
throw toTransportApiError(err);
|
|
600
634
|
}
|
|
601
635
|
}
|
|
@@ -663,7 +697,7 @@ export class CrtrClient {
|
|
|
663
697
|
}
|
|
664
698
|
return id;
|
|
665
699
|
}
|
|
666
|
-
transport(method, path, body, extraHeaders) {
|
|
700
|
+
transport(method, path, body, extraHeaders, timeoutMs = this.timeoutMs) {
|
|
667
701
|
const usingHttps = this.baseUrl?.protocol === 'https:';
|
|
668
702
|
const doRequest = usingHttps ? httpsRequest : httpRequest;
|
|
669
703
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
@@ -676,7 +710,7 @@ export class CrtrClient {
|
|
|
676
710
|
method,
|
|
677
711
|
path,
|
|
678
712
|
headers,
|
|
679
|
-
timeout:
|
|
713
|
+
timeout: timeoutMs,
|
|
680
714
|
};
|
|
681
715
|
if (this.socketPath !== undefined) {
|
|
682
716
|
options.socketPath = this.socketPath;
|
|
@@ -717,29 +751,25 @@ export class CrtrClient {
|
|
|
717
751
|
return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
|
|
718
752
|
}
|
|
719
753
|
/** A connection torn down MID-request (Node's "socket hang up" / a broken
|
|
720
|
-
*
|
|
721
|
-
|
|
722
|
-
* generation handover: the daemon acks, then tears itself down and hands
|
|
723
|
-
* over to a successor it spawned. The daemon IS coming back. */
|
|
724
|
-
isHandoverHangup(err) {
|
|
754
|
+
* pipe). It establishes only that the response was interrupted, not why. */
|
|
755
|
+
isInterruptedSocketError(err) {
|
|
725
756
|
if (this.socketPath === undefined)
|
|
726
757
|
return false;
|
|
727
758
|
const code = err?.code;
|
|
728
759
|
return code === 'ECONNRESET' || code === 'EPIPE';
|
|
729
760
|
}
|
|
730
|
-
/** Wait for the
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
throw new ApiError(503, 'daemon_unavailable', `crtrd went away mid-request and did not come back within ${this.coldStartPollWindowMs}ms.`);
|
|
761
|
+
/** Wait for the local API after an interrupted response. GET/HEAD can retry
|
|
762
|
+
* because they are idempotent. A mutation may already have been applied, so
|
|
763
|
+
* it never replays. */
|
|
764
|
+
async rideOutInterruptedRequest(method, path, body, extraHeaders) {
|
|
765
|
+
try {
|
|
766
|
+
await this.awaitAvailability();
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
throw toTransportApiError(error);
|
|
740
770
|
}
|
|
741
771
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
742
|
-
throw new ApiError(503, '
|
|
772
|
+
throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
|
|
743
773
|
}
|
|
744
774
|
try {
|
|
745
775
|
return await this.transport(method, path, body, extraHeaders);
|
|
@@ -748,65 +778,42 @@ export class CrtrClient {
|
|
|
748
778
|
throw toTransportApiError(err);
|
|
749
779
|
}
|
|
750
780
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
catch (err) {
|
|
763
|
-
if (!this.isColdSocketError(err) && !this.isHandoverHangup(err))
|
|
764
|
-
throw toTransportApiError(err);
|
|
765
|
-
}
|
|
766
|
-
if (Date.now() >= deadline)
|
|
767
|
-
return false;
|
|
768
|
-
await sleep(HEALTHZ_POLL_INTERVAL_MS);
|
|
769
|
-
}
|
|
781
|
+
async awaitAvailability(initialError) {
|
|
782
|
+
await waitForDaemonAvailability({
|
|
783
|
+
windowMs: this.coldStartPollWindowMs,
|
|
784
|
+
initialError,
|
|
785
|
+
probe: async (timeoutMs) => {
|
|
786
|
+
const response = await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs);
|
|
787
|
+
if (response.status < 200 || response.status >= 300) {
|
|
788
|
+
throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${response.text.slice(0, 500)}`);
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
});
|
|
770
792
|
}
|
|
771
|
-
/**
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
async handleColdSocket() {
|
|
775
|
-
|
|
776
|
-
|
|
793
|
+
/** Optionally start the daemon, then observe availability before retrying the
|
|
794
|
+
* request. Waiting is independent from permission to spawn: externally
|
|
795
|
+
* managed daemons still get the same bounded readiness window. */
|
|
796
|
+
async handleColdSocket(initialError) {
|
|
797
|
+
const startedHere = this.autostart && this.onColdSocket !== undefined && !this.coldStartAttempted;
|
|
798
|
+
if (startedHere) {
|
|
799
|
+
this.coldStartAttempted = true;
|
|
800
|
+
await this.onColdSocket();
|
|
777
801
|
}
|
|
778
|
-
|
|
779
|
-
|
|
802
|
+
try {
|
|
803
|
+
await this.awaitAvailability(initialError);
|
|
780
804
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const res = await this.transport('GET', routes.healthz());
|
|
787
|
-
if (res.status >= 200 && res.status < 300)
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
catch (err) {
|
|
791
|
-
if (!this.isColdSocketError(err))
|
|
792
|
-
throw toTransportApiError(err);
|
|
793
|
-
}
|
|
794
|
-
// Waiting cannot clear a standing block, and the repair is already known.
|
|
795
|
-
let standingBlock = null;
|
|
796
|
-
try {
|
|
797
|
-
standingBlock = this.coldStartAbort?.() ?? null;
|
|
798
|
-
}
|
|
799
|
-
catch {
|
|
800
|
-
standingBlock = null;
|
|
801
|
-
}
|
|
802
|
-
if (standingBlock !== null) {
|
|
803
|
-
throw new ApiError(503, 'daemon_unavailable', `crtrd cannot start: ${standingBlock}`);
|
|
804
|
-
}
|
|
805
|
-
if (Date.now() >= deadline) {
|
|
805
|
+
catch (error) {
|
|
806
|
+
if (startedHere) {
|
|
807
|
+
const standingBlock = safeColdStartAbort(this.coldStartAbort);
|
|
808
|
+
if (standingBlock !== null)
|
|
809
|
+
throw new ApiError(503, 'daemon_unavailable', `crtrd cannot start: ${standingBlock}`);
|
|
806
810
|
const diagnostic = safeColdStartDiagnostic(this.coldStartDiagnostic);
|
|
807
|
-
|
|
811
|
+
if (diagnostic !== undefined) {
|
|
812
|
+
const transportError = toTransportApiError(error);
|
|
813
|
+
throw new ApiError(transportError.status, transportError.code, `${transportError.message}\n${diagnostic}`);
|
|
814
|
+
}
|
|
808
815
|
}
|
|
809
|
-
|
|
816
|
+
throw error;
|
|
810
817
|
}
|
|
811
818
|
}
|
|
812
819
|
}
|
|
@@ -854,7 +861,7 @@ function toTransportApiError(err) {
|
|
|
854
861
|
return err;
|
|
855
862
|
const code = err?.code;
|
|
856
863
|
const message = err instanceof Error ? err.message : String(err);
|
|
857
|
-
if (code === 'ECONNREFUSED' || code === 'ENOENT') {
|
|
864
|
+
if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
|
|
858
865
|
return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
|
|
859
866
|
}
|
|
860
867
|
if (code === 'ETIMEDOUT') {
|
|
@@ -864,25 +871,22 @@ function toTransportApiError(err) {
|
|
|
864
871
|
}
|
|
865
872
|
return new ApiError(503, 'transport_error', message);
|
|
866
873
|
}
|
|
867
|
-
function
|
|
874
|
+
function sleepMs(ms) {
|
|
868
875
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
869
876
|
}
|
|
870
|
-
/** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
|
|
871
|
-
* appending the injected diagnostic when one is present instead of discarding
|
|
872
|
-
* the real startup failure behind a bare message. Exported (not
|
|
873
|
-
* from the package's public `index.ts` surface, which is dependency-light by
|
|
874
|
-
* design) purely so the regression test can assert the composition without
|
|
875
|
-
* waiting out the real poll window. */
|
|
876
|
-
export function coldStartTimeoutMessage(diagnostic) {
|
|
877
|
-
const base = 'crtrd did not start; run `crtr sys daemon start` and check crtrd.log.';
|
|
878
|
-
return diagnostic !== undefined && diagnostic !== '' ? `${base}\n${diagnostic}` : base;
|
|
879
|
-
}
|
|
880
877
|
/** Invoke the injected `coldStartDiagnostic` hook, treating a THROW the same
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
878
|
+
* as an absent/undefined result — the contract `CrtrClientOptions` documents
|
|
879
|
+
* ("a thrown/undefined result is treated as 'no diagnostic'"). */
|
|
880
|
+
function safeColdStartAbort(hook) {
|
|
881
|
+
if (hook === undefined)
|
|
882
|
+
return null;
|
|
883
|
+
try {
|
|
884
|
+
return hook();
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
886
890
|
export function safeColdStartDiagnostic(hook) {
|
|
887
891
|
if (hook === undefined)
|
|
888
892
|
return undefined;
|
|
@@ -55,12 +55,13 @@ export interface MessageResultDTO {
|
|
|
55
55
|
node_id: NodeIdDTO;
|
|
56
56
|
/** Whether an inbox entry was appended now. */
|
|
57
57
|
delivered: boolean;
|
|
58
|
-
/** Whether
|
|
58
|
+
/** Whether this request synchronously launched the target. Ordinary durable
|
|
59
|
+
* delivery to an active or idle dormant target always reports false: the
|
|
60
|
+
* lifecycle reconciler launches it from the durable inbox entry later. */
|
|
59
61
|
revived: boolean;
|
|
60
|
-
/** Present only when a wake
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* appended either way, and delivery follows that relaunch. */
|
|
62
|
+
/** Present only when this request attempted a synchronous wake and no broker
|
|
63
|
+
* slot was free. The target's row is frozen and the daemon relaunches it
|
|
64
|
+
* when one frees; the entry was durably appended either way. */
|
|
64
65
|
not_revived_reason?: 'capacity_frozen';
|
|
65
66
|
/** Present only when the entry was appended ABOVE the requested tier: a
|
|
66
67
|
* terminal target never takes the later cycle `normal`/`deferred` wait for,
|
package/dist/api/errors.d.ts
CHANGED
|
@@ -15,5 +15,8 @@ export declare class ApiError extends Error {
|
|
|
15
15
|
readonly details?: unknown;
|
|
16
16
|
constructor(status: number, code: string, message: string, details?: unknown);
|
|
17
17
|
}
|
|
18
|
+
/** A local API transport failure has stable status/code identity, independent
|
|
19
|
+
* of message prose. */
|
|
20
|
+
export declare function isDaemonTransportApiError(error: unknown): error is ApiError;
|
|
18
21
|
/** Type guard for an `ErrorBody`-shaped parsed payload. */
|
|
19
22
|
export declare function isErrorBody(value: unknown): value is ErrorBody;
|
package/dist/api/errors.js
CHANGED
|
@@ -18,6 +18,16 @@ export class ApiError extends Error {
|
|
|
18
18
|
this.details = details;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/** A local API transport failure has stable status/code identity, independent
|
|
22
|
+
* of message prose. */
|
|
23
|
+
export function isDaemonTransportApiError(error) {
|
|
24
|
+
if (!(error instanceof ApiError))
|
|
25
|
+
return false;
|
|
26
|
+
return (error.status === 503 && (error.code === 'daemon_unavailable'
|
|
27
|
+
|| error.code === 'transport_error'
|
|
28
|
+
|| error.code === 'daemon_request_interrupted')) || (error.status === 504 && error.code === 'request_timeout')
|
|
29
|
+
|| (error.status >= 500 && error.code === 'daemon_health_unavailable');
|
|
30
|
+
}
|
|
21
31
|
/** Type guard for an `ErrorBody`-shaped parsed payload. */
|
|
22
32
|
export function isErrorBody(value) {
|
|
23
33
|
if (typeof value !== 'object' || value === null)
|
package/dist/api/index.d.ts
CHANGED
package/dist/api/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `@north-light/crouter/api` — the single source of truth for crtrd's API DTOs,
|
|
2
2
|
// route constants, error contract, and the typed `CrtrClient`. Dependency-light
|
|
3
3
|
// by design (spec §3.1): Node built-ins + `src/api/*` only.
|
|
4
|
-
export { CrtrClient } from './client.js';
|
|
4
|
+
export { CrtrClient, waitForDaemonAvailability } from './client.js';
|
|
5
5
|
export { ApiError, isErrorBody } from './errors.js';
|
|
6
6
|
export { API_VERSION, routes } from './routes.js';
|
|
7
7
|
export * from '../shared/generated-context.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter-api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.304",
|
|
4
4
|
"description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/api/index.js",
|