@north-light/crouter-api 0.3.303 → 0.3.305

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.
@@ -1,30 +1,12 @@
1
- // client.test.ts regression for issue #516: a cold-start `/healthz` timeout
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 { coldStartTimeoutMessage, CrtrClient, safeColdStartDiagnostic } from '../../client.js';
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
- // REGRESSION: `crtr sys daemon restart` acks, then tears the old server down and
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('a hang-up mid-mutation reports daemon_restarting, not daemon_unavailable', async () => {
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 === 'daemon_restarting');
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('cliClient-style cold start succeeds when the injected poll window covers the (valid) startup delay', async () => {
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, 300);
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
- onColdSocket: async () => {
139
- /* fire-and-forget: the delayed listen() above models ensureDaemon() */
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 health = await client.healthz();
144
- assert.deepEqual(health, { ok: true });
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
- cancel(); // already fired by the time we get here; harmless no-op
147
+ if (listenTimer !== undefined)
148
+ clearTimeout(listenTimer);
148
149
  server.close();
149
150
  rmSync(dir, { recursive: true, force: true });
150
151
  }
@@ -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
- /** Bounded window (ms) to poll `/healthz` after `onColdSocket` resolves
51
- * before giving up with `daemon_unavailable`. Defaults to
52
- * `HEALTHZ_POLL_WINDOW_MS`. This must NOT silently drift from whatever
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 STANDING reason the daemon will never come up — one
62
- * that the poll window cannot outlast because nothing about it changes with
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 re-entering the autostart path more than once per client. */
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: autostart + handover recovery, no JSON parse. Every
318
- * wrapper goes through here; only the non-JSON routes call it directly. */
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
- * pipe) as distinct from a refused connect, which means nothing is
352
- * listening. On the local socket that is `crtr sys daemon restart` doing its
353
- * generation handover: the daemon acks, then tears itself down and hands
354
- * over to a successor it spawned. The daemon IS coming back. */
355
- private isHandoverHangup;
356
- /** Wait for the successor to answer `/healthz`, then replay the request when
357
- * replaying is safe. GET/HEAD are idempotent, so they retry transparently —
358
- * the handover stays invisible, which is the whole point of a restart that
359
- * resumes every node. A mutation may already have been applied server-side
360
- * before the socket dropped, so it fails with `daemon_restarting` (retry),
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;
@@ -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). Absent the hook,
8
- // a cold socket throws `daemon_unavailable` immediately.
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 re-entering the autostart path more than once per client. */
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: autostart + handover recovery, no JSON parse. Every
587
- * wrapper goes through here; only the non-JSON routes call it directly. */
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
- await this.handleColdSocket();
595
- return await this.transport(method, path, body, extraHeaders);
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.isHandoverHangup(err))
598
- return await this.rideOutHandover(method, path, body, extraHeaders);
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: this.timeoutMs,
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
- * pipe) as distinct from a refused connect, which means nothing is
721
- * listening. On the local socket that is `crtr sys daemon restart` doing its
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 successor to answer `/healthz`, then replay the request when
731
- * replaying is safe. GET/HEAD are idempotent, so they retry transparently
732
- * the handover stays invisible, which is the whole point of a restart that
733
- * resumes every node. A mutation may already have been applied server-side
734
- * before the socket dropped, so it fails with `daemon_restarting` (retry),
735
- * never `daemon_unavailable` ("start the daemon" is the wrong advice for a
736
- * daemon that is mid-handover). */
737
- async rideOutHandover(method, path, body, extraHeaders) {
738
- if (!(await this.awaitHandover())) {
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, 'daemon_restarting', `crtrd handed over to a new runtime generation mid-request; this ${method} may or may not have been applied.`);
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
- /** Poll `/healthz` until the successor daemon answers, bounded by the
752
- * cold-start window. Tolerates both the pre-listen gap (cold socket) and a
753
- * second hang-up from a server still tearing down. */
754
- async awaitHandover() {
755
- const deadline = Date.now() + this.coldStartPollWindowMs;
756
- for (;;) {
757
- try {
758
- const res = await this.transport('GET', routes.healthz());
759
- if (res.status >= 200 && res.status < 300)
760
- return true;
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
- /** Trigger the injected daemon-start hook, poll `/healthz`, then let the caller
772
- * retry once. Fail loud with `daemon_unavailable` when autostart is off, no
773
- * hook is wired, or the daemon never becomes reachable. */
774
- async handleColdSocket() {
775
- if (!this.autostart || this.onColdSocket === undefined) {
776
- throw new ApiError(503, 'daemon_unavailable', 'crtrd is not running and autostart is disabled; run `crtr sys daemon start`.');
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
- if (this.coldStartAttempted) {
779
- throw new ApiError(503, 'daemon_unavailable', 'crtrd did not become reachable after autostart.');
802
+ try {
803
+ await this.awaitAvailability(initialError);
780
804
  }
781
- this.coldStartAttempted = true;
782
- await this.onColdSocket();
783
- const deadline = Date.now() + this.coldStartPollWindowMs;
784
- for (;;) {
785
- try {
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
- throw new ApiError(503, 'daemon_unavailable', coldStartTimeoutMessage(diagnostic));
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
- await sleep(HEALTHZ_POLL_INTERVAL_MS);
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 sleep(ms) {
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
- * as an absent/undefined result — the contract `CrtrClientOptions` documents
882
- * ("a thrown/undefined result is treated as 'no diagnostic'"). Without this,
883
- * a broken hook would propagate and replace the typed `daemon_unavailable`
884
- * error the caller is entitled to. Exported alongside `coldStartTimeoutMessage`
885
- * for the same direct-unit-test reason. */
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 a dormant target was revived to receive the message. */
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 was attempted and did not happen:
61
- * `capacity_frozen` means no broker slot was free, so the target's row is
62
- * frozen and the daemon relaunches it when one frees. The entry is durably
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,
@@ -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;
@@ -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)
@@ -1,4 +1,4 @@
1
- export { CrtrClient } from './client.js';
1
+ export { CrtrClient, waitForDaemonAvailability } from './client.js';
2
2
  export type { CrtrClientOptions } from './client.js';
3
3
  export { ApiError, isErrorBody } from './errors.js';
4
4
  export type { ErrorBody } from './errors.js';
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.303",
3
+ "version": "0.3.305",
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",