bunite-core 0.9.0 → 0.10.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/rpc/peer.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  type CapDef,
3
3
  type Schema,
4
- type SchemaShape,
4
+ type SchemaRoots,
5
5
  type ClientOf,
6
6
  type ImplOf,
7
- type ServerDescriptor,
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 { IpcError, type IpcStatus, type IpcCode } from "./error";
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,7 @@ 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;
49
59
 
50
60
  const DEFAULT_DEADLINE_GRACE_MS = 500;
51
61
  const DEFAULT_STREAM_INITIAL_CREDIT = 32;
@@ -101,7 +111,11 @@ export class CapTable {
101
111
 
102
112
  allocate(entry: Omit<CapTableEntry, "capId">): CapTableEntry {
103
113
  if (this.entries.size >= this.capLimit) {
104
- throw new IpcError({ code: "resource_exhausted", message: `cap-table limit ${this.capLimit}` });
114
+ throw new IpcError({
115
+ code: "resource_exhausted",
116
+ message: `cap-table limit ${this.capLimit}`,
117
+ details: { reason: "max_caps_per_connection" as ResourceExhaustedReason },
118
+ });
105
119
  }
106
120
  let capId = this.nextCapId++;
107
121
  while (this.entries.has(capId)) capId = this.nextCapId++;
@@ -123,6 +137,11 @@ export class CapTable {
123
137
  return false;
124
138
  }
125
139
 
140
+ delete(capId: number): boolean {
141
+ if (capId < FIRST_USER_CAP_ID) return false;
142
+ return this.entries.delete(capId);
143
+ }
144
+
126
145
  clear(): void {
127
146
  this.entries.clear();
128
147
  this.nextCapId = FIRST_USER_CAP_ID;
@@ -143,14 +162,32 @@ export interface Transport {
143
162
  close(): void;
144
163
  }
145
164
 
165
+ export type Policy = (name: string, attestation: Attestation) => boolean | Promise<boolean>;
166
+
167
+ export type IfExists = "throw" | "replace" | "skip";
168
+
169
+ export interface ServeHandle extends Disposable {
170
+ readonly names: readonly string[];
171
+ }
172
+
173
+ export interface ConnectionEvents {
174
+ bootstrap: { name: string; version?: string; attestation: Attestation; result: "ok" | "denied" | "not_found" | "version_mismatch" | "invalid_argument" | "resource_exhausted" | "internal"; capId?: number };
175
+ call: { capId: number; capName?: string; method: string; callId: number; durationMs?: number; result: "ok" | "cancelled" | IpcCode };
176
+ stream: { capId: number; capName?: string; method: string; callId: number; event: "start" | "end" | "cancel" | "error"; count?: number };
177
+ revoke: { capIds: number[]; reason: "unserve" | "replace" };
178
+ error: { phase: string; error: Error };
179
+ }
180
+
146
181
  export interface Connection {
147
- bootstrap<S extends SchemaShape, K extends keyof S["roots"] & string>(
148
- schema: Schema<S>,
149
- name: K
150
- ): Promise<ClientOf<S["roots"][K]>>;
151
- serve<S extends SchemaShape>(descriptor: ServerDescriptor<S>): void;
182
+ bootstrap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>>;
183
+ bootstrap<R extends SchemaRoots>(schema: Schema<R>): Promise<{ [K in keyof R]: ClientOf<R[K]> }>;
184
+ serve<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>, opts?: { ifExists?: IfExists }): ServeHandle;
185
+ serveAll<R extends SchemaRoots>(schema: Schema<R>, impls: ImplsOf<R>, opts?: { ifExists?: IfExists }): ServeHandle;
186
+ unserve(target: CapDef<any, any> | ServeHandle): void;
187
+ replace<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>): void;
152
188
  runtime(): ClientOf<typeof RuntimeCap>;
153
189
  releaseRef(proxy: unknown): void;
190
+ on<K extends keyof ConnectionEvents>(event: K, handler: (e: ConnectionEvents[K]) => void): () => void;
154
191
  onClose(handler: () => void): () => void;
155
192
  readonly closed: boolean;
156
193
  }
@@ -162,9 +199,11 @@ export interface ConnectionOptions {
162
199
  features?: string[];
163
200
  maxBytes?: number;
164
201
  capLimit?: number;
202
+ maxInFlightCalls?: number;
165
203
  peerId?: string;
166
204
  attestation?: Attestation;
167
205
  runtime?: ImplOf<typeof RuntimeCap>;
206
+ policy?: Policy;
168
207
  }
169
208
 
170
209
  export interface PendingCall {
@@ -173,18 +212,25 @@ export interface PendingCall {
173
212
  abort: AbortController;
174
213
  decodeReturn?: ReturnDecoder;
175
214
  timer?: ReturnType<typeof setTimeout>;
215
+ startedAt?: number;
216
+ capId?: number;
217
+ capName?: string;
218
+ method?: string;
219
+ kind?: "call" | "stream";
176
220
  }
177
221
 
178
222
  type ReturnDecoder = (raw: unknown) => unknown;
179
223
 
224
+ // Default is conservative: "untrusted" until the host explicitly hands a real attestation
225
+ // (engine-mined for native, derived from req origin for web). Policy hooks should treat absence as deny-able.
180
226
  const DEFAULT_ATTESTATION: Attestation = {
181
- origin: "bunite://internal",
182
- topOrigin: "bunite://internal",
227
+ origin: "",
228
+ topOrigin: "",
183
229
  partition: "default",
184
- isAppRes: true,
185
- isMainFrame: true,
230
+ isAppRes: false,
231
+ isMainFrame: false,
186
232
  userGesture: false,
187
- level: "app-internal",
233
+ level: "untrusted",
188
234
  };
189
235
 
190
236
  const EXPORTED_CAP_BRAND = Symbol("bunite.rpc.ExportedCap");
@@ -209,14 +255,26 @@ interface ServerStreamCtx {
209
255
  cancelled: boolean;
210
256
  credit: number;
211
257
  creditWaker: (() => void) | null;
258
+ capId: number;
259
+ capName?: string;
260
+ method: string;
261
+ callId: number;
262
+ count: number;
212
263
  }
213
264
 
214
265
  interface ClientStreamCtx {
266
+ capId: number;
215
267
  push(chunk: unknown): void;
216
268
  end(): void;
217
269
  fail(error: IpcError): void;
218
270
  }
219
271
 
272
+ interface RegistryEntry {
273
+ cap: CapDef<any, any>;
274
+ impl: unknown;
275
+ version?: string;
276
+ }
277
+
220
278
  class ConnectionImpl implements Connection {
221
279
  private readonly transport: Transport;
222
280
  private readonly capTable: CapTable;
@@ -224,10 +282,15 @@ class ConnectionImpl implements Connection {
224
282
  private readonly clientStreams = new Map<number, ClientStreamCtx>();
225
283
  private readonly serverStreams = new Map<number, ServerStreamCtx>();
226
284
  private readonly serverCallChildren = new Map<number, { parentId: number }>();
227
- private readonly serverActiveCalls = new Map<number, AbortController>();
285
+ private readonly serverActiveCalls = new Map<number, { ctrl: AbortController; capId: number; capName: string; method: string; startedAt: number }>();
286
+ /** Server-side: name → registry entry. */
287
+ private readonly registry = new Map<string, RegistryEntry>();
288
+ /** Server-side: name → instance cap-id (cached bootstrap result). */
228
289
  private readonly rootInstances = new Map<string, number>();
229
- private userRootsSchema: Schema<any> | null = null;
290
+ /** Client-side: cap-ids that the server revoked via cap_revoked. */
291
+ private readonly revokedCapIds = new Set<number>();
230
292
  private readonly closeHandlers = new Set<() => void>();
293
+ private readonly observers: { [K in keyof ConnectionEvents]?: Set<(e: ConnectionEvents[K]) => void> } = {};
231
294
  private nextCallId = 1;
232
295
  private remoteHello: HelloFrame | null = null;
233
296
  private readonly remoteReady: Promise<HelloFrame>;
@@ -235,11 +298,13 @@ class ConnectionImpl implements Connection {
235
298
  private rejectRemoteReady!: (e: Error) => void;
236
299
  private closed_ = false;
237
300
  private readonly maxBytes: number;
301
+ private readonly maxInFlightCalls: number;
238
302
  private readonly mode: "native" | "web";
239
303
  private readonly origin: string;
240
304
  private readonly features: string[];
241
305
  private readonly attestation: Attestation;
242
306
  private readonly peerId: string;
307
+ private readonly policy: Policy | undefined;
243
308
 
244
309
  constructor(opts: ConnectionOptions) {
245
310
  this.transport = opts.transport;
@@ -247,16 +312,20 @@ class ConnectionImpl implements Connection {
247
312
  this.origin = opts.origin;
248
313
  this.features = opts.features ?? [];
249
314
  this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
315
+ this.maxInFlightCalls = opts.maxInFlightCalls ?? MAX_IN_FLIGHT_CALLS_PER_CONNECTION;
250
316
  this.attestation = opts.attestation ?? DEFAULT_ATTESTATION;
251
317
  this.peerId = opts.peerId ?? "peer";
318
+ this.policy = opts.policy;
252
319
  this.capTable = new CapTable(opts.capLimit ?? MAX_CAPS_PER_CONNECTION);
253
320
 
321
+ // cap-id 0 = bootstrap dispatcher (no cap def — special-cased in handleCall)
254
322
  this.capTable.install(USER_ROOTS_CAP_ID, {
255
323
  typeId: USER_ROOTS_TYPE_ID,
256
324
  cap: null,
257
325
  impl: null,
258
326
  refCount: 1,
259
327
  });
328
+ // cap-id 1 = Runtime instance (framework-stable, pre-installed)
260
329
  this.capTable.install(RUNTIME_CAP_ID, {
261
330
  typeId: RUNTIME_TYPE_ID,
262
331
  cap: RuntimeCap,
@@ -290,48 +359,146 @@ class ConnectionImpl implements Connection {
290
359
  return () => this.closeHandlers.delete(handler);
291
360
  }
292
361
 
293
- serve<S extends SchemaShape>(descriptor: ServerDescriptor<S>): void {
294
- const entry = this.capTable.get(USER_ROOTS_CAP_ID);
295
- if (!entry) throw new Error("UserRoots slot missing");
296
- this.rootInstances.clear();
297
- this.userRootsSchema = descriptor.schema;
298
- const built = this.buildUserRootsCap(descriptor.schema, descriptor.impls as Record<string, unknown>);
299
- entry.cap = built.def;
300
- entry.impl = built.impl;
362
+ on<K extends keyof ConnectionEvents>(event: K, handler: (e: ConnectionEvents[K]) => void): () => void {
363
+ let set = this.observers[event] as Set<(e: ConnectionEvents[K]) => void> | undefined;
364
+ if (!set) {
365
+ set = new Set();
366
+ (this.observers as Record<string, Set<unknown>>)[event] = set as unknown as Set<unknown>;
367
+ }
368
+ set.add(handler);
369
+ return () => { set!.delete(handler); };
301
370
  }
302
371
 
303
- private buildUserRootsCap(
304
- schema: Schema<any>,
305
- impls: Record<string, unknown>
306
- ): { def: CapDef<any, any>; impl: Record<string, (params: unknown, ctx: CallCtx) => unknown> } {
307
- const methods: Record<string, MethodDef> = {};
308
- const impl: Record<string, (params: unknown, ctx: CallCtx) => unknown> = {};
309
- for (const name of Object.keys(schema.roots)) {
310
- const rootCap = schema.roots[name] as CapDef<any, any>;
311
- methods[name] = call({ returns: cap(rootCap as never), idempotent: true });
312
- impl[name] = (_params, ctx) => {
313
- const cachedId = this.rootInstances.get(name);
314
- if (cachedId !== undefined) {
315
- const cached = this.capTable.get(cachedId);
316
- if (cached) {
317
- cached.refCount += 1;
318
- return {
319
- [EXPORTED_CAP_BRAND]: true,
320
- cap: rootCap,
321
- capId: cachedId,
322
- typeId: cached.typeId,
323
- } as unknown as ExportedCap<typeof rootCap>;
324
- }
325
- this.rootInstances.delete(name);
372
+ private emitObs<K extends keyof ConnectionEvents>(event: K, data: ConnectionEvents[K]): void {
373
+ const set = this.observers[event] as Set<(e: ConnectionEvents[K]) => void> | undefined;
374
+ if (!set || set.size === 0) return;
375
+ for (const h of set) {
376
+ try { h(data); } catch { /* swallow */ }
377
+ }
378
+ }
379
+
380
+ // ---- serve / unserve / replace ----
381
+
382
+ serve<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>, opts?: { ifExists?: IfExists }): ServeHandle {
383
+ this.assertNotFrameworkName(cap.name);
384
+ const ifExists = opts?.ifExists ?? "throw";
385
+ const existing = this.registry.get(cap.name);
386
+ if (existing) {
387
+ switch (ifExists) {
388
+ case "throw":
389
+ throw new IpcError({
390
+ code: "already_exists",
391
+ message: `cap "${cap.name}" already served`,
392
+ details: { reason: "name_collision" as AlreadyExistsReason },
393
+ });
394
+ case "skip":
395
+ // Empty handle: skipping means we are not the owner — unserve must not revoke someone else's registration.
396
+ return this.makeHandle([]);
397
+ case "replace":
398
+ this.replace(cap, impl);
399
+ return this.makeHandle([cap.name]);
400
+ }
401
+ }
402
+ this.registry.set(cap.name, { cap, impl, version: cap.version });
403
+ return this.makeHandle([cap.name]);
404
+ }
405
+
406
+ private makeHandle(names: string[]): ServeHandle {
407
+ const handle: ServeHandle = {
408
+ names,
409
+ [Symbol.dispose]: () => this.unserve(handle),
410
+ };
411
+ return handle;
412
+ }
413
+
414
+ serveAll<R extends SchemaRoots>(schema: Schema<R>, impls: ImplsOf<R>, opts?: { ifExists?: IfExists }): ServeHandle {
415
+ const ifExists = opts?.ifExists ?? "throw";
416
+ // Pre-validate everything that can fail across all modes before mutating any state (atomicity).
417
+ for (const k of Object.keys(schema.roots)) {
418
+ const c = schema.roots[k];
419
+ this.assertNotFrameworkName(c.name);
420
+ const existing = this.registry.get(c.name);
421
+ if (existing) {
422
+ if (ifExists === "throw") {
423
+ throw new IpcError({
424
+ code: "already_exists",
425
+ message: `cap "${c.name}" already served`,
426
+ details: { reason: "name_collision" as AlreadyExistsReason },
427
+ });
326
428
  }
327
- const exported = ctx.exportCap(rootCap, impls[name] as never);
328
- this.rootInstances.set(name, exported.capId);
329
- return exported;
330
- };
429
+ if (ifExists === "replace" && existing.version !== c.version) {
430
+ throw new IpcError({
431
+ code: "failed_precondition",
432
+ message: `version mismatch on replace for "${c.name}" (current "${existing.version}", new "${c.version}")`,
433
+ details: { reason: "version_mismatch" as FailedPreconditionReason },
434
+ });
435
+ }
436
+ }
437
+ }
438
+ // Mutations below cannot fail (pre-validation already cleared collision/version/prefix paths).
439
+ const names: string[] = [];
440
+ for (const k of Object.keys(schema.roots)) {
441
+ const c = schema.roots[k];
442
+ const i = (impls as Record<string, unknown>)[k];
443
+ const h = this.serve(c, i as ImplOf<typeof c>, { ifExists });
444
+ // serve(...) returns empty names[] only for skipped entries — exclude from the aggregate handle.
445
+ if (h.names.length > 0) names.push(c.name);
331
446
  }
332
- return { def: defineCap(methods), impl };
447
+ return this.makeHandle(names);
333
448
  }
334
449
 
450
+ unserve(target: CapDef<any, any> | ServeHandle): void {
451
+ const names = isCapDef(target) ? [target.name] : Array.from((target as ServeHandle).names);
452
+ const revoked: number[] = [];
453
+ for (const name of names) {
454
+ if (!this.registry.delete(name)) continue;
455
+ const instId = this.rootInstances.get(name);
456
+ if (instId !== undefined) {
457
+ const entry = this.capTable.get(instId);
458
+ if (entry) this.invokeServerDisposal(entry);
459
+ this.rootInstances.delete(name);
460
+ this.capTable.delete(instId);
461
+ revoked.push(instId);
462
+ }
463
+ }
464
+ if (revoked.length > 0) {
465
+ this.transport.send({ op: "cap_revoked", capIds: revoked });
466
+ this.emitObs("revoke", { capIds: revoked, reason: "unserve" });
467
+ }
468
+ }
469
+
470
+ replace<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>): void {
471
+ const entry = this.registry.get(cap.name);
472
+ if (!entry) throw new IpcError({ code: "not_found", message: `cap "${cap.name}" not served` });
473
+ if (entry.version !== cap.version) {
474
+ throw new IpcError({
475
+ code: "failed_precondition",
476
+ message: `version mismatch (current "${entry.version}", new "${cap.version}")`,
477
+ details: { reason: "version_mismatch" as FailedPreconditionReason },
478
+ });
479
+ }
480
+ entry.impl = impl;
481
+ entry.cap = cap;
482
+ const instId = this.rootInstances.get(cap.name);
483
+ if (instId !== undefined) {
484
+ const e = this.capTable.get(instId);
485
+ if (e) { e.impl = impl; e.cap = cap; }
486
+ }
487
+ this.emitObs("revoke", { capIds: instId !== undefined ? [instId] : [], reason: "replace" });
488
+ }
489
+
490
+ private assertNotFrameworkName(name: string): void {
491
+ if (name.startsWith(FRAMEWORK_NAME_PREFIX)) {
492
+ throw new IpcError({
493
+ code: "already_exists",
494
+ message: `cap name "${name}" uses reserved prefix "${FRAMEWORK_NAME_PREFIX}"`,
495
+ details: { reason: "reserved_namespace" as AlreadyExistsReason },
496
+ });
497
+ }
498
+ }
499
+
500
+ // ---- runtime / bootstrap ----
501
+
335
502
  private runtimeProxy: ClientOf<typeof RuntimeCap> | null = null;
336
503
 
337
504
  runtime(): ClientOf<typeof RuntimeCap> {
@@ -341,25 +508,49 @@ class ConnectionImpl implements Connection {
341
508
  return this.runtimeProxy;
342
509
  }
343
510
 
344
- async bootstrap<S extends SchemaShape, K extends keyof S["roots"] & string>(
345
- schema: Schema<S>,
346
- name: K
347
- ): Promise<ClientOf<S["roots"][K]>> {
511
+ bootstrap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>>;
512
+ bootstrap<R extends SchemaRoots>(schema: Schema<R>): Promise<{ [K in keyof R]: ClientOf<R[K]> }>;
513
+ async bootstrap(target: CapDef<any, any> | Schema<any>): Promise<unknown> {
514
+ if (isCapDef(target)) return this._bootstrapCap(target);
515
+ if (isSchema(target)) return this._bootstrapSchema(target);
516
+ throw new IpcError({ code: "invalid_argument", message: "bootstrap target must be CapDef or Schema" });
517
+ }
518
+
519
+ private async _bootstrapCap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>> {
348
520
  await this.remoteReady;
349
- const rootNames = Object.keys(schema.roots);
350
- const idx = rootNames.indexOf(name);
351
- if (idx < 0) {
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 });
521
+ const args: { name: string; version?: string } = { name: cap.name };
522
+ if (cap.version != null) args.version = cap.version;
523
+ const raw = await this.sendCallTyped(USER_ROOTS_CAP_ID, BOOTSTRAP_METHOD, args, undefined);
357
524
  if (!(raw instanceof CapRef)) {
358
- throw new IpcError({ code: "protocol_error", message: "bootstrap did not return a CapRef" });
525
+ throw new IpcError({ code: "invalid_argument", message: "bootstrap did not return a CapRef" });
526
+ }
527
+ return this.makeCapProxy(cap, raw.capId) as ClientOf<C>;
528
+ }
529
+
530
+ private async _bootstrapSchema<R extends SchemaRoots>(
531
+ schema: Schema<R>
532
+ ): Promise<{ [K in keyof R]: ClientOf<R[K]> }> {
533
+ const keys = Object.keys(schema.roots) as (keyof R & string)[];
534
+ const settled = await Promise.allSettled(keys.map((k) => this._bootstrapCap(schema.roots[k])));
535
+ const rejected = settled.find((r): r is PromiseRejectedResult => r.status === "rejected");
536
+ if (rejected) {
537
+ // Release server refCount on the roots that succeeded — otherwise their cap-table entries linger until connection close.
538
+ for (const r of settled) {
539
+ if (r.status === "fulfilled") {
540
+ try { this.releaseRef(r.value); } catch { /* swallow */ }
541
+ }
542
+ }
543
+ throw rejected.reason;
544
+ }
545
+ const out = {} as { [K in keyof R]: ClientOf<R[K]> };
546
+ for (let i = 0; i < keys.length; i++) {
547
+ (out as Record<string, unknown>)[keys[i]] = (settled[i] as PromiseFulfilledResult<unknown>).value;
359
548
  }
360
- return this.makeCapProxy(rootCap, raw.capId) as ClientOf<S["roots"][K]>;
549
+ return out;
361
550
  }
362
551
 
552
+ // ---- frame dispatch ----
553
+
363
554
  private handleFrame(frame: Frame): void {
364
555
  if (this.closed_) return;
365
556
  switch (frame.op) {
@@ -381,6 +572,9 @@ class ConnectionImpl implements Connection {
381
572
  case "drop":
382
573
  this.handleDrop(frame);
383
574
  return;
575
+ case "cap_revoked":
576
+ this.handleCapRevoked(frame);
577
+ return;
384
578
  case "goaway":
385
579
  this.handleGoaway(frame);
386
580
  return;
@@ -393,11 +587,11 @@ class ConnectionImpl implements Connection {
393
587
  private handleUnknownFrame(frame: unknown): void {
394
588
  const id = (frame as { id?: unknown })?.id;
395
589
  if (typeof id === "number") {
396
- this.transport.send({ op: "result", id, ok: false, error: { code: "protocol_error", message: "unknown opcode" } });
590
+ this.transport.send({ op: "result", id, ok: false, error: { code: "invalid_argument", message: "unknown opcode" } });
397
591
  return;
398
592
  }
399
- this.transport.send({ op: "goaway", reason: "protocol_error", error: { code: "protocol_error", message: "unknown opcode" } });
400
- this.shutdown("protocol_error");
593
+ this.transport.send({ op: "goaway", reason: "invalid_argument", error: { code: "invalid_argument", message: "unknown opcode" } });
594
+ this.shutdown("invalid_argument");
401
595
  }
402
596
 
403
597
  private handleHello(frame: HelloFrame): void {
@@ -412,44 +606,158 @@ class ConnectionImpl implements Connection {
412
606
  this.shutdown(frame.reason ?? "remote goaway");
413
607
  }
414
608
 
609
+ private handleCapRevoked(frame: CapRevokedFrame): void {
610
+ for (const capId of frame.capIds) {
611
+ this.revokedCapIds.add(capId);
612
+ const err = new IpcError({
613
+ code: "failed_precondition",
614
+ message: "cap revoked",
615
+ details: { reason: "revoked" as FailedPreconditionReason },
616
+ });
617
+ // Fail pending calls targeting this cap.
618
+ for (const [id, p] of this.pending) {
619
+ if (p.capId === capId) {
620
+ this.pending.delete(id);
621
+ if (p.timer) clearTimeout(p.timer);
622
+ p.reject(err);
623
+ }
624
+ }
625
+ // Fail active client streams targeting this cap — "revoked wins" symmetric with calls.
626
+ for (const [id, s] of this.clientStreams) {
627
+ if (s.capId === capId) {
628
+ this.clientStreams.delete(id);
629
+ s.fail(err);
630
+ }
631
+ }
632
+ }
633
+ }
634
+
415
635
  private async handleCall(frame: CallFrame): Promise<void> {
636
+ if (frame.target.id === USER_ROOTS_CAP_ID && frame.method === BOOTSTRAP_METHOD) {
637
+ await this.handleBootstrap(frame);
638
+ return;
639
+ }
640
+
416
641
  const entry = this.capTable.get(frame.target.id);
417
- if (!entry) return this.sendError(frame.id, "not_found", `cap-id ${frame.target.id} not found`);
418
-
419
- if (entry.capId === USER_ROOTS_CAP_ID) {
420
- if (!this.userRootsSchema) return this.sendError(frame.id, "not_supported", "no server attached");
421
- const clientHash = frame.meta?.topologyHash;
422
- if (clientHash) {
423
- const ourHash = await this.userRootsSchema.topologyHash();
424
- if (clientHash !== ourHash) {
642
+ if (!entry) {
643
+ this.emitObs("call", { capId: frame.target.id, method: frame.method, callId: frame.id, result: "not_found" });
644
+ return this.sendError(frame.id, "not_found", `cap-id ${frame.target.id} not found`);
645
+ }
646
+
647
+ const cap = entry.cap;
648
+ if (!cap || !entry.impl) {
649
+ this.emitObs("call", { capId: frame.target.id, method: frame.method, callId: frame.id, result: "not_found" });
650
+ return this.sendError(frame.id, "not_found", "cap has no impl");
651
+ }
652
+
653
+ const methodDef = cap.methods[frame.method] as MethodDef | undefined;
654
+ if (!methodDef) {
655
+ this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "not_found" });
656
+ return this.sendError(frame.id, "not_found", `method "${frame.method}" on cap "${cap.name}"`);
657
+ }
658
+ const impl = (entry.impl as Record<string, unknown>)[frame.method];
659
+ if (typeof impl !== "function") {
660
+ this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "not_found" });
661
+ return this.sendError(frame.id, "not_found", `method "${frame.method}" has no handler`);
662
+ }
663
+
664
+ // Bound inbound work — symmetric with sendCallTyped's outbound check.
665
+ if (this.serverActiveCalls.size + this.serverStreams.size >= this.maxInFlightCalls) {
666
+ this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "resource_exhausted" });
667
+ return this.sendError(
668
+ frame.id,
669
+ "resource_exhausted",
670
+ `in-flight calls limit ${this.maxInFlightCalls}`,
671
+ { reason: "max_concurrent_calls" as ResourceExhaustedReason },
672
+ );
673
+ }
674
+
675
+ await this.invokeServerMethod(frame, cap, methodDef, impl as (params: unknown, ctx: CallCtx) => unknown);
676
+ }
677
+
678
+ private async handleBootstrap(frame: CallFrame): Promise<void> {
679
+ const args = (frame.args ?? {}) as { name?: unknown; version?: unknown };
680
+ const name = args.name;
681
+ if (typeof name !== "string") {
682
+ this.emitObs("bootstrap", { name: String(name), attestation: this.attestation, result: "invalid_argument" });
683
+ return this.sendError(frame.id, "invalid_argument", "bootstrap requires {name: string}");
684
+ }
685
+ const clientVersion = args.version != null ? String(args.version) : undefined;
686
+
687
+ // Framework caps (cap-id 1 Runtime is pre-installed and accessed via .runtime(),
688
+ // not via bootstrap). For user-facing bootstrap, only the registry is consulted.
689
+ const entry = this.registry.get(name);
690
+ if (!entry) {
691
+ this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "not_found" });
692
+ return this.sendError(frame.id, "not_found", `cap "${name}" not served`);
693
+ }
694
+
695
+ const serverVersion = entry.version;
696
+ if (serverVersion != null && clientVersion != null && serverVersion !== clientVersion) {
697
+ this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "version_mismatch" });
698
+ return this.sendError(
699
+ frame.id,
700
+ "failed_precondition",
701
+ `version mismatch (server "${serverVersion}", client "${clientVersion}")`,
702
+ { reason: "version_mismatch" as FailedPreconditionReason }
703
+ );
704
+ }
705
+
706
+ if (this.policy) {
707
+ try {
708
+ const allowed = await this.policy(name, this.attestation);
709
+ if (!allowed) {
710
+ this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "denied" });
425
711
  return this.sendError(
426
712
  frame.id,
427
713
  "failed_precondition",
428
- `topologyHash mismatch (client ${clientHash.slice(0, 8)} vs server ${ourHash.slice(0, 8)})`
714
+ "policy denied",
715
+ { reason: "unauthorized" as FailedPreconditionReason }
429
716
  );
430
717
  }
718
+ } catch (err) {
719
+ this.emitObs("error", { phase: "policy", error: err instanceof Error ? err : new Error(String(err)) });
720
+ this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "internal" });
721
+ return this.sendError(frame.id, "internal", "policy threw");
431
722
  }
432
723
  }
433
724
 
434
- const cap = entry.cap;
435
- if (!cap || !entry.impl) return this.sendError(frame.id, "not_supported", "cap has no impl");
436
-
437
- const methodNames = Object.keys(cap.methods);
438
- if (frame.method >= methodNames.length) {
439
- return this.sendError(frame.id, "not_supported", `method index ${frame.method} out of range`);
725
+ let capId = this.rootInstances.get(name);
726
+ if (capId !== undefined) {
727
+ const cached = this.capTable.get(capId);
728
+ if (cached) {
729
+ cached.refCount += 1;
730
+ } else {
731
+ this.rootInstances.delete(name);
732
+ capId = undefined;
733
+ }
440
734
  }
441
- const methodName = methodNames[frame.method];
442
- const methodDef = cap.methods[methodName] as MethodDef;
443
- const impl = (entry.impl as Record<string, unknown>)[methodName];
444
- if (typeof impl !== "function") {
445
- return this.sendError(frame.id, "not_supported", `method "${methodName}" has no handler`);
735
+ if (capId === undefined) {
736
+ try {
737
+ const allocated = this.capTable.allocate({
738
+ typeId: frameworkTypeIdOf(entry.cap) ?? FIRST_USER_TYPE_ID,
739
+ cap: entry.cap,
740
+ impl: entry.impl,
741
+ refCount: 1,
742
+ });
743
+ capId = allocated.capId;
744
+ this.rootInstances.set(name, capId);
745
+ } catch (err) {
746
+ if (err instanceof IpcError) {
747
+ const result = err.code === "resource_exhausted" ? "resource_exhausted" : "internal";
748
+ this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result });
749
+ return this.sendError(frame.id, err.code, err.message, err.details as Record<string, unknown> | undefined);
750
+ }
751
+ throw err;
752
+ }
446
753
  }
447
-
448
- await this.invokeServerMethod(frame, methodDef, impl as (params: unknown, ctx: CallCtx) => unknown);
754
+ this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "ok", capId });
755
+ this.transport.send({ op: "result", id: frame.id, ok: true, value: new CapRef(capId) });
449
756
  }
450
757
 
451
758
  private async invokeServerMethod(
452
759
  frame: CallFrame,
760
+ cap: CapDef<any, any>,
453
761
  methodDef: MethodDef,
454
762
  impl: (params: unknown, ctx: CallCtx) => unknown
455
763
  ): Promise<void> {
@@ -464,16 +772,24 @@ class ConnectionImpl implements Connection {
464
772
  ? () => als.run({ callId: frame.id }, runStream)
465
773
  : runStream;
466
774
  const stream = scoped();
467
- this.runServerStream(frame.id, stream, ctx, hintBudget);
775
+ this.runServerStream(frame.id, stream, ctx, hintBudget, cap, frame.method, frame.target.id);
468
776
  } catch (err) {
777
+ this.emitObs("stream", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, event: "error" });
469
778
  this.transport.send({ op: "stream", id: frame.id, ev: "error", error: toStatus(err) });
470
779
  }
471
780
  return;
472
781
  }
473
782
 
474
783
  if (isCallDef(methodDef)) {
784
+ const startedAt = performance.now();
475
785
  const deadlineTimer = methodDef.idempotent ? undefined : this.armServerDeadline(frame, ctx);
476
- this.serverActiveCalls.set(frame.id, (ctx as unknown as { _ctrl: AbortController })._ctrl);
786
+ this.serverActiveCalls.set(frame.id, {
787
+ ctrl: (ctx as unknown as { _ctrl: AbortController })._ctrl,
788
+ capId: frame.target.id,
789
+ capName: cap.name,
790
+ method: frame.method,
791
+ startedAt,
792
+ });
477
793
  const encoder = makeReturnEncoder(methodDef.returns);
478
794
  const runImpl = () => Promise.resolve(impl(frame.args, ctx));
479
795
  const als = callContextStorage;
@@ -486,15 +802,18 @@ class ConnectionImpl implements Connection {
486
802
  if (!this.serverActiveCalls.delete(frame.id)) return;
487
803
  const encoded = encoder(result);
488
804
  this.transport.send({ op: "result", id: frame.id, ok: true, value: encoded });
805
+ this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, durationMs: performance.now() - startedAt, result: "ok" });
489
806
  } catch (err) {
490
807
  if (deadlineTimer) clearTimeout(deadlineTimer);
491
808
  if (!this.serverActiveCalls.delete(frame.id)) return;
492
- this.sendErrorFromException(frame.id, err);
809
+ const status = toStatus(err);
810
+ this.sendErrorFromStatus(frame.id, status);
811
+ this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, durationMs: performance.now() - startedAt, result: status.code });
493
812
  }
