@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.
- package/dist/bindings.d.ts +1 -0
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +2 -0
- package/dist/connections.d.ts +1 -1
- package/dist/connections.js +1 -1
- package/dist/do-calls.d.ts +71 -9
- package/dist/do-calls.d.ts.map +1 -1
- package/dist/do-calls.js +148 -22
- package/dist/facet-pool.d.ts +3 -0
- package/dist/facet-pool.d.ts.map +1 -1
- package/dist/facet-pool.js +3 -0
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +4 -3
- package/dist/fenced-work.d.ts +3 -3
- package/dist/image-store.js +2 -2
- package/dist/inner-do-registry.d.ts +9 -0
- package/dist/inner-do-registry.d.ts.map +1 -1
- package/dist/inner-do-registry.js +35 -0
- package/dist/isolate-pool.d.ts +1 -1
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +10 -8
- package/dist/process-fabric.d.ts +16 -12
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +18 -6
- package/dist/process-host.d.ts +3 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +13 -14
- package/dist/supervisor-props.d.ts +47 -0
- package/dist/supervisor-props.d.ts.map +1 -0
- package/dist/supervisor-props.js +37 -0
- package/dist/workerd-facet-host.d.ts +4 -17
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +95 -29
- package/package.json +5 -5
- package/src/bindings.ts +2 -0
- package/src/connections.ts +1 -1
- package/src/do-calls.ts +182 -25
- package/src/facet-pool.ts +5 -0
- package/src/fanout.ts +4 -3
- package/src/fenced-work.ts +3 -3
- package/src/image-store.ts +2 -2
- package/src/inner-do-registry.ts +31 -0
- package/src/isolate-pool.ts +10 -8
- package/src/process-fabric.ts +33 -17
- package/src/process-host.ts +16 -14
- package/src/supervisor-props.ts +56 -0
- package/src/workerd-facet-host.ts +96 -32
package/src/inner-do-registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/isolate-pool.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
package/src/process-fabric.ts
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
* `ResidentDiskReader` it was given.
|
|
75
75
|
*/
|
|
76
76
|
|
|
77
|
-
import type {
|
|
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(
|
|
214
|
-
|
|
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:
|
|
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
|
-
|
|
299
|
-
const
|
|
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
|
|
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);
|
package/src/process-host.ts
CHANGED
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
67
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
68
|
-
import {
|
|
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 {
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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:
|
|
388
|
-
route:
|
|
389
|
-
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 &&
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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`,
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
|
283
|
-
* allocated against DO storage instead (their owner survives a reset), so
|
|
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
|
-
/**
|
|
299
|
-
|
|
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
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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(
|
|
585
|
+
.get(loaderKey, () => residentWorkerConfig(env, disk, supervisor, params.boot))
|
|
522
586
|
.getDurableObjectClass(RESIDENT_PROCESS_CLASS);
|
|
523
|
-
recordLoaderId(ctx,
|
|
587
|
+
recordLoaderId(ctx, loaderKey);
|
|
524
588
|
return worker;
|
|
525
589
|
} catch (error) {
|
|
526
590
|
throw withDynamicWorkerCapNamed(ctx, error);
|