@nimbus-sh/fabric 0.7.1 → 0.8.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 (47) hide show
  1. package/dist/bindings.d.ts +1 -0
  2. package/dist/bindings.d.ts.map +1 -1
  3. package/dist/bindings.js +2 -0
  4. package/dist/connections.d.ts +1 -1
  5. package/dist/connections.js +1 -1
  6. package/dist/do-calls.d.ts +71 -9
  7. package/dist/do-calls.d.ts.map +1 -1
  8. package/dist/do-calls.js +148 -22
  9. package/dist/facet-pool.d.ts +3 -0
  10. package/dist/facet-pool.d.ts.map +1 -1
  11. package/dist/facet-pool.js +3 -0
  12. package/dist/fanout.d.ts.map +1 -1
  13. package/dist/fanout.js +4 -3
  14. package/dist/fenced-work.d.ts +3 -3
  15. package/dist/image-store.js +2 -2
  16. package/dist/inner-do-registry.d.ts +9 -0
  17. package/dist/inner-do-registry.d.ts.map +1 -1
  18. package/dist/inner-do-registry.js +35 -0
  19. package/dist/isolate-pool.d.ts +1 -1
  20. package/dist/isolate-pool.d.ts.map +1 -1
  21. package/dist/isolate-pool.js +10 -8
  22. package/dist/process-fabric.d.ts +16 -12
  23. package/dist/process-fabric.d.ts.map +1 -1
  24. package/dist/process-fabric.js +18 -6
  25. package/dist/process-host.d.ts +3 -1
  26. package/dist/process-host.d.ts.map +1 -1
  27. package/dist/process-host.js +13 -14
  28. package/dist/supervisor-props.d.ts +47 -0
  29. package/dist/supervisor-props.d.ts.map +1 -0
  30. package/dist/supervisor-props.js +37 -0
  31. package/dist/workerd-facet-host.d.ts +4 -17
  32. package/dist/workerd-facet-host.d.ts.map +1 -1
  33. package/dist/workerd-facet-host.js +95 -29
  34. package/package.json +5 -5
  35. package/src/bindings.ts +2 -0
  36. package/src/connections.ts +1 -1
  37. package/src/do-calls.ts +182 -25
  38. package/src/facet-pool.ts +5 -0
  39. package/src/fanout.ts +4 -3
  40. package/src/fenced-work.ts +3 -3
  41. package/src/image-store.ts +2 -2
  42. package/src/inner-do-registry.ts +31 -0
  43. package/src/isolate-pool.ts +10 -8
  44. package/src/process-fabric.ts +33 -17
  45. package/src/process-host.ts +16 -14
  46. package/src/supervisor-props.ts +56 -0
  47. package/src/workerd-facet-host.ts +96 -32
@@ -56,3 +56,34 @@ export function clearInnerDoClasses(supervisorDoId: string): void {
56
56
  if (k.startsWith(prefix)) _NIMBUS_INNER_DO_CLASSES.delete(k);
57
57
  }
58
58
  }
59
+
60
+ /** Inner-DO facet names each incarnation has opened, by binding; keyed off ctx, so a new incarnation starts empty. */
61
+ const openedInnerDoFacets = new WeakMap<DurableObjectState, Map<string, Set<string>>>();
62
+
63
+ /**
64
+ * Record that this incarnation opens `facetName` for `bindingName`. True on the
65
+ * name's first open since the incarnation began or the binding's facets were
66
+ * last aborted: whatever still runs under it has a class this build no longer
67
+ * uses, and a get() with the new class on it resets the whole object.
68
+ */
69
+ export function noteInnerDoFacetOpened(ctx: DurableObjectState, bindingName: string, facetName: string): boolean {
70
+ let byBinding = openedInnerDoFacets.get(ctx);
71
+ if (!byBinding) openedInnerDoFacets.set(ctx, byBinding = new Map());
72
+ let names = byBinding.get(bindingName);
73
+ if (!names) byBinding.set(bindingName, names = new Set());
74
+ if (names.has(facetName)) return false;
75
+ names.add(facetName);
76
+ return true;
77
+ }
78
+
79
+ /** Abort the facets this incarnation opened for `bindingNames`, keeping their storage, so each next request starts the class registered then. */
80
+ export function abortInnerDoFacets(ctx: DurableObjectState, bindingNames: Iterable<string>, reason: Error): void {
81
+ const byBinding = openedInnerDoFacets.get(ctx);
82
+ if (!byBinding) return;
83
+ for (const bindingName of bindingNames) {
84
+ for (const facetName of byBinding.get(bindingName) ?? []) {
85
+ try { ctx.facets.abort(facetName, reason); } catch { /* already gone */ }
86
+ }
87
+ byBinding.delete(bindingName);
88
+ }
89
+ }
@@ -15,7 +15,7 @@
15
15
  * 3. **Supervisor autoinjection**. The pool grabs the embedder's
