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/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,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({ code: "resource_exhausted", message: `cap-table limit ${this.capLimit}` });
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<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;
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: "bunite://internal",
182
- topOrigin: "bunite://internal",
229
+ origin: "",
230
+ topOrigin: "",
183
231
  partition: "default",
184
- isAppRes: true,
185
- isMainFrame: true,
232
+ isAppRes: false,
233
+ isMainFrame: false,
186
234
  userGesture: false,
187
- level: "app-internal",
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
- private userRootsSchema: Schema<any> | null = null;
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
- 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;
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 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);
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
- const exported = ctx.exportCap(rootCap, impls[name] as never);
328
- this.rootInstances.set(name, exported.capId);
329
- return exported;
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
- async bootstrap<S extends SchemaShape, K extends keyof S["roots"] & string>(
345
- schema: Schema<S>,
346
- name: K
347
- ): Promise<ClientOf<S["roots"][K]>> {
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 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 });
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: "protocol_error", message: "bootstrap did not return a CapRef" });
537
+ throw new IpcError({ code: "invalid_argument", message: "bootstrap did not return a CapRef" });
359
538
  }
360
- return this.makeCapProxy(rootCap, raw.capId) as ClientOf<S["roots"][K]>;
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: "protocol_error", message: "unknown opcode" } });
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: "protocol_error", error: { code: "protocol_error", message: "unknown opcode" } });
400
- this.shutdown("protocol_error");
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 async handleCall(frame: CallFrame): Promise<void> {
416
- 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) {
425
- return this.sendError(
426
- frame.id,
427
- "failed_precondition",
428
- `topologyHash mismatch (client ${clientHash.slice(0, 8)} vs server ${ourHash.slice(0, 8)})`
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) return this.sendError(frame.id, "not_supported", "cap has no 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 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`);
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 methodName = methodNames[frame.method];
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
- return this.sendError(frame.id, "not_supported", `method "${methodName}" has no handler`);
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
- await this.invokeServerMethod(frame, methodDef, impl as (params: unknown, ctx: CallCtx) => unknown);
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, (ctx as unknown as { _ctrl: AbortController })._ctrl);
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
- this.sendErrorFromException(frame.id, err);
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, "not_supported", "unknown method kind");
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(callId: number, stream: StreamType<unknown>, ctx: CallCtx, initialCredit: number): void {
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) this.transport.send({ op: "stream", id: callId, ev: "end" });
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: "protocol_error", message: "stream method returned result frame" })
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
- this.transport.send({ op: "result", id: callId, ok: false, error: { code, message } });
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 sendErrorFromException(callId: number, err: unknown): void {
688
- this.transport.send({ op: "result", id: callId, ok: false, error: toStatus(err) });
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
- methodIdx: number,
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 = { resolve, reject, abort, decodeReturn };
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: methodIdx, args, meta: finalMeta });
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>(capId: number, methodIdx: number, args: unknown, meta?: CallMeta): StreamType<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: methodIdx, args, meta });
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 (let i = 0; i < methodNames.length; i++) {
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, i, params);
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, i, params, decoder);
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 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
- };
1175
+ const conn = this;
787
1176
  proxy[Symbol.dispose] = () => {
788
- const r = invokeMethod();
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 ctrl of this.serverActiveCalls.values()) {
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({ code: "protocol_error", message: "expected ctx.exportCap return for cap method" });
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({ code: "protocol_error", message: "exported cap type mismatch with method returns" });
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: "protocol_error", message: "expected ExportedCap[] for cap.array method" });
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: "protocol_error", message: "expected Record<string, ExportedCap> for cap.record method" });
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: "protocol_error", message: "expected CapRef" });
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: "protocol_error", message: "expected array" });
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: "protocol_error", message: "expected CapRef in array" });
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: "protocol_error", message: "expected record" });
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: "protocol_error", message: "expected CapRef in record" });
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
- 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
- };
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: "unknown", message: err.message };
952
- return { code: "unknown", message: String(err) };
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;