@nimbus-sh/fabric 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,13 @@ import { supervisorEntrypoint, type HostRoute } from './composition.js';
29
29
  import { supervisorBindingProps, supervisorLoaderKey } from './supervisor-props.js';
30
30
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
31
31
  import { serializeFunction, hashSource } from './vendor/serialize.js';
32
- import { beginLoaderFetch, withDynamicWorkerCapNamed } from './budgets.js';
32
+ import {
33
+ beginLoaderFetch,
34
+ beginLoaderFetchWhenFree,
35
+ withDynamicWorkerCapNamed,
36
+ type DynamicWorkerClaim,
37
+ type EndLoaderFetch,
38
+ } from './budgets.js';
33
39
  import { assertModuleMapWithinCodeLimit } from './budgets.js';
34
40
  import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/platform/oom-discriminator.js';
35
41
  import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
@@ -69,6 +75,12 @@ export interface IsolatePoolOptions {
69
75
  * caller that wants more sizes it against that budget (Fanout does).
70
76
  */
71
77
  concurrency?: number;
78
+ /**
79
+ * The width the caller claimed for this pool's dispatches
80
+ * (`claimDynamicWorkers`): they are held inside it rather than on top of
81
+ * it, and a refused one waits for a slot of the claim.
82
+ */
83
+ claim?: DynamicWorkerClaim;
72
84
  /** Per-task timeout in ms. Default 60_000. */
73
85
  timeoutMs?: number;
74
86
  /**
@@ -250,10 +262,11 @@ interface ResolvedResilience {
250
262
  }
251
263
 
252
264
  /**
253
- * How long one call waits, in all, for the platform to admit it after
254
- * "Dynamic worker concurrency limit exceeded" (doubling from 50 ms, at most
255
- * 2 s a wait). A deployed Durable Object admitted the refused batch after a
256
- * 6 s pause; 15 s bounds a call that would never be admitted.
265
+ * How long one call waits, in all, on the Dynamic Worker ledger after
266
+ * "Dynamic worker concurrency limit exceeded" before the refusal surfaces
267
+ * (beginLoaderFetchWhenFree: let in when a hold ends or the refusal's pause
268
+ * passes). A deployed Durable Object admitted the refused batch after a 6 s
269
+ * pause; 15 s bounds a call that would never be admitted.
257
270
  */
258
271
  const CAP_REFUSAL_WAIT_MS = 15_000;
259
272
 
@@ -396,6 +409,8 @@ export class IsolatePool {
396
409
  private readonly loader: WorkerLoader;
397
410
  /** The hosting actor, as the loader budget ledger's per-DO key. */
398
411
  private readonly ctx: DurableObjectState;
412
+ /** The width this pool's dispatches are held inside (IsolatePoolOptions.claim). */
413
+ private readonly claim: DynamicWorkerClaim | undefined;
399
414
  private readonly concurrency: number;
400
415
  private readonly defaultTimeoutMs: number;
401
416
  private readonly defaultRetries: number;
@@ -474,6 +489,7 @@ export class IsolatePool {
474
489
  }
475
490
  this.loader = loader;
476
491
  this.ctx = ctx;
492
+ this.claim = opts?.claim;
477
493
  this.concurrency = Math.max(1, opts?.concurrency ?? 1);
478
494
  this.defaultTimeoutMs = opts?.timeoutMs ?? 60_000;
479
495
  this.defaultRetries = Math.max(0, opts?.retries ?? 0);
@@ -778,7 +794,7 @@ export class IsolatePool {
778
794
  invoke: (entrypoint: { execute(...args: unknown[]): Promise<unknown>; fetch(input: RequestInfo, init?: RequestInit): Promise<Response> }, attempt: number) => Promise<T>,
779
795
  resilience: ResolvedResilience,
780
796
  perCallWasm?: Record<string, ArrayBuffer>,
781
- wasAborted?: () => boolean,
797
+ signal?: AbortSignal,
782
798
  ): Promise<T> {
783
799
  // A warm slot executes one dispatch at a time: queue behind the
784
800
  // previous owner, then record this dispatch as the new tail. The
@@ -796,7 +812,7 @@ export class IsolatePool {
796
812
  }
797
813
  const inFlight: Promise<unknown>[] = [];
798
814
  try {
799
- return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
815
+ return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, signal);
800
816
  } finally {
801
817
  // Do not delay the caller's own outcome — the tail releases when
802
818
  // the RPCs the body launched have actually settled.
@@ -813,7 +829,7 @@ export class IsolatePool {
813
829
  resilience: ResolvedResilience,
814
830
  perCallWasm: Record<string, ArrayBuffer> | undefined,
815
831
  inFlight: Promise<unknown>[],
816
- wasAborted?: () => boolean,
832
+ signal?: AbortSignal,
817
833
  ): Promise<T> {
818
834
  // Per-call wasm fingerprint. Mixed into the cache key so two calls
819
835
  // with different bytes hit different slots (no cache poisoning).
@@ -837,6 +853,9 @@ export class IsolatePool {
837
853
  // slot updated on every dispatch.
838
854
  try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
839
855
 
856
+ // A hold the ledger already took for the next attempt, when a refused
857
+ // call waited on it for room; otherwise the attempt begins its own.
858
+ let admitted: EndLoaderFetch | undefined;
840
859
  const runOnce = async (): Promise<T> => {
841
860
  // loader.get() is synchronous from the caller's POV; the callback
842
861
  // is only invoked on cache miss. We wrap the callback tightly so a
@@ -862,14 +881,20 @@ export class IsolatePool {
862
881
  // fine and stays — it only tears down the long-lived SUPERVISOR
863
882
  // binding stub once the whole pool is done, which does NOT
864
883
  // invalidate any in-flight slot's entrypoint reference.
865
- const stub = this.loader.get(id, async () => code);
866
- const entrypoint = stub.getEntrypoint();
867
- // Direct property call, awaited by this frame — bracketed, never
868
- // wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
869
- const endFetch = beginLoaderFetch(this.ctx, id);
884
+ //
885
+ // The hold comes first, so it ends whatever setup throws: a retry's
886
+ // was taken when the ledger let it in.
887
+ const endFetch = admitted ?? beginLoaderFetch(this.ctx, id, this.claim);
888
+ admitted = undefined;
870
889
  try {
890
+ const stub = this.loader.get(id, async () => code);
891
+ const entrypoint = stub.getEntrypoint();
892
+ // Direct property call, awaited by this frame — bracketed, never
893
+ // wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
871
894
  return await invoke(entrypoint, attempt);
872
895
  } catch (err) {
896
+ // The ledger learns a limit refusal from the hold it ends.
897
+ endFetch(err);
873
898
  if (err instanceof Error) {
874
899
  throw new ExecutionError(err.message, err.stack);
875
900
  }
@@ -882,8 +907,8 @@ export class IsolatePool {
882
907
  const maxAttempts = 1 + resilience.retries;
883
908
  let lastError: Error | undefined;
884
909
  let retriedCloneRefusal = false;
885
- let capRefusals = 0;
886
- let capWaitedMs = 0;
910
+ // When this call's waits for room after a limit refusal run out.
911
+ let capDeadline: number | undefined;
887
912
  let attempt = 0;
888
913
  while (attempt < maxAttempts) {
889
914
  try {
@@ -942,7 +967,7 @@ export class IsolatePool {
942
967
  message: lastError.message,
943
968
  });
944
969
  } catch { /* fail-soft */ }
945
- if (wasAborted?.()) {
970
+ if (signal?.aborted) {
946
971
  // The caller aborted this dispatch's Request. workerd cancelled
947
972
  // the isolate's execution context wherever it was suspended —
948
973
  // mid-syscall, mid-stream — so the interpreter's heap may hold
@@ -965,16 +990,29 @@ export class IsolatePool {
965
990
  // reverse direction. This refresh targets the stale-loader case.
966
991
  continue;
967
992
  }
968
- if (cause === 'dynamic_worker_cap' && capWaitedMs < CAP_REFUSAL_WAIT_MS) {
969
- // The platform refused to start this call: it still counts a
970
- // worker this Durable Object's ledger has already given back (a
971
- // fan-out's workers stay counted for a moment after their calls
972
- // return). Nothing ran, so the call waits, as the platform asks,
973
- // and is sent again; it does not spend an attempt.
974
- const delay = Math.min(CAP_REFUSAL_WAIT_MS - capWaitedMs, 50 * 2 ** capRefusals++, 2000);
975
- capWaitedMs += delay;
976
- await new Promise<void>((resolve) => setTimeout(resolve, delay));
977
- continue;
993
+ if (cause === 'dynamic_worker_cap') {
994
+ // The platform refused to start this call. Nothing ran, so the
995
+ // call waits, as the platform asks, and is sent again without
996
+ // spending an attempt: on the ledger, which lets it in when a
997
+ // hold ends, or when the pause this refusal started has passed
998
+ // (the platform still counts a worker the ledger has given back:
999
+ // a fan-out's workers stay counted for a moment after their
1000
+ // calls return).
1001
+ capDeadline ??= Date.now() + CAP_REFUSAL_WAIT_MS;
1002
+ const remainingMs = capDeadline - Date.now();
1003
+ if (remainingMs > 0) {
1004
+ // The deadline's timer is cleared once the wait settles: a
1005
+ // pending timer keeps the hosting object from hibernating.
1006
+ const deadline = new AbortController();
1007
+ const timer = setTimeout(() => deadline.abort(), remainingMs);
1008
+ const waitFor = signal ? AbortSignal.any([deadline.signal, signal]) : deadline.signal;
1009
+ admitted = await beginLoaderFetchWhenFree(this.ctx, id, { claim: this.claim, signal: waitFor })
1010
+ .catch(() => undefined)
1011
+ .finally(() => clearTimeout(timer));
1012
+ if (admitted) continue;
1013
+ // The caller aborted while waiting: surface its abort as above.
1014
+ if (signal?.aborted) throw lastError;
1015
+ }
978
1016
  }
979
1017
  if (attempt < maxAttempts - 1) {
980
1018
  // 100 * 2^attempt, capped at 2s so retries don't compound waiting.
@@ -1053,7 +1091,7 @@ export class IsolatePool {
1053
1091
  (entrypoint) => entrypoint.fetch(request.clone()),
1054
1092
  resilience,
1055
1093
  opts?.wasmModules,
1056
- () => request.signal.aborted,
1094
+ request.signal,
1057
1095
  );
1058
1096
  }
1059
1097
 
@@ -652,6 +652,10 @@ async function runOneShot<T>(
652
652
  } finally {
653
653
  disposeRpcResource(response);
654
654
  }
655
+ } catch (error) {
656
+ // A limit refusal pauses the ledger's admissions (beginLoaderFetchWhenFree).
657
+ endFetch(error);
658
+ throw error;
655
659
  } finally {
656
660
  endFetch();
657
661
  }