494
813
  return;
495
814
  }
496
815
 
497
- this.sendError(frame.id, "not_supported", "unknown method kind");
816
+ this.sendError(frame.id, "not_found", "unknown method kind");
498
817
  }
499
818
 
500
819
  private armServerDeadline(frame: CallFrame, ctx: CallCtx): ReturnType<typeof setTimeout> | undefined {
@@ -536,7 +855,15 @@ class ConnectionImpl implements Connection {
536
855
  } as unknown as ExportedCap<C>;
537
856
  }
538
857
 
539
- private runServerStream(callId: number, stream: StreamType<unknown>, ctx: CallCtx, initialCredit: number): void {
858
+ private runServerStream(
859
+ callId: number,
860
+ stream: StreamType<unknown>,
861
+ ctx: CallCtx,
862
+ initialCredit: number,
863
+ cap: CapDef<any, any>,
864
+ method: string,
865
+ capId: number,
866
+ ): void {
540
867
  const iter = (stream as AsyncIterable<unknown>)[Symbol.asyncIterator]();
541
868
  const abort = (ctx as unknown as { _ctrl: AbortController })._ctrl;
542
869
  const slot: ServerStreamCtx = {
@@ -545,8 +872,14 @@ class ConnectionImpl implements Connection {
545
872
  cancelled: false,
546
873
  credit: initialCredit,
547
874
  creditWaker: null,
875
+ capId,
876
+ capName: cap.name,
877
+ method,
878
+ callId,
879
+ count: 0,
548
880
  };
549
881
  this.serverStreams.set(callId, slot);
882
+ this.emitObs("stream", { capId, capName: cap.name, method, callId, event: "start" });
550
883
 
551
884
  const waitForCredit = (): Promise<void> => {
552
885
  if (slot.credit > 0 || slot.cancelled) return Promise.resolve();
@@ -563,13 +896,18 @@ class ConnectionImpl implements Connection {
563
896
  if (done) break;
564
897
  if (slot.cancelled) break;
565
898
  slot.credit -= 1;
899
+ slot.count += 1;
566
900
  this.transport.send({ op: "stream", id: callId, ev: "next", value });
567
901
  }
568
902
  } catch (err) {
569
903
  errored = true;
570
904
  this.transport.send({ op: "stream", id: callId, ev: "error", error: toStatus(err) });
905
+ this.emitObs("stream", { capId, capName: cap.name, method, callId, event: "error", count: slot.count });
571
906
  } finally {
572
- if (!errored) this.transport.send({ op: "stream", id: callId, ev: "end" });
907
+ if (!errored) {
908
+ this.transport.send({ op: "stream", id: callId, ev: "end" });
909
+ this.emitObs("stream", { capId, capName: cap.name, method, callId, event: slot.cancelled ? "cancel" : "end", count: slot.count });
910
+ }
573
911
  this.serverStreams.delete(callId);
574
912
  }
575
913
  };
@@ -594,7 +932,7 @@ class ConnectionImpl implements Connection {
594
932
  if (stream) {
595
933
  this.clientStreams.delete(frame.id);
596
934
  const err = frame.ok
597
- ? new IpcError({ code: "protocol_error", message: "stream method returned result frame" })
935
+ ? new IpcError({ code: "invalid_argument", message: "stream method returned result frame" })
598
936
  : new IpcError(frame.error);
599
937
  stream.fail(err);
600
938
  }
@@ -611,13 +949,21 @@ class ConnectionImpl implements Connection {
611
949
  const active = this.serverActiveCalls.get(frame.id);
612
950
  if (active) {
613
951
  this.serverActiveCalls.delete(frame.id);
614
- active.abort();
952
+ active.ctrl.abort();
615
953
  this.transport.send({
616
954
  op: "result",
617
955
  id: frame.id,
618
956
  ok: false,
619
957
  error: { code: "cancelled", message: frame.reason },
620
958
  });
959
+ this.emitObs("call", {
960
+ capId: active.capId,
961
+ capName: active.capName,
962
+ method: active.method,
963
+ callId: frame.id,
964
+ durationMs: performance.now() - active.startedAt,
965
+ result: "cancelled",
966
+ });
621
967
  }
622
968
  for (const [childId, child] of this.serverCallChildren) {
623
969
  if (child.parentId !== frame.id) continue;
@@ -680,34 +1026,52 @@ class ConnectionImpl implements Connection {
680
1026
  }
681
1027
  }
682
1028
 
683
- private sendError(callId: number, code: IpcCode, message?: string): void {
684
- this.transport.send({ op: "result", id: callId, ok: false, error: { code, message } });
1029
+ private sendError(callId: number, code: IpcCode, message?: string, details?: unknown): void {
1030
+ const error: IpcStatus = { code, message };
1031
+ if (details !== undefined) error.details = details;
1032
+ this.transport.send({ op: "result", id: callId, ok: false, error });
685
1033
  }
686
1034
 
687
- private sendErrorFromException(callId: number, err: unknown): void {
688
- this.transport.send({ op: "result", id: callId, ok: false, error: toStatus(err) });
1035
+ private sendErrorFromStatus(callId: number, status: IpcStatus): void {
1036
+ this.transport.send({ op: "result", id: callId, ok: false, error: status });
689
1037
  }
690
1038
 
691
1039
  private nextId(): number {
692
1040
  return this.nextCallId++;
693
1041
  }
694
1042
 
695
- sendCallRaw(capId: number, methodIdx: number, args: unknown, meta?: CallMeta): Promise<unknown> {
696
- return this.sendCallTyped(capId, methodIdx, args, undefined, meta);
697
- }
698
-
699
1043
  sendCallTyped(
700
1044
  capId: number,
701
- methodIdx: number,
1045
+ method: string,
702
1046
  args: unknown,
703
1047
  decodeReturn: ReturnDecoder | undefined,
704
- meta?: CallMeta
1048
+ meta?: CallMeta,
1049
+ capName?: string,
705
1050
  ): Promise<unknown> {
706
1051
  if (this.closed_) return Promise.reject(new IpcError({ code: "unavailable", message: "connection closed" }));
1052
+ if (this.revokedCapIds.has(capId)) {
1053
+ return Promise.reject(new IpcError({
1054
+ code: "failed_precondition",
1055
+ message: "cap revoked",
1056
+ details: { reason: "revoked" as FailedPreconditionReason },
1057
+ }));
1058
+ }
1059
+ if (this.pending.size >= this.maxInFlightCalls) {
1060
+ return Promise.reject(new IpcError({
1061
+ code: "resource_exhausted",
1062
+ message: `in-flight calls limit ${this.maxInFlightCalls}`,
1063
+ details: { reason: "max_concurrent_calls" as ResourceExhaustedReason },
1064
+ }));
1065
+ }
707
1066
  const id = this.nextId();
708
1067
  return new Promise((resolve, reject) => {
709
1068
  const abort = new AbortController();
710
- const pending: PendingCall = { resolve, reject, abort, decodeReturn };
1069
+ const pending: PendingCall = {
1070
+ resolve, reject, abort, decodeReturn,
1071
+ startedAt: performance.now(),
1072
+ capId, method, capName,
1073
+ kind: "call",
1074
+ };
711
1075
  if (meta?.deadlineMs) {
712
1076
  pending.timer = setTimeout(() => {
713
1077
  if (this.pending.delete(id)) {
@@ -725,16 +1089,31 @@ class ConnectionImpl implements Connection {
725
1089
  if (finalMeta?.parentCallId !== undefined) {
726
1090
  this.serverCallChildren.set(id, { parentId: finalMeta.parentCallId });
727
1091
  }
728
- this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method: methodIdx, args, meta: finalMeta });
1092
+ this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method, args, meta: finalMeta });
729
1093
  });
730
1094
  }
731
1095
 
732
- openClientStream<T>(capId: number, methodIdx: number, args: unknown, meta?: CallMeta): StreamType<T> {
1096
+ openClientStream<T>(
1097
+ capId: number,
1098
+ method: string,
1099
+ args: unknown,
1100
+ meta?: CallMeta,
1101
+ capName?: string,
1102
+ ): StreamType<T> {
733
1103
  if (this.closed_) {
734
- const empty = makeClientStream<T>(0, () => {}, () => {});
1104
+ const empty = makeClientStream<T>(capId, 0, () => {}, () => {});
735
1105
  empty.fail(new IpcError({ code: "unavailable", message: "connection closed" }));
736
1106
  return empty.stream;
737
1107
  }
1108
+ if (this.revokedCapIds.has(capId)) {
1109
+ const empty = makeClientStream<T>(capId, 0, () => {}, () => {});
1110
+ empty.fail(new IpcError({
1111
+ code: "failed_precondition",
1112
+ message: "cap revoked",
1113
+ details: { reason: "revoked" as FailedPreconditionReason },
1114
+ }));
1115
+ return empty.stream;
1116
+ }
738
1117
  const id = this.nextId();
739
1118
  const cancel = () => {
740
1119
  if (this.clientStreams.delete(id)) {
@@ -745,14 +1124,14 @@ class ConnectionImpl implements Connection {
745
1124
  if (this.closed_ || !this.clientStreams.has(id)) return;
746
1125
  this.transport.send({ op: "stream", id, ev: "credit", credit: { messages } });
747
1126
  };
748
- const ctx = makeClientStream<T>(id, cancel, sendCredit);
1127
+ const ctx = makeClientStream<T>(capId, id, cancel, sendCredit);
749
1128
  this.clientStreams.set(id, ctx);
750
- this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method: methodIdx, args, meta });
1129
+ this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method, args, meta });
1130
+ void capName;
751
1131
  return ctx.stream;
752
1132
  }
753
1133
 
754
1134
  makeCapProxy<C extends CapDef<any, any>>(capDef: C, capId: number): ClientOf<C> {
755
- const methodNames = Object.keys(capDef.methods);
756
1135
  const proxy: Record<string | symbol, unknown> = {};
757
1136
  const meta = { capId, dropped: false };
758
1137
  proxy[CAP_PROXY_META] = meta;
@@ -762,32 +1141,31 @@ class ConnectionImpl implements Connection {
762
1141
  this.sendDrop(capId);
763
1142
  };
764
1143
 
765
- for (let i = 0; i < methodNames.length; i++) {
766
- const name = methodNames[i];
1144
+ for (const name of Object.keys(capDef.methods)) {
767
1145
  const def = capDef.methods[name] as MethodDef;
768
1146
  if (isStreamDef(def)) {
769
- proxy[name] = (params?: unknown) => this.openClientStream(capId, i, params);
1147
+ proxy[name] = (params?: unknown) => this.openClientStream(capId, name, params, undefined, capDef.name);
770
1148
  } else if (isCallDef(def)) {
771
1149
  const decoder = makeReturnDecoder(def.returns, (cd, cid) => this.makeCapProxy(cd, cid));
772
- proxy[name] = (params?: unknown) => this.sendCallTyped(capId, i, params, decoder);
1150
+ proxy[name] = (params?: unknown) => this.sendCallTyped(capId, name, params, decoder, undefined, capDef.name);
773
1151
  }
774
1152
  }
775
1153
 
776
1154
  const disposal = capDef.disposal as DisposalSpec | undefined;
777
1155
  if (disposal) {
778
- const invokeMethod = (): unknown => {
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
- };
1156
+ const conn = this;
787
1157
  proxy[Symbol.dispose] = () => {
788
- const r = invokeMethod();
1158
+ try {
1159
+ const r = (proxy[disposal.method] as (() => unknown) | undefined)?.();
1160
+ if (r && typeof (r as Promise<unknown>).then === "function") {
1161
+ (r as Promise<unknown>).catch((err) =>
1162
+ conn.emitObs("error", { phase: "dispose", error: err instanceof Error ? err : new Error(String(err)) })
1163
+ );
1164
+ }
1165
+ } catch (err) {
1166
+ conn.emitObs("error", { phase: "dispose", error: err instanceof Error ? err : new Error(String(err)) });
1167
+ }
789
1168
  drop();
790
- void r;
791
1169
  };
792
1170
  }
793
1171
 
@@ -824,8 +1202,8 @@ class ConnectionImpl implements Connection {
824
1202
  void slot.iter?.return?.();
825
1203
  }
826
1204
  this.serverStreams.clear();
827
- for (const ctrl of this.serverActiveCalls.values()) {
828
- ctrl.abort();
1205
+ for (const a of this.serverActiveCalls.values()) {
1206
+ a.ctrl.abort();
829
1207
  }
830
1208
  this.serverActiveCalls.clear();
831
1209
  for (const entry of this.capTable.values()) {
@@ -858,10 +1236,18 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
858
1236
  if (!returns) return (v) => v;
859
1237
  const expectExportedCap = (v: unknown, expectedCap: CapDef<any, any>): CapRef => {
860
1238
  if (!isExportedCap(v)) {
861
- throw new IpcError({ code: "protocol_error", message: "expected ctx.exportCap return for cap method" });
1239
+ throw new IpcError({
1240
+ code: "failed_precondition",
1241
+ message: "expected ctx.exportCap return for cap method",
1242
+ details: { reason: "unregistered_cap_return" as FailedPreconditionReason },
1243
+ });
862
1244
  }
863
1245
  if (v.cap !== expectedCap) {
864
- throw new IpcError({ code: "protocol_error", message: "exported cap type mismatch with method returns" });
1246
+ throw new IpcError({
1247
+ code: "failed_precondition",
1248
+ message: "exported cap type mismatch with method returns",
1249
+ details: { reason: "unregistered_cap_return" as FailedPreconditionReason },
1250
+ });
865
1251
  }
866
1252
  return new CapRef(v.capId);
867
1253
  };
@@ -869,7 +1255,7 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
869
1255
  if (isCapArray(returns)) {
870
1256
  return (v) => {
871
1257
  if (!Array.isArray(v)) {
872
- throw new IpcError({ code: "protocol_error", message: "expected ExportedCap[] for cap.array method" });
1258
+ throw new IpcError({ code: "invalid_argument", message: "expected ExportedCap[] for cap.array method" });
873
1259
  }
874
1260
  return v.map((item) => expectExportedCap(item, returns.cap));
875
1261
  };
@@ -877,7 +1263,7 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
877
1263
  if (isCapRecord(returns)) {
878
1264
  return (v) => {
879
1265
  if (!v || typeof v !== "object") {
880
- throw new IpcError({ code: "protocol_error", message: "expected Record<string, ExportedCap> for cap.record method" });
1266
+ throw new IpcError({ code: "invalid_argument", message: "expected Record<string, ExportedCap> for cap.record method" });
881
1267
  }
882
1268
  const out: Record<string, CapRef> = {};
883
1269
  for (const k of Object.keys(v as object)) {
@@ -897,7 +1283,7 @@ function makeReturnDecoder(
897
1283
  if (isCapRef(returns)) {
898
1284
  const inner = returns.cap;
899
1285
  return (raw) => {
900
- if (!(raw instanceof CapRef)) throw new IpcError({ code: "protocol_error", message: "expected CapRef" });
1286
+ if (!(raw instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef" });
901
1287
  return spawn(inner, raw.capId);
902
1288
  };
903
1289
  }
@@ -905,9 +1291,9 @@ function makeReturnDecoder(
905
1291
  const inner = returns.cap;
906
1292
  const disposal = inner.disposal as DisposalSpec | undefined;
907
1293
  return (raw) => {
908
- if (!Array.isArray(raw)) throw new IpcError({ code: "protocol_error", message: "expected array" });
1294
+ if (!Array.isArray(raw)) throw new IpcError({ code: "invalid_argument", message: "expected array" });
909
1295
  const proxies = raw.map((item) => {
910
- if (!(item instanceof CapRef)) throw new IpcError({ code: "protocol_error", message: "expected CapRef in array" });
1296
+ if (!(item instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef in array" });
911
1297
  return spawn(inner, item.capId);
912
1298
  });
913
1299
  attachArrayDisposal(proxies, disposal);
@@ -917,11 +1303,11 @@ function makeReturnDecoder(
917
1303
  if (isCapRecord(returns)) {
918
1304
  const inner = returns.cap;
919
1305
  return (raw) => {
920
- if (!raw || typeof raw !== "object") throw new IpcError({ code: "protocol_error", message: "expected record" });
1306
+ if (!raw || typeof raw !== "object") throw new IpcError({ code: "invalid_argument", message: "expected record" });
921
1307
  const out: Record<string, unknown> = {};
922
1308
  for (const k of Object.keys(raw as object)) {
923
1309
  const item = (raw as Record<string, unknown>)[k];
924
- if (!(item instanceof CapRef)) throw new IpcError({ code: "protocol_error", message: "expected CapRef in record" });
1310
+ if (!(item instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef in record" });
925
1311
  out[k] = spawn(inner, item.capId);
926
1312
  }
927
1313
  return out;
@@ -932,27 +1318,22 @@ function makeReturnDecoder(
932
1318
 
933
1319
  function attachArrayDisposal(arr: unknown[], disposal: DisposalSpec | undefined): void {
934
1320
  if (!disposal) return;
935
- const sym = disposal.async ? Symbol.asyncDispose : Symbol.dispose;
936
- (arr as any)[sym] = disposal.async
937
- ? () => Promise.all(arr.map((proxy) => {
938
- const fn = (proxy as Record<symbol, unknown>)[Symbol.asyncDispose] as (() => Promise<void>) | undefined;
939
- return fn ? fn.call(proxy) : undefined;
940
- })).then(() => undefined)
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
- };
1321
+ (arr as any)[Symbol.dispose] = () => {
1322
+ for (const proxy of arr) {
1323
+ const fn = (proxy as Record<symbol, unknown>)[Symbol.dispose] as (() => void) | undefined;
1324
+ fn?.call(proxy);
1325
+ }
1326
+ };
947
1327
  }
948
1328
 
949
1329
  function toStatus(err: unknown): IpcStatus {
950
1330
  if (err instanceof IpcError) return err.toStatus();
951
- if (err instanceof Error) return { code: "unknown", message: err.message };
952
- return { code: "unknown", message: String(err) };
1331
+ if (err instanceof Error) return { code: "internal", message: err.message };
1332
+ return { code: "internal", message: String(err) };
953
1333
  }
954
1334
 
955
1335
  interface ClientStreamHandle<T> {
1336
+ capId: number;
956
1337
  stream: StreamType<T>;
957
1338
  push(chunk: unknown): void;
958
1339
  end(): void;
@@ -960,6 +1341,7 @@ interface ClientStreamHandle<T> {
960
1341
  }
961
1342
 
962
1343
  function makeClientStream<T>(
1344
+ capId: number,
963
1345
  streamId: number,
964
1346
  cancel: () => void,
965
1347
  sendCredit: (messages: number) => void
@@ -1033,6 +1415,7 @@ function makeClientStream<T>(
1033
1415
  void streamId;
1034
1416
 
1035
1417
  return {
1418
+ capId,
1036
1419
  stream,
1037
1420
  push(chunk) {
1038
1421
  if (cancelled || ended || failure) return;