@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/dist/client.d.ts +16 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +22 -11
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/launch-nonce.d.ts +45 -6
- package/dist/launch-nonce.d.ts.map +1 -1
- package/dist/launch-nonce.js +159 -3
- package/dist/launch-nonce.js.map +1 -1
- package/dist/provider.d.ts +6 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +9 -6
- package/dist/provider.js.map +1 -1
- package/dist/socket.d.ts +29 -2
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +105 -14
- package/dist/socket.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +38 -11
- package/src/index.ts +5 -0
- package/src/launch-nonce.ts +167 -5
- package/src/provider.ts +17 -6
- package/src/socket.ts +111 -15
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 {
|
|
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
|
-
//
|
|
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 =
|
|
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,
|
package/src/launch-nonce.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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, {
|
|
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 =
|
|
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,
|