@nimbus-sh/fabric 0.8.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.
Files changed (51) hide show
  1. package/README.md +98 -11
  2. package/dist/bindings.d.ts +31 -33
  3. package/dist/bindings.d.ts.map +1 -1
  4. package/dist/bindings.js +108 -97
  5. package/dist/budgets.d.ts +102 -29
  6. package/dist/budgets.d.ts.map +1 -1
  7. package/dist/budgets.js +266 -44
  8. package/dist/do-calls.d.ts +20 -0
  9. package/dist/do-calls.d.ts.map +1 -1
  10. package/dist/do-calls.js +24 -11
  11. package/dist/fanout.d.ts +40 -32
  12. package/dist/fanout.d.ts.map +1 -1
  13. package/dist/fanout.js +48 -51
  14. package/dist/fenced-work.d.ts +3 -3
  15. package/dist/fenced-work.js +3 -3
  16. package/dist/host-wasm.d.ts +29 -0
  17. package/dist/host-wasm.d.ts.map +1 -0
  18. package/dist/host-wasm.js +31 -0
  19. package/dist/image-store.d.ts +1 -1
  20. package/dist/image-store.d.ts.map +1 -1
  21. package/dist/image-store.js +33 -1
  22. package/dist/inner-do-env.d.ts +83 -0
  23. package/dist/inner-do-env.d.ts.map +1 -0
  24. package/dist/inner-do-env.js +181 -0
  25. package/dist/isolate-pool.d.ts +40 -23
  26. package/dist/isolate-pool.d.ts.map +1 -1
  27. package/dist/isolate-pool.js +105 -55
  28. package/dist/process-fabric.d.ts +26 -11
  29. package/dist/process-fabric.d.ts.map +1 -1
  30. package/dist/process-fabric.js +44 -0
  31. package/dist/timers.d.ts +12 -0
  32. package/dist/timers.d.ts.map +1 -1
  33. package/dist/timers.js +44 -9
  34. package/dist/vendor/types.d.ts +11 -5
  35. package/dist/vendor/types.d.ts.map +1 -1
  36. package/dist/workerd-facet-host.d.ts.map +1 -1
  37. package/dist/workerd-facet-host.js +35 -30
  38. package/package.json +4 -4
  39. package/src/bindings.ts +121 -98
  40. package/src/budgets.ts +311 -53
  41. package/src/do-calls.ts +45 -11
  42. package/src/fanout.ts +62 -53
  43. package/src/fenced-work.ts +3 -3
  44. package/src/host-wasm.ts +41 -0
  45. package/src/image-store.ts +29 -2
  46. package/src/inner-do-env.ts +213 -0
  47. package/src/isolate-pool.ts +145 -75
  48. package/src/process-fabric.ts +55 -13
  49. package/src/timers.ts +45 -9
  50. package/src/vendor/types.ts +11 -5
  51. package/src/workerd-facet-host.ts +36 -31
package/src/timers.ts CHANGED
@@ -58,9 +58,12 @@ export const TIMER_REASONS_KEY = 'w1_next_alarm_reasons';
58
58
  * The host instance carrying the per-instance timer chain. The field lives on
59
59
  * the embedder's DO instance so one chain serializes every timer-map
60
60
  * read-modify-write for that instance (see {@link Timers.schedule}).
61
+ * `_timerEpoch` counts {@link Timers.reset}s: a schedule or dispatch writes
62
+ * only while the epoch it was requested in is still current.
61
63
  */
62
64
  export interface TimerHost {
63
65
  _timerChain?: Promise<unknown>;
66
+ _timerEpoch?: number;
64
67
  }
65
68
 
