@nimbus-sh/fabric 0.4.0 → 0.6.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.
@@ -93,6 +93,15 @@ export interface IsolatePoolOptions {
93
93
  * not receive a Supervisor binding and do not retain user state.
94
94
  */
95
95
  cacheScope?: 'session' | 'global';
96
+ /**
97
+ * Baked into the loader id. Two pools sharing tag, preamble, wasm and
98
+ * supervisor but differing in `scope` never reuse each other's warm
99
+ * workers: an interpreter scope that ENDS (REPL close, Ctrl-C reset)
100
+ * must not resurrect the loader-cached heap for the next owner.
101
+ * Default '' preserves the existing id bytes — only scoped pools get
102
+ * the extra segment.
103
+ */
104
+ scope?: string;
96
105
  /**
97
106
  * Override the `doId` baked into the auto-injected SUPERVISOR binding.
98
107
  * Default: `ctx.id.toString()` (the DO that constructs the pool).
@@ -308,6 +317,9 @@ export function assembleLoaderWorkerModuleSource(
308
317
  const callExpr = options.hasBindings
309
318
  ? '__fn__(...args, this.env)'
310
319
  : '__fn__(...args)';
320
+ const requestCallExpr = options.hasBindings
321
+ ? '__fn__(request, this.env)'
322
+ : '__fn__(request)';
311
323
  lines.push(
312
324
  'export default class extends WorkerEntrypoint {',
313
325
  ' execute(...args) {',
@@ -315,6 +327,26 @@ export function assembleLoaderWorkerModuleSource(
315
327
  ' if (result instanceof Promise) return result;',
316
328
  ' return result;',
317
329
  ' }',
330
+ '',
331
+ ' // Request/Response transport: the ONE dispatch path whose abort is',
332
+ ' // real. workerd cancels the inner execution context when the request',
333
+ ' // signal aborts while the fn is suspended on I/O; RPC execute() above',
334
+ ' // has no equivalent (measured: Symbol.dispose on a pending RPC does',
335
+ ' // not cancel). The fn is request-shaped — it encodes and decodes its',
336
+ ' // own payload; nothing generic is serialized here.',
337
+ ' async fetch(request) {',
338
+ ' try {',
339
+ ` const result = await ${requestCallExpr};`,
340
+ ' if (result instanceof Response) return result;',
341
+ ' return Response.json(result ?? null);',
342
+ ' } catch (err) {',
343
+ ' // Fail loud inside the response so an abort of the transport is',
344
+ ' // not conflated with a guest exception: the caller sees 500 only',
345
+ ' // for a real fn failure, and aborts arrive as fetch rejections.',
346
+ ' const message = err instanceof Error && err.message ? err.message : String(err);',
347
+ ' return Response.json({ __nimbusFacetError: message }, { status: 500 });',
348
+ ' }',
349
+ ' }',
318
350
  '}',
319
351
  );
320
352
  return lines.join('\n');
@@ -345,6 +377,18 @@ export class IsolatePool {
345
377
  private readonly defaultRetries: number;
346
378
  private readonly tag: string;
347
379
  private readonly slotGenerations = new Map<number, number>();
380
+ /**
381
+ * Per-slot execution ownership: the tail of each slot's in-flight
382
+ * dispatch chain. Two dispatches on the same warm isolate at once
383
+ * interleave on its QueueState — map() callers used to trust the
384
+ * caller's slot round-robin, which could not prevent submit() (slot
385
+ * 0) or a second map() landing on a slot a task still occupied. Every
386
+ * dispatch now waits for the slot's previous execution to settle
387
+ * before touching it.
388
+ */
389
+ private readonly slotTails = new Map<number, Promise<void>>();
390
+ /** Set by dispose() — queued dispatches reject instead of running. */
391
+ private disposed = false;
348
392
  private bindings: Record<string, unknown> | undefined;
349
393
 
350
394
  private readonly preamble: string | undefined;
@@ -380,7 +424,16 @@ export class IsolatePool {
380
424
  * 12 chars is enough entropy for DO ids to collide-free per process.
381
425
  */
382
426
  private readonly doIdShort: string;
427
+ /**
428
+ * The supervisor identity the minted worker's env.SUPERVISOR binding
429
+ * bakes (doId short-form + pid), folded into the loader cache key. 's-none'
430
+ * when no SUPERVISOR binding was minted, so a supervisor-less pool keeps
431
+ * the old key shape and its warm slots stay shared.
432
+ */
433
+ private readonly supervisorKey: string;
383
434
 
435
+ /** Extra loader-id segment from options.scope — see IsolatePoolOptions. */
436
+ private readonly scope: string;
384
437
  constructor(
385
438
  env: unknown,
386
439
  ctx: DurableObjectState,
@@ -408,6 +461,7 @@ export class IsolatePool {
408
461
  this.doIdShort = opts?.cacheScope === 'global'
409
462
  ? 'global'
410
463
  : ctx.id.toString().slice(0, 12);
464
+ this.scope = opts?.scope ?? '';
411
465
 
412
466
  // Materialise the wasm-modules table. Sanitise each name into a
413
467
  // valid JS identifier for the static import binding; key collisions
@@ -459,6 +513,7 @@ export class IsolatePool {
459
513
  }
460
514
 
461
515
  const bindings: Record<string, unknown> = { ...(opts?.extraBindings ?? {}) };
516
+ this.supervisorKey = 's-none';
462
517
  if (!opts?.omitSupervisor) {
463
518
  const supervisorRpc = supervisorEntrypoint();
464
519
  if (supervisorRpc) {
@@ -467,9 +522,19 @@ export class IsolatePool {
467
522
  // to the user's session DO, not the peer DO. Default to the
468
523
  // local ctx.id (single-DO callers and the in-DO in-DO fanout path).
469
524
  const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
525
+ const supPid = opts?.supervisorPid ?? 0;
470
526
  bindings.SUPERVISOR = supervisorRpc({
471
- props: { doId: supDoId, pid: opts?.supervisorPid ?? 0 },
527
+ props: { doId: supDoId, pid: supPid },
472
528
  });
529
+ // Whatever the minted worker's env carries must be in its loader
530
+ // cache key — workerd's loader cache survives a DO hibernation
531
+ // wake while generation-strided pids (1000001 → 2000001) do not:
532
+ // a warm slot keyed without the supervisor identity returns in
533
+ // the new generation still credentialed to the dead pid, and
534
+ // every pid-authorized RPC from it fails "process pid … does
535
+ // not exist". doIdShort alone cannot cover this — it changes
536
+ // across sessions, not across wakes of the same session.
537
+ this.supervisorKey = `s${supDoId.slice(0, 12)}-${supPid}`;
473
538
  } else {
474
539
  // Supervisor entrypoint unavailable — running without ctx.exports
475
540
  // (e.g. unit-test harness, or LOADER.load contexts where the
@@ -669,15 +734,59 @@ export class IsolatePool {
669
734
  /**
670
735
  * Dispatch a single task to the slot isolate. `slotIndex` picks which
671
736
  * warm isolate services the call; callers round-robin slots themselves.
737
+ * Serialized per slot: this call waits for the slot's previous
738
+ * execution to settle before touching the warm isolate.
739
+ *
740
+ * `invoke` is the single-attempt call against the slot's entrypoint
741
+ * stub. `execute` dispatches call `entrypoint.execute(...args)`;
742
+ * `fetch` dispatches call `entrypoint.fetch(<per-attempt Request>)` —
743
+ * the attempt index lets a fetch invoke mint a fresh clone for retries,
744
+ * since a Request's body is consumed once.
672
745
  */
673
- async #dispatchSlot(
746
+ async #dispatchSlot<T>(
674
747
  fnSource: string,
675
748
  fnHash: string,
676
749
  slotIndex: number,
677
- args: unknown[],
750
+ invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
678
751
  resilience: ResolvedResilience,
679
752
  perCallWasm?: Record<string, ArrayBuffer>,
680
- ): Promise<unknown> {
753
+ wasAborted?: () => boolean,
754
+ ): Promise<T> {
755
+ // A warm slot executes one dispatch at a time: queue behind the
756
+ // previous owner, then record this dispatch as the new tail. The
757
+ // tail outlives the caller's outcome: a timeout rejects the
758
+ // Promise.race while runOnce's RPC is still live on the Worker, so
759
+ // release waits for every execution the owned body started to
760
+ // settle — never the caller's settle alone.
761
+ const previous = this.slotTails.get(slotIndex) ?? Promise.resolve();
762
+ let release: () => void;
763
+ this.slotTails.set(slotIndex, new Promise<void>((resolve) => { release = resolve; }));
764
+ await previous;
765
+ if (this.disposed) {
766
+ release!();
767
+ throw new BindingError(`IsolatePool(${this.tag}) is disposed`);
768
+ }
769
+ const inFlight: Promise<unknown>[] = [];
770
+ try {
771
+ return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
772
+ } finally {
773
+ // Do not delay the caller's own outcome — the tail releases when
774
+ // the RPCs the body launched have actually settled.
775
+ void Promise.allSettled(inFlight).then(() => release!());
776
+ }
777
+ }
778
+
779
+
780
+ async #dispatchSlotOwned<T>(
781
+ fnSource: string,
782
+ fnHash: string,
783
+ slotIndex: number,
784
+ invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
785
+ resilience: ResolvedResilience,
786
+ perCallWasm: Record<string, ArrayBuffer> | undefined,
787
+ inFlight: Promise<unknown>[],
788
+ wasAborted?: () => boolean,
789
+ ): Promise<T> {
681
790
  // Per-call wasm fingerprint. Mixed into the cache key so two calls
682
791
  // with different bytes hit different slots (no cache poisoning).
683
792
  // For the common case (no per-call wasm) the fingerprint is '0',
@@ -686,12 +795,12 @@ export class IsolatePool {
686
795
  const perCallWasmHash = this.#fingerprintWasm(perCallWasmEntries);
687
796
 
688
797
  // Cache key includes the short DO id so warm isolates are scoped to
689
- // ONE session. See the doIdShort field comment for why — without it,
690
- // a later session's pool reuses the warm worker from a previous
691
- // session (which still carries the old session's env.SUPERVISOR
692
- // binding), and writeBatch RPCs land in the wrong DO's VFS.
798
+ // ONE session (see the doIdShort field comment), and the supervisor
799
+ // identity so a wake of that same session can never reuse a warm
800
+ // worker whose SUPERVISOR binding still names the dead generation's
801
+ // pid. See the supervisorKey field comment for the failure mode.
693
802
  const buildId = (generation: number): string =>
694
- `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:slot-${slotIndex}:g${generation}`;
803
+ `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}${this.scope ? `:${this.scope}` : ''}`;
695
804
  let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
696
805
  const code = this.#buildCode(fnSource, perCallWasmEntries);
697
806
 
@@ -700,7 +809,7 @@ export class IsolatePool {
700
809
  // slot updated on every dispatch.
701
810
  try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
702
811
 
703
- const runOnce = async (): Promise<unknown> => {
812
+ const runOnce = async (): Promise<T> => {
704
813
  // loader.get() is synchronous from the caller's POV; the callback
705
814
  // is only invoked on cache miss. We wrap the callback tightly so a
706
815
  // retry doesn't rebuild workerCode — that's already stable here.
@@ -732,8 +841,7 @@ export class IsolatePool {
732
841
  // wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
733
842
  const endFetch = beginLoaderFetch(this.ctx);
734
843
  try {
735
- const out = await entrypoint.execute(...args);
736
- return out;
844
+ return await invoke(entrypoint, attempt);
737
845
  } catch (err) {
738
846
  if (err instanceof Error) {
739
847
  throw new ExecutionError(err.message, err.stack);
@@ -767,8 +875,10 @@ export class IsolatePool {
767
875
  // See plan in close-plan-2026-04-28.
768
876
  let timerId: ReturnType<typeof setTimeout> | undefined;
769
877
  try {
878
+ const attemptPromise = runOnce();
879
+ inFlight.push(attemptPromise);
770
880
  return await Promise.race([
771
- runOnce(),
881
+ attemptPromise,
772
882
  new Promise<never>((_, reject) => {
773
883
  timerId = setTimeout(
774
884
  () => reject(new TimeoutError(resilience.timeoutMs)),
@@ -780,7 +890,9 @@ export class IsolatePool {
780
890
  if (timerId !== undefined) clearTimeout(timerId);
781
891
  }
782
892
  }
783
- return await runOnce();
893
+ const attemptPromise = runOnce();
894
+ inFlight.push(attemptPromise);
895
+ return await attemptPromise;
784
896
  } catch (err) {
785
897
  lastError = err instanceof Error ? err : new Error(String(err));
786
898
  const cause = classifyError(lastError);
@@ -801,6 +913,18 @@ export class IsolatePool {
801
913
  message: lastError.message,
802
914
  });
803
915
  } catch { /* fail-soft */ }
916
+ if (wasAborted?.()) {
917
+ // The caller aborted this dispatch's Request. workerd cancelled
918
+ // the isolate's execution context wherever it was suspended —
919
+ // mid-syscall, mid-stream — so the interpreter's heap may hold
920
+ // half-applied state. Bump the slot generation: the next
921
+ // dispatch lands on a FRESH worker under a new id rather than
922
+ // reusing the abandoned one. Retrying is wrong — the user
923
+ // interrupted; surface the abort.
924
+ const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
925
+ this.slotGenerations.set(slotIndex, generation);
926
+ throw lastError;
927
+ }
804
928
  if (cause === 'clone_refused' && !retriedCloneRefusal) {
805
929
  retriedCloneRefusal = true;
806
930
  const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
@@ -851,12 +975,49 @@ export class IsolatePool {
851
975
  fnSource,
852
976
  fnHash,
853
977
  0,
854
- [arg],
978
+ (entrypoint) => entrypoint.execute(arg) as Promise<Awaited<R>>,
855
979
  resilience,
856
980
  opts?.wasmModules,
857
- )) as Awaited<R>;
981
+ ));
858
982
  }
859
983
 
984
+ /**
985
+ * Dispatch `fn` through the fetch transport — the pool's only
986
+ * cancellable path: aborting `request.signal` cancels the inner
987
+ * execution context at its next I/O suspension (a synchronous CPU
988
+ * section cannot be preempted — the platform's honest limit), the
989
+ * call rejects, and the slot generation bumps so the next dispatch
990
+ * gets a fresh interpreter. `fn` is request-shaped: it encodes and
991
+ * decodes its own payload.
992
+ */
993
+ async submitRequest(
994
+ fn: (request: Request, env: FacetBindings) => Response | Promise<Response>,
995
+ request: Request,
996
+ opts?: IsolateCallOptions,
997
+ ): Promise<Response> {
998
+ if (request.bodyUsed) {
999
+ throw new BindingError(
1000
+ 'IsolatePool.submitRequest: request body is already consumed — ' +
1001
+ 'pass an unconsumed Request so retries can re-issue it.',
1002
+ );
1003
+ }
1004
+ const { fnSource, fnHash } = this.#prepare(fn);
1005
+ const resilience = this.#resolve(opts);
1006
+ // Every attempt fetches a clone: a Request's body is consumed once,
1007
+ // so the caller's original stays unspent and retries get a fresh
1008
+ // body that follows the same signal.
1009
+ return this.#dispatchSlot(
1010
+ fnSource,
1011
+ fnHash,
1012
+ 0,
1013
+ (entrypoint) => entrypoint.fetch(request.clone()),
1014
+ resilience,
1015
+ opts?.wasmModules,
1016
+ () => request.signal.aborted,
1017
+ );
1018
+ }
1019
+
1020
+
860
1021
  /**
861
1022
  * Run `fn` on every item in `items`, at most `concurrency` at a time,
862
1023
  * pinned to stable slots so warm isolates are reused.
@@ -929,7 +1090,7 @@ export class IsolatePool {
929
1090
  fnSource,
930
1091
  fnHash,
931
1092
  slotIndex,
932
- [items[idx]],
1093
+ (entrypoint) => entrypoint.execute(items[idx]),
933
1094
  resilience,
934
1095
  opts?.wasmModules,
935
1096
  )) as Awaited<R>;
@@ -974,6 +1135,7 @@ export class IsolatePool {
974
1135
  * Safe to call more than once; idempotent.
975
1136
  */
976
1137
  dispose(): void {
1138
+ this.disposed = true;
977
1139
  if (!this.bindings) return;
978
1140
  for (const key of Object.keys(this.bindings)) {
979
1141
  disposeRpcResource(this.bindings[key]);
@@ -67,6 +67,8 @@
67
67
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
68
68
  import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
69
69
  import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
70
+ import { hostNamespaceBinding, hostOpDispatch, type HostNamespaceBinding } from './host-dispatch.js';
71
+ import { z } from 'zod/v4';
70
72
  import {
71
73
  type HostedProcess,
72
74
  type OneShotParams,
@@ -219,20 +221,6 @@ export interface HostedHttpResponse {
219
221
  body: ReadableStream | null;
220
222
  }
221
223
 
222
- /**
223
- * Every method a hosting sibling must answer. Checked as a set at first
224
- * contact: a stub that has the probe but not the router is a deployment skew,
225
- * and finding that out mid-process is finding it out too late.
226
- */
227
- const PROCESS_PEER_METHODS = [
228
- '_rpcProcessHostProbe',
229
- '_rpcHostProcess',
230
- '_rpcAwaitHostedOpen',
231
- '_rpcAwaitHostedBoot',
232
- '_rpcRouteHostedHttp',
233
- '_rpcCancelHostProcess',
234
- ] as const;
235
-
236
224
  /** The host-process surface a sibling session DO exposes. */
237
225
  interface ProcessPeerStub {
238
226
  _rpcProcessHostProbe(): Promise<{ isolateToken: string }>;
@@ -257,41 +245,70 @@ interface ProcessPeerStub {
257
245
  export const HOSTED_WEBSOCKET_KEY_HEADER = 'x-nimbus-hosted-websocket';
258
246
  export const HOSTED_WEBSOCKET_CAPABILITY_HEADER = 'x-nimbus-hosted-websocket-capability';
259
247
 
260
- interface PeerNamespace {
261
- idFromName(name: string): unknown;
262
- get(id: unknown): unknown;
248
+ /**
249
+ * Peer stubs forward one supervisorOp entrypoint: every method the host-
250
+ * process surface needs is an envelope op, not a private _rpc* method.
251
+ */
252
+ function peerNamespace(env: object): HostNamespaceBinding {
253
+ try {
254
+ return hostNamespaceBinding(env, 'ProcessFabric');
255
+ } catch (e) {
256
+ if (e instanceof BindingError) {
257
+ throw new BindingError(
258
+ e.message
259
+ + " NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
260
+ + "Durable Object; name the host's own binding with composeFabric({ hostNamespace }).",
261
+ );
262
+ }
263
+ throw e;
264
+ }
263
265
  }
264
266
 
265
- function peerNamespace(env: unknown): PeerNamespace {
266
- const ns = (typeof env === 'object' || typeof env === 'function') && env !== null
267
- ? Reflect.get(env, 'NIMBUS_SESSION')
267
+ const PeerProbeResult = z.object({ isolateToken: z.string() });
268
+ const PeerOpenResult = z.object({ ok: z.boolean() });
269
+ const PeerBootResult = z.object({ payload: z.unknown() });
270
+ const PeerCancelResult = z.object({ cancelled: z.boolean() });
271
+ const PeerHttpResult = z.object({
272
+ status: z.number().int(),
273
+ statusText: z.string(),
274
+ headers: z.array(z.tuple([z.string(), z.string()])),
275
+ body: z.instanceof(ReadableStream).nullable(),
276
+ });
277
+
278
+ function processPeerStub(value: object, peerName: string): ProcessPeerStub {
279
+ const dispatch = hostOpDispatch(value, `ProcessFabric peer '${peerName}'`);
280
+ const fetchFn = (typeof value === 'object' || typeof value === 'function') && value !== null
281
+ ? Reflect.get(value, 'fetch')
268
282
  : undefined;
269
- if ((typeof ns !== 'object' && typeof ns !== 'function') || ns === null
270
- || typeof Reflect.get(ns, 'idFromName') !== 'function'
271
- || typeof Reflect.get(ns, 'get') !== 'function') {
283
+ if (typeof fetchFn !== 'function') {
272
284
  throw new BindingError(
273
- 'ProcessFabric: env.NIMBUS_SESSION binding missing or invalid. '
274
- + "NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
275
- + 'Durable Object; add the binding via durable_objects.bindings in wrangler.jsonc.',
285
+ `ProcessFabric: peer '${peerName}' exposes no fetch() — the hosted-websocket `
286
+ + 'upgrade leg still travels as a service-binding fetch.',
276
287
  );
277
288
  }
278
- return ns as unknown as PeerNamespace;
279
- }
280
-
281
- function processPeerStub(value: unknown, peerName: string): ProcessPeerStub {
282
- if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
283
- throw new BindingError(`ProcessFabric: NIMBUS_SESSION.get() returned no stub for peer '${peerName}'.`);
284
- }
285
- for (const method of PROCESS_PEER_METHODS) {
286
- if (typeof Reflect.get(value, method) !== 'function') {
287
- throw new BindingError(
288
- `ProcessFabric: peer '${peerName}' does not expose ${method}(). `
289
- + 'A sibling that answers some of the host-process surface and not the rest '
290
- + 'would fail somewhere inside a running process instead of here.',
291
- );
292
- }
289
+ const adapter: ProcessPeerStub = {
290
+ _rpcProcessHostProbe: async () => PeerProbeResult.parse(await dispatch({ op: 'processHostProbe', args: [] })),
291
+ _rpcHostProcess: async (boot, opts) => PeerOpenResult.parse(await dispatch({ op: 'hostProcess', args: [boot, opts] })),
292
+ _rpcAwaitHostedOpen: async (key) => PeerOpenResult.parse(await dispatch({ op: 'awaitHostedOpen', args: [key] })),
293
+ _rpcAwaitHostedBoot: async (key) => {
294
+ const result = PeerBootResult.parse(await dispatch({ op: 'awaitHostedBoot', args: [key] }));
295
+ return { payload: result.payload };
296
+ },
297
+ _rpcRouteHostedHttp: async (key, request) => PeerHttpResult.parse(await dispatch({ op: 'routeHostedHttp', args: [key, request] })),
298
+ _rpcCancelHostProcess: async (key) => PeerCancelResult.parse(await dispatch({ op: 'cancelHostProcess', args: [key] })),
299
+ fetch: async (request) => {
300
+ const response = await Reflect.apply(fetchFn, value, [request]);
301
+ if (!(response instanceof Response)) throw new BindingError(`ProcessFabric: peer '${peerName}' returned no Response`);
302
+ return response;
303
+ },
304
+ };
305
+ // The adapter wraps the raw stub; releasing the adapter must release it —
306
+ // a held RPC stub pins the peer DO for the session's life.
307
+ const disposerKey = Reflect.get(Symbol, 'dispose');
308
+ if (typeof disposerKey === 'symbol') {
309
+ Object.defineProperty(adapter, disposerKey, { value: () => disposeRpcResource(value) });
293
310
  }
294
- return value as unknown as ProcessPeerStub;
311
+ return adapter;
295
312
  }
296
313
 
297
314
  interface PeerPlacement {
@@ -312,13 +329,16 @@ class PeerProcessHost implements ProcessHost {
312
329
  storageSharedWithSession: false,
313
330
  };
314
331
 
315
- private readonly ns: PeerNamespace;
332
+ private readonly ns: HostNamespaceBinding;
316
333
  private readonly env: ResidentFacetEnv;
317
334
  private readonly coordDoId: string;
318
335
  /** pid → the isolate token of the peer currently hosting that process. */
319
336
  private readonly tokensInUse = new Map<number, string>();
320
337
 
321
338
  constructor(private readonly ctx: DurableObjectState, env: unknown) {
339
+ if (env === null || (typeof env !== 'object' && typeof env !== 'function')) {
340
+ throw new BindingError('ProcessFabric: a peer host requires environment bindings');
341
+ }
322
342
  this.ns = peerNamespace(env);
323
343
  this.env = (env ?? {}) as ResidentFacetEnv;
324
344
  this.coordDoId = ctx.id.toString();
@@ -445,7 +465,7 @@ class PeerProcessHost implements ProcessHost {
445
465
  /** One sibling name, resolved and probed. Leaks nothing on failure. */
446
466
  private async _probePlacement(pid: number, attempt: number): Promise<PeerPlacement> {
447
467
  const peerName = `${this.coordDoId}:proc:${pid}:${attempt}`;
448
- let resource: unknown;
468
+ let resource: DurableObjectStub | null = null;
449
469
  try {
450
470
  resource = this.ns.get(this.ns.idFromName(peerName));
451
471
  const stub = processPeerStub(resource, peerName);
@@ -63,10 +63,10 @@ export const TURN_CHUNK_MAX_BYTES = 2_000_000;
63
63
  * spent.
64
64
  *
65
65
  * Callers report the work they are about to do or have just done and await
66
- * the result; a pacer that is not yielding returns without suspending, so the
67
- * one-shot exec path — which passes no pacer at all — keeps its exact
68
- * behaviour and cost. Nothing here decides WHAT the launch does, only where it
69
- * is allowed to stop.
66
+ * the result; a pacer that is not yielding returns without suspending, so a
67
+ * build small enough to fit in one chunk — most one-shot execs, every cache
68
+ * hit — keeps its exact behaviour and cost. Nothing here decides WHAT the
69
+ * launch does, only where it is allowed to stop.
70
70
  */
71
71
  export class TurnBudget {
72
72
  /** Turn handoffs this launch has taken. Reported with the launch. */