@nimbus-sh/fabric 0.7.0 → 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 (52) hide show
  1. package/README.md +19 -1
  2. package/dist/bindings.d.ts +11 -0
  3. package/dist/bindings.d.ts.map +1 -1
  4. package/dist/bindings.js +19 -8
  5. package/dist/connections.d.ts +1 -1
  6. package/dist/connections.js +1 -1
  7. package/dist/do-calls.d.ts +71 -9
  8. package/dist/do-calls.d.ts.map +1 -1
  9. package/dist/do-calls.js +148 -22
  10. package/dist/facet-pool.d.ts +3 -0
  11. package/dist/facet-pool.d.ts.map +1 -1
  12. package/dist/facet-pool.js +3 -0
  13. package/dist/fanout.d.ts.map +1 -1
  14. package/dist/fanout.js +6 -3
  15. package/dist/fenced-work.d.ts +3 -3
  16. package/dist/host-dispatch.d.ts +12 -4
  17. package/dist/host-dispatch.d.ts.map +1 -1
  18. package/dist/host-dispatch.js +11 -4
  19. package/dist/image-store.js +2 -2
  20. package/dist/inner-do-registry.d.ts +9 -0
  21. package/dist/inner-do-registry.d.ts.map +1 -1
  22. package/dist/inner-do-registry.js +35 -0
  23. package/dist/isolate-pool.d.ts +8 -1
  24. package/dist/isolate-pool.d.ts.map +1 -1
  25. package/dist/isolate-pool.js +9 -7
  26. package/dist/process-fabric.d.ts +16 -5
  27. package/dist/process-fabric.d.ts.map +1 -1
  28. package/dist/process-fabric.js +18 -6
  29. package/dist/process-host.d.ts +5 -0
  30. package/dist/process-host.d.ts.map +1 -1
  31. package/dist/process-host.js +13 -11
  32. package/dist/supervisor-props.d.ts +47 -0
  33. package/dist/supervisor-props.d.ts.map +1 -0
  34. package/dist/supervisor-props.js +37 -0
  35. package/dist/workerd-facet-host.d.ts +4 -17
  36. package/dist/workerd-facet-host.d.ts.map +1 -1
  37. package/dist/workerd-facet-host.js +98 -32
  38. package/package.json +6 -6
  39. package/src/bindings.ts +25 -10
  40. package/src/connections.ts +1 -1
  41. package/src/do-calls.ts +182 -25
  42. package/src/facet-pool.ts +5 -0
  43. package/src/fanout.ts +6 -3
  44. package/src/fenced-work.ts +3 -3
  45. package/src/host-dispatch.ts +22 -7
  46. package/src/image-store.ts +2 -2
  47. package/src/inner-do-registry.ts +31 -0
  48. package/src/isolate-pool.ts +16 -8
  49. package/src/process-fabric.ts +33 -10
  50. package/src/process-host.ts +18 -11
  51. package/src/supervisor-props.ts +56 -0
  52. package/src/workerd-facet-host.ts +99 -37
package/src/facet-pool.ts CHANGED
@@ -42,6 +42,8 @@
42
42
  * and the facet-id ledger here counts only the names this pool minted.
43
43
  */
44
44
 
