@nimbus-sh/fabric 0.5.0 → 0.7.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/bindings.ts CHANGED
@@ -26,8 +26,9 @@
26
26
  import { WorkerEntrypoint } from 'cloudflare:workers';
27
27
  import { z } from 'zod/v4';
28
28
  import { disposeRpcResource, useRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
29
- import { supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
29
+ import { hostNamespace, supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
30
30
  import { stagedBootAssembler } from './composition.js';
31
+ import { hostNamespaceBinding, hostOpDispatch, type HostOpDispatch } from './host-dispatch.js';
31
32
  import { assertModuleMapWithinCodeLimit } from './budgets.js';
32
33
  import type { EntrypointLoopbackFactory } from './composition.js';
33
34
  import type { WorkerCode } from './vendor/types.js';
@@ -44,17 +45,6 @@ interface ShimCtxExports {
44
45
  NimbusDOStub?: EntrypointLoopbackFactory;
45
46
  }
46
47
 
47
- /**
48
- * The supervisor DO namespace a shim resolves ONE stub from, by the id its
49
- * props carry. `Stub` is that DO's RPC surface as the calling shim uses it —
50
- * the supervisor class belongs to the embedder, so each shim names the methods
51
- * it calls rather than the class.
52
- */
53
- interface SupervisorNamespace<Stub> {
54
- idFromString(id: string): DurableObjectId;
55
- get(id: DurableObjectId): Stub;
56
- }
57
-
58
48
  /**
59
49
  * A dynamic worker's entrypoint, as hop 3 relays to it. `fetch` is the
60
50
  * entrypoint contract every loaded worker answers; `handleHttpRequest` is the
@@ -116,34 +106,6 @@ function shimCtxExports(ctx: unknown): ShimCtxExports {
116
106
  return exports as ShimCtxExports;
117
107
  }
118
108
 
119
- // ── Inner-Worker loopback bindings ────────────────────────────────────
120
- //
121
- // These WorkerEntrypoint classes are top-level exports so that ctx.exports
122
- // auto-populates Service Bindings for them (enable_ctx_exports compat
123
- // flag is already enabled via default compatibility_date 2026-04-01).
124
- //
125
- // They are re-exported from src/index.ts so wrangler detects them as
126
- // reachable from the entry file and bundles their classes.
127
- //
128
- // Usage pattern (in nimbus-wrangler.ts):
129
- // ctx.exports.NimbusAssetsRPC({ props: { vfsRoot, assetsDir } })
130
- // produces a Service Binding stub that can be placed in the inner
131
- // Worker's `env` under whatever binding name the user declared in
132
- // wrangler.jsonc's `assets.binding` (typically "ASSETS").
133
-
134
- /**
135
- * What the assets shim reads off the supervisor DO: the VFS bytes of one path,
136
- * or null when it holds no such file.
137
- */
138
- interface AssetsSupervisorStub {
139
- _rpcReadFileBytes(path: string): Promise<ArrayBuffer | Uint8Array | null>;
140
- }
141
-
142
- /** `env` for the assets shim: the supervisor its VFS reads round-trip through. */
143
- interface NimbusAssetsEnv {
144
- NIMBUS_SESSION?: SupervisorNamespace<AssetsSupervisorStub>;
145
- }
146
-
147
109
  /** Props the assets shim is minted with. */
148
110
  interface NimbusAssetsProps {
149
111
  /** Project root in VFS (e.g. "home/user/myapp"). */
@@ -175,7 +137,7 @@ interface NimbusAssetsProps {
175
137
  * For Phase 1, we use a simpler approach: the props carry a supervisor
176
138
  * DO id so we can round-trip through an RPC method that reads the file.
177
139
  */
178
- export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAssetsProps> {
140
+ export class NimbusAssetsRPC extends WorkerEntrypoint<object, NimbusAssetsProps> {
179
141
  /**
180
142
  * Fetch a static asset. Called by the inner Worker as
181
143
  * `env.ASSETS.fetch(request)`. The request URL's pathname is used to
@@ -193,12 +155,22 @@ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAss
193
155
  const parts = clean.split('/').filter((p) => p && p !== '..' && p !== '.');
194
156
  clean = parts.join('/');
195
157
 
196
- // Resolve the supervisor DO stub so we can call its VFS read RPC.
197
- const ns = this.env.NIMBUS_SESSION;
198
- if (!ns || !doId) {
199
- return new Response('ASSETS binding not wired: missing NIMBUS_SESSION or doId', { status: 500 });
158
+ // Resolve the supervisor DO through the composed host namespace and
159
+ // dispatch readFileBytes through its supervisorOp entrypoint — a host
160
+ // forwards envelopes, not private _rpc* methods.
161
+ if (!doId) {
162
+ return new Response('ASSETS binding not wired: missing doId', { status: 500 });
163
+ }
164
+ let stub: DurableObjectStub | null = null;
165
+ let dispatch: HostOpDispatch;
166
+ try {
167
+ const ns = hostNamespaceBinding(this.env, 'NimbusAssetsRPC');
168
+ stub = ns.get(ns.idFromString(doId));
169
+ dispatch = hostOpDispatch(stub, 'NimbusAssetsRPC');
170
+ } catch (e) {
171
+ disposeRpcResource(stub);
172
+ return new Response(`ASSETS binding not wired: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
200
173
  }
201
- const stub = ns.get(ns.idFromString(doId));
202
174
 
203
175
  // Candidate VFS paths, tried in order. The assetsDir is relative to
204
176
  // the project root in VFS. Trailing-slash and bare dir → index.html.
@@ -219,10 +191,14 @@ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAss
219
191
  for (const candidate of candidates) {
220
192
  try {
221
193
  const response = await useRpcResource(
222
- stub._rpcReadFileBytes(candidate),
223
- (bytes: ArrayBuffer | Uint8Array | null) => {
224
- if (!bytes || bytes.byteLength === undefined) return null;
225
- return new Response(bytes, {
194
+ dispatch({ op: 'readFileBytes', args: [candidate] }),
195
+ (bytes) => {
196
+ if (bytes === null) return null;
197
+ const body = bytes instanceof ArrayBuffer ? bytes
198
+ : bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer
199
+ ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : null;
200
+ if (!body) return new Response('Nimbus: readFileBytes returned invalid bytes', { status: 502 });
201
+ return new Response(body, {
226
202
  status: 200,
227
203
  headers: {
228
204
  'Content-Type': mimeTypeForPath(candidate),
@@ -785,31 +761,6 @@ export class NimbusDurableObjectNamespace extends WorkerEntrypoint<unknown, Nimb
785
761
  }
786
762
  }
787
763
 
788
- /**
789
- * What the DO shim reads off the supervisor: one inner-DO request, answered
790
- * from the facet the supervisor resolves in its own request context.
791
- */
792
- interface InnerDoSupervisorStub {
793
- _rpcInnerDoFetch(request: {
794
- bindingName: string;
795
- id: string;
796
- method: string;
797
- url: string;
798
- headers: [string, string][];
799
- body: ArrayBuffer | null;
800
- }): Promise<{
801
- body: ArrayBuffer;
802
- status: number;
803
- statusText: string;
804
- headers: [string, string][];
805
- }>;
806
- }
807
-
808
- /** `env` for the DO shim: the supervisor that owns the facet. */
809
- interface NimbusInnerDoEnv {
810
- NIMBUS_SESSION?: SupervisorNamespace<InnerDoSupervisorStub>;
811
- }
812
-
813
764
  /** Props the DO stub carries: which binding, which supervisor, which id. */
814
765
  interface NimbusDoStubProps extends NimbusDoNamespaceProps {
815
766
  id?: string;
@@ -823,21 +774,28 @@ interface NimbusDoStubProps extends NimbusDoNamespaceProps {
823
774
  * spins up / attaches to a facet via the supervisor's ctx.facets in
824
775
  * the SAME outer request context — never reusing stubs across requests.
825
776
  */
826
- export class NimbusDOStub extends WorkerEntrypoint<NimbusInnerDoEnv, NimbusDoStubProps> {
777
+ export class NimbusDOStub extends WorkerEntrypoint<object, NimbusDoStubProps> {
827
778
  /**
828
- * Resolve the supervisor DO from env.NIMBUS_SESSION and route through
829
- * its _rpcInnerDoFetch RPC method, which runs ctx.facets.get(...) in
830
- * its own context and forwards the request.
779
+ * Resolve the supervisor DO through the composed host namespace and
780
+ * dispatch the innerDoFetch op through its one supervisorOp entrypoint —
781
+ * a host forwards envelopes, not private _rpc* methods.
831
782
  */
832
783
  async fetch(request: Request): Promise<Response> {
833
784
  const props: NimbusDoStubProps = this.ctx.props || {};
834
- const ns = this.env?.NIMBUS_SESSION;
835
- if (!ns) return new Response('Nimbus: env.NIMBUS_SESSION unavailable', { status: 500 });
836
785
  const supervisorDoId = String(props.supervisorDoId || '');
837
786
  if (!supervisorDoId) return new Response('Nimbus: supervisorDoId missing', { status: 500 });
838
787
  const bindingName = String(props.bindingName || '');
839
788
  const id = String(props.id || '');
840
- const stub = ns.get(ns.idFromString(supervisorDoId));
789
+ let stub: DurableObjectStub | null = null;
790
+ let dispatch: HostOpDispatch;
791
+ try {
792
+ const ns = hostNamespaceBinding(this.env ?? {}, 'NimbusDOStub');
793
+ stub = ns.get(ns.idFromString(supervisorDoId));
794
+ dispatch = hostOpDispatch(stub, 'NimbusDOStub');
795
+ } catch (e) {
796
+ disposeRpcResource(stub);
797
+ return new Response(`Nimbus: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
798
+ }
841
799
  // Forward the full request (method, body, headers preserved) by
842
800
  // serializing what's needed and reconstructing on the other side.
843
801
  // The supervisor reconstitutes the Request from these fields and
@@ -849,20 +807,27 @@ export class NimbusDOStub extends WorkerEntrypoint<NimbusInnerDoEnv, NimbusDoStu
849
807
  request.headers.forEach((v, k) => { headerList.push([k, v]); });
850
808
  try {
851
809
  return await useRpcResource(
852
- stub._rpcInnerDoFetch({
853
- bindingName,
854
- id,
855
- method: request.method,
856
- url: request.url,
857
- headers: headerList,
858
- body,
810
+ dispatch({
811
+ op: 'innerDoFetch',
812
+ args: [{
813
+ bindingName,
814
+ id,
815
+ method: request.method,
816
+ url: request.url,
817
+ headers: headerList,
818
+ body,
819
+ }],
859
820
  }),
860
- (res) =>
861
- new Response(res.body, {
821
+ (res) => {
822
+ if (!(res instanceof Response)) {
823
+ return new Response('Nimbus: innerDoFetch returned an invalid result', { status: 502 });
824
+ }
825
+ return new Response(res.body, {
862
826
  status: res.status,
863
827
  statusText: res.statusText,
864
828
  headers: res.headers,
865
- }),
829
+ });
830
+ },
866
831
  );
867
832
  } finally {
868
833
  disposeRpcResource(stub);
package/src/fanout.ts CHANGED
@@ -20,20 +20,18 @@ import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
20
20
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
21
21
  import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
22
22
  import type { WorkerLoader } from './vendor/types.js';
23
+ import {
24
+ hostNamespaceBinding,
25
+ hostOpDispatch,
26
+ type HostOpDispatch,
27
+ } from './host-dispatch.js';
23
28
 
24
- /**
25
- * The sibling-session namespace the peer-DO topology routes through. Ids are
26
- * derived from a name so a task key always lands on the same peer.
27
- */
28
- interface PeerSessionNamespace {
29
- idFromName(name: string): DurableObjectId;
30
- get(id: DurableObjectId): unknown;
31
- }
32
-
33
- /** The bindings a fan-out needs off the coordinator DO's env. */
29
+ /** The bindings a fan-out needs off the coordinator DO's env. The host
30
+ * namespace key is the composed one — whatever this host named its own
31
+ * binding (default NIMBUS_SESSION); peers dispatch supervisorOp envelopes. */
34
32
  export interface FanoutEnv {
35
33
  LOADER?: WorkerLoader;
36
- NIMBUS_SESSION?: PeerSessionNamespace;
34
+ NIMBUS_SESSION?: unknown;
37
35
  }
38
36
 
39
37
  /**
@@ -168,30 +166,13 @@ export interface FanoutOptions {
168
166
  maxPeers?: number;
169
167
  }
170
168
 
171
- interface FanoutPeerStub {
172
- _rpcFanoutExecute<R>(
173
- fnSource: string,
174
- args: unknown[],
175
- poolOpts?: Record<string, unknown>,
176
- ): Promise<{ results?: R[] }>;
169
+ interface FanoutPeerResult<R> {
170
+ results: R[];
177
171
  }
178
172
 
179
- function isFanoutPeerStub(value: unknown): value is FanoutPeerStub {
180
- if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
181
- return false;
182
- }
183
- const execute = Reflect.get(value, '_rpcFanoutExecute');
184
- return typeof execute === 'function';
185
- }
186
-
187
- function fanoutPeerStub(value: unknown): FanoutPeerStub {
188
- if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
189
- throw new BindingError('Fanout: NIMBUS_SESSION.get() did not return a peer stub.');
190
- }
191
- if (!isFanoutPeerStub(value)) {
192
- throw new BindingError('Fanout: peer stub does not expose _rpcFanoutExecute().');
193
- }
194
- return value;
173
+ // Task values retain the submitted function's type; validate the wire envelope.
174
+ function isPeerResult<R>(value: Awaited<ReturnType<HostOpDispatch>>): value is FanoutPeerResult<R> {
175
+ return value !== null && typeof value === 'object' && 'results' in value && Array.isArray(value.results);
195
176
  }
196
177
 
197
178
  /**
@@ -323,14 +304,10 @@ export class Fanout {
323
304
  tasks: FanoutTask<A>[],
324
305
  fn: FacetTaskFn<A, R>,
325
306
  ): Promise<R[]> {
326
- const ns = this.env?.NIMBUS_SESSION;
327
- if (!ns || typeof ns.idFromName !== 'function' || typeof ns.get !== 'function') {
328
- throw new BindingError(
329
- 'Fanout: env.NIMBUS_SESSION binding missing or invalid. ' +
330
- 'The peer-DO topology requires it. ' +
331
- 'Add the binding via durable_objects.bindings in wrangler.jsonc.',
332
- );
333
- }
307
+ // The peer-DO topology routes through the composed host namespace —
308
+ // a workspace host names its own binding, and its stub forwards one
309
+ // supervisorOp entrypoint, not private _rpc* methods.
310
+ const ns = hostNamespaceBinding(this.env ?? {}, 'Fanout');
334
311
 
335
312
  // Serialize the user function ONCE here on the supervisor side.
336
313
  // Each peer DO receives the same fnSource string; warm peer
@@ -380,38 +357,42 @@ export class Fanout {
380
357
  // stub points at a torn-down object, so a retry re-resolves the
381
358
  // sibling by its stable id.
382
359
  const peerStub = ns.get(id);
383
- const stub = fanoutPeerStub(peerStub);
384
360
  try {
361
+ const dispatch = hostOpDispatch(peerStub, `Fanout peer ${siblingName}`);
385
362
  // Each peer DO RPC call uses ONE LOADER worker on its side.
386
363
  // Supervisor → peer DO is a stub.fetch / RPC method call,
387
364
  // NOT an env.LOADER.get(); that's the cap-sidestep that
388
365
  // makes peer-DO fanout work.
389
- const rpcResp = await stub._rpcFanoutExecute<R>(
390
- fnSource,
391
- peerArgs,
392
- {
393
- tag: this.opts.tag,
394
- timeoutMs: this.opts.timeoutMs,
395
- preamble: this.opts.preamble,
396
- wasmModules: this.opts.wasmModules,
397
- extraBindings: this.opts.extraBindings,
398
- omitSupervisor: this.opts.omitSupervisor,
399
- // INSTALL-HONESTY: forward the COORDINATOR's full doId so
400
- // the peer's IsolatePool can mint a SUPERVISOR
401
- // binding that routes back HERE (the user's session DO),
402
- // not to the peer DO itself. Without this, peer DOs'
403
- // env.SUPERVISOR.writeBatch / writeBatchStream / stdout /
404
- // ... write into the peer's own VFS — invisible to the
405
- // user. See INSTALL-HONESTY-retro.md.
406
- coordinatorDoId: this.coordDoId,
407
- // Credential source for peer-side writeBatchStream — the
408
- // invoking process pid, so package writes are authorized
409
- // as the user (not rejected as pid:0).
410
- supervisorPid: this.opts.supervisorPid,
411
- },
412
- );
366
+ const rpcResp = await dispatch({
367
+ op: 'fanoutExecute',
368
+ args: [
369
+ fnSource,
370
+ peerArgs,
371
+ {
372
+ tag: this.opts.tag,
373
+ timeoutMs: this.opts.timeoutMs,
374
+ preamble: this.opts.preamble,
375
+ wasmModules: this.opts.wasmModules,
376
+ extraBindings: this.opts.extraBindings,
377
+ omitSupervisor: this.opts.omitSupervisor,
378
+ // INSTALL-HONESTY: forward the COORDINATOR's full doId so
379
+ // the peer's IsolatePool can mint a SUPERVISOR
380
+ // binding that routes back HERE (the user's session DO),
381
+ // not to the peer DO itself. Without this, peer DOs'
382
+ // env.SUPERVISOR.writeBatch / writeBatchStream / stdout /
383
+ // ... write into the peer's own VFS — invisible to the
384
+ // user. See INSTALL-HONESTY-retro.md.
385
+ coordinatorDoId: this.coordDoId,
386
+ // Credential source for peer-side writeBatchStream — the
387
+ // invoking process pid, so package writes are authorized
388
+ // as the user (not rejected as pid:0).
389
+ supervisorPid: this.opts.supervisorPid,
390
+ },
391
+ ],
392
+ });
413
393
  try {
414
- const peerResults = rpcResp.results ?? [];
394
+ if (!isPeerResult<R>(rpcResp)) throw new BindingError(`Fanout peer '${siblingName}' returned no result array`);
395
+ const peerResults = rpcResp.results;
415
396
  if (peerResults.length !== bucket.length) {
416
397
  throw new Error(
417
398
  `peer DO returned ${peerResults.length} results for ${bucket.length} tasks ` +
@@ -0,0 +1,34 @@
1
+ import { BindingError } from './vendor/errors.js';
2
+ import { hostDispatchMethod, hostNamespace } from './composition.js';
3
+ import type { createSupervisorOpHandler } from '@nimbus-sh/core/workspace/supervisor-op.js';
4
+
5
+ export type HostNamespaceBinding = Pick<DurableObjectNamespace, 'get' | 'idFromName' | 'idFromString'>;
6
+ export type HostOpDispatch = ReturnType<typeof createSupervisorOpHandler>;
7
+
8
+ function isNamespace(value: object): value is HostNamespaceBinding {
9
+ return typeof Reflect.get(value, 'idFromName') === 'function'
10
+ && typeof Reflect.get(value, 'idFromString') === 'function'
11
+ && typeof Reflect.get(value, 'get') === 'function';
12
+ }
13
+
14
+ export function hostNamespaceBinding(env: object | null | undefined, usage: string): HostNamespaceBinding {
15
+ const name = hostNamespace();
16
+ const binding = env ? Reflect.get(env, name) : undefined;
17
+ if (binding === null || (typeof binding !== 'object' && typeof binding !== 'function') || !isNamespace(binding)) {
18
+ throw new BindingError(`${usage}: env.${name} must be the Durable Object namespace configured by composeFabric`);
19
+ }
20
+ return binding;
21
+ }
22
+
23
+ export function hostOpDispatch(stub: object, usage: string): HostOpDispatch {
24
+ const name = hostDispatchMethod();
25
+ if (stub === null || (typeof stub !== 'object' && typeof stub !== 'function')) {
26
+ throw new BindingError(`${usage}: the workspace namespace returned no stub`);
27
+ }
28
+ const dispatch = Reflect.get(stub, name);
29
+ if (typeof dispatch !== 'function') {
30
+ throw new BindingError(`${usage}: the workspace host must forward ${name}(envelope) to the runtime`);
31
+ }
32
+ // RpcStub.call would invoke a remote method named "call".
33
+ return (envelope) => Promise.resolve(Reflect.apply(dispatch, stub, [envelope]));
34
+ }
@@ -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');
@@ -400,6 +432,8 @@ export class IsolatePool {
400
432
  */
401
433
  private readonly supervisorKey: string;
402
434
 
435
+ /** Extra loader-id segment from options.scope — see IsolatePoolOptions. */
436
+ private readonly scope: string;
403
437
  constructor(
404
438
  env: unknown,
405
439
  ctx: DurableObjectState,
@@ -427,6 +461,7 @@ export class IsolatePool {
427
461
  this.doIdShort = opts?.cacheScope === 'global'
428
462
  ? 'global'
429
463
  : ctx.id.toString().slice(0, 12);
464
+ this.scope = opts?.scope ?? '';
430
465
 
431
466
  // Materialise the wasm-modules table. Sanitise each name into a
432
467
  // valid JS identifier for the static import binding; key collisions
@@ -701,15 +736,22 @@ export class IsolatePool {
701
736
  * warm isolate services the call; callers round-robin slots themselves.
702
737
  * Serialized per slot: this call waits for the slot's previous
703
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.
704
745
  */
705
- async #dispatchSlot(
746
+ async #dispatchSlot<T>(
706
747
  fnSource: string,
707
748
  fnHash: string,
708
749
  slotIndex: number,
709
- args: unknown[],
750
+ invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
710
751
  resilience: ResolvedResilience,
711
752
  perCallWasm?: Record<string, ArrayBuffer>,
712
- ): Promise<unknown> {
753
+ wasAborted?: () => boolean,
754
+ ): Promise<T> {
713
755
  // A warm slot executes one dispatch at a time: queue behind the
714
756
  // previous owner, then record this dispatch as the new tail. The
715
757
  // tail outlives the caller's outcome: a timeout rejects the
@@ -726,7 +768,7 @@ export class IsolatePool {
726
768
  }
727
769
  const inFlight: Promise<unknown>[] = [];
728
770
  try {
729
- return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, args, resilience, perCallWasm, inFlight);
771
+ return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
730
772
  } finally {
731
773
  // Do not delay the caller's own outcome — the tail releases when
732
774
  // the RPCs the body launched have actually settled.
@@ -734,15 +776,17 @@ export class IsolatePool {
734
776
  }
735
777
  }
736
778
 
737
- async #dispatchSlotOwned(
779
+
780
+ async #dispatchSlotOwned<T>(
738
781
  fnSource: string,
739
782
  fnHash: string,
740
783
  slotIndex: number,
741
- args: unknown[],
784
+ invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
742
785
  resilience: ResolvedResilience,
743
786
  perCallWasm: Record<string, ArrayBuffer> | undefined,
744
787
  inFlight: Promise<unknown>[],
745
- ): Promise<unknown> {
788
+ wasAborted?: () => boolean,
789
+ ): Promise<T> {
746
790
  // Per-call wasm fingerprint. Mixed into the cache key so two calls
747
791
  // with different bytes hit different slots (no cache poisoning).
748
792
  // For the common case (no per-call wasm) the fingerprint is '0',
@@ -756,7 +800,7 @@ export class IsolatePool {
756
800
  // worker whose SUPERVISOR binding still names the dead generation's
757
801
  // pid. See the supervisorKey field comment for the failure mode.
758
802
  const buildId = (generation: number): string =>
759
- `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}: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}` : ''}`;
760
804
  let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
761
805
  const code = this.#buildCode(fnSource, perCallWasmEntries);
762
806
 
@@ -765,7 +809,7 @@ export class IsolatePool {
765
809
  // slot updated on every dispatch.
766
810
  try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
767
811
 
768
- const runOnce = async (): Promise<unknown> => {
812
+ const runOnce = async (): Promise<T> => {
769
813
  // loader.get() is synchronous from the caller's POV; the callback
770
814
  // is only invoked on cache miss. We wrap the callback tightly so a
771
815
  // retry doesn't rebuild workerCode — that's already stable here.
@@ -797,8 +841,7 @@ export class IsolatePool {
797
841
  // wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
798
842
  const endFetch = beginLoaderFetch(this.ctx);
799
843
  try {
800
- const out = await entrypoint.execute(...args);
801
- return out;
844
+ return await invoke(entrypoint, attempt);
802
845
  } catch (err) {
803
846
  if (err instanceof Error) {
804
847
  throw new ExecutionError(err.message, err.stack);
@@ -870,6 +913,18 @@ export class IsolatePool {
870
913
  message: lastError.message,
871
914
  });
872
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
+ }
873
928
  if (cause === 'clone_refused' && !retriedCloneRefusal) {
874
929
  retriedCloneRefusal = true;
875
930
  const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
@@ -920,12 +975,49 @@ export class IsolatePool {
920
975
  fnSource,
921
976
  fnHash,
922
977
  0,
923
- [arg],
978
+ (entrypoint) => entrypoint.execute(arg) as Promise<Awaited<R>>,
979
+ resilience,
980
+ opts?.wasmModules,
981
+ ));
982
+ }
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()),
924
1014
  resilience,
925
1015
  opts?.wasmModules,
926
- )) as Awaited<R>;
1016
+ () => request.signal.aborted,
1017
+ );
927
1018
  }
928
1019
 
1020
+
929
1021
  /**
930
1022
  * Run `fn` on every item in `items`, at most `concurrency` at a time,
931
1023
  * pinned to stable slots so warm isolates are reused.
@@ -998,7 +1090,7 @@ export class IsolatePool {
998
1090
  fnSource,
999
1091
  fnHash,
1000
1092
  slotIndex,
1001
- [items[idx]],
1093
+ (entrypoint) => entrypoint.execute(items[idx]),
1002
1094
  resilience,
1003
1095
  opts?.wasmModules,
1004
1096
  )) as Awaited<R>;