@nimbus-sh/fabric 0.7.1 → 0.9.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 (66) hide show
  1. package/README.md +26 -11
  2. package/dist/bindings.d.ts +1 -0
  3. package/dist/bindings.d.ts.map +1 -1
  4. package/dist/bindings.js +2 -0
  5. package/dist/budgets.d.ts +50 -29
  6. package/dist/budgets.d.ts.map +1 -1
  7. package/dist/budgets.js +88 -40
  8. package/dist/connections.d.ts +1 -1
  9. package/dist/connections.js +1 -1
  10. package/dist/do-calls.d.ts +91 -9
  11. package/dist/do-calls.d.ts.map +1 -1
  12. package/dist/do-calls.js +161 -22
  13. package/dist/facet-pool.d.ts +3 -0
  14. package/dist/facet-pool.d.ts.map +1 -1
  15. package/dist/facet-pool.js +3 -0
  16. package/dist/fanout.d.ts +40 -32
  17. package/dist/fanout.d.ts.map +1 -1
  18. package/dist/fanout.js +50 -53
  19. package/dist/fenced-work.d.ts +3 -3
  20. package/dist/host-wasm.d.ts +29 -0
  21. package/dist/host-wasm.d.ts.map +1 -0
  22. package/dist/host-wasm.js +31 -0
  23. package/dist/image-store.d.ts +1 -1
  24. package/dist/image-store.d.ts.map +1 -1
  25. package/dist/image-store.js +34 -2
  26. package/dist/inner-do-registry.d.ts +9 -0
  27. package/dist/inner-do-registry.d.ts.map +1 -1
  28. package/dist/inner-do-registry.js +35 -0
  29. package/dist/isolate-pool.d.ts +32 -24
  30. package/dist/isolate-pool.d.ts.map +1 -1
  31. package/dist/isolate-pool.js +78 -54
  32. package/dist/process-fabric.d.ts +42 -23
  33. package/dist/process-fabric.d.ts.map +1 -1
  34. package/dist/process-fabric.js +62 -6
  35. package/dist/process-host.d.ts +3 -1
  36. package/dist/process-host.d.ts.map +1 -1
  37. package/dist/process-host.js +13 -14
  38. package/dist/supervisor-props.d.ts +47 -0
  39. package/dist/supervisor-props.d.ts.map +1 -0
  40. package/dist/supervisor-props.js +37 -0
  41. package/dist/timers.d.ts +12 -0
  42. package/dist/timers.d.ts.map +1 -1
  43. package/dist/timers.js +44 -9
  44. package/dist/vendor/types.d.ts +11 -5
  45. package/dist/vendor/types.d.ts.map +1 -1
  46. package/dist/workerd-facet-host.d.ts +4 -17
  47. package/dist/workerd-facet-host.d.ts.map +1 -1
  48. package/dist/workerd-facet-host.js +120 -54
  49. package/package.json +6 -6
  50. package/src/bindings.ts +2 -0
  51. package/src/budgets.ts +96 -49
  52. package/src/connections.ts +1 -1
  53. package/src/do-calls.ts +216 -25
  54. package/src/facet-pool.ts +5 -0
  55. package/src/fanout.ts +64 -56
  56. package/src/fenced-work.ts +3 -3
  57. package/src/host-wasm.ts +41 -0
  58. package/src/image-store.ts +30 -3
  59. package/src/inner-do-registry.ts +31 -0
  60. package/src/isolate-pool.ts +108 -74
  61. package/src/process-fabric.ts +88 -30
  62. package/src/process-host.ts +16 -14
  63. package/src/supervisor-props.ts +56 -0
  64. package/src/timers.ts +45 -9
  65. package/src/vendor/types.ts +11 -5
  66. package/src/workerd-facet-host.ts +123 -58
@@ -65,7 +65,7 @@
65
65
  */
66
66
 
