bunite-core 0.9.0 → 0.10.1
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/package.json +1 -1
- package/src/host/core/App.ts +1 -1
- package/src/host/core/BrowserView.ts +18 -12
- package/src/host/core/BrowserWindow.ts +12 -11
- package/src/host/serveWeb.ts +33 -9
- package/src/preload/runtime.built.js +1 -1
- package/src/preload/runtime.ts +8 -10
- package/src/rpc/error.ts +25 -15
- package/src/rpc/framework.ts +12 -12
- package/src/rpc/index.ts +13 -4
- package/src/rpc/peer.ts +563 -161
- package/src/rpc/renderer.ts +16 -18
- package/src/rpc/schema.ts +30 -47
- package/src/rpc/wire.ts +18 -4
- package/src/rpc/hash.ts +0 -142
package/src/rpc/peer.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type CapDef,
|
|
3
3
|
type Schema,
|
|
4
|
-
type
|
|
4
|
+
type SchemaRoots,
|
|
5
5
|
type ClientOf,
|
|
6
6
|
type ImplOf,
|
|
7
|
-
type
|
|
7
|
+
type ImplsOf,
|
|
8
8
|
type CallCtx,
|
|
9
9
|
type Attestation,
|
|
10
10
|
type ExportedCap,
|
|
@@ -12,14 +12,13 @@ import {
|
|
|
12
12
|
type AnyCapToken,
|
|
13
13
|
type Stream as StreamType,
|
|
14
14
|
type DisposalSpec,
|
|
15
|
-
call,
|
|
16
|
-
cap,
|
|
17
|
-
defineCap,
|
|
18
15
|
isCallDef,
|
|
19
16
|
isStreamDef,
|
|
20
17
|
isCapRef,
|
|
21
18
|
isCapArray,
|
|
22
19
|
isCapRecord,
|
|
20
|
+
isCapDef,
|
|
21
|
+
isSchema,
|
|
23
22
|
} from "./schema";
|
|
24
23
|
import { RuntimeCap, frameworkTypeIdOf } from "./framework";
|
|
25
24
|
import {
|
|
@@ -30,12 +29,22 @@ import {
|
|
|
30
29
|
type CancelFrame,
|
|
31
30
|
type DropFrame,
|
|
32
31
|
type HelloFrame,
|
|
32
|
+
type CapRevokedFrame,
|
|
33
33
|
type CallMeta,
|
|
34
34
|
CapRef,
|
|
35
35
|
DEFAULT_MAX_BYTES,
|
|
36
36
|
PROTOCOL_VERSION,
|
|
37
|
+
FRAMEWORK_NAME_PREFIX,
|
|
38
|
+
BOOTSTRAP_METHOD,
|
|
37
39
|
} from "./wire";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
IpcError,
|
|
42
|
+
type IpcStatus,
|
|
43
|
+
type IpcCode,
|
|
44
|
+
type FailedPreconditionReason,
|
|
45
|
+
type ResourceExhaustedReason,
|
|
46
|
+
type AlreadyExistsReason,
|
|
47
|
+
} from "./error";
|
|
39
48
|
|
|
40
49
|
export const USER_ROOTS_CAP_ID = 0;
|
|
41
50
|
export const RUNTIME_CAP_ID = 1;
|
|
@@ -46,6 +55,9 @@ export const FIRST_USER_CAP_ID = 2;
|
|
|
46
55
|
export const FIRST_USER_TYPE_ID = 128;
|
|
47
56
|
|
|
48
57
|
export const MAX_CAPS_PER_CONNECTION = 1024;
|
|
58
|
+
export const MAX_IN_FLIGHT_CALLS_PER_CONNECTION = 1024;
|
|
59
|
+
/** Client-side LRU cap for revoked cap-ids — prevents unbounded growth on long-lived connections with frequent plugin churn (e.g. flmux). */
|
|
60
|
+
const REVOKED_CACHE_SIZE = MAX_CAPS_PER_CONNECTION * 4;
|
|
49
61
|
|
|
50
62
|
const DEFAULT_DEADLINE_GRACE_MS = 500;
|
|
51
63
|
const DEFAULT_STREAM_INITIAL_CREDIT = 32;
|
|
@@ -101,7 +113,11 @@ export class CapTable {
|
|
|
101
113
|
|
|
102
114
|
allocate(entry: Omit<CapTableEntry, "capId">): CapTableEntry {
|
|
103
115
|
if (this.entries.size >= this.capLimit) {
|
|
104
|
-
throw new IpcError({
|
|
116
|
+
throw new IpcError({
|
|
117
|
+
code: "resource_exhausted",
|
|
118
|
+
message: `cap-table limit ${this.capLimit}`,
|
|
119
|
+
details: { reason: "max_caps_per_connection" as ResourceExhaustedReason },
|
|
120
|
+
});
|
|
105
121
|
}
|
|
106
122
|
let capId = this.nextCapId++;
|
|
107
123
|
while (this.entries.has(capId)) capId = this.nextCapId++;
|
|
@@ -123,6 +139,11 @@ export class CapTable {
|
|
|
123
139
|
return false;
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
delete(capId: number): boolean {
|
|
143
|
+
if (capId < FIRST_USER_CAP_ID) return false;
|
|
144
|
+
return this.entries.delete(capId);
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
clear(): void {
|
|
127
148
|
this.entries.clear();
|
|
128
149
|
this.nextCapId = FIRST_USER_CAP_ID;
|
|
@@ -143,14 +164,32 @@ export interface Transport {
|
|
|
143
164
|
close(): void;
|
|
144
165
|
}
|
|
145
166
|
|
|
167
|
+
export type Policy = (name: string, attestation: Attestation) => boolean | Promise<boolean>;
|
|
168
|
+
|
|
169
|
+
export type IfExists = "throw" | "replace" | "skip";
|
|
170
|
+
|
|
171
|
+
export interface ServeHandle extends Disposable {
|
|
172
|
+
readonly names: readonly string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface ConnectionEvents {
|
|
176
|
+
bootstrap: { name: string; version?: string; attestation: Attestation; result: "ok" | "denied" | "not_found" | "version_mismatch" | "invalid_argument" | "resource_exhausted" | "internal"; capId?: number };
|
|
177
|
+
call: { capId: number; capName?: string; method: string; callId: number; durationMs?: number; result: "ok" | "cancelled" | IpcCode };
|
|
178
|
+
stream: { capId: number; capName?: string; method: string; callId: number; event: "start" | "end" | "cancel" | "error"; count?: number };
|
|
179
|
+
revoke: { capIds: number[]; reason: "unserve" | "replace" };
|
|
180
|
+
error: { phase: string; error: Error };
|
|
181
|
+
}
|
|
182
|
+
|
|
146
183
|
export interface Connection {
|
|
147
|
-
bootstrap<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
184
|
+
bootstrap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>>;
|
|
185
|
+
bootstrap<R extends SchemaRoots>(schema: Schema<R>): Promise<{ [K in keyof R]: ClientOf<R[K]> }>;
|
|
186
|
+
serve<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>, opts?: { ifExists?: IfExists }): ServeHandle;
|
|
187
|
+
serveAll<R extends SchemaRoots>(schema: Schema<R>, impls: ImplsOf<R>, opts?: { ifExists?: IfExists }): ServeHandle;
|
|
188
|
+
unserve(target: CapDef<any, any> | ServeHandle): void;
|
|
189
|
+
replace<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>): void;
|
|
152
190
|
runtime(): ClientOf<typeof RuntimeCap>;
|
|
153
191
|
releaseRef(proxy: unknown): void;
|
|
192
|
+
on<K extends keyof ConnectionEvents>(event: K, handler: (e: ConnectionEvents[K]) => void): () => void;
|
|
154
193
|
onClose(handler: () => void): () => void;
|
|
155
194
|
readonly closed: boolean;
|
|
156
195
|
}
|
|
@@ -162,9 +201,11 @@ export interface ConnectionOptions {
|
|
|
162
201
|
features?: string[];
|
|
163
202
|
maxBytes?: number;
|
|
164
203
|
capLimit?: number;
|
|
204
|
+
maxInFlightCalls?: number;
|
|
165
205
|
peerId?: string;
|
|
166
206
|
attestation?: Attestation;
|
|
167
207
|
runtime?: ImplOf<typeof RuntimeCap>;
|
|
208
|
+
policy?: Policy;
|
|
168
209
|
}
|
|
169
210
|
|
|
170
211
|
export interface PendingCall {
|
|
@@ -173,18 +214,25 @@ export interface PendingCall {
|
|
|
173
214
|
abort: AbortController;
|
|
174
215
|
decodeReturn?: ReturnDecoder;
|
|
175
216
|
timer?: ReturnType<typeof setTimeout>;
|
|
217
|
+
startedAt?: number;
|
|
218
|
+
capId?: number;
|
|
219
|
+
capName?: string;
|
|
220
|
+
method?: string;
|
|
221
|
+
kind?: "call" | "stream";
|
|
176
222
|
}
|
|
177
223
|
|
|
178
224
|
type ReturnDecoder = (raw: unknown) => unknown;
|
|
179
225
|
|
|
226
|
+
// Default is conservative: "untrusted" until the host explicitly hands a real attestation
|
|
227
|
+
// (engine-mined for native, derived from req origin for web). Policy hooks should treat absence as deny-able.
|
|
180
228
|
const DEFAULT_ATTESTATION: Attestation = {
|
|
181
|
-
origin: "
|
|
182
|
-
topOrigin: "
|
|
229
|
+
origin: "",
|
|
230
|
+
topOrigin: "",
|
|
183
231
|
partition: "default",
|
|
184
|
-
isAppRes:
|
|
185
|
-
isMainFrame:
|
|
232
|
+
isAppRes: false,
|
|
233
|
+
isMainFrame: false,
|
|
186
234
|
userGesture: false,
|
|
187
|
-
level: "
|
|
235
|
+
level: "untrusted",
|
|
188
236
|
};
|
|
189
237
|
|
|
190
238
|
const EXPORTED_CAP_BRAND = Symbol("bunite.rpc.ExportedCap");
|
|
@@ -209,14 +257,26 @@ interface ServerStreamCtx {
|
|
|
209
257
|
cancelled: boolean;
|
|
210
258
|
credit: number;
|
|
211
259
|
creditWaker: (() => void) | null;
|
|
260
|
+
capId: number;
|
|
261
|
+
capName?: string;
|
|
262
|
+
method: string;
|
|
263
|
+
callId: number;
|
|
264
|
+
count: number;
|
|
212
265
|
}
|
|
213
266
|
|
|
214
267
|
interface ClientStreamCtx {
|
|
268
|
+
capId: number;
|
|
215
269
|
push(chunk: unknown): void;
|
|
216
270
|
end(): void;
|
|
217
271
|
fail(error: IpcError): void;
|
|
218
272
|
}
|
|
219
273
|
|
|
274
|
+
interface RegistryEntry {
|
|
275
|
+
cap: CapDef<any, any>;
|
|
276
|
+
impl: unknown;
|
|
277
|
+
version?: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
220
280
|
class ConnectionImpl implements Connection {
|
|
221
281
|
private readonly transport: Transport;
|
|
222
282
|
private readonly capTable: CapTable;
|
|
@@ -224,10 +284,15 @@ class ConnectionImpl implements Connection {
|
|
|
224
284
|
private readonly clientStreams = new Map<number, ClientStreamCtx>();
|
|
225
285
|
private readonly serverStreams = new Map<number, ServerStreamCtx>();
|
|
226
286
|
private readonly serverCallChildren = new Map<number, { parentId: number }>();
|
|
227
|
-
private readonly serverActiveCalls = new Map<number, AbortController>();
|
|
287
|
+
private readonly serverActiveCalls = new Map<number, { ctrl: AbortController; capId: number; capName: string; method: string; startedAt: number }>();
|
|
288
|
+
/** Server-side: name → registry entry. */
|
|
289
|
+
private readonly registry = new Map<string, RegistryEntry>();
|
|
290
|
+
/** Server-side: name → instance cap-id (cached bootstrap result). */
|
|
228
291
|
private readonly rootInstances = new Map<string, number>();
|
|
229
|
-
|
|
292
|
+
/** Client-side: cap-ids that the server revoked via cap_revoked. */
|
|
293
|
+
private readonly revokedCapIds = new Set<number>();
|
|
230
294
|
private readonly closeHandlers = new Set<() => void>();
|
|
295
|
+
private readonly observers: { [K in keyof ConnectionEvents]?: Set<(e: ConnectionEvents[K]) => void> } = {};
|
|
231
296
|
private nextCallId = 1;
|
|
232
297
|
private remoteHello: HelloFrame | null = null;
|
|
233
298
|
private readonly remoteReady: Promise<HelloFrame>;
|
|
@@ -235,11 +300,13 @@ class ConnectionImpl implements Connection {
|
|
|
235
300
|
private rejectRemoteReady!: (e: Error) => void;
|
|
236
301
|
private closed_ = false;
|
|
237
302
|
private readonly maxBytes: number;
|
|
303
|
+
private readonly maxInFlightCalls: number;
|
|
238
304
|
private readonly mode: "native" | "web";
|
|
239
305
|
private readonly origin: string;
|
|
240
306
|
private readonly features: string[];
|
|
241
307
|
private readonly attestation: Attestation;
|
|
242
308
|
private readonly peerId: string;
|
|
309
|
+
private readonly policy: Policy | undefined;
|
|
243
310
|
|
|
244
311
|
constructor(opts: ConnectionOptions) {
|
|
245
312
|
this.transport = opts.transport;
|
|
@@ -247,16 +314,20 @@ class ConnectionImpl implements Connection {
|
|
|
247
314
|
this.origin = opts.origin;
|
|
248
315
|
this.features = opts.features ?? [];
|
|
249
316
|
this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
317
|
+
this.maxInFlightCalls = opts.maxInFlightCalls ?? MAX_IN_FLIGHT_CALLS_PER_CONNECTION;
|
|
250
318
|
this.attestation = opts.attestation ?? DEFAULT_ATTESTATION;
|
|
251
319
|
this.peerId = opts.peerId ?? "peer";
|
|
320
|
+
this.policy = opts.policy;
|
|
252
321
|
this.capTable = new CapTable(opts.capLimit ?? MAX_CAPS_PER_CONNECTION);
|
|
253
322
|
|
|
323
|
+
// cap-id 0 = bootstrap dispatcher (no cap def — special-cased in handleCall)
|
|
254
324
|
this.capTable.install(USER_ROOTS_CAP_ID, {
|
|
255
325
|
typeId: USER_ROOTS_TYPE_ID,
|
|
256
326
|
cap: null,
|
|
257
327
|
impl: null,
|
|
258
328
|
refCount: 1,
|
|
259
329
|
});
|
|
330
|
+
// cap-id 1 = Runtime instance (framework-stable, pre-installed)
|
|
260
331
|
this.capTable.install(RUNTIME_CAP_ID, {
|
|
261
332
|
typeId: RUNTIME_TYPE_ID,
|
|
262
333
|
cap: RuntimeCap,
|
|
@@ -290,48 +361,156 @@ class ConnectionImpl implements Connection {
|
|
|
290
361
|
return () => this.closeHandlers.delete(handler);
|
|
291
362
|
}
|
|
292
363
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (!
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
364
|
+
on<K extends keyof ConnectionEvents>(event: K, handler: (e: ConnectionEvents[K]) => void): () => void {
|
|
365
|
+
let set = this.observers[event] as Set<(e: ConnectionEvents[K]) => void> | undefined;
|
|
366
|
+
if (!set) {
|
|
367
|
+
set = new Set();
|
|
368
|
+
(this.observers as Record<string, Set<unknown>>)[event] = set as unknown as Set<unknown>;
|
|
369
|
+
}
|
|
370
|
+
set.add(handler);
|
|
371
|
+
return () => { set!.delete(handler); };
|
|
301
372
|
}
|
|
302
373
|
|
|
303
|
-
private
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
374
|
+
private emitObs<K extends keyof ConnectionEvents>(event: K, data: ConnectionEvents[K]): void {
|
|
375
|
+
const set = this.observers[event] as Set<(e: ConnectionEvents[K]) => void> | undefined;
|
|
376
|
+
if (!set || set.size === 0) return;
|
|
377
|
+
for (const h of set) {
|
|
378
|
+
try { h(data); } catch { /* swallow */ }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private markRevoked(capId: number): void {
|
|
383
|
+
// Set preserves insertion order — drop oldest when at cap.
|
|
384
|
+
this.revokedCapIds.add(capId);
|
|
385
|
+
while (this.revokedCapIds.size > REVOKED_CACHE_SIZE) {
|
|
386
|
+
const oldest = this.revokedCapIds.values().next().value;
|
|
387
|
+
if (oldest === undefined) break;
|
|
388
|
+
this.revokedCapIds.delete(oldest);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- serve / unserve / replace ----
|
|
393
|
+
|
|
394
|
+
serve<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>, opts?: { ifExists?: IfExists }): ServeHandle {
|
|
395
|
+
this.assertNotFrameworkName(cap.name);
|
|
396
|
+
const ifExists = opts?.ifExists ?? "throw";
|
|
397
|
+
const existing = this.registry.get(cap.name);
|
|
398
|
+
if (existing) {
|
|
399
|
+
switch (ifExists) {
|
|
400
|
+
case "throw":
|
|
401
|
+
throw new IpcError({
|
|
402
|
+
code: "already_exists",
|
|
403
|
+
message: `cap "${cap.name}" already served`,
|
|
404
|
+
details: { reason: "name_collision" as AlreadyExistsReason },
|
|
405
|
+
});
|
|
406
|
+
case "skip":
|
|
407
|
+
// Empty handle: skipping means we are not the owner — unserve must not revoke someone else's registration.
|
|
408
|
+
return this.makeHandle([]);
|
|
409
|
+
case "replace":
|
|
410
|
+
this.replace(cap, impl);
|
|
411
|
+
return this.makeHandle([cap.name]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.registry.set(cap.name, { cap, impl, version: cap.version });
|
|
415
|
+
return this.makeHandle([cap.name]);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private makeHandle(names: string[]): ServeHandle {
|
|
419
|
+
const handle: ServeHandle = {
|
|
420
|
+
names,
|
|
421
|
+
[Symbol.dispose]: () => this.unserve(handle),
|
|
422
|
+
};
|
|
423
|
+
return handle;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
serveAll<R extends SchemaRoots>(schema: Schema<R>, impls: ImplsOf<R>, opts?: { ifExists?: IfExists }): ServeHandle {
|
|
427
|
+
const ifExists = opts?.ifExists ?? "throw";
|
|
428
|
+
// Pre-validate everything that can fail across all modes before mutating any state (atomicity).
|
|
429
|
+
for (const k of Object.keys(schema.roots)) {
|
|
430
|
+
const c = schema.roots[k];
|
|
431
|
+
this.assertNotFrameworkName(c.name);
|
|
432
|
+
const existing = this.registry.get(c.name);
|
|
433
|
+
if (existing) {
|
|
434
|
+
if (ifExists === "throw") {
|
|
435
|
+
throw new IpcError({
|
|
436
|
+
code: "already_exists",
|
|
437
|
+
message: `cap "${c.name}" already served`,
|
|
438
|
+
details: { reason: "name_collision" as AlreadyExistsReason },
|
|
439
|
+
});
|
|
326
440
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
441
|
+
if (ifExists === "replace" && existing.version !== c.version) {
|
|
442
|
+
throw new IpcError({
|
|
443
|
+
code: "failed_precondition",
|
|
444
|
+
message: `version mismatch on replace for "${c.name}" (current "${existing.version}", new "${c.version}")`,
|
|
445
|
+
details: { reason: "version_mismatch" as FailedPreconditionReason },
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Mutations below cannot fail (pre-validation already cleared collision/version/prefix paths).
|
|
451
|
+
const names: string[] = [];
|
|
452
|
+
for (const k of Object.keys(schema.roots)) {
|
|
453
|
+
const c = schema.roots[k];
|
|
454
|
+
const i = (impls as Record<string, unknown>)[k];
|
|
455
|
+
const h = this.serve(c, i as ImplOf<typeof c>, { ifExists });
|
|
456
|
+
// serve(...) returns empty names[] only for skipped entries — exclude from the aggregate handle.
|
|
457
|
+
if (h.names.length > 0) names.push(c.name);
|
|
458
|
+
}
|
|
459
|
+
return this.makeHandle(names);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
unserve(target: CapDef<any, any> | ServeHandle): void {
|
|
463
|
+
const names = isCapDef(target) ? [target.name] : Array.from((target as ServeHandle).names);
|
|
464
|
+
const revoked: number[] = [];
|
|
465
|
+
for (const name of names) {
|
|
466
|
+
if (!this.registry.delete(name)) continue;
|
|
467
|
+
const instId = this.rootInstances.get(name);
|
|
468
|
+
if (instId !== undefined) {
|
|
469
|
+
const entry = this.capTable.get(instId);
|
|
470
|
+
if (entry) this.invokeServerDisposal(entry);
|
|
471
|
+
this.rootInstances.delete(name);
|
|
472
|
+
this.capTable.delete(instId);
|
|
473
|
+
revoked.push(instId);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (revoked.length > 0) {
|
|
477
|
+
this.transport.send({ op: "cap_revoked", capIds: revoked });
|
|
478
|
+
this.emitObs("revoke", { capIds: revoked, reason: "unserve" });
|
|
331
479
|
}
|
|
332
|
-
return { def: defineCap(methods), impl };
|
|
333
480
|
}
|
|
334
481
|
|
|
482
|
+
replace<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>): void {
|
|
483
|
+
const entry = this.registry.get(cap.name);
|
|
484
|
+
if (!entry) throw new IpcError({ code: "not_found", message: `cap "${cap.name}" not served` });
|
|
485
|
+
if (entry.version !== cap.version) {
|
|
486
|
+
throw new IpcError({
|
|
487
|
+
code: "failed_precondition",
|
|
488
|
+
message: `version mismatch (current "${entry.version}", new "${cap.version}")`,
|
|
489
|
+
details: { reason: "version_mismatch" as FailedPreconditionReason },
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
entry.impl = impl;
|
|
493
|
+
entry.cap = cap;
|
|
494
|
+
const instId = this.rootInstances.get(cap.name);
|
|
495
|
+
if (instId !== undefined) {
|
|
496
|
+
const e = this.capTable.get(instId);
|
|
497
|
+
if (e) { e.impl = impl; e.cap = cap; }
|
|
498
|
+
}
|
|
499
|
+
this.emitObs("revoke", { capIds: instId !== undefined ? [instId] : [], reason: "replace" });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private assertNotFrameworkName(name: string): void {
|
|
503
|
+
if (name.startsWith(FRAMEWORK_NAME_PREFIX)) {
|
|
504
|
+
throw new IpcError({
|
|
505
|
+
code: "already_exists",
|
|
506
|
+
message: `cap name "${name}" uses reserved prefix "${FRAMEWORK_NAME_PREFIX}"`,
|
|
507
|
+
details: { reason: "reserved_namespace" as AlreadyExistsReason },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---- runtime / bootstrap ----
|
|
513
|
+
|
|
335
514
|
private runtimeProxy: ClientOf<typeof RuntimeCap> | null = null;
|
|
336
515
|
|
|
337
516
|
runtime(): ClientOf<typeof RuntimeCap> {
|
|
@@ -341,25 +520,49 @@ class ConnectionImpl implements Connection {
|
|
|
341
520
|
return this.runtimeProxy;
|
|
342
521
|
}
|
|
343
522
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
523
|
+
bootstrap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>>;
|
|
524
|
+
bootstrap<R extends SchemaRoots>(schema: Schema<R>): Promise<{ [K in keyof R]: ClientOf<R[K]> }>;
|
|
525
|
+
async bootstrap(target: CapDef<any, any> | Schema<any>): Promise<unknown> {
|
|
526
|
+
if (isCapDef(target)) return this._bootstrapCap(target);
|
|
527
|
+
if (isSchema(target)) return this._bootstrapSchema(target);
|
|
528
|
+
throw new IpcError({ code: "invalid_argument", message: "bootstrap target must be CapDef or Schema" });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private async _bootstrapCap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>> {
|
|
348
532
|
await this.remoteReady;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
throw new IpcError({ code: "invalid_argument", message: `root "${name}" not in schema` });
|
|
353
|
-
}
|
|
354
|
-
const rootCap = schema.roots[name] as CapDef<any, any>;
|
|
355
|
-
const hash = await schema.topologyHash();
|
|
356
|
-
const raw = await this.sendCallTyped(USER_ROOTS_CAP_ID, idx, undefined, undefined, { topologyHash: hash });
|
|
533
|
+
const args: { name: string; version?: string } = { name: cap.name };
|
|
534
|
+
if (cap.version != null) args.version = cap.version;
|
|
535
|
+
const raw = await this.sendCallTyped(USER_ROOTS_CAP_ID, BOOTSTRAP_METHOD, args, undefined);
|
|
357
536
|
if (!(raw instanceof CapRef)) {
|
|
358
|
-
throw new IpcError({ code: "
|
|
537
|
+
throw new IpcError({ code: "invalid_argument", message: "bootstrap did not return a CapRef" });
|
|
359
538
|
}
|
|
360
|
-
return this.makeCapProxy(
|
|
539
|
+
return this.makeCapProxy(cap, raw.capId) as ClientOf<C>;
|
|
361
540
|
}
|
|
362
541
|
|
|
542
|
+
private async _bootstrapSchema<R extends SchemaRoots>(
|
|
543
|
+
schema: Schema<R>
|
|
544
|
+
): Promise<{ [K in keyof R]: ClientOf<R[K]> }> {
|
|
545
|
+
const keys = Object.keys(schema.roots) as (keyof R & string)[];
|
|
546
|
+
const settled = await Promise.allSettled(keys.map((k) => this._bootstrapCap(schema.roots[k])));
|
|
547
|
+
const rejected = settled.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
|
548
|
+
if (rejected) {
|
|
549
|
+
// Release server refCount on the roots that succeeded — otherwise their cap-table entries linger until connection close.
|
|
550
|
+
for (const r of settled) {
|
|
551
|
+
if (r.status === "fulfilled") {
|
|
552
|
+
try { this.releaseRef(r.value); } catch { /* swallow */ }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
throw rejected.reason;
|
|
556
|
+
}
|
|
557
|
+
const out = {} as { [K in keyof R]: ClientOf<R[K]> };
|
|
558
|
+
for (let i = 0; i < keys.length; i++) {
|
|
559
|
+
(out as Record<string, unknown>)[keys[i]] = (settled[i] as PromiseFulfilledResult<unknown>).value;
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---- frame dispatch ----
|
|
565
|
+
|
|
363
566
|
private handleFrame(frame: Frame): void {
|
|
364
567
|
if (this.closed_) return;
|
|
365
568
|
switch (frame.op) {
|
|
@@ -381,6 +584,9 @@ class ConnectionImpl implements Connection {
|
|
|
381
584
|
case "drop":
|
|
382
585
|
this.handleDrop(frame);
|
|
383
586
|
return;
|
|
587
|
+
case "cap_revoked":
|
|
588
|
+
this.handleCapRevoked(frame);
|
|
589
|
+
return;
|
|
384
590
|
case "goaway":
|
|
385
591
|
this.handleGoaway(frame);
|
|
386
592
|
return;
|
|
@@ -393,11 +599,11 @@ class ConnectionImpl implements Connection {
|
|
|
393
599
|
private handleUnknownFrame(frame: unknown): void {
|
|
394
600
|
const id = (frame as { id?: unknown })?.id;
|
|
395
601
|
if (typeof id === "number") {
|
|
396
|
-
this.transport.send({ op: "result", id, ok: false, error: { code: "
|
|
602
|
+
this.transport.send({ op: "result", id, ok: false, error: { code: "invalid_argument", message: "unknown opcode" } });
|
|
397
603
|
return;
|
|
398
604
|
}
|
|
399
|
-
this.transport.send({ op: "goaway", reason: "
|
|
400
|
-
this.shutdown("
|
|
605
|
+
this.transport.send({ op: "goaway", reason: "invalid_argument", error: { code: "invalid_argument", message: "unknown opcode" } });
|
|
606
|
+
this.shutdown("invalid_argument");
|
|
401
607
|
}
|
|
402
608
|
|
|
403
609
|
private handleHello(frame: HelloFrame): void {
|
|
@@ -412,44 +618,165 @@ class ConnectionImpl implements Connection {
|
|
|
412
618
|
this.shutdown(frame.reason ?? "remote goaway");
|
|
413
619
|
}
|
|
414
620
|
|
|
415
|
-
private
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
621
|
+
private handleCapRevoked(frame: CapRevokedFrame): void {
|
|
622
|
+
for (const capId of frame.capIds) {
|
|
623
|
+
this.markRevoked(capId);
|
|
624
|
+
const err = new IpcError({
|
|
625
|
+
code: "failed_precondition",
|
|
626
|
+
message: "cap revoked",
|
|
627
|
+
details: { reason: "revoked" as FailedPreconditionReason },
|
|
628
|
+
});
|
|
629
|
+
// Fail pending calls targeting this cap.
|
|
630
|
+
for (const [id, p] of this.pending) {
|
|
631
|
+
if (p.capId === capId) {
|
|
632
|
+
this.pending.delete(id);
|
|
633
|
+
if (p.timer) clearTimeout(p.timer);
|
|
634
|
+
p.reject(err);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Fail active client streams targeting this cap — "revoked wins" symmetric with calls.
|
|
638
|
+
for (const [id, s] of this.clientStreams) {
|
|
639
|
+
if (s.capId === capId) {
|
|
640
|
+
this.clientStreams.delete(id);
|
|
641
|
+
s.fail(err);
|
|
430
642
|
}
|
|
431
643
|
}
|
|
432
644
|
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private async handleCall(frame: CallFrame): Promise<void> {
|
|
648
|
+
if (frame.target.id === USER_ROOTS_CAP_ID && frame.method === BOOTSTRAP_METHOD) {
|
|
649
|
+
await this.handleBootstrap(frame);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const entry = this.capTable.get(frame.target.id);
|
|
654
|
+
if (!entry) {
|
|
655
|
+
this.emitObs("call", { capId: frame.target.id, method: frame.method, callId: frame.id, result: "not_found" });
|
|
656
|
+
return this.sendError(frame.id, "not_found", `cap-id ${frame.target.id} not found`);
|
|
657
|
+
}
|
|
433
658
|
|
|
434
659
|
const cap = entry.cap;
|
|
435
|
-
if (!cap || !entry.impl)
|
|
660
|
+
if (!cap || !entry.impl) {
|
|
661
|
+
this.emitObs("call", { capId: frame.target.id, method: frame.method, callId: frame.id, result: "not_found" });
|
|
662
|
+
return this.sendError(frame.id, "not_found", "cap has no impl");
|
|
663
|
+
}
|
|
436
664
|
|
|
437
|
-
const
|
|
438
|
-
if (
|
|
439
|
-
|
|
665
|
+
const methodDef = cap.methods[frame.method] as MethodDef | undefined;
|
|
666
|
+
if (!methodDef) {
|
|
667
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "not_found" });
|
|
668
|
+
return this.sendError(frame.id, "not_found", `method "${frame.method}" on cap "${cap.name}"`);
|
|
440
669
|
}
|
|
441
|
-
const
|
|
442
|
-
const methodDef = cap.methods[methodName] as MethodDef;
|
|
443
|
-
const impl = (entry.impl as Record<string, unknown>)[methodName];
|
|
670
|
+
const impl = (entry.impl as Record<string, unknown>)[frame.method];
|
|
444
671
|
if (typeof impl !== "function") {
|
|
445
|
-
|
|
672
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "not_found" });
|
|
673
|
+
return this.sendError(frame.id, "not_found", `method "${frame.method}" has no handler`);
|
|
446
674
|
}
|
|
447
675
|
|
|
448
|
-
|
|
676
|
+
// Bound inbound work — symmetric with sendCallTyped's outbound check.
|
|
677
|
+
if (this.serverActiveCalls.size + this.serverStreams.size >= this.maxInFlightCalls) {
|
|
678
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "resource_exhausted" });
|
|
679
|
+
return this.sendError(
|
|
680
|
+
frame.id,
|
|
681
|
+
"resource_exhausted",
|
|
682
|
+
`in-flight calls limit ${this.maxInFlightCalls}`,
|
|
683
|
+
{ reason: "max_concurrent_calls" as ResourceExhaustedReason },
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
await this.invokeServerMethod(frame, cap, methodDef, impl as (params: unknown, ctx: CallCtx) => unknown);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private async handleBootstrap(frame: CallFrame): Promise<void> {
|
|
691
|
+
const args = (frame.args ?? {}) as { name?: unknown; version?: unknown };
|
|
692
|
+
const name = args.name;
|
|
693
|
+
if (typeof name !== "string") {
|
|
694
|
+
this.emitObs("bootstrap", { name: String(name), attestation: this.attestation, result: "invalid_argument" });
|
|
695
|
+
return this.sendError(frame.id, "invalid_argument", "bootstrap requires {name: string}");
|
|
696
|
+
}
|
|
697
|
+
const clientVersion = args.version != null ? String(args.version) : undefined;
|
|
698
|
+
|
|
699
|
+
// Framework caps (cap-id 1 Runtime is pre-installed and accessed via .runtime(),
|
|
700
|
+
// not via bootstrap). For user-facing bootstrap, only the registry is consulted.
|
|
701
|
+
const entry = this.registry.get(name);
|
|
702
|
+
if (!entry) {
|
|
703
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "not_found" });
|
|
704
|
+
return this.sendError(frame.id, "not_found", `cap "${name}" not served`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const serverVersion = entry.version;
|
|
708
|
+
if (serverVersion != null && clientVersion != null && serverVersion !== clientVersion) {
|
|
709
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "version_mismatch" });
|
|
710
|
+
return this.sendError(
|
|
711
|
+
frame.id,
|
|
712
|
+
"failed_precondition",
|
|
713
|
+
`version mismatch (server "${serverVersion}", client "${clientVersion}")`,
|
|
714
|
+
{ reason: "version_mismatch" as FailedPreconditionReason }
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (this.policy) {
|
|
719
|
+
let allowed: boolean | Promise<boolean>;
|
|
720
|
+
try {
|
|
721
|
+
allowed = await this.policy(name, this.attestation);
|
|
722
|
+
} catch (err) {
|
|
723
|
+
this.emitObs("error", { phase: "policy", error: err instanceof Error ? err : new Error(String(err)) });
|
|
724
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "internal" });
|
|
725
|
+
return this.sendError(frame.id, "internal", "policy threw");
|
|
726
|
+
}
|
|
727
|
+
if (typeof allowed !== "boolean") {
|
|
728
|
+
// Non-boolean return = programming bug; surface explicitly (not silent deny).
|
|
729
|
+
this.emitObs("error", { phase: "policy", error: new Error(`policy must return boolean (got ${typeof allowed})`) });
|
|
730
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "internal" });
|
|
731
|
+
return this.sendError(frame.id, "internal", "policy returned non-boolean");
|
|
732
|
+
}
|
|
733
|
+
if (!allowed) {
|
|
734
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "denied" });
|
|
735
|
+
return this.sendError(
|
|
736
|
+
frame.id,
|
|
737
|
+
"failed_precondition",
|
|
738
|
+
"policy denied",
|
|
739
|
+
{ reason: "unauthorized" as FailedPreconditionReason }
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
let capId = this.rootInstances.get(name);
|
|
745
|
+
if (capId !== undefined) {
|
|
746
|
+
const cached = this.capTable.get(capId);
|
|
747
|
+
if (cached) {
|
|
748
|
+
cached.refCount += 1;
|
|
749
|
+
} else {
|
|
750
|
+
this.rootInstances.delete(name);
|
|
751
|
+
capId = undefined;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (capId === undefined) {
|
|
755
|
+
try {
|
|
756
|
+
const allocated = this.capTable.allocate({
|
|
757
|
+
typeId: frameworkTypeIdOf(entry.cap) ?? FIRST_USER_TYPE_ID,
|
|
758
|
+
cap: entry.cap,
|
|
759
|
+
impl: entry.impl,
|
|
760
|
+
refCount: 1,
|
|
761
|
+
});
|
|
762
|
+
capId = allocated.capId;
|
|
763
|
+
this.rootInstances.set(name, capId);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
if (err instanceof IpcError) {
|
|
766
|
+
const result = err.code === "resource_exhausted" ? "resource_exhausted" : "internal";
|
|
767
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result });
|
|
768
|
+
return this.sendError(frame.id, err.code, err.message, err.details as Record<string, unknown> | undefined);
|
|
769
|
+
}
|
|
770
|
+
throw err;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "ok", capId });
|
|
774
|
+
this.transport.send({ op: "result", id: frame.id, ok: true, value: new CapRef(capId) });
|
|
449
775
|
}
|
|
450
776
|
|
|
451
777
|
private async invokeServerMethod(
|
|
452
778
|
frame: CallFrame,
|
|
779
|
+
cap: CapDef<any, any>,
|
|
453
780
|
methodDef: MethodDef,
|
|
454
781
|
impl: (params: unknown, ctx: CallCtx) => unknown
|
|
455
782
|
): Promise<void> {
|
|
@@ -464,16 +791,24 @@ class ConnectionImpl implements Connection {
|
|
|
464
791
|
? () => als.run({ callId: frame.id }, runStream)
|
|
465
792
|
: runStream;
|
|
466
793
|
const stream = scoped();
|
|
467
|
-
this.runServerStream(frame.id, stream, ctx, hintBudget);
|
|
794
|
+
this.runServerStream(frame.id, stream, ctx, hintBudget, cap, frame.method, frame.target.id);
|
|
468
795
|
} catch (err) {
|
|
796
|
+
this.emitObs("stream", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, event: "error" });
|
|
469
797
|
this.transport.send({ op: "stream", id: frame.id, ev: "error", error: toStatus(err) });
|
|
470
798
|
}
|
|
471
799
|
return;
|
|
472
800
|
}
|
|
473
801
|
|
|
474
802
|
if (isCallDef(methodDef)) {
|
|
803
|
+
const startedAt = performance.now();
|
|
475
804
|
const deadlineTimer = methodDef.idempotent ? undefined : this.armServerDeadline(frame, ctx);
|
|
476
|
-
this.serverActiveCalls.set(frame.id,
|
|
805
|
+
this.serverActiveCalls.set(frame.id, {
|
|
806
|
+
ctrl: (ctx as unknown as { _ctrl: AbortController })._ctrl,
|
|
807
|
+
capId: frame.target.id,
|
|
808
|
+
capName: cap.name,
|
|
809
|
+
method: frame.method,
|
|
810
|
+
startedAt,
|
|
811
|
+
});
|
|
477
812
|
const encoder = makeReturnEncoder(methodDef.returns);
|
|
478
813
|
const runImpl = () => Promise.resolve(impl(frame.args, ctx));
|
|
479
814
|
const als = callContextStorage;
|
|
@@ -486,15 +821,18 @@ class ConnectionImpl implements Connection {
|
|
|
486
821
|
if (!this.serverActiveCalls.delete(frame.id)) return;
|
|
487
822
|
const encoded = encoder(result);
|
|
488
823
|
this.transport.send({ op: "result", id: frame.id, ok: true, value: encoded });
|
|
824
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, durationMs: performance.now() - startedAt, result: "ok" });
|
|
489
825
|
} catch (err) {
|
|
490
826
|
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
491
827
|
if (!this.serverActiveCalls.delete(frame.id)) return;
|
|
492
|
-
|
|
828
|
+
const status = toStatus(err);
|
|
829
|
+
this.sendErrorFromStatus(frame.id, status);
|
|
830
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, durationMs: performance.now() - startedAt, result: status.code });
|
|
493
831
|
}
|
|
494
832
|
return;
|
|
495
833
|
}
|
|
496
834
|
|
|
497
|
-
this.sendError(frame.id, "
|
|
835
|
+
this.sendError(frame.id, "not_found", "unknown method kind");
|
|
498
836
|
}
|
|
499
837
|
|
|
500
838
|
private armServerDeadline(frame: CallFrame, ctx: CallCtx): ReturnType<typeof setTimeout> | undefined {
|
|
@@ -536,7 +874,15 @@ class ConnectionImpl implements Connection {
|
|
|
536
874
|
} as unknown as ExportedCap<C>;
|
|
537
875
|
}
|
|
538
876
|
|
|
539
|
-
private runServerStream(
|
|
877
|
+
private runServerStream(
|
|
878
|
+
callId: number,
|
|
879
|
+
stream: StreamType<unknown>,
|
|
880
|
+
ctx: CallCtx,
|
|
881
|
+
initialCredit: number,
|
|
882
|
+
cap: CapDef<any, any>,
|
|
883
|
+
method: string,
|
|
884
|
+
capId: number,
|
|
885
|
+
): void {
|
|
540
886
|
const iter = (stream as AsyncIterable<unknown>)[Symbol.asyncIterator]();
|
|
541
887
|
const abort = (ctx as unknown as { _ctrl: AbortController })._ctrl;
|
|
542
888
|
const slot: ServerStreamCtx = {
|
|
@@ -545,8 +891,14 @@ class ConnectionImpl implements Connection {
|
|
|
545
891
|
cancelled: false,
|
|
546
892
|
credit: initialCredit,
|
|
547
893
|
creditWaker: null,
|
|
894
|
+
capId,
|
|
895
|
+
capName: cap.name,
|
|
896
|
+
method,
|
|
897
|
+
callId,
|
|
898
|
+
count: 0,
|
|
548
899
|
};
|
|
549
900
|
this.serverStreams.set(callId, slot);
|
|
901
|
+
this.emitObs("stream", { capId, capName: cap.name, method, callId, event: "start" });
|
|
550
902
|
|
|
551
903
|
const waitForCredit = (): Promise<void> => {
|
|
552
904
|
if (slot.credit > 0 || slot.cancelled) return Promise.resolve();
|
|
@@ -563,13 +915,18 @@ class ConnectionImpl implements Connection {
|
|
|
563
915
|
if (done) break;
|
|
564
916
|
if (slot.cancelled) break;
|
|
565
917
|
slot.credit -= 1;
|
|
918
|
+
slot.count += 1;
|
|
566
919
|
this.transport.send({ op: "stream", id: callId, ev: "next", value });
|
|
567
920
|
}
|
|
568
921
|
} catch (err) {
|
|
569
922
|
errored = true;
|
|
570
923
|
this.transport.send({ op: "stream", id: callId, ev: "error", error: toStatus(err) });
|
|
924
|
+
this.emitObs("stream", { capId, capName: cap.name, method, callId, event: "error", count: slot.count });
|
|
571
925
|
} finally {
|
|
572
|
-
if (!errored)
|
|
926
|
+
if (!errored) {
|
|
927
|
+
this.transport.send({ op: "stream", id: callId, ev: "end" });
|
|
928
|
+
this.emitObs("stream", { capId, capName: cap.name, method, callId, event: slot.cancelled ? "cancel" : "end", count: slot.count });
|
|
929
|
+
}
|
|
573
930
|
this.serverStreams.delete(callId);
|
|
574
931
|
}
|
|
575
932
|
};
|
|
@@ -594,7 +951,7 @@ class ConnectionImpl implements Connection {
|
|
|
594
951
|
if (stream) {
|
|
595
952
|
this.clientStreams.delete(frame.id);
|
|
596
953
|
const err = frame.ok
|
|
597
|
-
? new IpcError({ code: "
|
|
954
|
+
? new IpcError({ code: "invalid_argument", message: "stream method returned result frame" })
|
|
598
955
|
: new IpcError(frame.error);
|
|
599
956
|
stream.fail(err);
|
|
600
957
|
}
|
|
@@ -611,13 +968,21 @@ class ConnectionImpl implements Connection {
|
|
|
611
968
|
const active = this.serverActiveCalls.get(frame.id);
|
|
612
969
|
if (active) {
|
|
613
970
|
this.serverActiveCalls.delete(frame.id);
|
|
614
|
-
active.abort();
|
|
971
|
+
active.ctrl.abort();
|
|
615
972
|
this.transport.send({
|
|
616
973
|
op: "result",
|
|
617
974
|
id: frame.id,
|
|
618
975
|
ok: false,
|
|
619
976
|
error: { code: "cancelled", message: frame.reason },
|
|
620
977
|
});
|
|
978
|
+
this.emitObs("call", {
|
|
979
|
+
capId: active.capId,
|
|
980
|
+
capName: active.capName,
|
|
981
|
+
method: active.method,
|
|
982
|
+
callId: frame.id,
|
|
983
|
+
durationMs: performance.now() - active.startedAt,
|
|
984
|
+
result: "cancelled",
|
|
985
|
+
});
|
|
621
986
|
}
|
|
622
987
|
for (const [childId, child] of this.serverCallChildren) {
|
|
623
988
|
if (child.parentId !== frame.id) continue;
|
|
@@ -680,34 +1045,52 @@ class ConnectionImpl implements Connection {
|
|
|
680
1045
|
}
|
|
681
1046
|
}
|
|
682
1047
|
|
|
683
|
-
private sendError(callId: number, code: IpcCode, message?: string): void {
|
|
684
|
-
|
|
1048
|
+
private sendError(callId: number, code: IpcCode, message?: string, details?: unknown): void {
|
|
1049
|
+
const error: IpcStatus = { code, message };
|
|
1050
|
+
if (details !== undefined) error.details = details;
|
|
1051
|
+
this.transport.send({ op: "result", id: callId, ok: false, error });
|
|
685
1052
|
}
|
|
686
1053
|
|
|
687
|
-
private
|
|
688
|
-
this.transport.send({ op: "result", id: callId, ok: false, error:
|
|
1054
|
+
private sendErrorFromStatus(callId: number, status: IpcStatus): void {
|
|
1055
|
+
this.transport.send({ op: "result", id: callId, ok: false, error: status });
|
|
689
1056
|
}
|
|
690
1057
|
|
|
691
1058
|
private nextId(): number {
|
|
692
1059
|
return this.nextCallId++;
|
|
693
1060
|
}
|
|
694
1061
|
|
|
695
|
-
sendCallRaw(capId: number, methodIdx: number, args: unknown, meta?: CallMeta): Promise<unknown> {
|
|
696
|
-
return this.sendCallTyped(capId, methodIdx, args, undefined, meta);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
1062
|
sendCallTyped(
|
|
700
1063
|
capId: number,
|
|
701
|
-
|
|
1064
|
+
method: string,
|
|
702
1065
|
args: unknown,
|
|
703
1066
|
decodeReturn: ReturnDecoder | undefined,
|
|
704
|
-
meta?: CallMeta
|
|
1067
|
+
meta?: CallMeta,
|
|
1068
|
+
capName?: string,
|
|
705
1069
|
): Promise<unknown> {
|
|
706
1070
|
if (this.closed_) return Promise.reject(new IpcError({ code: "unavailable", message: "connection closed" }));
|
|
1071
|
+
if (this.revokedCapIds.has(capId)) {
|
|
1072
|
+
return Promise.reject(new IpcError({
|
|
1073
|
+
code: "failed_precondition",
|
|
1074
|
+
message: "cap revoked",
|
|
1075
|
+
details: { reason: "revoked" as FailedPreconditionReason },
|
|
1076
|
+
}));
|
|
1077
|
+
}
|
|
1078
|
+
if (this.pending.size >= this.maxInFlightCalls) {
|
|
1079
|
+
return Promise.reject(new IpcError({
|
|
1080
|
+
code: "resource_exhausted",
|
|
1081
|
+
message: `in-flight calls limit ${this.maxInFlightCalls}`,
|
|
1082
|
+
details: { reason: "max_concurrent_calls" as ResourceExhaustedReason },
|
|
1083
|
+
}));
|
|
1084
|
+
}
|
|
707
1085
|
const id = this.nextId();
|
|
708
1086
|
return new Promise((resolve, reject) => {
|
|
709
1087
|
const abort = new AbortController();
|
|
710
|
-
const pending: PendingCall = {
|
|
1088
|
+
const pending: PendingCall = {
|
|
1089
|
+
resolve, reject, abort, decodeReturn,
|
|
1090
|
+
startedAt: performance.now(),
|
|
1091
|
+
capId, method, capName,
|
|
1092
|
+
kind: "call",
|
|
1093
|
+
};
|
|
711
1094
|
if (meta?.deadlineMs) {
|
|
712
1095
|
pending.timer = setTimeout(() => {
|
|
713
1096
|
if (this.pending.delete(id)) {
|
|
@@ -725,16 +1108,31 @@ class ConnectionImpl implements Connection {
|
|
|
725
1108
|
if (finalMeta?.parentCallId !== undefined) {
|
|
726
1109
|
this.serverCallChildren.set(id, { parentId: finalMeta.parentCallId });
|
|
727
1110
|
}
|
|
728
|
-
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method
|
|
1111
|
+
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method, args, meta: finalMeta });
|
|
729
1112
|
});
|
|
730
1113
|
}
|
|
731
1114
|
|
|
732
|
-
openClientStream<T>(
|
|
1115
|
+
openClientStream<T>(
|
|
1116
|
+
capId: number,
|
|
1117
|
+
method: string,
|
|
1118
|
+
args: unknown,
|
|
1119
|
+
meta?: CallMeta,
|
|
1120
|
+
capName?: string,
|
|
1121
|
+
): StreamType<T> {
|
|
733
1122
|
if (this.closed_) {
|
|
734
|
-
const empty = makeClientStream<T>(0, () => {}, () => {});
|
|
1123
|
+
const empty = makeClientStream<T>(capId, 0, () => {}, () => {});
|
|
735
1124
|
empty.fail(new IpcError({ code: "unavailable", message: "connection closed" }));
|
|
736
1125
|
return empty.stream;
|
|
737
1126
|
}
|
|
1127
|
+
if (this.revokedCapIds.has(capId)) {
|
|
1128
|
+
const empty = makeClientStream<T>(capId, 0, () => {}, () => {});
|
|
1129
|
+
empty.fail(new IpcError({
|
|
1130
|
+
code: "failed_precondition",
|
|
1131
|
+
message: "cap revoked",
|
|
1132
|
+
details: { reason: "revoked" as FailedPreconditionReason },
|
|
1133
|
+
}));
|
|
1134
|
+
return empty.stream;
|
|
1135
|
+
}
|
|
738
1136
|
const id = this.nextId();
|
|
739
1137
|
const cancel = () => {
|
|
740
1138
|
if (this.clientStreams.delete(id)) {
|
|
@@ -745,14 +1143,14 @@ class ConnectionImpl implements Connection {
|
|
|
745
1143
|
if (this.closed_ || !this.clientStreams.has(id)) return;
|
|
746
1144
|
this.transport.send({ op: "stream", id, ev: "credit", credit: { messages } });
|
|
747
1145
|
};
|
|
748
|
-
const ctx = makeClientStream<T>(id, cancel, sendCredit);
|
|
1146
|
+
const ctx = makeClientStream<T>(capId, id, cancel, sendCredit);
|
|
749
1147
|
this.clientStreams.set(id, ctx);
|
|
750
|
-
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method
|
|
1148
|
+
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method, args, meta });
|
|
1149
|
+
void capName;
|
|
751
1150
|
return ctx.stream;
|
|
752
1151
|
}
|
|
753
1152
|
|
|
754
1153
|
makeCapProxy<C extends CapDef<any, any>>(capDef: C, capId: number): ClientOf<C> {
|
|
755
|
-
const methodNames = Object.keys(capDef.methods);
|
|
756
1154
|
const proxy: Record<string | symbol, unknown> = {};
|
|
757
1155
|
const meta = { capId, dropped: false };
|
|
758
1156
|
proxy[CAP_PROXY_META] = meta;
|
|
@@ -762,32 +1160,31 @@ class ConnectionImpl implements Connection {
|
|
|
762
1160
|
this.sendDrop(capId);
|
|
763
1161
|
};
|
|
764
1162
|
|
|
765
|
-
for (
|
|
766
|
-
const name = methodNames[i];
|
|
1163
|
+
for (const name of Object.keys(capDef.methods)) {
|
|
767
1164
|
const def = capDef.methods[name] as MethodDef;
|
|
768
1165
|
if (isStreamDef(def)) {
|
|
769
|
-
proxy[name] = (params?: unknown) => this.openClientStream(capId,
|
|
1166
|
+
proxy[name] = (params?: unknown) => this.openClientStream(capId, name, params, undefined, capDef.name);
|
|
770
1167
|
} else if (isCallDef(def)) {
|
|
771
1168
|
const decoder = makeReturnDecoder(def.returns, (cd, cid) => this.makeCapProxy(cd, cid));
|
|
772
|
-
proxy[name] = (params?: unknown) => this.sendCallTyped(capId,
|
|
1169
|
+
proxy[name] = (params?: unknown) => this.sendCallTyped(capId, name, params, decoder, undefined, capDef.name);
|
|
773
1170
|
}
|
|
774
1171
|
}
|
|
775
1172
|
|
|
776
1173
|
const disposal = capDef.disposal as DisposalSpec | undefined;
|
|
777
1174
|
if (disposal) {
|
|
778
|
-
const
|
|
779
|
-
const fn = proxy[disposal.method] as (() => unknown) | undefined;
|
|
780
|
-
return fn?.();
|
|
781
|
-
};
|
|
782
|
-
proxy[Symbol.asyncDispose] = async () => {
|
|
783
|
-
const r = invokeMethod();
|
|
784
|
-
await Promise.resolve(r);
|
|
785
|
-
drop();
|
|
786
|
-
};
|
|
1175
|
+
const conn = this;
|
|
787
1176
|
proxy[Symbol.dispose] = () => {
|
|
788
|
-
|
|
1177
|
+
try {
|
|
1178
|
+
const r = (proxy[disposal.method] as (() => unknown) | undefined)?.();
|
|
1179
|
+
if (r && typeof (r as Promise<unknown>).then === "function") {
|
|
1180
|
+
(r as Promise<unknown>).catch((err) =>
|
|
1181
|
+
conn.emitObs("error", { phase: "dispose", error: err instanceof Error ? err : new Error(String(err)) })
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
conn.emitObs("error", { phase: "dispose", error: err instanceof Error ? err : new Error(String(err)) });
|
|
1186
|
+
}
|
|
789
1187
|
drop();
|
|
790
|
-
void r;
|
|
791
1188
|
};
|
|
792
1189
|
}
|
|
793
1190
|
|
|
@@ -824,8 +1221,8 @@ class ConnectionImpl implements Connection {
|
|
|
824
1221
|
void slot.iter?.return?.();
|
|
825
1222
|
}
|
|
826
1223
|
this.serverStreams.clear();
|
|
827
|
-
for (const
|
|
828
|
-
ctrl.abort();
|
|
1224
|
+
for (const a of this.serverActiveCalls.values()) {
|
|
1225
|
+
a.ctrl.abort();
|
|
829
1226
|
}
|
|
830
1227
|
this.serverActiveCalls.clear();
|
|
831
1228
|
for (const entry of this.capTable.values()) {
|
|
@@ -858,10 +1255,18 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
|
|
|
858
1255
|
if (!returns) return (v) => v;
|
|
859
1256
|
const expectExportedCap = (v: unknown, expectedCap: CapDef<any, any>): CapRef => {
|
|
860
1257
|
if (!isExportedCap(v)) {
|
|
861
|
-
throw new IpcError({
|
|
1258
|
+
throw new IpcError({
|
|
1259
|
+
code: "failed_precondition",
|
|
1260
|
+
message: "expected ctx.exportCap return for cap method",
|
|
1261
|
+
details: { reason: "unregistered_cap_return" as FailedPreconditionReason },
|
|
1262
|
+
});
|
|
862
1263
|
}
|
|
863
1264
|
if (v.cap !== expectedCap) {
|
|
864
|
-
throw new IpcError({
|
|
1265
|
+
throw new IpcError({
|
|
1266
|
+
code: "failed_precondition",
|
|
1267
|
+
message: "exported cap type mismatch with method returns",
|
|
1268
|
+
details: { reason: "unregistered_cap_return" as FailedPreconditionReason },
|
|
1269
|
+
});
|
|
865
1270
|
}
|
|
866
1271
|
return new CapRef(v.capId);
|
|
867
1272
|
};
|
|
@@ -869,7 +1274,7 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
|
|
|
869
1274
|
if (isCapArray(returns)) {
|
|
870
1275
|
return (v) => {
|
|
871
1276
|
if (!Array.isArray(v)) {
|
|
872
|
-
throw new IpcError({ code: "
|
|
1277
|
+
throw new IpcError({ code: "invalid_argument", message: "expected ExportedCap[] for cap.array method" });
|
|
873
1278
|
}
|
|
874
1279
|
return v.map((item) => expectExportedCap(item, returns.cap));
|
|
875
1280
|
};
|
|
@@ -877,7 +1282,7 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
|
|
|
877
1282
|
if (isCapRecord(returns)) {
|
|
878
1283
|
return (v) => {
|
|
879
1284
|
if (!v || typeof v !== "object") {
|
|
880
|
-
throw new IpcError({ code: "
|
|
1285
|
+
throw new IpcError({ code: "invalid_argument", message: "expected Record<string, ExportedCap> for cap.record method" });
|
|
881
1286
|
}
|
|
882
1287
|
const out: Record<string, CapRef> = {};
|
|
883
1288
|
for (const k of Object.keys(v as object)) {
|
|
@@ -897,7 +1302,7 @@ function makeReturnDecoder(
|
|
|
897
1302
|
if (isCapRef(returns)) {
|
|
898
1303
|
const inner = returns.cap;
|
|
899
1304
|
return (raw) => {
|
|
900
|
-
if (!(raw instanceof CapRef)) throw new IpcError({ code: "
|
|
1305
|
+
if (!(raw instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef" });
|
|
901
1306
|
return spawn(inner, raw.capId);
|
|
902
1307
|
};
|
|
903
1308
|
}
|
|
@@ -905,9 +1310,9 @@ function makeReturnDecoder(
|
|
|
905
1310
|
const inner = returns.cap;
|
|
906
1311
|
const disposal = inner.disposal as DisposalSpec | undefined;
|
|
907
1312
|
return (raw) => {
|
|
908
|
-
if (!Array.isArray(raw)) throw new IpcError({ code: "
|
|
1313
|
+
if (!Array.isArray(raw)) throw new IpcError({ code: "invalid_argument", message: "expected array" });
|
|
909
1314
|
const proxies = raw.map((item) => {
|
|
910
|
-
if (!(item instanceof CapRef)) throw new IpcError({ code: "
|
|
1315
|
+
if (!(item instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef in array" });
|
|
911
1316
|
return spawn(inner, item.capId);
|
|
912
1317
|
});
|
|
913
1318
|
attachArrayDisposal(proxies, disposal);
|
|
@@ -917,11 +1322,11 @@ function makeReturnDecoder(
|
|
|
917
1322
|
if (isCapRecord(returns)) {
|
|
918
1323
|
const inner = returns.cap;
|
|
919
1324
|
return (raw) => {
|
|
920
|
-
if (!raw || typeof raw !== "object") throw new IpcError({ code: "
|
|
1325
|
+
if (!raw || typeof raw !== "object") throw new IpcError({ code: "invalid_argument", message: "expected record" });
|
|
921
1326
|
const out: Record<string, unknown> = {};
|
|
922
1327
|
for (const k of Object.keys(raw as object)) {
|
|
923
1328
|
const item = (raw as Record<string, unknown>)[k];
|
|
924
|
-
if (!(item instanceof CapRef)) throw new IpcError({ code: "
|
|
1329
|
+
if (!(item instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef in record" });
|
|
925
1330
|
out[k] = spawn(inner, item.capId);
|
|
926
1331
|
}
|
|
927
1332
|
return out;
|
|
@@ -932,27 +1337,22 @@ function makeReturnDecoder(
|
|
|
932
1337
|
|
|
933
1338
|
function attachArrayDisposal(arr: unknown[], disposal: DisposalSpec | undefined): void {
|
|
934
1339
|
if (!disposal) return;
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
: () => {
|
|
942
|
-
for (const proxy of arr) {
|
|
943
|
-
const fn = (proxy as Record<symbol, unknown>)[Symbol.dispose] as (() => void) | undefined;
|
|
944
|
-
fn?.call(proxy);
|
|
945
|
-
}
|
|
946
|
-
};
|
|
1340
|
+
(arr as any)[Symbol.dispose] = () => {
|
|
1341
|
+
for (const proxy of arr) {
|
|
1342
|
+
const fn = (proxy as Record<symbol, unknown>)[Symbol.dispose] as (() => void) | undefined;
|
|
1343
|
+
fn?.call(proxy);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
947
1346
|
}
|
|
948
1347
|
|
|
949
1348
|
function toStatus(err: unknown): IpcStatus {
|
|
950
1349
|
if (err instanceof IpcError) return err.toStatus();
|
|
951
|
-
if (err instanceof Error) return { code: "
|
|
952
|
-
return { code: "
|
|
1350
|
+
if (err instanceof Error) return { code: "internal", message: err.message };
|
|
1351
|
+
return { code: "internal", message: String(err) };
|
|
953
1352
|
}
|
|
954
1353
|
|
|
955
1354
|
interface ClientStreamHandle<T> {
|
|
1355
|
+
capId: number;
|
|
956
1356
|
stream: StreamType<T>;
|
|
957
1357
|
push(chunk: unknown): void;
|
|
958
1358
|
end(): void;
|
|
@@ -960,6 +1360,7 @@ interface ClientStreamHandle<T> {
|
|
|
960
1360
|
}
|
|
961
1361
|
|
|
962
1362
|
function makeClientStream<T>(
|
|
1363
|
+
capId: number,
|
|
963
1364
|
streamId: number,
|
|
964
1365
|
cancel: () => void,
|
|
965
1366
|
sendCredit: (messages: number) => void
|
|
@@ -1033,6 +1434,7 @@ function makeClientStream<T>(
|
|
|
1033
1434
|
void streamId;
|
|
1034
1435
|
|
|
1035
1436
|
return {
|
|
1437
|
+
capId,
|
|
1036
1438
|
stream,
|
|
1037
1439
|
push(chunk) {
|
|
1038
1440
|
if (cancelled || ended || failure) return;
|