@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.
@@ -85,7 +85,10 @@ export function assembleLoaderWorkerModuleSource(options) {
85
85
  const callExpr = options.hasBindings
86
86
  ? '__fn__(...args, this.env)'
87
87
  : '__fn__(...args)';
88
- lines.push('export default class extends WorkerEntrypoint {', ' execute(...args) {', ` const result = ${callExpr};`, ' if (result instanceof Promise) return result;', ' return result;', ' }', '}');
88
+ const requestCallExpr = options.hasBindings
89
+ ? '__fn__(request, this.env)'
90
+ : '__fn__(request)';
91
+ lines.push('export default class extends WorkerEntrypoint {', ' execute(...args) {', ` const result = ${callExpr};`, ' if (result instanceof Promise) return result;', ' return result;', ' }', '', ' // Request/Response transport: the ONE dispatch path whose abort is', ' // real. workerd cancels the inner execution context when the request', ' // signal aborts while the fn is suspended on I/O; RPC execute() above', ' // has no equivalent (measured: Symbol.dispose on a pending RPC does', ' // not cancel). The fn is request-shaped — it encodes and decodes its', ' // own payload; nothing generic is serialized here.', ' async fetch(request) {', ' try {', ` const result = await ${requestCallExpr};`, ' if (result instanceof Response) return result;', ' return Response.json(result ?? null);', ' } catch (err) {', ' // Fail loud inside the response so an abort of the transport is', ' // not conflated with a guest exception: the caller sees 500 only', ' // for a real fn failure, and aborts arrive as fetch rejections.', ' const message = err instanceof Error && err.message ? err.message : String(err);', ' return Response.json({ __nimbusFacetError: message }, { status: 500 });', ' }', ' }', '}');
89
92
  return lines.join('\n');
90
93
  }
91
94
  /**
@@ -159,6 +162,8 @@ export class IsolatePool {
159
162
  * the old key shape and its warm slots stay shared.
160
163
  */
161
164
  supervisorKey;