66
69
  /**
@@ -159,6 +162,7 @@ export class Timers {
159
162
  arms.push({ reason, whenMs });
160
163
  return Promise.resolve(true);
161
164
  }
165
+ const epoch = host._timerEpoch ?? 0;
162
166
  // Serialize every read-modify-write of the reasons map through one
163
167
  // per-instance chain: two schedulers firing back-to-back from one activity
164
168
  // hook would otherwise interleave their get→put cycles and silently drop
@@ -170,15 +174,20 @@ export class Timers {
170
174
  const existing = (await ctx.storage.get(TIMER_REASONS_KEY)) as
171
175
  | Record<string, number>
172
176
  | undefined;
177
+ // Requested before a reset: it belongs to the timers the reset voided.
178
+ if ((host._timerEpoch ?? 0) !== epoch) return false;
173
179
  const map: Record<string, number> = { ...(existing || {}) };
174
180
  // Earliest-deadline-first: only update if new request is sooner or
175
181
  // this reason has no pending entry.
182
+ let written: Promise<unknown> | undefined;
176
183
  if (!(reason in map) || whenMs < map[reason]) {
177
184
  map[reason] = whenMs;
178
- await ctx.storage.put(TIMER_REASONS_KEY, map);
185
+ written = ctx.storage.put(TIMER_REASONS_KEY, map);
179
186
  }
180
- const earliest = Math.min(...Object.values(map));
181
- setAlarmFn.call(ctx.storage, earliest);
187
+ // Issued in the turn the epoch was checked in: a reset after this
188
+ // point wipes and disarms after these, never before.
189
+ setAlarmFn.call(ctx.storage, Math.min(...Object.values(map)));
190
+ await written;
182
191
  return true;
183
192
  } catch (e) {
184
193
  console.warn('[nimbus/W1] timers.schedule threw:', errorText(e));
@@ -190,6 +199,18 @@ export class Timers {
190
199
  return chained;
191
200
  }
192
201
 
202
+ /**
203
+ * Void every timer of this instance: a schedule or dispatch already
204
+ * requested — still queued on the chain, or a dispatch whose handlers are
205
+ * running — writes no reason and arms no alarm from here on. For a
206
+ * deliberate end of the actor's state (a session destroy): call it before
207
+ * wiping storage and deleting the alarm, so nothing in flight writes the
208
+ * map back or re-arms after the wipe. Requests made after it proceed.
209
+ */
210
+ reset(): void {
211
+ this.host._timerEpoch = (this.host._timerEpoch ?? 0) + 1;
212
+ }
213
+
193
214
  /**
194
215
  * Multi-reason timer dispatcher. Called from the DO's `alarm()` handler
195
216
  * with the embedder's handler map.
@@ -216,19 +237,28 @@ export class Timers {
216
237
  alarmInfo?: TimerAlarmInfo,
217
238
  ): Promise<void> {
218
239
  const { host, ctx } = this;
240
+ const epoch = host._timerEpoch ?? 0;
241
+ const current = (): boolean => (host._timerEpoch ?? 0) === epoch;
219
242
  // Same serialization as schedule: the dispatcher's read→handlers→write
220
243
  // cycle must not interleave with an activity-hook schedule.
221
244
  const chained = (host._timerChain ?? Promise.resolve()).then(
222
- () => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
223
- () => dispatchBody(ctx, handlers, onLegacyAlarm, alarmInfo),
245
+ () => dispatchBody(ctx, current, handlers, onLegacyAlarm, alarmInfo),
246
+ () => dispatchBody(ctx, current, handlers, onLegacyAlarm, alarmInfo),
224
247
  );
225
248
  host._timerChain = chained;
226
249
  return chained;
227
250
  }
228
251
  }
229
252
 
253
+ /**
254
+ * One dispatch. `current` answers whether the epoch it was requested in still
255
+ * stands: once a reset voids it, no further handler runs and nothing is
256
+ * written or armed — the reset's caller is wiping this state, and a write
257
+ * after the wipe would outlive it.
258
+ */
230
259
  async function dispatchBody(
231
260
  ctx: TimerContext,
261
+ current: () => boolean,
232
262
  handlers: TimerHandlers,
233
263
  onLegacyAlarm?: () => void,
234
264
  alarmInfo?: TimerAlarmInfo,
@@ -242,6 +272,7 @@ async function dispatchBody(
242
272
  const existing = (await ctx?.storage?.get?.(TIMER_REASONS_KEY)) as
243
273
  | Record<string, number>
244
274
  | undefined;
275
+ if (!current()) return;
245
276
  const map: Record<string, number> = { ...(existing || {}) };
246
277
  const hadMap = Object.keys(map).length > 0;
247
278
  if (!hadMap) {
@@ -255,6 +286,7 @@ async function dispatchBody(
255
286
  if (when <= now) fired.push(reason);
256
287
  }
257
288
  for (const reason of fired) {
289
+ if (!current()) return;
258
290
  delete map[reason];
259
291
  const handler = handlers[reason];
260
292
  // Unknown reasons silently dropped (forward-compat).
@@ -269,20 +301,24 @@ async function dispatchBody(
269
301
  }
270
302
  }
271
303
  }
304
+ // A reset while the handlers ran: the map read above is gone, and
305
+ // writing it back — or arming for it — would revive what was ended.
306
+ if (!current()) return;
272
307
  // Fold the in-dispatch arms, earliest-deadline-first per reason.
273
308
  for (const arm of arms) {
274
309
  if (!(arm.reason in map) || arm.whenMs < map[arm.reason]) {
275
310
  map[arm.reason] = arm.whenMs;
276
311
  }
277
312
  }
278
- // Re-arm or clear.
313
+ // Re-arm or clear, issued in the turn the epoch was checked in: a reset
314
+ // after this point wipes and disarms after these, never before.
279
315
  const setAlarmFn = ctx?.storage?.setAlarm;
280
316
  if (Object.keys(map).length > 0) {
281
- await ctx.storage.put(TIMER_REASONS_KEY, map);
282
- const earliest = Math.min(...Object.values(map));
317
+ const written = ctx.storage.put(TIMER_REASONS_KEY, map);
283
318
  if (typeof setAlarmFn === 'function') {
284
- setAlarmFn.call(ctx.storage, earliest);
319
+ setAlarmFn.call(ctx.storage, Math.min(...Object.values(map)));
285
320
  }
321
+ await written;
286
322
  } else if (hadMap) {
287
323
  try { await ctx.storage.delete(TIMER_REASONS_KEY); } catch {}
288
324
  // No remaining reasons → no setAlarm call → DO becomes
@@ -9,12 +9,18 @@ export interface TextModule { text: string }
9
9
  export interface DataModule { data: ArrayBuffer }
10
10
  export interface JsonModule { json: unknown }
11
11
  /**
12
- * A compiled WebAssembly module, importable from a facet by module name.
13
- * Nimbus ships these on every path — sql.js for node:sqlite, the interpreter
14
- * images for python and ruby — so the omission here was the type lagging the
15
- * API, not a kind the loader lacks.
12
+ * A WebAssembly module, importable from a facet by module name. Nimbus ships
13
+ * these on every path — sql.js for node:sqlite, the interpreter images for
14
+ * python and ruby — so the omission here was the type lagging the API, not a
15
+ * kind the loader lacks. The loader takes either the bytes, which it
16
+ * compiles, or a module the caller holds compiled already, whose compiled
17
+ * code the dynamic worker then shares (workerd src/workerd/api/
18
+ * worker-loader.c++, extractWasmModuleContent). A module handed over this
19
+ * way should be described with describeHostWasm (host-wasm.ts): the
20
+ * code-size budget and the loader cache key cannot read a Module's size or
21
+ * identity from JS.
16
22
  */
17
- export interface WasmModule { wasm: ArrayBuffer }
23
+ export interface WasmModule { wasm: ArrayBuffer | WebAssembly.Module }
18
24
 
19
25
  /** Plain string = type inferred from file extension (.js or .py). */
20
26
  export type ModuleContent =
@@ -28,7 +28,6 @@ import {
28
28
  facetNameCount,
29
29
  facetNameCountDurable,
30
30
  recordFacetNameMinted,
31
- recordLoaderId,
32
31
  withDynamicWorkerCapNamed,
33
32
  withFacetBudgetNamed,
34
33
  } from './budgets.js';
@@ -121,9 +120,10 @@ interface FacetContainer {
121
120
  abort(name: string, reason?: unknown): void;
122
121
  delete(name: string): void;
123
122
  /**
124
- * Present on deployed Cloudflare workerd, absent from the pinned
125
- * `@cloudflare/workers-types` and from local workerd ≤ 1.20260603.1 — see
126
- * {@link cloneStorage}, the one way the fabric calls it.
123
+ * Declared by `@cloudflare/workers-types` 5 and present in workerd
124
+ * ≥ 1.20260926.1 and in production; an embedder's older local workerd
125
+ * (≤ 1.20260603.1) lacks it — see {@link cloneStorage}, the one way the
126
+ * fabric calls it.
127
127
  */
128
128
  clone?(src: string, dst: string): void;
129
129
  }
@@ -212,7 +212,7 @@ export async function cloneStorage(
212
212
  if (typeof facets.clone !== 'function') {
213
213
  throw new Error(
214
214
  'Nimbus: ctx.facets.clone is unavailable in this runtime; the reflink image '
215
- + 'path needs deployed Cloudflare workerd (local workerd <= 1.20260603.1 lacks it)',
215
+ + 'path needs workerd 1.20260926.1 or later, or deployed Cloudflare workerd',
216
216
  );
217
217
  }
218
218
  const { src, dst } = clone;
@@ -471,10 +471,13 @@ function spawnResident(
471
471
  );
472
472
  }
473
473
  evaluated = true;
474
- return { class: residentProcessClass(ctx, env, disk, supervisor, params) };
474
+ return { class: residentProcessClass(env, disk, supervisor, params, loaderKey) };
475
475
  };
476
476
  const book = slotBook(ctx);
477
477
  const ledger = sessionLedger(ctx);
478
+ // A warm worker keeps the SUPERVISOR binding it was built with, and the
479
+ // loader outlives this instance.
480
+ const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
478
481
  let facet: ResidentFacetStub;
479
482
  try {
480
483
  // N18: the fill is admitted, and recorded under the facet's name, before
@@ -490,6 +493,12 @@ function spawnResident(
490
493
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
491
494
  }
492
495
  if (explicit) book.live.add(name);
496
+ // The facet's worker is one Dynamic Worker in flight for as long as the
497
+ // process is resident, not only while a call is open: its WebSockets and
498
+ // streamed responses outlive the calls the ledger could bracket, and a
499
+ // request can reach it at any moment. Held from here to `release`, so no
500
+ // fan-out spends the slot a running process needs.
501
+ const endResidency = beginLoaderFetch(ctx, loaderKey);
493
502
  facetOfPid(ctx).set(params.pid, name);
494
503
 
495
504
  let disposed = false;
@@ -498,6 +507,7 @@ function spawnResident(
498
507
  disposed = true;
499
508
  released = true;
500
509
  facetOfPid(ctx).delete(params.pid);
510
+ endResidency();
501
511
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
502
512
  if (explicit) book.live.delete(name);
503
513
  // The two release classes: an ephemeral facet's SQLite is slot-reuse
@@ -564,11 +574,11 @@ function spawnResident(
564
574
  * the hosting DO's heap.
565
575
  */
566
576
  function residentProcessClass(
567
- ctx: DurableObjectState,
568
577
  env: ResidentFacetEnv,
569
578
  disk: () => ResidentDiskReader,
570
579
  supervisor: ResidentSupervisorProps,
571
580
  params: ProcessHostParams,
581
+ loaderKey: string,
572
582
  ): unknown {
573
583
  const loader = env.LOADER;
574
584
  if (!loader || typeof loader.get !== 'function') {
@@ -577,18 +587,9 @@ function residentProcessClass(
577
587
  + 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
578
588
  );
579
589
  }
580
- // A warm worker keeps the SUPERVISOR binding it was built with, and the
581
- // loader outlives this instance.
582
- const loaderKey = supervisorLoaderKey(params.workerKey, supervisor);
583
- try {
584
- const worker = loader
585
- .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
586
- .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
587
- recordLoaderId(ctx, loaderKey);
588
- return worker;
589
- } catch (error) {
590
- throw withDynamicWorkerCapNamed(ctx, error);
591
- }
590
+ return loader
591
+ .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
592
+ .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
592
593
  }
593
594
 
594
595
  async function runOneShot<T>(
@@ -638,22 +639,26 @@ async function runOneShot<T>(
638
639
  throw new Error('Nimbus: one-shot runtime entrypoint has no fetch method');
639
640
  }
640
641
  params.onLoaded?.();
641
- // The unkeyed worker is a live dynamic worker for exactly this call, so
642
- // the run is a Loader fetch on the hosting actor's ledger — bracketed,
643
- // never wrapped: see beginLoaderFetch for the measured DO-poisoning
644
- // hazard, and the pipelined-`fetch.call` note above for its sibling.
645
- const endFetch = beginLoaderFetch(ctx);
646
- let response: Response;
642
+ // The unkeyed worker is one distinct dynamic worker in flight until its
643
+ // response is consumed (the body streams from it), keyed by this run's
644
+ // writer id — bracketed, never wrapped: see beginLoaderFetch for the
645
+ // measured DO-poisoning hazard, and the pipelined-`fetch.call` note above
646
+ // for its sibling.
647
+ const endFetch = beginLoaderFetch(ctx, `one-shot:${params.writerId}`);
647
648
  try {
648
- response = await ep.fetch(params.request);
649
+ const response = await ep.fetch(params.request);
650
+ try {
651
+ return await consume(response);
652
+ } finally {
653
+ disposeRpcResource(response);
654
+ }
655
+ } catch (error) {
656
+ // A limit refusal pauses the ledger's admissions (beginLoaderFetchWhenFree).
657
+ endFetch(error);
658
+ throw error;
649
659
  } finally {
650
660
  endFetch();
651
661
  }
652
- try {
653
- return await consume(response);
654
- } finally {
655
- disposeRpcResource(response);
656
- }
657
662
  } catch (error) {
658
663
  throw withDynamicWorkerCapNamed(ctx, error);
659
664
  } finally {