@nimbus-sh/fabric 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -11
- package/dist/bindings.d.ts +1 -0
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +2 -0
- package/dist/budgets.d.ts +50 -29
- package/dist/budgets.d.ts.map +1 -1
- package/dist/budgets.js +88 -40
- package/dist/connections.d.ts +1 -1
- package/dist/connections.js +1 -1
- package/dist/do-calls.d.ts +91 -9
- package/dist/do-calls.d.ts.map +1 -1
- package/dist/do-calls.js +161 -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 +40 -32
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +50 -53
- package/dist/fenced-work.d.ts +3 -3
- package/dist/host-wasm.d.ts +29 -0
- package/dist/host-wasm.d.ts.map +1 -0
- package/dist/host-wasm.js +31 -0
- package/dist/image-store.d.ts +1 -1
- package/dist/image-store.d.ts.map +1 -1
- package/dist/image-store.js +34 -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 +32 -24
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +78 -54
- package/dist/process-fabric.d.ts +42 -23
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +62 -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/timers.d.ts +12 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +44 -9
- package/dist/vendor/types.d.ts +11 -5
- package/dist/vendor/types.d.ts.map +1 -1
- 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 +120 -54
- package/package.json +6 -6
- package/src/bindings.ts +2 -0
- package/src/budgets.ts +96 -49
- package/src/connections.ts +1 -1
- package/src/do-calls.ts +216 -25
- package/src/facet-pool.ts +5 -0
- package/src/fanout.ts +64 -56
- package/src/fenced-work.ts +3 -3
- package/src/host-wasm.ts +41 -0
- package/src/image-store.ts +30 -3
- package/src/inner-do-registry.ts +31 -0
- package/src/isolate-pool.ts +108 -74
- package/src/process-fabric.ts +88 -30
- package/src/process-host.ts +16 -14
- package/src/supervisor-props.ts +56 -0
- package/src/timers.ts +45 -9
- package/src/vendor/types.ts +11 -5
- package/src/workerd-facet-host.ts +123 -58
package/src/isolate-pool.ts
CHANGED
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
* running 67 npm tarball extractions (cold-start dominates). We pin
|
|
8
8
|
* each job to `slot = cursor % concurrency` and use stable loader
|
|
9
9
|
* IDs `nfp:${fnHash}:slot-${i}:g${generation}`, so a pool of
|
|
10
|
-
* concurrency=
|
|
10
|
+
* concurrency=N keeps at most N warm isolates rather than one per job.
|
|
11
11
|
* 2. **Nimbus defaults**: compatibilityDate = CF_COMPAT_DATE (matches
|
|
12
|
-
* the supervisor worker), compatibilityFlags =
|
|
12
|
+
* the supervisor worker), compatibilityFlags = GUEST_COMPAT_FLAGS,
|
|
13
13
|
* globalOutbound = undefined (inherit parent network so the facet can
|
|
14
14
|
* reach https://registry.npmjs.org without a proxy binding).
|
|
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.
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
* and binding types used by this implementation.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { CF_COMPAT_DATE } from '@nimbus-sh/core/constants.js';
|
|
28
|
-
import {
|
|
27
|
+
import { CF_COMPAT_DATE, GUEST_COMPAT_FLAGS } from '@nimbus-sh/core/constants.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
|
-
import { beginLoaderFetch,
|
|
32
|
+
import { beginLoaderFetch, withDynamicWorkerCapNamed } from './budgets.js';
|
|
32
33
|
import { assertModuleMapWithinCodeLimit } from './budgets.js';
|
|
33
34
|
import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/platform/oom-discriminator.js';
|
|
34
35
|
import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
} from './vendor/errors.js';
|
|
41
42
|
import type { FacetBindings } from '@nimbus-sh/core/runtime/facet-host.js';
|
|
42
43
|
import type { ModuleContent, WorkerLoader } from './vendor/types.js';
|
|
44
|
+
import { hostWasmIdentity } from './host-wasm.js';
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* A function dispatched into a facet isolate, with the bindings that facet was
|
|
@@ -61,7 +63,11 @@ export interface IsolatePoolEnv {
|
|
|
61
63
|
|
|
62
64
|
/** Options handed to IsolatePool's constructor. */
|
|
63
65
|
export interface IsolatePoolOptions {
|
|
64
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Maximum concurrent in-flight facets, each a distinct Dynamic Worker
|
|
68
|
+
* spent from the hosting DO's `DO_DYNAMIC_WORKER_LIMIT`. Default 1; a
|
|
69
|
+
* caller that wants more sizes it against that budget (Fanout does).
|
|
70
|
+
*/
|
|
65
71
|
concurrency?: number;
|
|
66
72
|
/** Per-task timeout in ms. Default 60_000. */
|
|
67
73
|
timeoutMs?: number;
|
|
@@ -156,15 +162,19 @@ export interface IsolatePoolOptions {
|
|
|
156
162
|
/**
|
|
157
163
|
* WebAssembly modules to ship into the facet via the LOADER's
|
|
158
164
|
* `modules` map. Map keys are module specifier paths (e.g.
|
|
159
|
-
* `'esbuild.wasm'`); values are the raw bytes
|
|
165
|
+
* `'esbuild.wasm'`); values are the raw bytes, or a module the host
|
|
166
|
+
* already holds compiled.
|
|
160
167
|
*
|
|
161
|
-
* Workerd registers each entry as `{ wasm
|
|
162
|
-
*
|
|
168
|
+
* Workerd registers each entry as `{ wasm }` in the worker's modules
|
|
169
|
+
* map. The pool prepends a static
|
|
163
170
|
* `import __NIMBUS_WASM_<id> from './<key>';` to the generated
|
|
164
|
-
* worker.js
|
|
165
|
-
* where wasm code generation is permitted)
|
|
166
|
-
*
|
|
167
|
-
*
|
|
171
|
+
* worker.js; bytes are compiled at the facet's module-load (startup
|
|
172
|
+
* phase, where wasm code generation is permitted), and a compiled
|
|
173
|
+
* module is shared with the facet as is, its compiled code included
|
|
174
|
+
* (workerd src/workerd/api/worker-loader.c++,
|
|
175
|
+
* extractWasmModuleContent). The Modules are exposed via
|
|
176
|
+
* `globalThis.__NIMBUS_WASM[<key>]` for the user function to read at
|
|
177
|
+
* request time.
|
|
168
178
|
*
|
|
169
179
|
* Why this works when other paths don't:
|
|
170
180
|
* - request-time `WebAssembly.compile()` — disallowed by workerd
|
|
@@ -173,15 +183,15 @@ export interface IsolatePoolOptions {
|
|
|
173
183
|
* structured-clone refuses ("Unable to deserialize cloned data").
|
|
174
184
|
* - inlining bytes in the preamble — 16 MiB string per dispatch
|
|
175
185
|
* OOMs the supervisor at module-source allocation time.
|
|
176
|
-
* - LOADER modules-map (this) —
|
|
177
|
-
* blob
|
|
178
|
-
*
|
|
186
|
+
* - LOADER modules-map (this) — the module rides INSIDE the worker
|
|
187
|
+
* code blob, never crossing structured-clone, never executing JS
|
|
188
|
+
* eval.
|
|
179
189
|
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
190
|
+
* A compiled module must be described (host-wasm.ts describeHostWasm):
|
|
191
|
+
* its identity keys warm slots, as the bytes' fingerprint does, and its
|
|
192
|
+
* size counts toward the dynamic-worker code limit.
|
|
183
193
|
*/
|
|
184
|
-
wasmModules?: Record<string, ArrayBuffer>;
|
|
194
|
+
wasmModules?: Record<string, ArrayBuffer | WebAssembly.Module>;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
/** Per-call override (merged with pool defaults). */
|
|
@@ -239,6 +249,14 @@ interface ResolvedResilience {
|
|
|
239
249
|
retries: number;
|
|
240
250
|
}
|
|
241
251
|
|
|
252
|
+
/**
|
|
253
|
+
* How long one call waits, in all, for the platform to admit it after
|
|
254
|
+
* "Dynamic worker concurrency limit exceeded" (doubling from 50 ms, at most
|
|
255
|
+
* 2 s a wait). A deployed Durable Object admitted the refused batch after a
|
|
256
|
+
* 6 s pause; 15 s bounds a call that would never be admitted.
|
|
257
|
+
*/
|
|
258
|
+
const CAP_REFUSAL_WAIT_MS = 15_000;
|
|
259
|
+
|
|
242
260
|
/**
|
|
243
261
|
* esbuild runtime helpers re-declared at the top of every generated facet
|
|
244
262
|
* module. esbuild emits `__name(fn, "fn")` wrappers around every named
|
|
@@ -366,7 +384,7 @@ export function assembleLoaderWorkerModuleSource(
|
|
|
366
384
|
* Typical use:
|
|
367
385
|
*
|
|
368
386
|
* const pool = new IsolatePool(env, ctx, {
|
|
369
|
-
* concurrency:
|
|
387
|
+
* concurrency: 2,
|
|
370
388
|
* tag: 'npm-install',
|
|
371
389
|
* });
|
|
372
390
|
* const results = await pool.map(
|
|
@@ -411,13 +429,13 @@ export class IsolatePool {
|
|
|
411
429
|
/** Identifier used inside the generated worker for both the static
|
|
412
430
|
* import binding and the globalThis exposure. Sanitised from `name`. */
|
|
413
431
|
id: string;
|
|
414
|
-
|
|
432
|
+
wasm: ArrayBuffer | WebAssembly.Module;
|
|
415
433
|
}>;
|
|
416
|
-
/** Hash of
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
434
|
+
/** Hash of every constructor-time wasm module, folded into the loader
|
|
435
|
+
* cache key so changes invalidate warm slots: a compiled module by the
|
|
436
|
+
* identity its host described, bytes by name + length + first/last
|
|
437
|
+
* byte. Hashing the FULL bytes would be O(20+ MiB) per dispatch and is
|
|
438
|
+
* unnecessary — they are pinned at deploy time. */
|
|
421
439
|
private readonly wasmHash: string;
|
|
422
440
|
/**
|
|
423
441
|
* Short prefix of the owning DO's id, baked into the loader.get()
|
|
@@ -456,7 +474,7 @@ export class IsolatePool {
|
|
|
456
474
|
}
|
|
457
475
|
this.loader = loader;
|
|
458
476
|
this.ctx = ctx;
|
|
459
|
-
this.concurrency = Math.max(1, opts?.concurrency ??
|
|
477
|
+
this.concurrency = Math.max(1, opts?.concurrency ?? 1);
|
|
460
478
|
this.defaultTimeoutMs = opts?.timeoutMs ?? 60_000;
|
|
461
479
|
this.defaultRetries = Math.max(0, opts?.retries ?? 0);
|
|
462
480
|
this.tag = opts?.tag ?? 'facet';
|
|
@@ -474,17 +492,36 @@ export class IsolatePool {
|
|
|
474
492
|
// (e.g. 'esbuild.wasm' and 'esbuild_wasm' both sanitise to
|
|
475
493
|
// 'esbuild_wasm') are rejected loudly because the generated worker
|
|
476
494
|
// would otherwise have duplicate imports. Order is preserved.
|
|
477
|
-
const wasmEntries: Array<{ name: string; id: string;
|
|
495
|
+
const wasmEntries: Array<{ name: string; id: string; wasm: ArrayBuffer | WebAssembly.Module }> = [];
|
|
496
|
+
const fingerprints: string[] = [];
|
|
478
497
|
const seenIds = new Set<string>();
|
|
479
498
|
if (opts?.wasmModules) {
|
|
480
|
-
for (const [name,
|
|
481
|
-
|
|
499
|
+
for (const [name, wasm] of Object.entries(opts.wasmModules)) {
|
|
500
|
+
let fingerprint: string;
|
|
501
|
+
if (wasm instanceof ArrayBuffer) {
|
|
502
|
+
// Name + length + first/last byte: hashing 20+ MiB of wasm per
|
|
503
|
+
// dispatch would be wasteful, and these bytes change only with
|
|
504
|
+
// the deployed bundle.
|
|
505
|
+
const u = new Uint8Array(wasm);
|
|
506
|
+
const len = u.byteLength;
|
|
507
|
+
fingerprint = `${name}:${len}:${len > 0 ? u[0] : 0}:${len > 0 ? u[len - 1] : 0}`;
|
|
508
|
+
} else if (wasm instanceof WebAssembly.Module) {
|
|
509
|
+
const identity = hostWasmIdentity(wasm);
|
|
510
|
+
if (!identity) {
|
|
511
|
+
throw new BindingError(
|
|
512
|
+
`IsolatePool: wasmModules['${name}'] is a WebAssembly.Module nobody described; ` +
|
|
513
|
+
'pass it through describeHostWasm (@nimbus-sh/fabric/host-wasm.js) so warm slots ' +
|
|
514
|
+
'can be keyed by it and the code limit can count it.',
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
fingerprint = `${name}:host:${identity.id}:${identity.bytes}`;
|
|
518
|
+
} else {
|
|
482
519
|
// Reached only when a caller broke the declared option type, so the
|
|
483
|
-
// value is whatever it really was rather than the
|
|
484
|
-
const got = (
|
|
520
|
+
// value is whatever it really was rather than the union here.
|
|
521
|
+
const got = (wasm as { constructor?: { name?: string } } | null | undefined)?.constructor?.name;
|
|
485
522
|
throw new BindingError(
|
|
486
|
-
`IsolatePool: wasmModules['${name}'] must be ArrayBuffer ` +
|
|
487
|
-
`(got ${got || typeof
|
|
523
|
+
`IsolatePool: wasmModules['${name}'] must be an ArrayBuffer or a WebAssembly.Module ` +
|
|
524
|
+
`(got ${got || typeof wasm}).`,
|
|
488
525
|
);
|
|
489
526
|
}
|
|
490
527
|
const id = name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^[^A-Za-z_]/, '_');
|
|
@@ -495,28 +532,12 @@ export class IsolatePool {
|
|
|
495
532
|
);
|
|
496
533
|
}
|
|
497
534
|
seenIds.add(id);
|
|
498
|
-
wasmEntries.push({ name, id,
|
|
535
|
+
wasmEntries.push({ name, id, wasm });
|
|
536
|
+
fingerprints.push(fingerprint);
|
|
499
537
|
}
|
|
500
538
|
}
|
|
501
539
|
this.wasmModules = wasmEntries;
|
|
502
|
-
|
|
503
|
-
// Hashing 20+ MiB of wasm per dispatch would be wasteful; this
|
|
504
|
-
// fingerprint is bytes-stable for a given deployed bundle and only
|
|
505
|
-
// changes when the wasm itself changes (deploy-time event).
|
|
506
|
-
if (wasmEntries.length === 0) {
|
|
507
|
-
this.wasmHash = '0';
|
|
508
|
-
} else {
|
|
509
|
-
const fp = wasmEntries
|
|
510
|
-
.map((w) => {
|
|
511
|
-
const u = new Uint8Array(w.bytes);
|
|
512
|
-
const len = u.byteLength;
|
|
513
|
-
const first = len > 0 ? u[0] : 0;
|
|
514
|
-
const last = len > 0 ? u[len - 1] : 0;
|
|
515
|
-
return `${w.name}:${len}:${first}:${last}`;
|
|
516
|
-
})
|
|
517
|
-
.join('|');
|
|
518
|
-
this.wasmHash = hashSource(fp);
|
|
519
|
-
}
|
|
540
|
+
this.wasmHash = fingerprints.length === 0 ? '0' : hashSource(fingerprints.join('|'));
|
|
520
541
|
|
|
521
542
|
const bindings: Record<string, unknown> = { ...(opts?.extraBindings ?? {}) };
|
|
522
543
|
this.supervisorKey = 's-none';
|
|
@@ -527,11 +548,11 @@ export class IsolatePool {
|
|
|
527
548
|
// via supervisorDoIdOverride so SUPERVISOR.* RPCs route back
|
|
528
549
|
// to the user's session DO, not the peer DO. Default to the
|
|
529
550
|
// 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 },
|
|
551
|
+
const supervisor = supervisorBindingProps(ctx, opts?.supervisorPid ?? 0, {
|
|
552
|
+
doId: opts?.supervisorDoIdOverride,
|
|
553
|
+
route: opts?.supervisorRoute,
|
|
534
554
|
});
|
|
555
|
+
bindings.SUPERVISOR = supervisorRpc({ props: supervisor });
|
|
535
556
|
// Whatever the minted worker's env carries must be in its loader
|
|
536
557
|
// cache key — workerd's loader cache survives a DO hibernation
|
|
537
558
|
// wake while generation-strided pids (1000001 → 2000001) do not:
|
|
@@ -539,8 +560,9 @@ export class IsolatePool {
|
|
|
539
560
|
// the new generation still credentialed to the dead pid, and
|
|
540
561
|
// every pid-authorized RPC from it fails "process pid … does
|
|
541
562
|
// not exist". doIdShort alone cannot cover this — it changes
|
|
542
|
-
// across sessions, not across wakes of the same session.
|
|
543
|
-
|
|
563
|
+
// across sessions, not across wakes of the same session. So does
|
|
564
|
+
// the instance a binding delivers mutations to, when it names one.
|
|
565
|
+
this.supervisorKey = supervisorLoaderKey(`s${supervisor.doId.slice(0, 12)}-${supervisor.pid}`, supervisor);
|
|
544
566
|
} else {
|
|
545
567
|
// Supervisor entrypoint unavailable — running without ctx.exports
|
|
546
568
|
// (e.g. unit-test harness, or LOADER.load contexts where the
|
|
@@ -577,9 +599,9 @@ export class IsolatePool {
|
|
|
577
599
|
*/
|
|
578
600
|
#materialisePerCallWasm(
|
|
579
601
|
perCall: Record<string, ArrayBuffer> | undefined,
|
|
580
|
-
): Array<{ name: string; id: string;
|
|
602
|
+
): Array<{ name: string; id: string; wasm: ArrayBuffer }> {
|
|
581
603
|
if (!perCall) return [];
|
|
582
|
-
const out: Array<{ name: string; id: string;
|
|
604
|
+
const out: Array<{ name: string; id: string; wasm: ArrayBuffer }> = [];
|
|
583
605
|
const ctorIds = new Set(this.wasmModules.map((w) => w.id));
|
|
584
606
|
const seen = new Set<string>();
|
|
585
607
|
for (const [name, bytes] of Object.entries(perCall)) {
|
|
@@ -605,7 +627,7 @@ export class IsolatePool {
|
|
|
605
627
|
);
|
|
606
628
|
}
|
|
607
629
|
seen.add(id);
|
|
608
|
-
out.push({ name, id, bytes });
|
|
630
|
+
out.push({ name, id, wasm: bytes });
|
|
609
631
|
}
|
|
610
632
|
return out;
|
|
611
633
|
}
|
|
@@ -638,12 +660,12 @@ export class IsolatePool {
|
|
|
638
660
|
* hashing the wasm were marginal; the correctness cost was severe.
|
|
639
661
|
*/
|
|
640
662
|
#fingerprintWasm(
|
|
641
|
-
entries: Array<{ name: string;
|
|
663
|
+
entries: Array<{ name: string; wasm: ArrayBuffer }>,
|
|
642
664
|
): string {
|
|
643
665
|
if (entries.length === 0) return '0';
|
|
644
666
|
const parts: string[] = [];
|
|
645
667
|
for (const w of entries) {
|
|
646
|
-
const u = new Uint8Array(w.
|
|
668
|
+
const u = new Uint8Array(w.wasm);
|
|
647
669
|
const len = u.byteLength;
|
|
648
670
|
// djb2 over the bytes. Faster than crypto.subtle.digest at small
|
|
649
671
|
// sizes, deterministic, and good enough for cache-key
|
|
@@ -670,11 +692,11 @@ export class IsolatePool {
|
|
|
670
692
|
*/
|
|
671
693
|
#buildCode(
|
|
672
694
|
fnSource: string,
|
|
673
|
-
perCallWasmEntries?: Array<{ name: string; id: string;
|
|
695
|
+
perCallWasmEntries?: Array<{ name: string; id: string; wasm: ArrayBuffer }>,
|
|
674
696
|
) {
|
|
675
697
|
const workerOpts = {
|
|
676
698
|
compatibilityDate: CF_COMPAT_DATE,
|
|
677
|
-
compatibilityFlags: [
|
|
699
|
+
compatibilityFlags: [...GUEST_COMPAT_FLAGS],
|
|
678
700
|
// Inherit parent network so the facet can reach registry.npmjs.org.
|
|
679
701
|
globalOutbound: undefined,
|
|
680
702
|
env: this.bindings,
|
|
@@ -720,7 +742,7 @@ export class IsolatePool {
|
|
|
720
742
|
// (matters only for human-readable diffs; workerd doesn't care).
|
|
721
743
|
const modules: Record<string, ModuleContent> = { 'worker.js': moduleSource };
|
|
722
744
|
for (const w of allWasmEntries) {
|
|
723
|
-
modules[w.name] = { wasm: w.
|
|
745
|
+
modules[w.name] = { wasm: w.wasm };
|
|
724
746
|
}
|
|
725
747
|
assertModuleMapWithinCodeLimit(modules);
|
|
726
748
|
|
|
@@ -841,11 +863,10 @@ export class IsolatePool {
|
|
|
841
863
|
// binding stub once the whole pool is done, which does NOT
|
|
842
864
|
// invalidate any in-flight slot's entrypoint reference.
|
|
843
865
|
const stub = this.loader.get(id, async () => code);
|
|
844
|
-
recordLoaderId(this.ctx, id);
|
|
845
866
|
const entrypoint = stub.getEntrypoint();
|
|
846
867
|
// Direct property call, awaited by this frame — bracketed, never
|
|
847
868
|
// wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
|
|
848
|
-
const endFetch = beginLoaderFetch(this.ctx);
|
|
869
|
+
const endFetch = beginLoaderFetch(this.ctx, id);
|
|
849
870
|
try {
|
|
850
871
|
return await invoke(entrypoint, attempt);
|
|
851
872
|
} catch (err) {
|
|
@@ -861,6 +882,8 @@ export class IsolatePool {
|
|
|
861
882
|
const maxAttempts = 1 + resilience.retries;
|
|
862
883
|
let lastError: Error | undefined;
|
|
863
884
|
let retriedCloneRefusal = false;
|
|
885
|
+
let capRefusals = 0;
|
|
886
|
+
let capWaitedMs = 0;
|
|
864
887
|
let attempt = 0;
|
|
865
888
|
while (attempt < maxAttempts) {
|
|
866
889
|
try {
|
|
@@ -942,6 +965,17 @@ export class IsolatePool {
|
|
|
942
965
|
// reverse direction. This refresh targets the stale-loader case.
|
|
943
966
|
continue;
|
|
944
967
|
}
|
|
968
|
+
if (cause === 'dynamic_worker_cap' && capWaitedMs < CAP_REFUSAL_WAIT_MS) {
|
|
969
|
+
// The platform refused to start this call: it still counts a
|
|
970
|
+
// worker this Durable Object's ledger has already given back (a
|
|
971
|
+
// fan-out's workers stay counted for a moment after their calls
|
|
972
|
+
// return). Nothing ran, so the call waits, as the platform asks,
|
|
973
|
+
// and is sent again; it does not spend an attempt.
|
|
974
|
+
const delay = Math.min(CAP_REFUSAL_WAIT_MS - capWaitedMs, 50 * 2 ** capRefusals++, 2000);
|
|
975
|
+
capWaitedMs += delay;
|
|
976
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay));
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
945
979
|
if (attempt < maxAttempts - 1) {
|
|
946
980
|
// 100 * 2^attempt, capped at 2s so retries don't compound waiting.
|
|
947
981
|
const delay = Math.min(2000, 100 * Math.pow(2, attempt));
|
|
@@ -950,9 +984,9 @@ export class IsolatePool {
|
|
|
950
984
|
attempt++;
|
|
951
985
|
}
|
|
952
986
|
}
|
|
953
|
-
// On the way out only, so retries do not stack the annotation: a
|
|
954
|
-
// carries the ledger — which
|
|
955
|
-
//
|
|
987
|
+
// On the way out only, so retries do not stack the annotation: a limit
|
|
988
|
+
// hit carries the ledger — which workers were in flight — instead of the
|
|
989
|
+
// platform's bare message.
|
|
956
990
|
const named = withDynamicWorkerCapNamed(this.ctx, lastError!);
|
|
957
991
|
if (maxAttempts > 1) {
|
|
958
992
|
throw new RetryExhaustedError(maxAttempts, named);
|
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';
|
|
@@ -133,6 +133,14 @@ export const ResidentCodeSpecSchema = z.object({
|
|
|
133
133
|
* image store below and the spec names it.
|
|
134
134
|
*/
|
|
135
135
|
vfsTextModules: z.record(z.string(), z.string()).optional(),
|
|
136
|
+
/**
|
|
137
|
+
* VFS paths of generated CommonJS PACKS (encodeCommonJsPack): each image
|
|
138
|
+
* carries many `{ cjs }` modules, and the map gains every one of them at
|
|
139
|
+
* load. A node process's module cells travel this way — one module per
|
|
140
|
+
* file, so the guest's registry compiles only what the program requires,
|
|
141
|
+
* and one image per launch, so the boot spec names a path and not thousands.
|
|
142
|
+
*/
|
|
143
|
+
vfsCommonJsPacks: z.array(z.string()).optional(),
|
|
136
144
|
/**
|
|
137
145
|
* The isolate's exact `env`: one entry per binding the embedder minted,
|
|
138
146
|
* carried by reference so loopback stubs survive untouched. Defined —
|
|
@@ -210,8 +218,10 @@ export const FACET_IMAGE_DIR = 'var/lib/nimbus/facet-images';
|
|
|
210
218
|
* generated text into `startArgs` would make every image per-PROGRAM and
|
|
211
219
|
* shareable across spawns and sessions; the sweep bounds the store either way.
|
|
212
220
|
*/
|
|
213
|
-
export async function facetImageDigest(
|
|
214
|
-
|
|
221
|
+
export async function facetImageDigest(image: string | Uint8Array): Promise<string> {
|
|
222
|
+
// An image is its UTF-8 bytes; a caller holding them is not made to encode a second copy.
|
|
223
|
+
const bytes = typeof image === 'string' ? new TextEncoder().encode(image) : image;
|
|
224
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
215
225
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
216
226
|
}
|
|
217
227
|
|
|
@@ -263,16 +273,27 @@ export async function residentLoaderConfig(
|
|
|
263
273
|
spec: ResidentCodeSpec,
|
|
264
274
|
disk: ResidentDiskReader,
|
|
265
275
|
): Promise<Record<string, unknown>> {
|
|
266
|
-
const resolved: Record<string, string | { wasm: ArrayBuffer }> = {};
|
|
276
|
+
const resolved: Record<string, string | { wasm: ArrayBuffer } | { cjs: string }> = {};
|
|
267
277
|
for (const [moduleName, path] of Object.entries(spec.vfsWasmModules ?? {})) {
|
|
268
278
|
const bytes = await disk.readFile(path);
|
|
279
|
+
// The read's own buffer when it fits exactly, and only otherwise a copy.
|
|
280
|
+
// These are the largest members a spec carries — ruby's interpreter image
|
|
281
|
+
// is 34.3 MiB, esbuild's 13.3 — and an unconditional slice held both
|
|
282
|
+
// copies at once in the coordinator's 128 MiB isolate, at the one moment
|
|
283
|
+
// the module map is also resident.
|
|
284
|
+
const exact = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength;
|
|
269
285
|
resolved[moduleName] = {
|
|
270
|
-
wasm:
|
|
286
|
+
wasm: exact
|
|
287
|
+
? bytes.buffer as ArrayBuffer
|
|
288
|
+
: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
|
|
271
289
|
};
|
|
272
290
|
}
|
|
273
291
|
for (const [moduleName, path] of Object.entries(spec.vfsTextModules ?? {})) {
|
|
274
292
|
resolved[moduleName] = await readFacetImage(disk, path);
|
|
275
293
|
}
|
|
294
|
+
for (const path of spec.vfsCommonJsPacks ?? []) {
|
|
295
|
+
Object.assign(resolved, decodeCommonJsPack(await readFacetImage(disk, path)));
|
|
296
|
+
}
|
|
276
297
|
return {
|
|
277
298
|
compatibilityDate: spec.compatibilityDate,
|
|
278
299
|
compatibilityFlags: spec.compatibilityFlags,
|
|
@@ -283,6 +304,40 @@ export async function residentLoaderConfig(
|
|
|
283
304
|
};
|
|
284
305
|
}
|
|
285
306
|
|
|
307
|
+
/**
|
|
308
|
+
* One image holding many `{ cjs }` modules: a JSON index of `[name, length]`
|
|
309
|
+
* rows, a newline, and the module texts back to back. Lengths are UTF-16 code
|
|
310
|
+
* units, the unit the decoded text is sliced in, so decoding copies nothing:
|
|
311
|
+
* each module is a slice of the one string read.
|
|
312
|
+
*
|
|
313
|
+
* Encoded as its parts, in order, never joined: the image store encodes them
|
|
314
|
+
* straight into the image's bytes, and a joined copy would be a second full
|
|
315
|
+
* copy of the program's code on the coordinator.
|
|
316
|
+
*/
|
|
317
|
+
export function encodeCommonJsPack(modules: Record<string, string>): string[] {
|
|
318
|
+
const index: [string, number][] = [];
|
|
319
|
+
const parts: string[] = [''];
|
|
320
|
+
for (const [name, text] of Object.entries(modules)) {
|
|
321
|
+
index.push([name, text.length]);
|
|
322
|
+
parts.push(text);
|
|
323
|
+
}
|
|
324
|
+
parts[0] = JSON.stringify(index) + '\n';
|
|
325
|
+
return parts;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function decodeCommonJsPack(pack: string): Record<string, { cjs: string }> {
|
|
329
|
+
const newline = pack.indexOf('\n');
|
|
330
|
+
const index = z.array(z.tuple([z.string(), z.number().int().nonnegative()])).parse(JSON.parse(pack.slice(0, newline)));
|
|
331
|
+
const modules: Record<string, { cjs: string }> = {};
|
|
332
|
+
let offset = newline + 1;
|
|
333
|
+
for (const [name, length] of index) {
|
|
334
|
+
modules[name] = { cjs: pack.slice(offset, offset + length) };
|
|
335
|
+
offset += length;
|
|
336
|
+
}
|
|
337
|
+
if (offset !== pack.length) throw new Error(`Nimbus: CommonJS pack holds ${pack.length - offset} bytes its index does not name`);
|
|
338
|
+
return modules;
|
|
339
|
+
}
|
|
340
|
+
|
|
286
341
|
/**
|
|
287
342
|
* Read one content-addressed facet image and verify it against the digest its
|
|
288
343
|
* path claims. Content addressing is only a guarantee if the bytes are checked
|
|
@@ -295,15 +350,16 @@ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<s
|
|
|
295
350
|
if (!expected) {
|
|
296
351
|
throw new Error(`Nimbus: '${path}' is not a content-addressed facet image path`);
|
|
297
352
|
}
|
|
298
|
-
|
|
299
|
-
const
|
|
353
|
+
// Verified from the bytes read, and decoded only after: re-encoding the decoded string held a third copy of the largest member.
|
|
354
|
+
const bytes = await disk.readFile(path);
|
|
355
|
+
const actual = await facetImageDigest(bytes);
|
|
300
356
|
if (actual !== expected) {
|
|
301
357
|
throw new Error(
|
|
302
358
|
`Nimbus: facet image '${path}' does not match its digest (read ${actual}); `
|
|
303
359
|
+ 'the image store is corrupt and the process cannot boot from it',
|
|
304
360
|
);
|
|
305
361
|
}
|
|
306
|
-
return
|
|
362
|
+
return new TextDecoder().decode(bytes);
|
|
307
363
|
}
|
|
308
364
|
|
|
309
365
|
// ── The hosting substrate ───────────────────────────────────────────────────
|
|
@@ -311,18 +367,13 @@ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<s
|
|
|
311
367
|
/**
|
|
312
368
|
* The identity a resident process's SUPERVISOR binding is minted for. Always
|
|
313
369
|
* 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.
|
|
370
|
+
* the user's disk, and still reports to the user's process table. Minted by
|
|
371
|
+
* `supervisorBindingProps` on the coordinator; `route` is absent only when
|
|
372
|
+
* the coordinator's isolate composed nothing, in which case no supervisor
|
|
373
|
+
* binding is minted either.
|
|
315
374
|
*/
|
|
316
|
-
export interface ResidentSupervisorProps {
|
|
317
|
-
doId: string;
|
|
318
|
-
pid: number;
|
|
375
|
+
export interface ResidentSupervisorProps extends SupervisorBindingProps {
|
|
319
376
|
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
377
|
}
|
|
327
378
|
|
|
328
379
|
/** Everything a host needs to run one process. Substrate-free by construction. */
|
|
@@ -345,6 +396,13 @@ export interface ProcessHostParams {
|
|
|
345
396
|
* the store on release.
|
|
346
397
|
*/
|
|
347
398
|
facet?: { name: string; durable: boolean };
|
|
399
|
+
/**
|
|
400
|
+
* Bytes the process's store is filled with before it runs (its data plan).
|
|
401
|
+
* The hosting actor's storage ledger (N18) admits them under the facet's
|
|
402
|
+
* name before the facet starts: ENOSPC, and no facet, when they would cross
|
|
403
|
+
* the storage limit.
|
|
404
|
+
*/
|
|
405
|
+
storageBytes?: number;
|
|
348
406
|
}
|
|
349
407
|
|
|
350
408
|
/**
|
|
@@ -415,17 +473,14 @@ export interface ProcessImageDelivery {
|
|
|
415
473
|
* across one. A peer-hosted process can only ever receive an image
|
|
416
474
|
* through `moduleCeilingBytes` below, or by streaming it.
|
|
417
475
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* workerd
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
* destination of a DIFFERENT class had all 500 seeded files readable from the
|
|
427
|
-
* destination's CONSTRUCTOR. No compat-date gate. So calling it is a
|
|
428
|
-
* lockfile-and-types problem, not a platform one.
|
|
476
|
+
* Present in production, and since the pins moved to
|
|
477
|
+
* `@cloudflare/workers-types` 5.20260928.1 and workerd 1.20260926.1 also in
|
|
478
|
+
* the type checker and `wrangler dev` (DurableObjectFacets.clone;
|
|
479
|
+
* src/workerd/api/actor-state.h). Before that the types declared no `clone`
|
|
480
|
+
* and the pinned workerd 1.20260603.1 lacked it, while on a live Worker the
|
|
481
|
+
* binding enumerated `["abort","clone","constructor","delete","get"]` and a
|
|
482
|
+
* clone into a destination of a DIFFERENT class had all 500 seeded files
|
|
483
|
+
* readable from the destination's CONSTRUCTOR. No compat-date gate.
|
|
429
484
|
*
|
|
430
485
|
* The hazard that comes with it, measured rather than assumed: ANY `src`
|
|
431
486
|
* that does not resolve to a populated facet — a typo, a name not created
|
|
@@ -475,7 +530,7 @@ export interface OneShotCodeSpec {
|
|
|
475
530
|
compatibilityDate: string;
|
|
476
531
|
compatibilityFlags: string[];
|
|
477
532
|
mainModule: string;
|
|
478
|
-
modules: Record<string, string | { wasm: ArrayBuffer }>;
|
|
533
|
+
modules: Record<string, string | { wasm: ArrayBuffer } | { cjs: string }>;
|
|
479
534
|
}
|
|
480
535
|
|
|
481
536
|
/**
|
|
@@ -657,6 +712,8 @@ export interface ResidentProcessSpawn {
|
|
|
657
712
|
* ephemeral process, which takes a `proc-slot-<n>` name from the book.
|
|
658
713
|
*/
|
|
659
714
|
facet?: { name: string; durable: boolean };
|
|
715
|
+
/** See {@link ProcessHostParams.storageBytes}. */
|
|
716
|
+
storageBytes?: number;
|
|
660
717
|
/**
|
|
661
718
|
* Called before any concrete host capability can expose this writer.
|
|
662
719
|
* A spawn must not proceed unless the supervisor accepts the authority.
|
|
@@ -701,6 +758,7 @@ export class ProcessFabric {
|
|
|
701
758
|
writerId,
|
|
702
759
|
startArgs: spawn.startArgs,
|
|
703
760
|
...(spawn.facet !== undefined ? { facet: spawn.facet } : {}),
|
|
761
|
+
...(spawn.storageBytes !== undefined ? { storageBytes: spawn.storageBytes } : {}),
|
|
704
762
|
});
|
|
705
763
|
} catch (error) {
|
|
706
764
|
spawn.onWriterRetired(writerId);
|