67
67
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
68
- import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
68
+ import { classifyDoCall, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
69
69
  import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
70
70
  import { hostNamespaceBinding, hostOpDispatch, type HostNamespaceBinding } from './host-dispatch.js';
71
71
  import { z } from 'zod/v4';
@@ -85,7 +85,8 @@ import {
85
85
  processes,
86
86
  type ResidentFacetEnv,
87
87
  } from './workerd-facet-host.js';
88
- import { hostRoute, type HostRoute } from './composition.js';
88
+ import type { HostRoute } from './composition.js';
89
+ import { supervisorBindingProps } from './supervisor-props.js';
89
90
 
90
91
  /** The substrates this deployment can be configured for. */
91
92
  export type ProcessHostMode = 'facet' | 'peer';
@@ -138,19 +139,14 @@ class FacetProcessHost implements ProcessHost {
138
139
 
139
140
  runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
140
141
  return processes(this.ctx, this.env).run(
141
- { doId: this.coordDoId, pid: params.pid, writerId: params.writerId, route: hostRoute() ?? undefined },
142
+ { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId },
142
143
  params,
143
144
  consume,
144
145
  );
145
146
  }
146
147
 
147
148
  async open(params: ProcessHostParams): Promise<HostedProcess> {
148
- const supervisor: ResidentSupervisorProps = {
149
- doId: this.coordDoId,
150
- pid: params.pid,
151
- writerId: params.writerId,
152
- route: hostRoute() ?? undefined,
153
- };
149
+ const supervisor: ResidentSupervisorProps = { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId };
154
150
  const { name, ...facet } = processes(this.ctx, this.env).spawn(this.disk, supervisor, params);
155
151
  return {
156
152
  ...facet,
@@ -198,6 +194,8 @@ export interface HostProcessOpts {
198
194
  route?: HostRoute;
199
195
  pid: number;
200
196
  writerId: string;
197
+ /** The coordinator instance's delivery incarnation, minted into the SUPERVISOR binding (ResidentSupervisorProps). */
198
+ hostIncarnation?: string;
201
199
  workerKey: string;
202
200
  /** Unforgeable capability for the fetch-semantic WebSocket hop. */
203
201
  webSocketCapability: string;
@@ -358,7 +356,7 @@ class PeerProcessHost implements ProcessHost {
358
356
  */
359
357
  runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
360
358
  return processes(this.ctx, this.env).run(
361
- { doId: this.coordDoId, pid: params.pid, writerId: params.writerId, route: hostRoute() ?? undefined },
359
+ { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId },
362
360
  params,
363
361
  consume,
364
362
  );
@@ -383,11 +381,15 @@ class PeerProcessHost implements ProcessHost {
383
381
  // also what keeps the hosting DO resident. It settles when a `lifetime`
384
382
  // runner exits or when a `boot` runner's host is cancelled, and rejects if
385
383
  // the peer dies under either.
384
+ // The peer mints the process's binding from these, for THIS object: the
385
+ // coordinator's doId, route and delivery instance.
386
+ const supervisor = supervisorBindingProps(this.ctx, params.pid);
386
387
  const hostLeg = placement.stub._rpcHostProcess(params.boot, {
387
- coordinatorDoId: this.coordDoId,
388
- route: hostRoute() ?? undefined,
389
- pid: params.pid,
388
+ coordinatorDoId: supervisor.doId,
389
+ route: supervisor.route,
390
+ pid: supervisor.pid,
390
391
  writerId: params.writerId,
392
+ hostIncarnation: supervisor.hostIncarnation,
391
393
  workerKey: params.workerKey,
392
394
  webSocketCapability,
393
395
  startArgs: params.startArgs,
@@ -496,7 +498,7 @@ class PeerProcessHost implements ProcessHost {
496
498
  }
497
499
  return probe;
498
500
  } catch (err) {
499
- if (attempt < PEER_TRANSIENT_RESET_RETRIES && isTransientDoReset(err)) {
501
+ if (attempt < PEER_TRANSIENT_RESET_RETRIES && isRetryableDoCall(classifyDoCall(err))) {
500
502
  await new Promise((r) => setTimeout(
501
503
  r, PEER_RETRY_BACKOFF_MS[Math.min(attempt, PEER_RETRY_BACKOFF_MS.length - 1)],
502
504
  ));
@@ -0,0 +1,56 @@
1
+ /**
2
+ * supervisor-props.ts — what every SUPERVISOR binding a Durable Object mints
3
+ * for a process carries, and the one rule for naming the instance in it.
4
+ *
5
+ * A binding names its host INSTANCE (`hostIncarnation`) exactly when a
6
+ * mutation sent through it can be delivered once
7
+ * (@nimbus-sh/core/workspace/supervisor-delivery.js): the host opened a
8
+ * delivery store, the binding acts as a real process (SupervisorRPC refuses
9
+ * every filesystem mutation of pid 0), and it routes back to this very
10
+ * object rather than another (a fanout pool writes to its coordinator).
11
+ * Minting every binding here is what keeps a new mint site from forgetting
12
+ * that, which would fail silently: its mutations would simply never be
13
+ * re-sent.
14
+ */
15
+
16
+ import { supervisorDeliveryProps } from '@nimbus-sh/core/workspace/supervisor-delivery.js';
17
+ import { hostRoute, type HostRoute } from './composition.js';
18
+
19
+ /** The props every SUPERVISOR binding for a process carries. */
20
+ export interface SupervisorBindingProps {
21
+ /** The Durable Object the binding's calls reach. */
22
+ doId: string;
23
+ /** The process the calls act as; 0 is none, and can mutate nothing. */
24
+ pid: number;
25
+ /** The way back, minted with the binding in the host's isolate. */
26
+ route?: HostRoute;
27
+ /** The host instance that applies this binding's mutations once, when there is one. */
28
+ hostIncarnation?: string;
29
+ }
30
+
31
+ /**
32
+ * The props of a SUPERVISOR binding minted in the Durable Object whose state
33
+ * is `ctx`, for process `pid`, reaching `options.doId` (this object by
34
+ * default) by `options.route` (this isolate's composition by default).
35
+ */
36
+ export function supervisorBindingProps(
37
+ ctx: { readonly id: { toString(): string } },
38
+ pid: number,
39
+ options: { doId?: string; route?: HostRoute } = {},
40
+ ): SupervisorBindingProps {
41
+ const own = ctx.id.toString();
42
+ const doId = options.doId ?? own;
43
+ const route = options.route ?? hostRoute() ?? undefined;
44
+ const delivery = pid > 0 && doId === own ? supervisorDeliveryProps(ctx) : {};
45
+ return { doId, pid, route, ...delivery };
46
+ }
47
+
48
+ /**
49
+ * `key`, for a loader cache entry whose worker holds a binding with `props`:
50
+ * made specific to the host instance the binding names, since the loader
51
+ * outlives that instance and the next one refuses every mutation the binding
52
+ * would deliver. A binding that names none keeps `key`, and its warm worker.
53
+ */
54
+ export function supervisorLoaderKey(key: string, props: Pick<SupervisorBindingProps, 'hostIncarnation'>): string {
55
+ return props.hostIncarnation === undefined ? key : `${key}:${props.hostIncarnation}`;
56
+ }
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 =
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
17
+ import { StorageLedger, forgetFacetStorage } from '@nimbus-sh/core/runtime/storage-ledger.js';
18
+ import type { SqlDatabase } from '@nimbus-sh/core/runtime/os-contracts.js';
17
19
  import {
18
20
  getCtxExports,
19
21
  stagedBootAssembler,
@@ -26,7 +28,6 @@ import {
26
28
  facetNameCount,
27
29
  facetNameCountDurable,
28
30
  recordFacetNameMinted,
29
- recordLoaderId,
30
31
  withDynamicWorkerCapNamed,
31
32
  withFacetBudgetNamed,
32
33
  } from './budgets.js';
@@ -41,6 +42,7 @@ import {
41
42
  type ResidentDiskReader,
42
43
  type ResidentSupervisorProps,
43
44
  } from './process-fabric.js';
45
+ import { supervisorLoaderKey } from './supervisor-props.js';
44
46
 
45
47
  // ── Loaded-worker entrypoint plumbing ───────────────────────────────────────
46
48
 
@@ -56,7 +58,7 @@ export interface NimbusCtxExports {
56
58
  key: string;
57
59
  name: string | null;
58
60
  depth: number;
59
- supervisor: { doId: string; pid: number; writerId: string };
61
+ supervisor: ResidentSupervisorProps;
60
62
  stage?: unknown;
61
63
  };
62
64
  }) => LoadedWorkerEntrypointStub;
@@ -78,7 +80,7 @@ export function getNimbusCtxExports(): NimbusCtxExports {
78
80
  */
79
81
  export async function createLoadedWorkerEntrypoint(
80
82
  ctxExports: NimbusCtxExports,
81
- supervisor: { doId: string; pid: number; writerId: string },
83
+ supervisor: ResidentSupervisorProps,
82
84
  stage: unknown,
83
85
  name: string | null = null,
84
86
  ): Promise<LoadedWorkerEntrypointStub> {
@@ -87,7 +89,9 @@ export async function createLoadedWorkerEntrypoint(
87
89
  }
88
90
  return await ctxExports.NimbusLoadedEntrypoint({
89
91
  props: {
90
- key: `nimbus-process:${supervisor.doId}:${supervisor.pid}`,
92
+ // The entrypoint's loader outlives this instance, and a warm worker keeps
93
+ // the SUPERVISOR binding it was built with.
94
+ key: supervisorLoaderKey(`nimbus-process:${supervisor.doId}:${supervisor.pid}`, supervisor),
91
95
  name,
92
96
  depth: 0,
93
97
  supervisor,
@@ -116,9 +120,10 @@ interface FacetContainer {
116
120
  abort(name: string, reason?: unknown): void;
117
121
  delete(name: string): void;
118
122
  /**
119
- * Present on deployed Cloudflare workerd, absent from the pinned
120
- * `@cloudflare/workers-types` and from local workerd ≤ 1.20260603.1 — see
121
- * {@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.
122
127
  */
123
128
  clone?(src: string, dst: string): void;
124
129
  }
@@ -207,7 +212,7 @@ export async function cloneStorage(
207
212
  if (typeof facets.clone !== 'function') {
208
213
  throw new Error(
209
214
  'Nimbus: ctx.facets.clone is unavailable in this runtime; the reflink image '
210
- + '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',
211
216
  );
212
217
  }
213
218
  const { src, dst } = clone;
@@ -262,26 +267,25 @@ interface SlotBook {
262
267
  next: number;
263
268
  /** Slot held by each live pid, so release can find it. */
264
269
  held: Map<number, number>;
270
+ /** Explicit (`app-slot-`) names this incarnation started a process under and has not released. */
271
+ live: Set<string>;
265
272
  }
266
273
 
267
274
  /**
268
275
  * Slot books, per hosting actor, because the facet index is per Durable
269
276
  * Object.
270
277
  *
271
- * Keyed weakly off `ctx`, and that is sound rather than lossy: a facet cannot
272
- * outlive the Durable Object hosting it, so a book that goes away with its
273
- * host describes nothing that still exists. A fresh incarnation restarts at
274
- * slot 0 and re-attaches to the SQLite a previous incarnation left there —
275
- * which is safe for the reason the store is sealed until it has reconciled.
276
- * Its persisted cursor is either datable against the current authority, in
277
- * which case the ACQUIRE delta brings it current, or it carries a different
278
- * VFS epoch, in which case `invalidatedSince` can only answer poison and the
279
- * whole store is dropped. A process therefore cannot boot onto a previous
280
- * tenant's filesystem even when release never ran.
278
+ * Keyed weakly off `ctx`, so a book describes one incarnation. A facet that
279
+ * is still running when that incarnation ends outlives it (measured: a timer
280
+ * or an outgoing call keeps it going), and a `get` of its name with a new
281
+ * class then resets the whole object. So a fresh incarnation's first get of
282
+ * each name ends whatever runs there first: a minted `proc-slot-` name is
283
+ * deleted, which also wipes the storage a previous incarnation left, and an
284
+ * explicit name is aborted, which keeps it.
281
285
  *
282
- * The book names only the `proc-slot-` space. Durable `app-slot-` names are
283
- * allocated against DO storage instead (their owner survives a reset), so a
284
- * fresh incarnation's `next` starting at 0 can never collide with them even
286
+ * The book allocates only the `proc-slot-` space. Durable `app-slot-` names
287
+ * are allocated against DO storage instead (their owner survives a reset), so
288
+ * a fresh incarnation's `next` starting at 0 can never collide with them even
285
289
  * before the durable ledger is adopted.
286
290
  */
287
291
  const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
@@ -289,24 +293,28 @@ const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
289
293
  function slotBook(ctx: DurableObjectState): SlotBook {
290
294
  let book = slotBooks.get(ctx);
291
295
  if (!book) {
292
- book = { free: [], next: 0, held: new Map() };
296
+ book = { free: [], next: 0, held: new Map(), live: new Set() };
293
297
  slotBooks.set(ctx, book);
294
298
  }
295
299
  return book;
296
300
  }
297
301
 
298
- /** Take a slot for `pid`, reusing a returned one before minting a new name. */
299
- function acquireSlot(ctx: DurableObjectState, pid: number): number {
302
+ /**
303
+ * Take a slot for `pid`, reusing a returned one before minting a new name.
304
+ * `minted` names may still hold storage a previous incarnation of this actor
305
+ * left there, so the caller deletes it before the first get.
306
+ */
307
+ function acquireSlot(ctx: DurableObjectState, pid: number): { slot: number; minted: boolean } {
300
308
  const book = slotBook(ctx);
301
309
  const existing = book.held.get(pid);
302
- if (existing !== undefined) return existing;
310
+ if (existing !== undefined) return { slot: existing, minted: false };
303
311
  const reused = book.free.length > 0;
304
312
  const slot = reused ? book.free.shift()! : book.next++;
305
313
  book.held.set(pid, slot);
306
314
  // A fresh name is a permanently consumed facet ID; the durable count lives
307
315
  // in the budgets ledger (see budgets.ts).
308
316
  if (!reused) recordFacetNameMinted(ctx, book.next);
309
- return slot;
317
+ return { slot, minted: !reused };
310
318
  }
311
319
 
312
320
  /** Return `pid`'s slot to the free list. */
@@ -320,14 +328,36 @@ function releaseSlot(ctx: DurableObjectState, pid: number): void {
320
328
  }
321
329
 
322
330
  /**
323
- * Drop one facet's SQLite by name — the ONLY call site that may delete facet
324
- * storage. `spawnResident` releases ephemeral processes with abort+delete
325
- * (storage is slot-reuse hygiene) and durable ones with abort alone (the
326
- * storage IS the durable application's state); explicit removal arrives here
327
- * through the coordinator's durable-slot book, owner-checked.
331
+ * Drop one facet's SQLite by name, and its row in the session's storage
332
+ * ledger (N18) in the same step: the only way a facet database is deleted.
333
+ * `spawnResident` releases ephemeral processes with abort+delete (storage is
334
+ * slot-reuse hygiene) and durable ones with abort alone (the storage IS the
335
+ * durable application's state); explicit removal arrives here through the
336
+ * coordinator's durable-slot book, owner-checked.
328
337
  */
338
+ /** The session's storage ledger (N18), over this actor's SQL; null where it has none. */
339
+ function sessionLedger(ctx: DurableObjectState): StorageLedger | null {
340
+ const sql = (ctx as { storage?: { sql?: SqlDatabase } }).storage?.sql;
341
+ return sql ? new StorageLedger(sql) : null;
342
+ }
343
+
344
+ const facetNames = new WeakMap<object, Map<number, string>>();
345
+
346
+ function facetOfPid(ctx: DurableObjectState): Map<number, string> {
347
+ let names = facetNames.get(ctx);
348
+ if (!names) facetNames.set(ctx, names = new Map());
349
+ return names;
350
+ }
351
+
352
+ /** The facet a running resident process `pid` lives in on this actor, for its storage ledger row. */
353
+ export function residentFacetOf(ctx: DurableObjectState, pid: number): string | undefined {
354
+ return facetNames.get(ctx)?.get(pid);
355
+ }
356
+
329
357
  export function deleteFacetStorage(ctx: DurableObjectState, name: string): void {
330
358
  facetContainer(ctx).delete(name);
359
+ const sql = (ctx as { storage?: { sql?: SqlDatabase } }).storage?.sql;
360
+ if (sql) forgetFacetStorage(sql, name);
331
361
  }
332
362
 
333
363
 
@@ -416,8 +446,12 @@ function spawnResident(
416
446
  + `prefix, got '${explicit.name}'`,
417
447
  );
418
448
  }
419
- const slot = explicit ? undefined : acquireSlot(ctx, params.pid);
449
+ const grant = explicit ? undefined : acquireSlot(ctx, params.pid);
450
+ const slot = grant?.slot;
420
451
  const name = explicit ? explicit.name : residentFacetName(slot!);
452
+ if (grant?.minted) {
453
+ try { deleteFacetStorage(ctx, name); } catch { /* nothing stored under this name */ }
454
+ }
421
455
  // The start callback is the ONLY way this facet is ever created, and it
422
456
  // fires AT MOST ONCE. Every later use goes through the stub below, so the
423
457
  // callback running a second time means the facet was released or died —
@@ -437,29 +471,52 @@ function spawnResident(
437
471
  );
438
472
  }
439
473
  evaluated = true;
440
- return { class: residentProcessClass(ctx, env, disk, supervisor, params) };
474
+ return { class: residentProcessClass(env, disk, supervisor, params, loaderKey) };
441
475
  };
476
+ const book = slotBook(ctx);
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);
442
481
  let facet: ResidentFacetStub;
443
482
  try {
483
+ // N18: the fill is admitted, and recorded under the facet's name, before
484
+ // the facet exists; a refusal leaves no facet.
485
+ if (ledger !== null && params.storageBytes !== undefined) ledger.fill(name, params.storageBytes);
486
+ // get() with a new class on a facet an earlier incarnation left running resets this object.
487
+ if (explicit && !book.live.has(name)) {
488
+ facets.abort(name, new Error('Nimbus: a new incarnation takes this facet name'));
489
+ }
444
490
  facet = facets.get(name, start);
445
491
  } catch (error) {
446
492
  if (slot !== undefined) releaseSlot(ctx, params.pid);
447
493
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
448
494
  }
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);
502
+ facetOfPid(ctx).set(params.pid, name);
449
503
 
450
504
  let disposed = false;
451
505
  const release = async () => {
452
506
  if (disposed) return;
453
507
  disposed = true;
454
508
  released = true;
509
+ facetOfPid(ctx).delete(params.pid);
510
+ endResidency();
455
511
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
512
+ if (explicit) book.live.delete(name);
456
513
  // The two release classes: an ephemeral facet's SQLite is slot-reuse
457
514
  // hygiene — the name is handed out again, so the store must not be — and
458
515
  // a durable one's is the application itself: abort ends the process, the
459
516
  // data stays for the next boot, and only removeDurableApp's explicit
460
517
  // deleteFacetStorage call ever drops it.
461
518
  if (!explicit?.durable) {
462
- try { facets.delete(name); } catch { /* already gone */ }
519
+ try { deleteFacetStorage(ctx, name); } catch { /* already gone */ }
463
520
  }
464
521
  // Only after the facet is gone. A slot handed out while its previous
465
522
  // tenant were still being torn down would have two processes on one name.
@@ -468,7 +525,11 @@ function spawnResident(
468
525
 
469
526
  let started: Promise<unknown>;
470
527
  try {
471
- started = facet.startProcess(params.startArgs);
528
+ // The allowance the ledger admitted, for the facet's store to keep under.
529
+ const startArgs = ledger !== null && params.storageBytes !== undefined && params.startArgs !== null && typeof params.startArgs === 'object'
530
+ ? { ...(params.startArgs as Record<string, unknown>), storage: { facet: name, grant: params.storageBytes } }
531
+ : params.startArgs;
532
+ started = facet.startProcess(startArgs);
472
533
  } catch (error) {
473
534
  void release();
474
535
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
@@ -477,7 +538,17 @@ function spawnResident(
477
538
  // this one, and it is annotated AFTER awaiting the ledger — the first
478
539
  // failure of a fresh incarnation must compare against the persisted count,
479
540
  // not the zero its adoption read has not yet replaced.
480
- started = started.catch(async (error) => {
541
+ started = started.then((payload) => {
542
+ // Once the facet is up (N18) its row is the cap its store keeps under
543
+ // (what it measures plus what it may still grow into), or what it
544
+ // measures if that is more (overshoot).
545
+ const { databaseSize: size, storageCap: cap } = (payload ?? {}) as { databaseSize?: unknown; storageCap?: unknown };
546
+ const measured = typeof size === 'number' && Number.isFinite(size) ? size : null;
547
+ const capped = typeof cap === 'number' && Number.isFinite(cap) ? cap : null;
548
+ const row = capped !== null ? Math.max(capped, measured ?? 0) : measured;
549
+ if (ledger !== null && row !== null) ledger.reportSize(name, row);
550
+ return payload;
551
+ }, async (error) => {
481
552
  throw withFacetBudgetNamed(await facetNameCountDurable(ctx), error);
482
553
  });
483
554
  // A caller reads whichever of `started` and the lifecycle it needs, so keep
@@ -503,11 +574,11 @@ function spawnResident(
503
574
  * the hosting DO's heap.
504
575
  */
505
576
  function residentProcessClass(
506
- ctx: DurableObjectState,
507
577
  env: ResidentFacetEnv,
508
578
  disk: () => ResidentDiskReader,
509
579
  supervisor: ResidentSupervisorProps,
510
580
  params: ProcessHostParams,
581
+ loaderKey: string,
511
582
  ): unknown {
512
583
  const loader = env.LOADER;
513
584
  if (!loader || typeof loader.get !== 'function') {
@@ -516,15 +587,9 @@ function residentProcessClass(
516
587
  + 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
517
588
  );
518
589
  }
519
- try {
520
- const worker = loader
521
- .get(params.workerKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
522
- .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
523
- recordLoaderId(ctx, params.workerKey);
524
- return worker;
525
- } catch (error) {
526
- throw withDynamicWorkerCapNamed(ctx, error);
527
- }
590
+ return loader
591
+ .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
592
+ .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
528
593
  }
529
594
 
530
595
  async function runOneShot<T>(
@@ -574,22 +639,22 @@ async function runOneShot<T>(
574
639
  throw new Error('Nimbus: one-shot runtime entrypoint has no fetch method');
575
640
  }
576
641
  params.onLoaded?.();
577
- // The unkeyed worker is a live dynamic worker for exactly this call, so
578
- // the run is a Loader fetch on the hosting actor's ledger — bracketed,
579
- // never wrapped: see beginLoaderFetch for the measured DO-poisoning
580
- // hazard, and the pipelined-`fetch.call` note above for its sibling.
581
- const endFetch = beginLoaderFetch(ctx);
582
- 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}`);
583
648
  try {
584
- 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
+ }
585
655
  } finally {
586
656
  endFetch();
587
657
  }
588
- try {
589
- return await consume(response);
590
- } finally {
591
- disposeRpcResource(response);
592
- }
593
658
  } catch (error) {
594
659
  throw withDynamicWorkerCapNamed(ctx, error);
595
660
  } finally {