45
+ import { forgetFacetStorage } from '@nimbus-sh/core/runtime/storage-ledger.js';
46
+ import type { SqlDatabase } from '@nimbus-sh/core/runtime/os-contracts.js';
45
47
  import {
46
48
  FACET_ID_LIFETIME_BUDGET,
47
49
  facetNameCount,
@@ -63,6 +65,8 @@ export interface FacetPoolContext {
63
65
  storage: {
64
66
  get(key: string): Promise<unknown> | unknown;
65
67
  put(key: string, value: unknown): Promise<void>;
68
+ /** The session's SQL, where the storage ledger (N18) records facet databases. */
69
+ sql?: SqlDatabase;
66
70
  };
67
71
  }
68
72
 
@@ -134,6 +138,7 @@ export class FacetPool {
134
138
  if (keepStorage) return;
135
139
  try {
136
140
  facets.delete(name);
141
+ if (this.ctx.storage.sql) forgetFacetStorage(this.ctx.storage.sql, name);
137
142
  } catch (e) {
138
143
  throw new Error(
139
144
  `fabric: facet '${name}' was evicted but its storage was not reclaimed — `
package/src/fanout.ts CHANGED
@@ -17,8 +17,9 @@
17
17
  import { serializeFunction } from './vendor/serialize.js';
18
18
  import { BindingError } from './vendor/errors.js';
19
19
  import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
20
+ import { hostRoute } from './composition.js';
20
21
  import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
21
- import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
22
+ import { classifyDoCall, describeError, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
22
23
  import type { WorkerLoader } from './vendor/types.js';
23
24
  import {
24
25
  hostNamespaceBinding,
@@ -383,6 +384,7 @@ export class Fanout {
383
384
  // ... write into the peer's own VFS — invisible to the
384
385
  // user. See INSTALL-HONESTY-retro.md.
385
386
  coordinatorDoId: this.coordDoId,
387
+ coordinatorRoute: hostRoute() ?? undefined,
386
388
  // Credential source for peer-side writeBatchStream — the
387
389
  // invoking process pid, so package writes are authorized
388
390
  // as the user (not rejected as pid:0).
@@ -412,8 +414,9 @@ export class Fanout {
412
414
  }
413
415
  return;
414
416
  } catch (err) {
415
- const schedule = isTransientDoReset(err) ? PEER_RETRY_BACKOFF_MS
416
- : isDoOverloaded(err) ? PEER_OVERLOAD_BACKOFF_MS
417
+ const cls = classifyDoCall(err);
418
+ const schedule = cls === 'overloaded' ? PEER_OVERLOAD_BACKOFF_MS
419
+ : isRetryableDoCall(cls) ? PEER_RETRY_BACKOFF_MS
417
420
  : null;
418
421
  if (schedule && attempt < PEER_TRANSIENT_RESET_RETRIES) {
419
422
  const backoff = schedule[Math.min(attempt, schedule.length - 1)];
@@ -56,9 +56,9 @@ export const FENCED_WORK_MAX_ATTEMPT = 1;
56
56
  * (staging, 2026-08-13): every observed reset struck seconds AFTER the launch
57
57
  * settled — the platform kills the object while the resident runs, which is
58
58
  * when a launch-scoped row had already been deleted and recovery had nothing
59
- * to find. A resident's facet cannot outlive its session instance (the
60
- * process host's held-open leg dies with it), so a row from a previous
61
- * generation always names a process that is genuinely gone.
59
+ * to find. A resident's facet can outlive its session instance (a pending
60
+ * timer keeps it running), but nothing routes to it from the next one, and a
61
+ * spawn that takes its facet name ends it first.
62
62
  */
63
63
  export interface FencedWorkRecord {
64
64
  pid: number;
@@ -1,9 +1,9 @@
1
1
  import { BindingError } from './vendor/errors.js';
2
- import { hostDispatchMethod, hostNamespace } from './composition.js';
3
- import type { createSupervisorOpHandler } from '@nimbus-sh/core/workspace/supervisor-op.js';
2
+ import { hostDispatchMethod, hostNamespace, type HostRoute } from './composition.js';
3
+ import type { SupervisorOpDispatch } from '@nimbus-sh/core/workspace/supervisor-op.js';
4
4
 
5
5
  export type HostNamespaceBinding = Pick<DurableObjectNamespace, 'get' | 'idFromName' | 'idFromString'>;
6
- export type HostOpDispatch = ReturnType<typeof createSupervisorOpHandler>;
6
+ export type HostOpDispatch = SupervisorOpDispatch;
7
7
 
8
8
  function isNamespace(value: object): value is HostNamespaceBinding {
9
9
  return typeof Reflect.get(value, 'idFromName') === 'function'
@@ -11,8 +11,19 @@ function isNamespace(value: object): value is HostNamespaceBinding {
11
11
  && typeof Reflect.get(value, 'get') === 'function';
12
12
  }
13
13
 
14
- export function hostNamespaceBinding(env: object | null | undefined, usage: string): HostNamespaceBinding {
15
- const name = hostNamespace();
14
+ /**
15
+ * The host's namespace binding. An entrypoint answering a facet passes the
16
+ * route the binding's props carry, minted in the host's isolate; the host
17
+ * itself, and a binding minted before routes travelled (a facet outlives
18
+ * the deploy that minted its binding), resolve from this isolate's
19
+ * composition.
20
+ */
21
+ export function hostNamespaceBinding(
22
+ env: object | null | undefined,
23
+ usage: string,
24
+ route?: Pick<HostRoute, 'hostNamespace'>,
25
+ ): HostNamespaceBinding {
26
+ const name = route?.hostNamespace ?? hostNamespace();
16
27
  const binding = env ? Reflect.get(env, name) : undefined;
17
28
  if (binding === null || (typeof binding !== 'object' && typeof binding !== 'function') || !isNamespace(binding)) {
18
29
  throw new BindingError(`${usage}: env.${name} must be the Durable Object namespace configured by composeFabric`);
@@ -20,8 +31,12 @@ export function hostNamespaceBinding(env: object | null | undefined, usage: stri
20
31
  return binding;
21
32
  }
22
33
 
23
- export function hostOpDispatch(stub: object, usage: string): HostOpDispatch {
24
- const name = hostDispatchMethod();
34
+ export function hostOpDispatch(
35
+ stub: object,
36
+ usage: string,
37
+ route?: Pick<HostRoute, 'hostDispatchMethod'>,
38
+ ): HostOpDispatch {
39
+ const name = route?.hostDispatchMethod ?? hostDispatchMethod();
25
40
  if (stub === null || (typeof stub !== 'object' && typeof stub !== 'function')) {
26
41
  throw new BindingError(`${usage}: the workspace namespace returned no stub`);
27
42
  }
@@ -138,12 +138,12 @@ export class ImageStore {
138
138
  fs.mkdirp(FACET_IMAGE_DIR);
139
139
  let count = 0;
140
140
  for await (const [moduleName, source] of images) {
141
- const path = facetImagePath(await facetImageDigest(source));
141
+ const bytes = new TextEncoder().encode(source);
142
+ const path = facetImagePath(await facetImageDigest(bytes));
142
143
  paths[moduleName] = path;
143
144
  rooted.push(path);
144
145
  count++;
145
146
  const stored = path.replace(/^\/+/, '');
146
- const bytes = new TextEncoder().encode(source);
147
147
  console.log(
148
148
  '[image-store] pid=' + pid + ' image ' + count + ' ' + moduleName + ' → '
149
149
  + path.slice(-12) + ' ' + bytes.byteLength + ' bytes, slice=' + FACET_IMAGE_WRITE_SLICE_BYTES
@@ -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 { supervisorEntrypoint } 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';
@@ -120,6 +121,12 @@ export interface IsolatePoolOptions {
120
121
  * coordinator. Effective only when `omitSupervisor !== true`.
121
122
  */
122
123
  supervisorDoIdOverride?: string;
124
+ /**
125
+ * The coordinator's route, forwarded with `supervisorDoIdOverride`: the
126
+ * binding is the coordinator's, so its way back is the coordinator's
127
+ * too, whatever this peer's isolate composed.
128
+ */
129
+ supervisorRoute?: HostRoute;
123
130
  /**
124
131
  * Process pid baked into the auto-injected SUPERVISOR binding's props.
125
132
  * The supervisor derives the write credential from this pid
@@ -521,11 +528,11 @@ export class IsolatePool {
521
528
  // via supervisorDoIdOverride so SUPERVISOR.* RPCs route back
522
529
  // to the user's session DO, not the peer DO. Default to the
523
530
  // local ctx.id (single-DO callers and the in-DO in-DO fanout path).
524
- const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
525
- const supPid = opts?.supervisorPid ?? 0;
526
- bindings.SUPERVISOR = supervisorRpc({
527
- props: { doId: supDoId, pid: supPid },
531
+ const supervisor = supervisorBindingProps(ctx, opts?.supervisorPid ?? 0, {
532
+ doId: opts?.supervisorDoIdOverride,
533
+ route: opts?.supervisorRoute,
528
534
  });
535
+ bindings.SUPERVISOR = supervisorRpc({ props: supervisor });
529
536
  // Whatever the minted worker's env carries must be in its loader
530
537
  // cache key — workerd's loader cache survives a DO hibernation
531
538
  // wake while generation-strided pids (1000001 → 2000001) do not:
@@ -533,8 +540,9 @@ export class IsolatePool {
533
540
  // the new generation still credentialed to the dead pid, and
534
541
  // every pid-authorized RPC from it fails "process pid … does
535
542
  // not exist". doIdShort alone cannot cover this — it changes
536
- // across sessions, not across wakes of the same session.
537
- 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);
538
546
  } else {
539
547
  // Supervisor entrypoint unavailable — running without ctx.exports
540
548
  // (e.g. unit-test harness, or LOADER.load contexts where the
@@ -74,6 +74,7 @@
74
74
  * `ResidentDiskReader` it was given.
75
75
  */
76
76
 
77
+ import type { SupervisorBindingProps } from './supervisor-props.js';
77
78
  import { z } from 'zod/v4';
78
79
  import type { RouteableFacetTarget } from '@nimbus-sh/core/runtime/os-contracts.js';
79
80
  import type { ServiceStub } from './vendor/types.js';
@@ -209,8 +210,10 @@ export const FACET_IMAGE_DIR = 'var/lib/nimbus/facet-images';
209
210
  * generated text into `startArgs` would make every image per-PROGRAM and
210
211
  * shareable across spawns and sessions; the sweep bounds the store either way.
211
212
  */
212
- export async function facetImageDigest(source: string): Promise<string> {
213
- 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);
214
217
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
215
218
  }
216
219
 
@@ -265,8 +268,16 @@ export async function residentLoaderConfig(
265
268
  const resolved: Record<string, string | { wasm: ArrayBuffer }> = {};
266
269
  for (const [moduleName, path] of Object.entries(spec.vfsWasmModules ?? {})) {
267
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;
268
277
  resolved[moduleName] = {
269
- 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,
270
281
  };
271
282
  }
272
283
  for (const [moduleName, path] of Object.entries(spec.vfsTextModules ?? {})) {
@@ -294,15 +305,16 @@ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<s
294
305
  if (!expected) {
295
306
  throw new Error(`Nimbus: '${path}' is not a content-addressed facet image path`);
296
307
  }
297
- const source = new TextDecoder().decode(await disk.readFile(path));
298
- 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);
299
311
  if (actual !== expected) {
300
312
  throw new Error(
301
313
  `Nimbus: facet image '${path}' does not match its digest (read ${actual}); `
302
314
  + 'the image store is corrupt and the process cannot boot from it',
303
315
  );
304
316
  }
305
- return source;
317
+ return new TextDecoder().decode(bytes);
306
318
  }
307
319
 
308
320
  // ── The hosting substrate ───────────────────────────────────────────────────
@@ -310,11 +322,12 @@ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<s
310
322
  /**
311
323
  * The identity a resident process's SUPERVISOR binding is minted for. Always
312
324
  * the COORDINATOR's — a process hosted somewhere else still reads and writes
313
- * 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.
314
329
  */
315
- export interface ResidentSupervisorProps {
316
- doId: string;
317
- pid: number;
330
+ export interface ResidentSupervisorProps extends SupervisorBindingProps {
318
331
  writerId: string;
319
332
  }
320
333
 
@@ -338,6 +351,13 @@ export interface ProcessHostParams {
338
351
  * the store on release.
339
352
  */
340
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;
341
361
  }
342
362
 
343
363
  /**
@@ -650,6 +670,8 @@ export interface ResidentProcessSpawn {
650
670
  * ephemeral process, which takes a `proc-slot-<n>` name from the book.
651
671
  */
652
672
  facet?: { name: string; durable: boolean };
673
+ /** See {@link ProcessHostParams.storageBytes}. */
674
+ storageBytes?: number;
653
675
  /**
654
676
  * Called before any concrete host capability can expose this writer.
655
677
  * A spawn must not proceed unless the supervisor accepts the authority.
@@ -694,6 +716,7 @@ export class ProcessFabric {
694
716
  writerId,
695
717
  startArgs: spawn.startArgs,
696
718
  ...(spawn.facet !== undefined ? { facet: spawn.facet } : {}),
719
+ ...(spawn.storageBytes !== undefined ? { storageBytes: spawn.storageBytes } : {}),
697
720
  });
698
721
  } catch (error) {
699
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,6 +85,8 @@ import {
85
85
  processes,
86
86
  type ResidentFacetEnv,
87
87
  } from './workerd-facet-host.js';
88
+ import type { HostRoute } from './composition.js';
89
+ import { supervisorBindingProps } from './supervisor-props.js';
88
90
 
89
91
  /** The substrates this deployment can be configured for. */
90
92
  export type ProcessHostMode = 'facet' | 'peer';
@@ -137,18 +139,14 @@ class FacetProcessHost implements ProcessHost {
137
139
 
138
140
  runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
139
141
  return processes(this.ctx, this.env).run(
140
- { doId: this.coordDoId, pid: params.pid, writerId: params.writerId },
142
+ { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId },
141
143
  params,
142
144
  consume,
143
145
  );
144
146
  }
145
147
 
146
148
  async open(params: ProcessHostParams): Promise<HostedProcess> {
147
- const supervisor: ResidentSupervisorProps = {
148
- doId: this.coordDoId,
149
- pid: params.pid,
150
- writerId: params.writerId,
151
- };
149
+ const supervisor: ResidentSupervisorProps = { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId };
152
150
  const { name, ...facet } = processes(this.ctx, this.env).spawn(this.disk, supervisor, params);
153
151
  return {
154
152
  ...facet,
@@ -192,8 +190,12 @@ export function isolateToken(): string {
192
190
  /** Options the coordinator hands a hosting peer. */
193
191
  export interface HostProcessOpts {
194
192
  coordinatorDoId: string;
193
+ /** The coordinator's route, minted into the process's SUPERVISOR binding. */
194
+ route?: HostRoute;
195
195
  pid: number;
196
196
  writerId: string;
197
+ /** The coordinator instance's delivery incarnation, minted into the SUPERVISOR binding (ResidentSupervisorProps). */
198
+ hostIncarnation?: string;
197
199
  workerKey: string;
198
200
  /** Unforgeable capability for the fetch-semantic WebSocket hop. */
199
201
  webSocketCapability: string;
@@ -354,7 +356,7 @@ class PeerProcessHost implements ProcessHost {
354
356
  */
355
357
  runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T> {
356
358
  return processes(this.ctx, this.env).run(
357
- { doId: this.coordDoId, pid: params.pid, writerId: params.writerId },
359
+ { ...supervisorBindingProps(this.ctx, params.pid), writerId: params.writerId },
358
360
  params,
359
361
  consume,
360
362
  );
@@ -379,10 +381,15 @@ class PeerProcessHost implements ProcessHost {
379
381
  // also what keeps the hosting DO resident. It settles when a `lifetime`
380
382
  // runner exits or when a `boot` runner's host is cancelled, and rejects if
381
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);
382
387
  const hostLeg = placement.stub._rpcHostProcess(params.boot, {
383
- coordinatorDoId: this.coordDoId,
384
- pid: params.pid,
388
+ coordinatorDoId: supervisor.doId,
389
+ route: supervisor.route,
390
+ pid: supervisor.pid,
385
391
  writerId: params.writerId,
392
+ hostIncarnation: supervisor.hostIncarnation,
386
393
  workerKey: params.workerKey,
387
394
  webSocketCapability,
388
395
  startArgs: params.startArgs,
@@ -491,7 +498,7 @@ class PeerProcessHost implements ProcessHost {
491
498
  }
492
499
  return probe;
493
500
  } catch (err) {
494
- if (attempt < PEER_TRANSIENT_RESET_RETRIES && isTransientDoReset(err)) {
501
+ if (attempt < PEER_TRANSIENT_RESET_RETRIES && isRetryableDoCall(classifyDoCall(err))) {
495
502
  await new Promise((r) => setTimeout(
496
503
  r, PEER_RETRY_BACKOFF_MS[Math.min(attempt, PEER_RETRY_BACKOFF_MS.length - 1)],
497
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
+ }