@cortexkit/subc-client 0.21.1 → 0.22.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/src/client.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // correlation id, not arrival order.
11
11
 
12
12
  import { promises as fs } from "node:fs";
13
+ import { createHash } from "node:crypto";
13
14
  import { debuglog } from "node:util";
14
15
 
15
16
  import { AuthError, authenticateClient } from "./auth.js";
@@ -25,7 +26,7 @@ import {
25
26
  SUBSCRIPTION_FLAG,
26
27
  type Frame,
27
28
  } from "./envelope.js";
28
- import { launchNonceOrUndefined } from "./launch-nonce.js";
29
+ import { launchNonceOrUndefinedAsync } from "./launch-nonce.js";
29
30
  import type { Principal } from "./provider.js";
30
31
  import {
31
32
  belongsToConnection,
@@ -127,6 +128,8 @@ export interface RouteScope {
127
128
  ref: string;
128
129
  /** Safe non-negative integer, including zero; serialized as scope_epoch. */
129
130
  scopeEpoch: number;
131
+ /** Base64url of a 32-byte secret, without padding; sent as bind_proof. Ignored by scopes without a hash. */
132
+ bindProof?: string;
130
133
  }
131
134
 
132
135
  export interface RouteOpenOptions {
@@ -174,6 +177,13 @@ export interface CatalogSelfSignalDeclaration {
174
177
  note?: string | null;
175
178
  }
176
179
 
180
+ export interface CatalogEventDeclaration {
181
+ name: string;
182
+ version: number;
183
+ headers: string[];
184
+ summary?: string | null;
185
+ }
186
+
177
187
  export interface CatalogEntry {
178
188
  module_id: string;
179
189
  roles: unknown[];
@@ -182,6 +192,8 @@ export interface CatalogEntry {
182
192
  capabilities?: CatalogCapabilities | null;
183
193
  /** Self-signal declarations mirrored verbatim from the registered manifest. */
184
194
  self_signals?: CatalogSelfSignalDeclaration[] | null;
195
+ /** Bus events mirrored verbatim from the registered module manifest. */
196
+ events?: CatalogEventDeclaration[] | null;
185
197
  }
186
198
 
187
199
  export interface RequestOptions {
@@ -427,6 +439,12 @@ export interface ConnectOptions {
427
439
  * exposed mainly so tests can prove the arbitration deterministically.
428
440
  */
429
441
  timeoutArbitrationGraceMs?: number;
442
+ /**
443
+ * Deadline budget (ms) without progress for reading a frame body after its
444
+ * header arrives. Each incoming chunk pushes the deadline out again.
445
+ * Defaults to 30,000 ms.
446
+ */
447
+ bodyReadTimeoutMs?: number;
430
448
  /**
431
449
  * Observer for parsed daemon control pushes, including unrecognized ops which
432
450
  * callers should ignore. Malformed JSON is dropped and observer exceptions are
@@ -531,6 +549,7 @@ interface NormalizedConnectOptions {
531
549
  random: () => number;
532
550
  routeOpenRetryDeadlineMs: number;
533
551
  timeoutArbitrationGraceMs: number;
552
+ bodyReadTimeoutMs?: number;
534
553
  livenessProbeWindowMs: number;
535
554
  onControlPush?: (push: ControlPush) => void;
536
555
  }
@@ -755,7 +774,7 @@ export class SubcClient {
755
774
  opts: RouteOpenOptions = {},
756
775
  ): Promise<RouteHandle> {
757
776
  const scope = validatedRouteScope(opts.scope);
758
- const consumerIdentity = routeOpenConsumerIdentity(opts);
777
+ const consumerIdentity = await routeOpenConsumerIdentity(opts);
759
778
  const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
760
779
  reverseRequests.seal();
761
780
  const consumerCapabilities = reverseRequests.capabilities();
@@ -765,7 +784,9 @@ export class SubcClient {
765
784
  identity,
766
785
  ...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
767
786
  ...(consumerCapabilities.length > 0 ? { consumer_capabilities: consumerCapabilities } : {}),
768
- ...(scope ? { scope: { owner: scope.owner, ref: scope.ref, scope_epoch: scope.scopeEpoch } } : {}),
787
+ ...(scope ? { scope: { owner: scope.owner, ref: scope.ref, scope_epoch: scope.scopeEpoch,
788
+ ...(scope.bindProof !== undefined ? { bind_proof: scope.bindProof } : {}),
789
+ } } : {}),
769
790
  });
770
791
 
771
792
  let installed: RouteHandle | null = null;
@@ -1088,7 +1109,7 @@ export class SubcClient {
1088
1109
  const key = routeCacheKey(
1089
1110
  target,
1090
1111
  identity,
1091
- routeOpenConsumerIdentity(opts),
1112
+ await routeOpenConsumerIdentity(opts),
1092
1113
  (opts.reverseRequests ?? new ReverseRequestRegistry()).capabilities(),
1093
1114
  validatedRouteScope(opts.scope),
1094
1115
  );
@@ -1376,7 +1397,7 @@ export class SubcClient {
1376
1397
  RouteTarget,
1377
1398
  { kind: ManagedRouteKind }
1378
1399
  >;
1379
- const consumerIdentity = routeOpenConsumerIdentity(opts);
1400
+ const consumerIdentity = await routeOpenConsumerIdentity(opts);
1380
1401
  const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
1381
1402
  reverseRequests.seal();
1382
1403
  const key = routeCacheKey(target, identity, consumerIdentity, reverseRequests.capabilities(), scope);
@@ -1766,7 +1787,7 @@ export class SubcClient {
1766
1787
  this.readerActive = false;
1767
1788
  const frame = await sock.readFrame(
1768
1789
  Number.POSITIVE_INFINITY,
1769
- { afterHeaderMs: BODY_READ_TIMEOUT_MS },
1790
+ { afterHeaderMs: this.opts.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS },
1770
1791
  () => {
1771
1792
  this.readerActive = true;
1772
1793
  },
@@ -2269,8 +2290,10 @@ function isRouteOpenRefusal(err: unknown): err is SubcError & { code: string } {
2269
2290
  export function isRetryableRouteOpenCode(code: string | undefined): boolean {
2270
2291
  // A capability deny is policy, never a transient target-availability failure.
2271
2292
  if (code === "capability_forbidden") return false;
2272
- // Decoding a flow stamp alone does not promise flow behaviour.
2293
+ // Targets must explicitly opt into flow and agent-run scope behaviour.
2273
2294
  if (code === "target_flow_unsupported") return false;
2295
+ if (code === "target_agent_run_unsupported") return false;
2296
+ if (code === "scope_bind_proof_required" || code === "scope_bind_proof_mismatch") return false;
2274
2297
  return (
2275
2298
  code === "module_reloading" ||
2276
2299
  code === "module_warming" ||
@@ -2370,6 +2393,7 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
2370
2393
  random: opts.random ?? Math.random,
2371
2394
  routeOpenRetryDeadlineMs: opts.routeOpenRetryDeadlineMs ?? ROUTE_OPEN_RETRY_DEADLINE_MS,
2372
2395
  timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
2396
+ bodyReadTimeoutMs: opts.bodyReadTimeoutMs,
2373
2397
  livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
2374
2398
  onControlPush: opts.onControlPush,
2375
2399
  };
@@ -2403,7 +2427,8 @@ function routeCacheKey(
2403
2427
  const capabilitiesPart = [...consumerCapabilities].sort().join("\0");
2404
2428
  // A JSON tuple keeps opaque refs (including embedded separators) distinct.
2405
2429
  const scopePart = scope
2406
- ? JSON.stringify([scope.owner.kind, scope.owner.kind === "reserved" ? scope.owner.module_id : null, scope.ref, scope.scopeEpoch])
2430
+ ? JSON.stringify([scope.owner.kind, scope.owner.kind === "reserved" ? scope.owner.module_id : null, scope.ref, scope.scopeEpoch,
2431
+ scope.bindProof === undefined ? null : createHash("sha256").update(scope.bindProof).digest("hex")])
2407
2432
  : "";
2408
2433
  return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}\0${capabilitiesPart}\0${scopePart}`;
2409
2434
  }
@@ -2425,10 +2450,12 @@ function validatedRouteScope(scope: RouteScope | undefined): RouteScope | undefi
2425
2450
  if (!Number.isSafeInteger(scope.scopeEpoch) || scope.scopeEpoch < 0) {
2426
2451
  throw new SubcError("scope.scopeEpoch must be a safe non-negative integer", "invalid_scope");
2427
2452
  }
2428
- return { owner: { ...scope.owner }, ref: scope.ref, scopeEpoch: scope.scopeEpoch };
2453
+ return { owner: { ...scope.owner }, ref: scope.ref, scopeEpoch: scope.scopeEpoch,
2454
+ ...(scope.bindProof !== undefined ? { bindProof: scope.bindProof } : {}),
2455
+ };
2429
2456
  }
2430
2457
 
2431
- function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentity | undefined {
2458
+ async function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): Promise<ConsumerIdentity | undefined> {
2432
2459
  if (opts.consumerIdentity !== undefined) return opts.consumerIdentity ?? undefined;
2433
2460
  const moduleId = process.env[SUBC_MODULE_ID_ENV];
2434
2461
  // The nonce comes from the one process-wide accessor, never straight from the
@@ -2436,7 +2463,7 @@ function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentit
2436
2463
  // descriptor, and a second reader would find some other file at its number.
2437
2464
  // A refused descriptor means this process has no identity to present, so the
2438
2465
  // route opens without one, as the Rust consumer does.
2439
- const launchNonce = launchNonceOrUndefined()?.value;
2466
+ const launchNonce = (await launchNonceOrUndefinedAsync())?.value;
2440
2467
  if (!moduleId || !launchNonce) return undefined;
2441
2468
  return { module_id: moduleId, launch_nonce: launchNonce };
2442
2469
  }
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  type RouteOpenOptions,
24
24
  type CatalogCapabilities,
25
25
  type CatalogCapabilityRequirement,
26
+ type CatalogEventDeclaration,
26
27
  type CatalogEntry,
27
28
  type RequestOptions,
28
29
  type ManagedCallOptions,
@@ -89,10 +90,14 @@ export { SubcSocket, SocketClosedError, SocketTimeoutError } from "./socket.js";
89
90
  export {
90
91
  launchNonce,
91
92
  launchNonceOrUndefined,
93
+ launchNonceAsync,
94
+ launchNonceOrUndefinedAsync,
92
95
  isLaunchNonceError,
93
96
  LaunchNonceError,
94
97
  LAUNCH_NONCE_FD,
95
98
  SUBC_LAUNCH_NONCE_FD_ENV,
99
+ SUBC_LAUNCH_NONCE_PIPE_ENV,
100
+ SUBC_LAUNCH_NONCE_PIPE_FALLBACK_ENV,
96
101
  type LaunchNonce,
97
102
  type LaunchNonceErrorKind,
98
103
  type LaunchNonceSource,
@@ -9,14 +9,15 @@
9
9
  * {@link launchNonce} reads that descriptor once, closes it, and caches the
10
10
  * value for the life of the process. While modules move over, the daemon also
11
11
  * keeps setting the environment copy (`SUBC_LAUNCH_NONCE`), which
12
- * {@link launchNonce} reads when no descriptor is named. Windows has no
13
- * descriptor handoff, so there only the environment copy is read.
12
+ * {@link launchNonce} reads when no descriptor is named. Windows uses a
13
+ * PID-authenticated named pipe; call {@link launchNonceAsync} to initialize it.
14
14
  *
15
15
  * This mirrors the Rust accessor in `subc-os` (`subc_os::launch_nonce`): the
16
16
  * same checks in the same order, the same error names and messages.
17
17
  */
18
18
 
19
19
  import { closeSync, fstatSync, readSync, type BigIntStats } from "node:fs";
20
+ import { connect, type Socket } from "node:net";
20
21
 
21
22
  /** The environment copy of the nonce, kept only while modules move to the descriptor. */
22
23
  export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
@@ -30,11 +31,20 @@ export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
30
31
  */
31
32
  export const SUBC_LAUNCH_NONCE_FD_ENV = "SUBC_LAUNCH_NONCE_FD";
32
33
 
34
+ /** The one-time Windows pipe name. Its name is not a secret. */
35
+ export const SUBC_LAUNCH_NONCE_PIPE_ENV = "SUBC_LAUNCH_NONCE_PIPE";
36
+
37
+ /** When the daemon sets this variable to `env`, a failed pipe read may use
38
+ * `SUBC_LAUNCH_NONCE` instead. The daemon keeps that copy until every module
39
+ * reads its secret from the pipe, so older modules can still register.
40
+ */
41
+ export const SUBC_LAUNCH_NONCE_PIPE_FALLBACK_ENV = "SUBC_LAUNCH_NONCE_PIPE_FALLBACK";
42
+
33
43
  /** The descriptor number the daemon gives the pipe's read end in the module. */
34
44
  export const LAUNCH_NONCE_FD = 3;
35
45
 
36
46
  /** Where a process got its launch nonce from, as modules report it in provenance. */
37
- export type LaunchNonceSource = "fd" | "env";
47
+ export type LaunchNonceSource = "fd" | "pipe" | "env";
38
48
 
39
49
  /** The nonce this process was launched with, and where it came from. */
40
50
  export interface LaunchNonce {
@@ -46,7 +56,9 @@ export interface LaunchNonce {
46
56
  * Why the descriptor named by `SUBC_LAUNCH_NONCE_FD` gave no nonce. The names
47
57
  * are the Rust `LaunchNonceError` variants.
48
58
  *
49
- * None of these falls back to the environment copy. A named descriptor that
59
+ * These errors never fall back unless the Windows daemon explicitly sets
60
+ * `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env` while offering a nonempty environment copy.
61
+ * A named descriptor that
50
62
  * cannot be read means the handoff went wrong, or that this process inherited
51
63
  * the variable from a module without inheriting the pipe; reading the
52
64
  * environment instead would hide the first and defeat the second.
@@ -65,7 +77,12 @@ export type LaunchNonceErrorKind =
65
77
  /** Reading the named pipe failed. */
66
78
  | "Unreadable"
67
79
  /** The named pipe held bytes that are not UTF-8. */
68
- | "NotUtf8";
80
+ | "NotUtf8"
81
+ | "PipeNeedsAsync"
82
+ | "PipeNotOpen"
83
+ | "PipeEmpty"
84
+ | "PipeUnreadable"
85
+ | "PipeNotUtf8";
69
86
 
70
87
  export interface LaunchNonceErrorDetails {
71
88
  /** The descriptor number, for every kind except `Malformed`. */
@@ -134,6 +151,10 @@ export function isLaunchNonceError(error: unknown): error is LaunchNonceError {
134
151
  * names is taken only if it is a pipe with the named inode and holds bytes;
135
152
  * it is then read to end of file and closed. Anything else throws and leaves
136
153
  * the descriptor as it was, and never falls back to the environment.
154
+ * - Windows pipe reads use the async accessor. An uninitialized sync pipe
155
+ * reader throws `PipeNeedsAsync`, except when the daemon explicitly permits
156
+ * fallback with `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env`; then a sync reader
157
+ * caches the environment copy.
137
158
  * - Otherwise the value of `SUBC_LAUNCH_NONCE` is used.
138
159
  *
139
160
  * It never changes `process.env`. Removing either variable would break any
@@ -164,6 +185,33 @@ export function launchNonceOrUndefined(): LaunchNonce | undefined {
164
185
  return result.ok ? result.nonce ?? undefined : undefined;
165
186
  }
166
187
 
188
+ /** Initialize the Windows pipe, or use the synchronous Unix/environment reader.
189
+ * Concurrent callers share one in-flight read and its cached result. After this
190
+ * completes, synchronous accessors return that same result. With the daemon's
191
+ * explicit `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env` permission, failed or incomplete
192
+ * pipe reads cache `SUBC_LAUNCH_NONCE` and its `env` source. Without that variable
193
+ * set to `env`, a failed pipe read remains an error even if the copy exists.
194
+ * Node's public pipe API cannot ask Windows to let the server inspect the
195
+ * client's identity without also allowing it to impersonate the client's token.
196
+ */
197
+ export async function launchNonceAsync(): Promise<LaunchNonce | undefined> {
198
+ const cell = processCell();
199
+ return unwrap(await (cell.resultAsync?.() ?? cell.result()));
200
+ }
201
+
202
+ /** Return the cached nonce asynchronously, or `undefined` if its read failed.
203
+ * The SDK uses this when building `route.open`: without a nonce it omits
204
+ * `consumer_identity` instead of presenting an unread or invalid credential.
205
+ */
206
+ export async function launchNonceOrUndefinedAsync(): Promise<LaunchNonce | undefined> {
207
+ try {
208
+ return await launchNonceAsync();
209
+ } catch (error) {
210
+ if (isLaunchNonceError(error)) return undefined;
211
+ throw error;
212
+ }
213
+ }
214
+
167
215
  type CachedResult =
168
216
  | { readonly ok: true; readonly nonce: LaunchNonce | null }
169
217
  | { readonly ok: false; readonly error: LaunchNonceError };
@@ -174,6 +222,7 @@ type CachedResult =
174
222
  */
175
223
  interface SharedLaunchNonceCell {
176
224
  result(): CachedResult;
225
+ resultAsync?(): Promise<CachedResult>;
177
226
  }
178
227
 
179
228
  /**
@@ -219,27 +268,68 @@ export type LaunchNonceStat = (fd: number) => Pick<BigIntStats, "isFIFO" | "ino"
219
268
  */
220
269
  export class LaunchNonceCell implements SharedLaunchNonceCell {
221
270
  private cached: CachedResult | undefined;
271
+ private pending: Promise<CachedResult> | undefined;
222
272
  /** How many times this cell has taken a descriptor. At most one. */
223
273
  descriptorReads = 0;
224
274
  private readonly lookup: (key: string) => string | undefined;
225
275
  private readonly platform: NodeJS.Platform;
226
276
  private readonly stat: LaunchNonceStat;
277
+ private readonly pipeReader: (name: string) => Promise<LaunchNonce>;
227
278
 
228
279
  constructor(
229
280
  lookup: (key: string) => string | undefined,
230
281
  platform: NodeJS.Platform = process.platform,
231
282
  stat: LaunchNonceStat = (fd) => fstatSync(fd, { bigint: true }),
283
+ pipeReader: (name: string) => Promise<LaunchNonce> = readLaunchNoncePipe,
232
284
  ) {
233
285
  this.lookup = lookup;
234
286
  this.platform = platform;
235
287
  this.stat = stat;
288
+ this.pipeReader = pipeReader;
236
289
  }
237
290
 
238
291
  result(): CachedResult {
292
+ if (this.cached === undefined && this.platform === "win32" && this.lookup(SUBC_LAUNCH_NONCE_PIPE_ENV) !== undefined) {
293
+ const fallback = this.permittedPipeFallback();
294
+ if (fallback === undefined) {
295
+ throw new LaunchNonceError("PipeNeedsAsync", "call launchNonceAsync() before the synchronous reader when SUBC_LAUNCH_NONCE_PIPE is set");
296
+ }
297
+ // Legacy synchronous readers cannot use net.connect. Only the daemon's
298
+ // explicit fallback permission lets them retain environment startup while
299
+ // environment-only modules migrate to async pipe reads.
300
+ this.cached = { ok: true, nonce: fallback };
301
+ }
239
302
  if (this.cached === undefined) this.cached = this.read();
240
303
  return this.cached;
241
304
  }
242
305
 
306
+ async resultAsync(): Promise<CachedResult> {
307
+ if (this.cached !== undefined) return this.cached;
308
+ if (this.pending !== undefined) return this.pending;
309
+ const named = this.platform === "win32" ? this.lookup(SUBC_LAUNCH_NONCE_PIPE_ENV) : undefined;
310
+ if (named === undefined) return this.result();
311
+ const fallback = this.permittedPipeFallback();
312
+ this.pending = this.pipeReader(named).then(
313
+ (nonce): CachedResult => ({ ok: true, nonce: fallback !== undefined && nonce.value !== fallback.value ? fallback : nonce }),
314
+ (error: unknown): CachedResult => {
315
+ if (fallback !== undefined) return { ok: true, nonce: fallback };
316
+ if (!isLaunchNonceError(error)) throw error;
317
+ return { ok: false, error };
318
+ },
319
+ ).then((result) => { this.cached ??= result; return this.cached; });
320
+ return this.pending;
321
+ }
322
+
323
+ private permittedPipeFallback(): LaunchNonce | undefined {
324
+ if (this.lookup(SUBC_LAUNCH_NONCE_PIPE_FALLBACK_ENV) !== "env") return undefined;
325
+ const value = this.lookup(SUBC_LAUNCH_NONCE_ENV);
326
+ return value ? makeLaunchNonce(value, "env") : undefined;
327
+ }
328
+
329
+ async getAsync(): Promise<LaunchNonce | undefined> {
330
+ return unwrap(await this.resultAsync());
331
+ }
332
+
243
333
  /** The cached value, reading it first if no caller has yet; throws a refusal. */
244
334
  get(): LaunchNonce | undefined {
245
335
  return unwrap(this.result());
@@ -445,3 +535,75 @@ function closeQuietly(fd: number): void {
445
535
  // Nothing useful can be done if closing the consumed pipe fails.
446
536
  }
447
537
  }
538
+
539
+ /** Read a one-time pipe to EOF. The injected connector and budgets let unit
540
+ * tests exercise busy retry and timeouts without requiring a Windows host.
541
+ */
542
+ export async function readLaunchNoncePipe(
543
+ name: string,
544
+ connector: (name: string) => Socket = (path) => connect(path),
545
+ openWindowMs = 500,
546
+ readWindowMs = 2000,
547
+ ): Promise<LaunchNonce> {
548
+ const deadline = Date.now() + openWindowMs;
549
+ for (;;) {
550
+ try {
551
+ const bytes = await pipeAttempt(name, connector, Math.max(1, deadline - Date.now()), readWindowMs);
552
+ if (bytes.length === 0) throw new LaunchNonceError("PipeEmpty", "the launch nonce named pipe is empty");
553
+ let value: string;
554
+ try {
555
+ value = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
556
+ } catch {
557
+ throw new LaunchNonceError("PipeNotUtf8", "the launch nonce named pipe held bytes that are not UTF-8");
558
+ }
559
+ return makeLaunchNonce(value, "pipe");
560
+ } catch (error) {
561
+ if (isLaunchNonceError(error)) throw error;
562
+ const busy = (error as NodeJS.ErrnoException)?.code === "EBUSY" || errnoOf(error) === 231;
563
+ if (!busy || Date.now() >= deadline) {
564
+ throw new LaunchNonceError("PipeNotOpen", "SUBC_LAUNCH_NONCE_PIPE names a pipe that could not be opened", { errno: errnoOf(error) });
565
+ }
566
+ await new Promise((resolve) => setTimeout(resolve, Math.min(10, Math.max(1, deadline - Date.now()))));
567
+ }
568
+ }
569
+ }
570
+
571
+ function pipeAttempt(
572
+ name: string,
573
+ connector: (name: string) => Socket,
574
+ openWindowMs: number,
575
+ readWindowMs: number,
576
+ ): Promise<Buffer> {
577
+ return new Promise((resolve, reject) => {
578
+ let socket: Socket;
579
+ try { socket = connector(name); } catch (error) { reject(error); return; }
580
+ let connected = false;
581
+ let settled = false;
582
+ let size = 0;
583
+ const chunks: Buffer[] = [];
584
+ let timer = setTimeout(() => finish(new LaunchNonceError("PipeNotOpen", "launch nonce pipe open timed out")), openWindowMs);
585
+ function finish(error?: unknown): void {
586
+ if (settled) return;
587
+ settled = true;
588
+ clearTimeout(timer);
589
+ socket.destroy();
590
+ if (error !== undefined) reject(error);
591
+ else resolve(Buffer.concat(chunks));
592
+ }
593
+ socket.once("connect", () => {
594
+ connected = true;
595
+ clearTimeout(timer);
596
+ timer = setTimeout(() => finish(new LaunchNonceError("PipeUnreadable", "launch nonce pipe read timed out")), readWindowMs);
597
+ });
598
+ socket.on("data", (chunk: Buffer) => {
599
+ size += chunk.length;
600
+ if (size > 4096) { finish(new LaunchNonceError("PipeUnreadable", "launch nonce pipe exceeded its byte limit")); return; }
601
+ chunks.push(Buffer.from(chunk));
602
+ });
603
+ socket.once("end", () => finish());
604
+ socket.once("error", (error) => finish(connected ? new LaunchNonceError("PipeUnreadable", "could not read the launch nonce from the named pipe", { errno: errnoOf(error) }) : error));
605
+ socket.once("close", () => {
606
+ if (!settled) finish(new LaunchNonceError("PipeUnreadable", "launch nonce pipe closed before EOF"));
607
+ });
608
+ });
609
+ }
package/src/provider.ts CHANGED
@@ -27,7 +27,8 @@ import {
27
27
  } from "./envelope.js";
28
28
  import {
29
29
  isLaunchNonceError,
30
- launchNonce as processLaunchNonce,
30
+ launchNonceAsync as processLaunchNonce,
31
+ launchNonceOrUndefinedAsync,
31
32
  launchNonceOrUndefined,
32
33
  type LaunchNonceError,
33
34
  type LaunchNonceSource,
@@ -312,6 +313,12 @@ export interface SubcProviderConnectOptions {
312
313
  sleep?: (ms: number) => Promise<void>;
313
314
  /** Milliseconds to wait before emitting restored after the provider re-registers following a reconnect. */
314
315
  restoredDebounceMs?: number;
316
+ /**
317
+ * Deadline budget (ms) without progress for reading a frame body after its
318
+ * header arrives. Each incoming chunk pushes the deadline out again.
319
+ * Defaults to 30,000 ms.
320
+ */
321
+ bodyReadTimeoutMs?: number;
315
322
  /** Callback that receives ProviderConnectionState events one at a time and in order. */
316
323
  onConnectionState?: (event: ProviderConnectionState) => void | Promise<void>;
317
324
  /**
@@ -351,6 +358,7 @@ interface NormalizedSubcProviderConnectOptions {
351
358
  reconnectBackoff: ReconnectBackoff;
352
359
  sleep: (ms: number) => Promise<void>;
353
360
  restoredDebounceMs: number;
361
+ bodyReadTimeoutMs?: number;
354
362
  onConnectionState?: (event: ProviderConnectionState) => void | Promise<void>;
355
363
  /** The launch nonce to echo in HELLO, already resolved against the environment. */
356
364
  launchNonce?: string;
@@ -673,7 +681,7 @@ export class SubcProvider {
673
681
  );
674
682
  }
675
683
 
676
- const normalized = normalizeProviderConnectOptions(opts);
684
+ const normalized = await normalizeProviderConnectOptions(opts);
677
685
  const opened = await SubcProvider.openConnection(normalized);
678
686
  return new SubcProvider(opened.sock, opened.conn, normalized, opened.ack);
679
687
  }
@@ -718,7 +726,9 @@ export class SubcProvider {
718
726
  const control: ControlPushState = { undecodablePushLogged: false, drainingHooks: new Set() };
719
727
  try {
720
728
  for (;;) {
721
- const frame = await sock.readFrame(Number.POSITIVE_INFINITY, { afterHeaderMs: BODY_READ_TIMEOUT_MS });
729
+ const frame = await sock.readFrame(Number.POSITIVE_INFINITY, {
730
+ afterHeaderMs: this.opts.bodyReadTimeoutMs ?? BODY_READ_TIMEOUT_MS,
731
+ });
722
732
  const keepGoing = await this.dispatch(frame, sock, generation, control);
723
733
  if (!keepGoing) {
724
734
  if (this.sock === sock && this.generation === generation) {
@@ -1457,7 +1467,7 @@ function nonEmpty(value: string | undefined): string | undefined {
1457
1467
  return value && value.length > 0 ? value : undefined;
1458
1468
  }
1459
1469
 
1460
- function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): NormalizedSubcProviderConnectOptions {
1470
+ async function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Promise<NormalizedSubcProviderConnectOptions> {
1461
1471
  // The environment is read once here. The daemon injects both variables when it
1462
1472
  // spawns a module; together they mark the process as supervised, and the nonce
1463
1473
  // is also what HELLO echoes for a reserved module_id.
@@ -1469,13 +1479,13 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
1469
1479
  let processNonce: string | undefined;
1470
1480
  if (opts.launchNonce === undefined) {
1471
1481
  try {
1472
- processNonce = processLaunchNonce()?.value;
1482
+ processNonce = (await processLaunchNonce())?.value;
1473
1483
  } catch (error) {
1474
1484
  if (isLaunchNonceError(error)) throw launchNonceUnavailable(error);
1475
1485
  throw error;
1476
1486
  }
1477
1487
  } else {
1478
- processNonce = launchNonceOrUndefined()?.value;
1488
+ processNonce = (await launchNonceOrUndefinedAsync())?.value;
1479
1489
  }
1480
1490
  const envLaunchNonce = nonEmpty(processNonce);
1481
1491
  const supervised = envModuleId !== undefined && envLaunchNonce !== undefined;
@@ -1493,6 +1503,7 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
1493
1503
  reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
1494
1504
  sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1495
1505
  restoredDebounceMs: opts.restoredDebounceMs ?? DEFAULT_RESTORED_DEBOUNCE_MS,
1506
+ bodyReadTimeoutMs: opts.bodyReadTimeoutMs,
1496
1507
  onConnectionState: opts.onConnectionState,
1497
1508
  launchNonce: nonEmpty(opts.launchNonce ?? envLaunchNonce),
1498
1509
  reconnectOnDrop: opts.reconnectOnDrop ?? !supervised,