165
+ /** Extra loader-id segment from options.scope — see IsolatePoolOptions. */
166
+ scope;
162
167
  constructor(env, ctx, opts) {
163
168
  // A host hands its whole env over; the binding is claimed here and the
164
169
  // claim is checked on the next line.
@@ -180,6 +185,7 @@ export class IsolatePool {
180
185
  this.doIdShort = opts?.cacheScope === 'global'
181
186
  ? 'global'
182
187
  : ctx.id.toString().slice(0, 12);
188
+ this.scope = opts?.scope ?? '';
183
189
  // Materialise the wasm-modules table. Sanitise each name into a
184
190
  // valid JS identifier for the static import binding; key collisions
185
191
  // (e.g. 'esbuild.wasm' and 'esbuild_wasm' both sanitise to
@@ -430,8 +436,14 @@ export class IsolatePool {
430
436
  * warm isolate services the call; callers round-robin slots themselves.
431
437
  * Serialized per slot: this call waits for the slot's previous
432
438
  * execution to settle before touching the warm isolate.
439
+ *
440
+ * `invoke` is the single-attempt call against the slot's entrypoint
441
+ * stub. `execute` dispatches call `entrypoint.execute(...args)`;
442
+ * `fetch` dispatches call `entrypoint.fetch(<per-attempt Request>)` —
443
+ * the attempt index lets a fetch invoke mint a fresh clone for retries,
444
+ * since a Request's body is consumed once.
433
445
  */
434
- async #dispatchSlot(fnSource, fnHash, slotIndex, args, resilience, perCallWasm) {
446
+ async #dispatchSlot(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, wasAborted) {
435
447
  // A warm slot executes one dispatch at a time: queue behind the
436
448
  // previous owner, then record this dispatch as the new tail. The
437
449
  // tail outlives the caller's outcome: a timeout rejects the
@@ -448,7 +460,7 @@ export class IsolatePool {
448
460
  }
449
461
  const inFlight = [];
450
462
  try {
451
- return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, args, resilience, perCallWasm, inFlight);
463
+ return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
452
464
  }
453
465
  finally {
454
466
  // Do not delay the caller's own outcome — the tail releases when
@@ -456,7 +468,7 @@ export class IsolatePool {
456
468
  void Promise.allSettled(inFlight).then(() => release());
457
469
  }
458
470
  }
459
- async #dispatchSlotOwned(fnSource, fnHash, slotIndex, args, resilience, perCallWasm, inFlight) {
471
+ async #dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted) {
460
472
  // Per-call wasm fingerprint. Mixed into the cache key so two calls
461
473
  // with different bytes hit different slots (no cache poisoning).
462
474
  // For the common case (no per-call wasm) the fingerprint is '0',
@@ -468,7 +480,7 @@ export class IsolatePool {
468
480
  // identity so a wake of that same session can never reuse a warm
469
481
  // worker whose SUPERVISOR binding still names the dead generation's
470
482
  // pid. See the supervisorKey field comment for the failure mode.
471
- const buildId = (generation) => `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}`;
483
+ const buildId = (generation) => `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}${this.scope ? `:${this.scope}` : ''}`;
472
484
  let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
473
485
  const code = this.#buildCode(fnSource, perCallWasmEntries);
474
486
  // W5 Lever 5: record the dispatch so /api/_diag/memory shows the
@@ -510,8 +522,7 @@ export class IsolatePool {
510
522
  // wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
511
523
  const endFetch = beginLoaderFetch(this.ctx);
512
524
  try {
513
- const out = await entrypoint.execute(...args);
514
- return out;
525
+ return await invoke(entrypoint, attempt);
515
526
  }
516
527
  catch (err) {
517
528
  if (err instanceof Error) {
@@ -585,6 +596,18 @@ export class IsolatePool {
585
596
  });
586
597
  }
587
598
  catch { /* fail-soft */ }
599
+ if (wasAborted?.()) {
600
+ // The caller aborted this dispatch's Request. workerd cancelled
601
+ // the isolate's execution context wherever it was suspended —
602
+ // mid-syscall, mid-stream — so the interpreter's heap may hold
603
+ // half-applied state. Bump the slot generation: the next
604
+ // dispatch lands on a FRESH worker under a new id rather than
605
+ // reusing the abandoned one. Retrying is wrong — the user
606
+ // interrupted; surface the abort.
607
+ const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
608
+ this.slotGenerations.set(slotIndex, generation);
609
+ throw lastError;
610
+ }
588
611
  if (cause === 'clone_refused' && !retriedCloneRefusal) {
589
612
  retriedCloneRefusal = true;
590
613
  const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
@@ -628,7 +651,28 @@ export class IsolatePool {
628
651
  async submit(fn, arg, opts) {
629
652
  const { fnSource, fnHash } = this.#prepare(fn);
630
653
  const resilience = this.#resolve(opts);
631
- return (await this.#dispatchSlot(fnSource, fnHash, 0, [arg], resilience, opts?.wasmModules));
654
+ return (await this.#dispatchSlot(fnSource, fnHash, 0, (entrypoint) => entrypoint.execute(arg), resilience, opts?.wasmModules));
655
+ }
656
+ /**
657
+ * Dispatch `fn` through the fetch transport — the pool's only
658
+ * cancellable path: aborting `request.signal` cancels the inner
659
+ * execution context at its next I/O suspension (a synchronous CPU
660
+ * section cannot be preempted — the platform's honest limit), the
661
+ * call rejects, and the slot generation bumps so the next dispatch
662
+ * gets a fresh interpreter. `fn` is request-shaped: it encodes and
663
+ * decodes its own payload.
664
+ */
665
+ async submitRequest(fn, request, opts) {
666
+ if (request.bodyUsed) {
667
+ throw new BindingError('IsolatePool.submitRequest: request body is already consumed — ' +
668
+ 'pass an unconsumed Request so retries can re-issue it.');
669
+ }
670
+ const { fnSource, fnHash } = this.#prepare(fn);
671
+ const resilience = this.#resolve(opts);
672
+ // Every attempt fetches a clone: a Request's body is consumed once,
673
+ // so the caller's original stays unspent and retries get a fresh
674
+ // body that follows the same signal.
675
+ return this.#dispatchSlot(fnSource, fnHash, 0, (entrypoint) => entrypoint.fetch(request.clone()), resilience, opts?.wasmModules, () => request.signal.aborted);
632
676
  }
633
677
  /**
634
678
  * Run `fn` on every item in `items`, at most `concurrency` at a time,
@@ -677,7 +721,7 @@ export class IsolatePool {
677
721
  if (idx >= items.length)
678
722
  return;
679
723
  try {
680
- const value = (await this.#dispatchSlot(fnSource, fnHash, slotIndex, [items[idx]], resilience, opts?.wasmModules));
724
+ const value = (await this.#dispatchSlot(fnSource, fnHash, slotIndex, (entrypoint) => entrypoint.execute(items[idx]), resilience, opts?.wasmModules));
681
725
  settled[idx] = { ok: true, value };
682
726
  }
683
727
  catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"process-host.d.ts","sourceRoot":"","sources":["../src/process-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAKH,OAAO,EAGL,KAAK,WAAW,EAIhB,KAAK,kBAAkB,EAExB,MAAM,qBAAqB,CAAC;AAQ7B,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,CAAC;AAE/C;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,eAAe,EACrB,GAAG,EAAE,kBAAkB,EACvB,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,kBAAkB,GAC7B,WAAW,CAIb;AA8ED,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,oDAAoD;AACpD,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;CAC7B;AA2BD;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,8BAA8B,CAAC;AACvE,eAAO,MAAM,kCAAkC,yCAAyC,CAAC;AAgSzF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAWhE"}
1
+ {"version":3,"file":"process-host.d.ts","sourceRoot":"","sources":["../src/process-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAOH,OAAO,EAGL,KAAK,WAAW,EAIhB,KAAK,kBAAkB,EAExB,MAAM,qBAAqB,CAAC;AAQ7B,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,CAAC;AAE/C;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,eAAe,EACrB,GAAG,EAAE,kBAAkB,EACvB,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,kBAAkB,GAC7B,WAAW,CAIb;AA8ED,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,oDAAoD;AACpD,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;CAC7B;AAaD;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,8BAA8B,CAAC;AACvE,eAAO,MAAM,kCAAkC,yCAAyC,CAAC;AAgUzF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAWhE"}
@@ -66,6 +66,8 @@
66
66
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
67
67
  import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
68
68
  import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
69
+ import { hostNamespaceBinding, hostOpDispatch } from './host-dispatch.js';
70
+ import { z } from 'zod/v4';
69
71
  import { DYNAMIC_WORKER_CODE_LIMIT_BYTES } from './budgets.js';
70
72
  import { BindingError } from './vendor/errors.js';
71
73
  import { processes, } from './workerd-facet-host.js';
@@ -149,19 +151,6 @@ export function isolateToken() {
149
151
  _isolateToken = crypto.randomUUID();
150
152
  return _isolateToken;
151
153
  }
152
- /**
153
- * Every method a hosting sibling must answer. Checked as a set at first
154
- * contact: a stub that has the probe but not the router is a deployment skew,
155
- * and finding that out mid-process is finding it out too late.
156
- */
157
- const PROCESS_PEER_METHODS = [
158
- '_rpcProcessHostProbe',
159
- '_rpcHostProcess',
160
- '_rpcAwaitHostedOpen',
161
- '_rpcAwaitHostedBoot',
162
- '_rpcRouteHostedHttp',
163
- '_rpcCancelHostProcess',
164
- ];
165
154
  /**
166
155
  * Which hosted process a fetched upgrade is for. An upgrade cannot travel as
167
156
  * RPC arguments, so the two values `_rpcRouteHostedHttp` would have taken ride
@@ -174,31 +163,66 @@ const PROCESS_PEER_METHODS = [
174
163
  */
175
164
  export const HOSTED_WEBSOCKET_KEY_HEADER = 'x-nimbus-hosted-websocket';
176
165
  export const HOSTED_WEBSOCKET_CAPABILITY_HEADER = 'x-nimbus-hosted-websocket-capability';
166
+ /**
167
+ * Peer stubs forward one supervisorOp entrypoint: every method the host-
168
+ * process surface needs is an envelope op, not a private _rpc* method.
169
+ */
177
170
  function peerNamespace(env) {
178
- const ns = (typeof env === 'object' || typeof env === 'function') && env !== null
179
- ? Reflect.get(env, 'NIMBUS_SESSION')
180
- : undefined;
181
- if ((typeof ns !== 'object' && typeof ns !== 'function') || ns === null
182
- || typeof Reflect.get(ns, 'idFromName') !== 'function'
183
- || typeof Reflect.get(ns, 'get') !== 'function') {
184
- throw new BindingError('ProcessFabric: env.NIMBUS_SESSION binding missing or invalid. '
185
- + "NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
186
- + 'Durable Object; add the binding via durable_objects.bindings in wrangler.jsonc.');
171
+ try {
172
+ return hostNamespaceBinding(env, 'ProcessFabric');
173
+ }
174
+ catch (e) {
175
+ if (e instanceof BindingError) {
176
+ throw new BindingError(e.message
177
+ + " NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
178
+ + "Durable Object; name the host's own binding with composeFabric({ hostNamespace }).");
179
+ }
180
+ throw e;
187
181
  }
188
- return ns;
189
182
  }
183
+ const PeerProbeResult = z.object({ isolateToken: z.string() });
184
+ const PeerOpenResult = z.object({ ok: z.boolean() });
185
+ const PeerBootResult = z.object({ payload: z.unknown() });
186
+ const PeerCancelResult = z.object({ cancelled: z.boolean() });
187
+ const PeerHttpResult = z.object({
188
+ status: z.number().int(),
189
+ statusText: z.string(),
190
+ headers: z.array(z.tuple([z.string(), z.string()])),
191
+ body: z.instanceof(ReadableStream).nullable(),
192
+ });
190
193
  function processPeerStub(value, peerName) {
191
- if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
192
- throw new BindingError(`ProcessFabric: NIMBUS_SESSION.get() returned no stub for peer '${peerName}'.`);
194
+ const dispatch = hostOpDispatch(value, `ProcessFabric peer '${peerName}'`);
195
+ const fetchFn = (typeof value === 'object' || typeof value === 'function') && value !== null
196
+ ? Reflect.get(value, 'fetch')
197
+ : undefined;
198
+ if (typeof fetchFn !== 'function') {
199
+ throw new BindingError(`ProcessFabric: peer '${peerName}' exposes no fetch() — the hosted-websocket `
200
+ + 'upgrade leg still travels as a service-binding fetch.');
193
201
  }
194
- for (const method of PROCESS_PEER_METHODS) {
195
- if (typeof Reflect.get(value, method) !== 'function') {
196
- throw new BindingError(`ProcessFabric: peer '${peerName}' does not expose ${method}(). `
197
- + 'A sibling that answers some of the host-process surface and not the rest '
198
- + 'would fail somewhere inside a running process instead of here.');
199
- }
202
+ const adapter = {
203
+ _rpcProcessHostProbe: async () => PeerProbeResult.parse(await dispatch({ op: 'processHostProbe', args: [] })),
204
+ _rpcHostProcess: async (boot, opts) => PeerOpenResult.parse(await dispatch({ op: 'hostProcess', args: [boot, opts] })),
205
+ _rpcAwaitHostedOpen: async (key) => PeerOpenResult.parse(await dispatch({ op: 'awaitHostedOpen', args: [key] })),
206
+ _rpcAwaitHostedBoot: async (key) => {
207
+ const result = PeerBootResult.parse(await dispatch({ op: 'awaitHostedBoot', args: [key] }));
208
+ return { payload: result.payload };
209
+ },
210
+ _rpcRouteHostedHttp: async (key, request) => PeerHttpResult.parse(await dispatch({ op: 'routeHostedHttp', args: [key, request] })),
211
+ _rpcCancelHostProcess: async (key) => PeerCancelResult.parse(await dispatch({ op: 'cancelHostProcess', args: [key] })),
212
+ fetch: async (request) => {
213
+ const response = await Reflect.apply(fetchFn, value, [request]);
214
+ if (!(response instanceof Response))
215
+ throw new BindingError(`ProcessFabric: peer '${peerName}' returned no Response`);
216
+ return response;
217
+ },
218
+ };
219
+ // The adapter wraps the raw stub; releasing the adapter must release it —
220
+ // a held RPC stub pins the peer DO for the session's life.
221
+ const disposerKey = Reflect.get(Symbol, 'dispose');
222
+ if (typeof disposerKey === 'symbol') {
223
+ Object.defineProperty(adapter, disposerKey, { value: () => disposeRpcResource(value) });
200
224
  }
201
- return value;
225
+ return adapter;
202
226
  }
203
227
  class PeerProcessHost {
204
228
  ctx;
@@ -219,6 +243,9 @@ class PeerProcessHost {
219
243
  tokensInUse = new Map();
220
244
  constructor(ctx, env) {
221
245
  this.ctx = ctx;
246
+ if (env === null || (typeof env !== 'object' && typeof env !== 'function')) {
247
+ throw new BindingError('ProcessFabric: a peer host requires environment bindings');
248
+ }
222
249
  this.ns = peerNamespace(env);
223
250
  this.env = (env ?? {});
224
251
  this.coordDoId = ctx.id.toString();
@@ -339,7 +366,7 @@ class PeerProcessHost {
339
366
  /** One sibling name, resolved and probed. Leaks nothing on failure. */
340
367
  async _probePlacement(pid, attempt) {
341
368
  const peerName = `${this.coordDoId}:proc:${pid}:${attempt}`;
342
- let resource;
369
+ let resource = null;
343
370
  try {
344
371
  resource = this.ns.get(this.ns.idFromName(peerName));
345
372
  const stub = processPeerStub(resource, peerName);
@@ -114,7 +114,7 @@ export interface PacedWorkHost {
114
114
  * reason. Without it the pump degrades to a same-context timer — see
115
115
  * {@link PacedWork.nextTurn}.
116
116
  */
117
- requestTurn?: (notBefore?: number) => void;
117
+ requestTurn?: (notBefore?: number) => void | Promise<void>;
118
118
  }
119
119
  /**
120
120
  * The granting side of {@link TurnScheduler}: parks suspended launches
@@ -134,6 +134,7 @@ export declare class PacedWork implements TurnScheduler {
134
134
  * from its inputs is the same idempotent work again.
135
135
  */
136
136
  private waiters;
137
+ private closed;
137
138
  /**
138
139
  * `ctx` keys the cold-start queue the pump drains first on every turn it
139
140
  * grants — see {@link pump}.
@@ -159,6 +160,9 @@ export declare class PacedWork implements TurnScheduler {
159
160
  * the runtime may tear the context down mid-chunk.
160
161
  */
161
162
  pump(): Promise<void>;
163
+ private requestTurn;
164
+ /** Reject parked work before the owner drains launch cleanup and cancels its alarm. */
165
+ close(reason?: Error): void;
162
166
  /** Whether any launch is suspended waiting for a turn. */
163
167
  get hasPending(): boolean;
164
168
  }
@@ -1 +1 @@
1
- {"version":3,"file":"turn-budget.d.ts","sourceRoot":"","sources":["../src/turn-budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAIH,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,UAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,qBAAa,UAAU;IAkBnB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAnB/B,qEAAqE;IACrE,MAAM,SAAK;IACX,iDAAiD;IACjD,KAAK,SAAK;IAEV,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,UAAU,CAA8D;IAEhF;;;;;;;OAOG;gBAEgB,SAAS,EAAE,aAAa,EACxB,aAAa,GAAE,MAA6B,EAC5C,WAAW,CAAC,GAAE,MAAM,IAAI,aAAA;IAG3C;;;;;;OAMG;IACG,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAczC;;;;OAIG;IACH,MAAM,IAAI,IAAI;CAIf;AAED,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,CAAC,GAAG,IAAI,KAAK;IAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;CAAE,CAI/G;AAED,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C;AAED;;;GAGG;AACH,qBAAa,SAAU,YAAW,aAAa;IAkB3C,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAlBvB;;;;;;;;;OASG;IACH,OAAO,CAAC,OAAO,CAAmF;IAElG;;;OAGG;gBAEgB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,aAAa;IAGtC;;;;;;;;;OASG;IACH,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAWjE;;;;;;;OAOG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAe3B,0DAA0D;IAC1D,IAAI,UAAU,IAAI,OAAO,CAExB;CACF;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAMtD"}
1
+ {"version":3,"file":"turn-budget.d.ts","sourceRoot":"","sources":["../src/turn-budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAIH,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,UAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,qBAAa,UAAU;IAkBnB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAnB/B,qEAAqE;IACrE,MAAM,SAAK;IACX,iDAAiD;IACjD,KAAK,SAAK;IAEV,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,UAAU,CAA8D;IAEhF;;;;;;;OAOG;gBAEgB,SAAS,EAAE,aAAa,EACxB,aAAa,GAAE,MAA6B,EAC5C,WAAW,CAAC,GAAE,MAAM,IAAI,aAAA;IAG3C;;;;;;OAMG;IACG,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAczC;;;;OAIG;IACH,MAAM,IAAI,IAAI;CAIf;AAED,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,CAAC,GAAG,IAAI,KAAK;IAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;CAAE,CAI/G;AAED,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAED;;;GAGG;AACH,qBAAa,SAAU,YAAW,aAAa;IAmB3C,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAnBvB;;;;;;;;;OASG;IACH,OAAO,CAAC,OAAO,CAAqH;IACpI,OAAO,CAAC,MAAM,CAAoB;IAElC;;;OAGG;gBAEgB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,aAAa;IAGtC;;;;;;;;;OASG;IACH,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjE;;;;;;;OAOG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAyBb,WAAW;IAKzB,uFAAuF;IACvF,KAAK,CAAC,MAAM,QAA0C,GAAG,IAAI;IAK7D,0DAA0D;IAC1D,IAAI,UAAU,IAAI,OAAO,CAExB;CACF;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAMtD"}
@@ -130,6 +130,7 @@ export class PacedWork {
130
130
  * from its inputs is the same idempotent work again.
131
131
  */
132
132
  waiters = [];
133
+ closed;
133
134
  /**
134
135
  * `ctx` keys the cold-start queue the pump drains first on every turn it
135
136
  * grants — see {@link pump}.
@@ -149,10 +150,19 @@ export class PacedWork {
149
150
  * responsiveness but keeps the behaviour.
150
151
  */
151
152
  nextTurn(chunkEnded, notBefore = 0) {
152
- return new Promise((resume) => {
153
- this.waiters.push({ resume, chunkEnded, notBefore });
153
+ if (this.closed)
154
+ return Promise.reject(this.closed);
155
+ return new Promise((resume, reject) => {
156
+ const waiter = { resume, reject, chunkEnded, notBefore };
157
+ this.waiters.push(waiter);
154
158
  if (this.host.requestTurn) {
155
- this.host.requestTurn(notBefore);
159
+ void this.requestTurn(notBefore).catch((error) => {
160
+ const index = this.waiters.indexOf(waiter);
161
+ if (index < 0)
162
+ return;
163
+ this.waiters.splice(index, 1);
164
+ waiter.reject(error);
165
+ });
156
166
  return;
157
167
  }
158
168
  setTimeout(() => { void this.pump(); }, Math.max(0, notBefore - Date.now()));
@@ -167,21 +177,44 @@ export class PacedWork {
167
177
  * the runtime may tear the context down mid-chunk.
168
178
  */
169
179
  async pump() {
170
- // Deferred reconciliation runs first, before any waiter resumes. Where
171
- // the resident-launch journal's recovery sits (`onColdStart`): the pump
172
- // is what an alarm calls, and the first turn after a reset is the
173
- // re-delivered alarm of a launch the reset interrupted.
180
+ if (this.closed)
181
+ return;
174
182
  await runColdStart(this.ctx);
183
+ if (this.closed)
184
+ return;
175
185
  const now = Date.now();
176
186
  const waiting = this.waiters.filter((waiter) => waiter.notBefore <= now);
177
187
  this.waiters = this.waiters.filter((waiter) => waiter.notBefore > now);
178
- if (this.waiters.length > 0)
179
- this.host.requestTurn?.(Math.min(...this.waiters.map((waiter) => waiter.notBefore)));
180
- if (waiting.length === 0)
181
- return;
188
+ const future = [...this.waiters];
189
+ const scheduled = future.length > 0
190
+ ? this.requestTurn(Math.min(...future.map((waiter) => waiter.notBefore))).catch((error) => {
191
+ for (const waiter of future) {
192
+ const index = this.waiters.indexOf(waiter);
193
+ if (index < 0)
194
+ continue;
195
+ this.waiters.splice(index, 1);
196
+ waiter.reject(error);
197
+ }
198
+ throw error;
199
+ })
200
+ : Promise.resolve();
182
201
  for (const waiter of waiting)
183
202
  waiter.resume();
184
- await Promise.all(waiting.map((waiter) => waiter.chunkEnded));
203
+ const results = await Promise.allSettled([scheduled, ...waiting.map((waiter) => waiter.chunkEnded)]);
204
+ const failure = results.find((result) => result.status === 'rejected');
205
+ if (failure?.status === 'rejected')
206
+ throw failure.reason;
207
+ }
208
+ async requestTurn(notBefore) {
209
+ if (this.closed)
210
+ throw this.closed;
211
+ await this.host.requestTurn?.(notBefore);
212
+ }
213
+ /** Reject parked work before the owner drains launch cleanup and cancels its alarm. */
214
+ close(reason = new Error('Launch scheduler is closed')) {
215
+ this.closed ??= reason;
216
+ for (const waiter of this.waiters.splice(0))
217
+ waiter.reject(this.closed);
185
218
  }
186
219
  /** Whether any launch is suspended waiting for a turn. */
187
220
  get hasPending() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-sh/fabric",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,12 +46,12 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "build": "tsc -p tsconfig.json --noCheck --noEmit false --declaration true --declarationMap true --outDir dist --rootDir src",
49
- "prepack": "npm run build",
49
+ "prepack": "bun run build",
50
50
  "typecheck": "tsc --noEmit"
51
51
  },
52
52
  "dependencies": {
53
- "@nimbus-sh/core": "^0.9.0",
54
- "@nimbus-sh/platform": "^0.4.0",
53
+ "@nimbus-sh/core": "^0.11.0",
54
+ "@nimbus-sh/platform": "^0.5.0",
55
55
  "zod": "^4.4.3"
56
56
  },
57
57
  "devDependencies": {