16
16
  * registered supervisor entrypoint stub (see `supervisorEntrypoint` in
17
17
  * composition.ts) and forwards it as `env.SUPERVISOR` to every facet,
18
- * same pattern as git-network-facet.ts. Callers can add more bindings
18
+ * same pattern as git/network-facet.ts. Callers can add more bindings
19
19
  * via `extraBindings`.
20
20
  * 4. **Fail-loud defaults**: timeout 60s, retries 0, onError 'throw'.
21
21
  * Caller opts in to leniency.
@@ -25,7 +25,8 @@
25
25
  */
26
26
 
27
27
  import { CF_COMPAT_DATE } from '@nimbus-sh/core/constants.js';
28
- import { hostRoute, supervisorEntrypoint, type HostRoute } from './composition.js';
28
+ import { supervisorEntrypoint, type HostRoute } from './composition.js';
29
+ import { supervisorBindingProps, supervisorLoaderKey } from './supervisor-props.js';
29
30
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
30
31
  import { serializeFunction, hashSource } from './vendor/serialize.js';
31
32
  import { beginLoaderFetch, recordLoaderId, withDynamicWorkerCapNamed } from './budgets.js';
@@ -527,11 +528,11 @@ export class IsolatePool {
527
528
  // via supervisorDoIdOverride so SUPERVISOR.* RPCs route back
528
529
  // to the user's session DO, not the peer DO. Default to the
529
530
  // local ctx.id (single-DO callers and the in-DO in-DO fanout path).
530
- const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
531
- const supPid = opts?.supervisorPid ?? 0;
532
- bindings.SUPERVISOR = supervisorRpc({
533
- props: { doId: supDoId, pid: supPid, route: opts?.supervisorRoute ?? hostRoute() ?? undefined },
531
+ const supervisor = supervisorBindingProps(ctx, opts?.supervisorPid ?? 0, {
532
+ doId: opts?.supervisorDoIdOverride,
533
+ route: opts?.supervisorRoute,
534
534
  });
535
+ bindings.SUPERVISOR = supervisorRpc({ props: supervisor });
535
536
  // Whatever the minted worker's env carries must be in its loader
536
537
  // cache key — workerd's loader cache survives a DO hibernation
537
538
  // wake while generation-strided pids (1000001 → 2000001) do not:
@@ -539,8 +540,9 @@ export class IsolatePool {
539
540
  // the new generation still credentialed to the dead pid, and
540
541
  // every pid-authorized RPC from it fails "process pid … does
541
542
  // not exist". doIdShort alone cannot cover this — it changes
542
- // across sessions, not across wakes of the same session.
543
- this.supervisorKey = `s${supDoId.slice(0, 12)}-${supPid}`;
543
+ // across sessions, not across wakes of the same session. So does
544
+ // the instance a binding delivers mutations to, when it names one.
545
+ this.supervisorKey = supervisorLoaderKey(`s${supervisor.doId.slice(0, 12)}-${supervisor.pid}`, supervisor);
544
546
  } else {
545
547
  // Supervisor entrypoint unavailable — running without ctx.exports
546
548
  // (e.g. unit-test harness, or LOADER.load contexts where the
@@ -74,7 +74,7 @@
74
74
  * `ResidentDiskReader` it was given.
75
75
  */
76
76
 
77
- import type { HostRoute } from './composition.js';
77
+ import type { SupervisorBindingProps } from './supervisor-props.js';
78
78
  import { z } from 'zod/v4';
79
79
  import type { RouteableFacetTarget } from '@nimbus-sh/core/runtime/os-contracts.js';
80
80
  import type { ServiceStub } from './vendor/types.js';
@@ -210,8 +210,10 @@ export const FACET_IMAGE_DIR = 'var/lib/nimbus/facet-images';
210
210
  * generated text into `startArgs` would make every image per-PROGRAM and
211
211
  * shareable across spawns and sessions; the sweep bounds the store either way.
212
212
  */
213
- export async function facetImageDigest(source: string): Promise<string> {
214
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
213
+ export async function facetImageDigest(image: string | Uint8Array): Promise<string> {
214
+ // An image is its UTF-8 bytes; a caller holding them is not made to encode a second copy.
215
+ const bytes = typeof image === 'string' ? new TextEncoder().encode(image) : image;
216
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
215
217
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
216
218
  }
217
219
 
@@ -266,8 +268,16 @@ export async function residentLoaderConfig(
266
268
  const resolved: Record<string, string | { wasm: ArrayBuffer }> = {};
267
269
  for (const [moduleName, path] of Object.entries(spec.vfsWasmModules ?? {})) {
268
270
  const bytes = await disk.readFile(path);
271
+ // The read's own buffer when it fits exactly, and only otherwise a copy.
272
+ // These are the largest members a spec carries — ruby's interpreter image
273
+ // is 34.3 MiB, esbuild's 13.3 — and an unconditional slice held both
274
+ // copies at once in the coordinator's 128 MiB isolate, at the one moment
275
+ // the module map is also resident.
276
+ const exact = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength;
269
277
  resolved[moduleName] = {
270
- wasm: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
278
+ wasm: exact
279
+ ? bytes.buffer as ArrayBuffer
280
+ : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
271
281
  };
272
282
  }
273
283
  for (const [moduleName, path] of Object.entries(spec.vfsTextModules ?? {})) {
@@ -295,15 +305,16 @@ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<s
295
305
  if (!expected) {
296
306
  throw new Error(`Nimbus: '${path}' is not a content-addressed facet image path`);
297
307
  }
298
- const source = new TextDecoder().decode(await disk.readFile(path));
299
- const actual = await facetImageDigest(source);
308
+ // Verified from the bytes read, and decoded only after: re-encoding the decoded string held a third copy of the largest member.
309
+ const bytes = await disk.readFile(path);
310
+ const actual = await facetImageDigest(bytes);
300
311
  if (actual !== expected) {
301
312
  throw new Error(
302
313
  `Nimbus: facet image '${path}' does not match its digest (read ${actual}); `
303
314
  + 'the image store is corrupt and the process cannot boot from it',
304
315
  );
305
316
  }
306
- return source;
317
+ return new TextDecoder().decode(bytes);
307
318
  }
308
319
 
309
320
  // ── The hosting substrate ───────────────────────────────────────────────────
@@ -311,18 +322,13 @@ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<s
311
322
  /**
312
323
  * The identity a resident process's SUPERVISOR binding is minted for. Always
313
324
  * the COORDINATOR's — a process hosted somewhere else still reads and writes
314
- * the user's disk, and still reports to the user's process table.
325
+ * the user's disk, and still reports to the user's process table. Minted by
326
+ * `supervisorBindingProps` on the coordinator; `route` is absent only when
327
+ * the coordinator's isolate composed nothing, in which case no supervisor
328
+ * binding is minted either.
315
329
  */
316
- export interface ResidentSupervisorProps {
317
- doId: string;
318
- pid: number;
330
+ export interface ResidentSupervisorProps extends SupervisorBindingProps {
319
331
  writerId: string;
320
- /**
321
- * The way back to the coordinator, minted with the binding. Absent only
322
- * when the coordinator's isolate composed nothing, in which case no
323
- * supervisor binding is minted either.
324
- */
325
- route?: HostRoute;
326
332
  }
327
333
 
328
334
  /** Everything a host needs to run one process. Substrate-free by construction. */
@@ -345,6 +351,13 @@ export interface ProcessHostParams {
345
351
  * the store on release.
346
352
  */
347
353
  facet?: { name: string; durable: boolean };
354
+ /**
355
+ * Bytes the process's store is filled with before it runs (its data plan).
356
+ * The hosting actor's storage ledger (N18) admits them under the facet's
357
+ * name before the facet starts: ENOSPC, and no facet, when they would cross
358
+ * the storage limit.
359
+ */
360
+ storageBytes?: number;
348
361
  }
349
362
 
350
363
  /**
@@ -657,6 +670,8 @@ export interface ResidentProcessSpawn {
657
670
  * ephemeral process, which takes a `proc-slot-<n>` name from the book.
658
671
  */
659
672
  facet?: { name: string; durable: boolean };
673
+ /** See {@link ProcessHostParams.storageBytes}. */
674
+ storageBytes?: number;
660
675
  /**
661
676
  * Called before any concrete host capability can expose this writer.
662
677
  * A spawn must not proceed unless the supervisor accepts the authority.
@@ -701,6 +716,7 @@ export class ProcessFabric {
701
716
  writerId,
702
717
  startArgs: spawn.startArgs,
703
718
  ...(spawn.facet !== undefined ? { facet: spawn.facet } : {}),
719
+ ...(spawn.storageBytes !== undefined ? { storageBytes: spawn.storageBytes } : {}),
704
720
  });
705
721
  } catch (error) {
706
722
  spawn.onWriterRetired(writerId);
@@ -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
+ }
@@ -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,
@@ -41,6 +43,7 @@ import {
41
43
  type ResidentDiskReader,
42
44
  type ResidentSupervisorProps,
43
45
  } from './process-fabric.js';
46
+ import { supervisorLoaderKey } from './supervisor-props.js';
44
47
 
45
48
  // ── Loaded-worker entrypoint plumbing ───────────────────────────────────────
46
49
 
@@ -56,7 +59,7 @@ export interface NimbusCtxExports {
56
59
  key: string;
57
60
  name: string | null;
58
61
  depth: number;
59
- supervisor: { doId: string; pid: number; writerId: string };
62
+ supervisor: ResidentSupervisorProps;
60
63
  stage?: unknown;
61
64
  };
62
65
  }) => LoadedWorkerEntrypointStub;
@@ -78,7 +81,7 @@ export function getNimbusCtxExports(): NimbusCtxExports {
78
81
  */
79
82
  export async function createLoadedWorkerEntrypoint(
80
83
  ctxExports: NimbusCtxExports,
81
- supervisor: { doId: string; pid: number; writerId: string },
84
+ supervisor: ResidentSupervisorProps,
82
85
  stage: unknown,
83
86
  name: string | null = null,
84
87
  ): Promise<LoadedWorkerEntrypointStub> {
@@ -87,7 +90,9 @@ export async function createLoadedWorkerEntrypoint(
87
90
  }
88
91
  return await ctxExports.NimbusLoadedEntrypoint({
89
92
  props: {
90
- key: `nimbus-process:${supervisor.doId}:${supervisor.pid}`,
93
+ // The entrypoint's loader outlives this instance, and a warm worker keeps
94
+ // the SUPERVISOR binding it was built with.
95
+ key: supervisorLoaderKey(`nimbus-process:${supervisor.doId}:${supervisor.pid}`, supervisor),
91
96
  name,
92
97
  depth: 0,
93
98
  supervisor,
@@ -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 —
@@ -439,27 +473,40 @@ function spawnResident(
439
473
  evaluated = true;
440
474
  return { class: residentProcessClass(ctx, env, disk, supervisor, params) };
441
475
  };
476
+ const book = slotBook(ctx);
477
+ const ledger = sessionLedger(ctx);
442
478
  let facet: ResidentFacetStub;
443
479
  try {
480
+ // N18: the fill is admitted, and recorded under the facet's name, before
481
+ // the facet exists; a refusal leaves no facet.
482
+ if (ledger !== null && params.storageBytes !== undefined) ledger.fill(name, params.storageBytes);
483
+ // get() with a new class on a facet an earlier incarnation left running resets this object.
484
+ if (explicit && !book.live.has(name)) {
485
+ facets.abort(name, new Error('Nimbus: a new incarnation takes this facet name'));
486
+ }
444
487
  facet = facets.get(name, start);
445
488
  } catch (error) {
446
489
  if (slot !== undefined) releaseSlot(ctx, params.pid);
447
490
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
448
491
  }
492
+ if (explicit) book.live.add(name);
493
+ facetOfPid(ctx).set(params.pid, name);
449
494
 
450
495
  let disposed = false;
451
496
  const release = async () => {
452
497
  if (disposed) return;
453
498
  disposed = true;
454
499
  released = true;
500
+ facetOfPid(ctx).delete(params.pid);
455
501
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
502
+ if (explicit) book.live.delete(name);
456
503
  // The two release classes: an ephemeral facet's SQLite is slot-reuse
457
504
  // hygiene — the name is handed out again, so the store must not be — and
458
505
  // a durable one's is the application itself: abort ends the process, the
459
506
  // data stays for the next boot, and only removeDurableApp's explicit
460
507
  // deleteFacetStorage call ever drops it.
461
508
  if (!explicit?.durable) {
462
- try { facets.delete(name); } catch { /* already gone */ }
509
+ try { deleteFacetStorage(ctx, name); } catch { /* already gone */ }
463
510
  }
464
511
  // Only after the facet is gone. A slot handed out while its previous
465
512
  // tenant were still being torn down would have two processes on one name.
@@ -468,7 +515,11 @@ function spawnResident(
468
515
 
469
516
  let started: Promise<unknown>;
470
517
  try {
471
- started = facet.startProcess(params.startArgs);
518
+ // The allowance the ledger admitted, for the facet's store to keep under.
519
+ const startArgs = ledger !== null && params.storageBytes !== undefined && params.startArgs !== null && typeof params.startArgs === 'object'
520
+ ? { ...(params.startArgs as Record<string, unknown>), storage: { facet: name, grant: params.storageBytes } }
521
+ : params.startArgs;
522
+ started = facet.startProcess(startArgs);
472
523
  } catch (error) {
473
524
  void release();
474
525
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
@@ -477,7 +528,17 @@ function spawnResident(
477
528
  // this one, and it is annotated AFTER awaiting the ledger — the first
478
529
  // failure of a fresh incarnation must compare against the persisted count,
479
530
  // not the zero its adoption read has not yet replaced.
480
- started = started.catch(async (error) => {
531
+ started = started.then((payload) => {
532
+ // Once the facet is up (N18) its row is the cap its store keeps under
533
+ // (what it measures plus what it may still grow into), or what it
534
+ // measures if that is more (overshoot).
535
+ const { databaseSize: size, storageCap: cap } = (payload ?? {}) as { databaseSize?: unknown; storageCap?: unknown };
536
+ const measured = typeof size === 'number' && Number.isFinite(size) ? size : null;
537
+ const capped = typeof cap === 'number' && Number.isFinite(cap) ? cap : null;
538
+ const row = capped !== null ? Math.max(capped, measured ?? 0) : measured;
539
+ if (ledger !== null && row !== null) ledger.reportSize(name, row);
540
+ return payload;
541
+ }, async (error) => {
481
542
  throw withFacetBudgetNamed(await facetNameCountDurable(ctx), error);
482
543
  });
483
544
  // A caller reads whichever of `started` and the lifecycle it needs, so keep
@@ -516,11 +577,14 @@ function residentProcessClass(
516
577
  + 'the Worker Loader binding; add it via worker_loaders in wrangler.jsonc.',
517
578
  );
518
579
  }
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);
519
583
  try {
520
584
  const worker = loader
521
- .get(params.workerKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
585
+ .get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
522
586
  .getDurableObjectClass(RESIDENT_PROCESS_CLASS);
523
- recordLoaderId(ctx, params.workerKey);
587
+ recordLoaderId(ctx, loaderKey);
524
588
  return worker;
525
589
  } catch (error) {
526
590
  throw withDynamicWorkerCapNamed(ctx, error);