@nimbus-sh/fabric 0.1.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/LICENSE +21 -0
- package/README.md +487 -0
- package/dist/alarms.d.ts +134 -0
- package/dist/alarms.d.ts.map +1 -0
- package/dist/alarms.js +214 -0
- package/dist/bindings.d.ts +316 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +678 -0
- package/dist/ctx-exports.d.ts +47 -0
- package/dist/ctx-exports.d.ts.map +1 -0
- package/dist/ctx-exports.js +54 -0
- package/dist/facet-image-store.d.ts +112 -0
- package/dist/facet-image-store.d.ts.map +1 -0
- package/dist/facet-image-store.js +181 -0
- package/dist/fanout-pool.d.ts +223 -0
- package/dist/fanout-pool.d.ts.map +1 -0
- package/dist/fanout-pool.js +368 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/inner-do-registry.d.ts +41 -0
- package/dist/inner-do-registry.d.ts.map +1 -0
- package/dist/inner-do-registry.js +51 -0
- package/dist/launch-journal.d.ts +170 -0
- package/dist/launch-journal.d.ts.map +1 -0
- package/dist/launch-journal.js +154 -0
- package/dist/launch-pacer.d.ts +173 -0
- package/dist/launch-pacer.d.ts.map +1 -0
- package/dist/launch-pacer.js +193 -0
- package/dist/loader-ledger.d.ts +57 -0
- package/dist/loader-ledger.d.ts.map +1 -0
- package/dist/loader-ledger.js +91 -0
- package/dist/loader-pool.d.ts +315 -0
- package/dist/loader-pool.d.ts.map +1 -0
- package/dist/loader-pool.js +666 -0
- package/dist/process-fabric.d.ts +524 -0
- package/dist/process-fabric.d.ts.map +1 -0
- package/dist/process-fabric.js +388 -0
- package/dist/process-host.d.ts +132 -0
- package/dist/process-host.d.ts.map +1 -0
- package/dist/process-host.js +444 -0
- package/dist/vendor/errors.d.ts +24 -0
- package/dist/vendor/errors.d.ts.map +1 -0
- package/dist/vendor/errors.js +46 -0
- package/dist/vendor/serialize.d.ts +3 -0
- package/dist/vendor/serialize.d.ts.map +1 -0
- package/dist/vendor/serialize.js +25 -0
- package/dist/vendor/types.d.ts +69 -0
- package/dist/vendor/types.d.ts.map +1 -0
- package/dist/vendor/types.js +4 -0
- package/dist/workerd-facet-host.d.ts +207 -0
- package/dist/workerd-facet-host.d.ts.map +1 -0
- package/dist/workerd-facet-host.js +508 -0
- package/dist/ws-hibernation-config.d.ts +73 -0
- package/dist/ws-hibernation-config.d.ts.map +1 -0
- package/dist/ws-hibernation-config.js +93 -0
- package/package.json +62 -0
- package/src/alarms.ts +275 -0
- package/src/bindings.ts +871 -0
- package/src/ctx-exports.ts +77 -0
- package/src/facet-image-store.ts +196 -0
- package/src/fanout-pool.ts +503 -0
- package/src/index.ts +26 -0
- package/src/inner-do-registry.ts +58 -0
- package/src/launch-journal.ts +229 -0
- package/src/launch-pacer.ts +231 -0
- package/src/loader-ledger.ts +112 -0
- package/src/loader-pool.ts +984 -0
- package/src/process-fabric.ts +729 -0
- package/src/process-host.ts +566 -0
- package/src/vendor/errors.ts +56 -0
- package/src/vendor/serialize.ts +37 -0
- package/src/vendor/types.ts +75 -0
- package/src/workerd-facet-host.ts +694 -0
- package/src/ws-hibernation-config.ts +123 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loader-pool.ts — Nimbus loader-isolate pool based on cloudflare-parallel.
|
|
3
|
+
*
|
|
4
|
+
* Adds Nimbus-specific behavior to the upstream pool design:
|
|
5
|
+
* 1. **Stable-slot isolate reuse**. Upstream's #counter++ gives every
|
|
6
|
+
* dispatch a fresh isolate — fine for one-off AI calls, terrible for
|
|
7
|
+
* running 67 npm tarball extractions (cold-start dominates). We pin
|
|
8
|
+
* each job to `slot = cursor % concurrency` and use stable loader
|
|
9
|
+
* IDs `nfp:${fnHash}:slot-${i}:g${generation}`, so a pool of
|
|
10
|
+
* concurrency=4 keeps at most 4 warm isolates rather than N fresh ones.
|
|
11
|
+
* 2. **Nimbus defaults**: compatibilityDate = CF_COMPAT_DATE (matches
|
|
12
|
+
* the supervisor worker), compatibilityFlags = ['nodejs_compat'],
|
|
13
|
+
* globalOutbound = undefined (inherit parent network so the facet can
|
|
14
|
+
* reach https://registry.npmjs.org without a proxy binding).
|
|
15
|
+
* 3. **Supervisor autoinjection**. The pool grabs the embedder's
|
|
16
|
+
* registered supervisor entrypoint stub (see `supervisorEntrypoint` in
|
|
17
|
+
* ctx-exports.ts) and forwards it as `env.SUPERVISOR` to every facet,
|
|
18
|
+
* same pattern as git-network-facet.ts. Callers can add more bindings
|
|
19
|
+
* via `extraBindings`.
|
|
20
|
+
* 4. **Fail-loud defaults**: timeout 60s, retries 0, onError 'throw'.
|
|
21
|
+
* Caller opts in to leniency.
|
|
22
|
+
*
|
|
23
|
+
* The vendored directory contains only the upstream serialization, error,
|
|
24
|
+
* and binding types used by this implementation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { CF_COMPAT_DATE } from '@nimbus-sh/core/constants.js';
|
|
28
|
+
import { supervisorEntrypoint } from './ctx-exports.js';
|
|
29
|
+
import { disposeRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
|
|
30
|
+
import { serializeFunction, hashSource } from './vendor/serialize.js';
|
|
31
|
+
import { beginLoaderFetch, recordLoaderId, withDynamicWorkerCapNamed } from './loader-ledger.js';
|
|
32
|
+
import { assertModuleMapWithinCodeLimit } from './workerd-facet-host.js';
|
|
33
|
+
import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/core/observability/oom-discriminator.js';
|
|
34
|
+
import { classifyError } from '@nimbus-sh/core/observability/oom-classify.js';
|
|
35
|
+
import {
|
|
36
|
+
BindingError,
|
|
37
|
+
ExecutionError,
|
|
38
|
+
RetryExhaustedError,
|
|
39
|
+
TimeoutError,
|
|
40
|
+
} from './vendor/errors.js';
|
|
41
|
+
import type { FacetBindings } from '@nimbus-sh/core/runtime/facet-host.js';
|
|
42
|
+
import type { ModuleContent, WorkerLoader } from './vendor/types.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A function dispatched into a facet isolate, with the bindings that facet was
|
|
46
|
+
* minted with as its second argument.
|
|
47
|
+
*
|
|
48
|
+
* Declared through a method so the bindings parameter compares BIVARIANTLY: a
|
|
49
|
+
* task body annotates the exact surface it calls (`env.SUPERVISOR` is the
|
|
50
|
+
* embedder's RPC class, which the fabric cannot name), and accepting that
|
|
51
|
+
* narrowing is the whole point of handing the bindings over.
|
|
52
|
+
*/
|
|
53
|
+
export type FacetTaskFn<A, R> = {
|
|
54
|
+
task(args: A, env: FacetBindings): R | Promise<R>;
|
|
55
|
+
}['task'];
|
|
56
|
+
|
|
57
|
+
/** The one binding a pool needs off whichever env its host hands it. */
|
|
58
|
+
export interface LoaderPoolEnv {
|
|
59
|
+
LOADER?: WorkerLoader;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Options handed to LoaderPool's constructor. */
|
|
63
|
+
export interface LoaderPoolOptions {
|
|
64
|
+
/** Maximum concurrent in-flight facets. Default 4. */
|
|
65
|
+
concurrency?: number;
|
|
66
|
+
/** Per-task timeout in ms. Default 60_000. */
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Per-task retry attempts AFTER the initial failure. Default 0.
|
|
70
|
+
* Set to a small number only if transient RPC errors are common.
|
|
71
|
+
*/
|
|
72
|
+
retries?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Additional bindings forwarded to each facet. These merge on top of the
|
|
75
|
+
* default `{ SUPERVISOR: supervisorRpc({ doId, pid:0 }) }`. Use this to
|
|
76
|
+
* give facets access to KV, R2, AI, or additional supervisor-level APIs.
|
|
77
|
+
*/
|
|
78
|
+
extraBindings?: Record<string, unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* Optional tag used in loader IDs for debugging (e.g. "npm-install").
|
|
81
|
+
* Does NOT affect isolate identity — same fn + same tag = same slot.
|
|
82
|
+
*/
|
|
83
|
+
tag?: string;
|
|
84
|
+
/**
|
|
85
|
+
* If true, omit the default SUPERVISOR binding. Use this for pools
|
|
86
|
+
* that don't need DO callbacks (e.g. a pure CPU compute pool).
|
|
87
|
+
*/
|
|
88
|
+
omitSupervisor?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Loader cache scope. Defaults to `session`, which bakes the owning DO id
|
|
91
|
+
* into the loader key so stateful facets cannot leak bindings or globals
|
|
92
|
+
* across sessions. Use `global` only for stateless compute modules that do
|
|
93
|
+
* not receive a Supervisor binding and do not retain user state.
|
|
94
|
+
*/
|
|
95
|
+
cacheScope?: 'session' | 'global';
|
|
96
|
+
/**
|
|
97
|
+
* Override the `doId` baked into the auto-injected SUPERVISOR binding.
|
|
98
|
+
* Default: `ctx.id.toString()` (the DO that constructs the pool).
|
|
99
|
+
*
|
|
100
|
+
* Used by FanoutPool's peer-DO branch (peer-DO fanout): peer DOs
|
|
101
|
+
* construct their per-task LoaderPool from inside
|
|
102
|
+
* `_rpcFanoutExecute`, where `ctx` is the PEER DO's ctx. Without this
|
|
103
|
+
* override the peer's auto-injected SUPERVISOR routes back to the
|
|
104
|
+
* peer DO itself — so writes (e.g. install-batch-facet's
|
|
105
|
+
* writeBatchStream) land in the peer's VFS instead of the
|
|
106
|
+
* COORDINATOR's. The user's terminal session is on the coordinator;
|
|
107
|
+
* writes-to-peer are invisible. See INSTALL-HONESTY-retro.md.
|
|
108
|
+
*
|
|
109
|
+
* When set, the auto-injected supervisor binding uses this string as the
|
|
110
|
+
* `props.doId`, routing all SUPERVISOR.* calls back to the
|
|
111
|
+
* coordinator. Effective only when `omitSupervisor !== true`.
|
|
112
|
+
*/
|
|
113
|
+
supervisorDoIdOverride?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Process pid baked into the auto-injected SUPERVISOR binding's props.
|
|
116
|
+
* The supervisor derives the write credential from this pid
|
|
117
|
+
* (`SupervisorRPC._pid()` → `processes.cred(pid)`), so any facet that
|
|
118
|
+
* calls a filesystem RPC (`writeBatchStream`, `writeFile`, …) must be
|
|
119
|
+
* dispatched with the invoking process's real pid — otherwise the RPC
|
|
120
|
+
* throws "missing or invalid process pid in props". npm install threads
|
|
121
|
+
* the shell command's `ctx.pid` here so package files land as the user.
|
|
122
|
+
* Left 0 (default) for pools whose facets touch only cache/registry RPCs
|
|
123
|
+
* (npm resolve, pre-bundle), which never call `_pid()`.
|
|
124
|
+
*/
|
|
125
|
+
supervisorPid?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Raw JavaScript source prepended to every generated worker module.
|
|
128
|
+
* Lets callers inject bundled helpers, such as a tar parser. The user
|
|
129
|
+
* function can reference top-level names declared in the preamble as if
|
|
130
|
+
* they were in lexical scope.
|
|
131
|
+
*
|
|
132
|
+
* Example: `preamble: 'export const parse = ...; const helper = ...;'`
|
|
133
|
+
* — the preamble runs at module-load time; any side effects happen
|
|
134
|
+
* inside the facet isolate.
|
|
135
|
+
*
|
|
136
|
+
* Preamble text is bytes-stable for a given pool — it's part of the
|
|
137
|
+
* loader-cache key (fnHash), so changing the preamble invalidates all
|
|
138
|
+
* warm slots.
|
|
139
|
+
*/
|
|
140
|
+
preamble?: string;
|
|
141
|
+
/**
|
|
142
|
+
* WebAssembly modules to ship into the facet via the LOADER's
|
|
143
|
+
* `modules` map. Map keys are module specifier paths (e.g.
|
|
144
|
+
* `'esbuild.wasm'`); values are the raw bytes.
|
|
145
|
+
*
|
|
146
|
+
* Workerd registers each entry as `{ wasm: ArrayBuffer }` in the
|
|
147
|
+
* worker's modules map. The pool prepends a static
|
|
148
|
+
* `import __NIMBUS_WASM_<id> from './<key>';` to the generated
|
|
149
|
+
* worker.js so workerd compiles each at module-load (startup phase,
|
|
150
|
+
* where wasm code generation is permitted). The compiled Modules
|
|
151
|
+
* are exposed via `globalThis.__NIMBUS_WASM[<key>]` for the user
|
|
152
|
+
* function to read at request time.
|
|
153
|
+
*
|
|
154
|
+
* Why this works when other paths don't:
|
|
155
|
+
* - request-time `WebAssembly.compile()` — disallowed by workerd
|
|
156
|
+
* in this deploy.
|
|
157
|
+
* - request-time RPC of a pre-compiled Module — workerd
|
|
158
|
+
* structured-clone refuses ("Unable to deserialize cloned data").
|
|
159
|
+
* - inlining bytes in the preamble — 16 MiB string per dispatch
|
|
160
|
+
* OOMs the supervisor at module-source allocation time.
|
|
161
|
+
* - LOADER modules-map (this) — bytes ride INSIDE the worker code
|
|
162
|
+
* blob; workerd compiles wasm during its own startup pipeline,
|
|
163
|
+
* never crossing structured-clone, never executing JS eval.
|
|
164
|
+
*
|
|
165
|
+
* The bytes ARE part of the loader-cache key (workerd hashes the
|
|
166
|
+
* whole WorkerCode), so changing the wasm bytes invalidates warm
|
|
167
|
+
* slots — desirable when the bundled wasm version changes.
|
|
168
|
+
*/
|
|
169
|
+
wasmModules?: Record<string, ArrayBuffer>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Per-call override (merged with pool defaults). */
|
|
173
|
+
export interface LoaderCallOptions {
|
|
174
|
+
timeoutMs?: number;
|
|
175
|
+
retries?: number;
|
|
176
|
+
/**
|
|
177
|
+
* Per-call WebAssembly modules. Merged with the pool's
|
|
178
|
+
* constructor-time `wasmModules` at dispatch time and shipped via
|
|
179
|
+
* the LOADER's modules map (same `{ wasm: ArrayBuffer }` shape).
|
|
180
|
+
*
|
|
181
|
+
* Shipping path validated empirically against prod (see
|
|
182
|
+
* `WebAssembly.instantiate(bytes)` is blocked at request-time but
|
|
183
|
+
* the LOADER-modules path compiles bytes during the inner
|
|
184
|
+
* worker's module-load phase, where wasm code generation IS
|
|
185
|
+
* permitted. The bytes ride INSIDE the worker code blob; workerd
|
|
186
|
+
* never crosses structured-clone, never executes user-eval.
|
|
187
|
+
*
|
|
188
|
+
* Cache key impact: per-call bytes are fingerprinted (length +
|
|
189
|
+
* first/last byte per module) and folded into the loader cache
|
|
190
|
+
* key. Identical bytes on the same slot → warm reuse; different
|
|
191
|
+
* bytes → fresh isolate. The pool's existing `wasmHash` field
|
|
192
|
+
* captures CONSTRUCTOR-time bytes only; per-call bytes get an
|
|
193
|
+
* independent fingerprint mixed into the slot id at dispatch.
|
|
194
|
+
*
|
|
195
|
+
* Naming collision rule: a per-call key MUST NOT collide with a
|
|
196
|
+
* constructor-time key (after identifier sanitisation). The
|
|
197
|
+
* dispatch path throws BindingError if it does — silently
|
|
198
|
+
* shadowing the constructor's wasm would break the cache-key
|
|
199
|
+
* invariant downstream callers rely on.
|
|
200
|
+
*
|
|
201
|
+
* Used by the `wasm-runner` shell command in src/runtime/
|
|
202
|
+
* wasm-runner.ts to ship user-supplied .wasm bytes from VFS into
|
|
203
|
+
* a fresh facet isolate per invocation.
|
|
204
|
+
*/
|
|
205
|
+
wasmModules?: Record<string, ArrayBuffer>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Per-map override. Adds onError strategy for partial failures. */
|
|
209
|
+
export interface LoaderMapOptions extends LoaderCallOptions {
|
|
210
|
+
/** Concurrency override for this call. Defaults to pool's concurrency. */
|
|
211
|
+
concurrency?: number;
|
|
212
|
+
/**
|
|
213
|
+
* What to do when an individual item fails:
|
|
214
|
+
* - 'throw' (default): reject whole map on first failure.
|
|
215
|
+
* - 'null': replace failed items with null in the result array.
|
|
216
|
+
* - 'skip': omit failed items from the result array.
|
|
217
|
+
* We default to 'throw' — install-time failures are not silently ignored.
|
|
218
|
+
*/
|
|
219
|
+
onError?: 'throw' | 'null' | 'skip';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface ResolvedResilience {
|
|
223
|
+
timeoutMs: number;
|
|
224
|
+
retries: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* esbuild runtime helpers re-declared at the top of every generated facet
|
|
229
|
+
* module. esbuild emits `__name(fn, "fn")` wrappers around every named
|
|
230
|
+
* function or arrow-with-binding-name; `fn.toString()` yields a body that
|
|
231
|
+
* references `__name` by bare identifier. The supervisor bundle declares
|
|
232
|
+
* `__name` at its own top level, but the binding does NOT cross isolate
|
|
233
|
+
* boundaries — the facet's worker.js must re-declare it.
|
|
234
|
+
*
|
|
235
|
+
* The shim is bytes-stable so it doesn't perturb the loader-cache key;
|
|
236
|
+
* if esbuild ever emits a new helper we'll see a "<name> is not defined"
|
|
237
|
+
* error in the facet, add it here, and every slot rebuilds.
|
|
238
|
+
*/
|
|
239
|
+
const ESBUILD_RUNTIME_SHIM = [
|
|
240
|
+
'const __defProp = Object.defineProperty;',
|
|
241
|
+
'const __name = (target, value) => __defProp(target, "name", { value, configurable: true });',
|
|
242
|
+
'const __nimbusDisposeRpcResult = (value) => {',
|
|
243
|
+
' if ((typeof value !== "object" && typeof value !== "function") || value === null) return;',
|
|
244
|
+
' const dispose = value[Symbol.dispose];',
|
|
245
|
+
' if (typeof dispose === "function") { try { dispose.call(value); } catch {} }',
|
|
246
|
+
'};',
|
|
247
|
+
'const __nimbusUseRpcResult = async (promise, use) => {',
|
|
248
|
+
' const value = await promise;',
|
|
249
|
+
' try { return await use(value); }',
|
|
250
|
+
' finally { __nimbusDisposeRpcResult(value); }',
|
|
251
|
+
'};',
|
|
252
|
+
].join('\n');
|
|
253
|
+
|
|
254
|
+
export interface LoaderWorkerModuleSourceOptions {
|
|
255
|
+
fnSource: string;
|
|
256
|
+
preamble?: string;
|
|
257
|
+
wasmEntries?: ReadonlyArray<{ name: string; id: string }>;
|
|
258
|
+
hasBindings: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Assemble the exact JavaScript module parsed by a dynamic loader worker. */
|
|
262
|
+
export function assembleLoaderWorkerModuleSource(
|
|
263
|
+
options: LoaderWorkerModuleSourceOptions,
|
|
264
|
+
): string {
|
|
265
|
+
const lines: string[] = [
|
|
266
|
+
'import { WorkerEntrypoint } from "cloudflare:workers";',
|
|
267
|
+
];
|
|
268
|
+
const wasmEntries = options.wasmEntries ?? [];
|
|
269
|
+
|
|
270
|
+
if (wasmEntries.length > 0) {
|
|
271
|
+
lines.push('');
|
|
272
|
+
lines.push('// ── Pool-injected WebAssembly modules ─────────────────────');
|
|
273
|
+
for (const entry of wasmEntries) {
|
|
274
|
+
lines.push(`import __NIMBUS_WASM_${entry.id} from './${entry.name}';`);
|
|
275
|
+
}
|
|
276
|
+
lines.push('globalThis.__NIMBUS_WASM = globalThis.__NIMBUS_WASM || {};');
|
|
277
|
+
for (const entry of wasmEntries) {
|
|
278
|
+
lines.push(
|
|
279
|
+
`globalThis.__NIMBUS_WASM[${JSON.stringify(entry.name)}] = __NIMBUS_WASM_${entry.id};`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
lines.push('// ── End pool-injected WebAssembly modules ─────────────────');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
lines.push(
|
|
286
|
+
'',
|
|
287
|
+
'// ── esbuild runtime shim ──────────────────────────────────',
|
|
288
|
+
'// When Nimbus is bundled by wrangler/esbuild, our facet function',
|
|
289
|
+
'// is transformed into `__name(async function …, "…")` at emit',
|
|
290
|
+
'// time. `fn.toString()` then yields the wrapped function body,',
|
|
291
|
+
'// but `__name` and its helpers are module-local in the SUPERVISOR',
|
|
292
|
+
'// bundle and do NOT cross into the facet isolate. Redeclare them',
|
|
293
|
+
'// here so facet bodies survive the toString() round-trip.',
|
|
294
|
+
ESBUILD_RUNTIME_SHIM,
|
|
295
|
+
'// ── End esbuild runtime shim ──────────────────────────────',
|
|
296
|
+
'',
|
|
297
|
+
);
|
|
298
|
+
if (options.preamble) {
|
|
299
|
+
lines.push(
|
|
300
|
+
'// ── Preamble (pool-level helpers) ─────────────────────────',
|
|
301
|
+
options.preamble,
|
|
302
|
+
'// ── End preamble ──────────────────────────────────────────',
|
|
303
|
+
'',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
lines.push(`const __fn__ = ${options.fnSource};`);
|
|
307
|
+
lines.push('');
|
|
308
|
+
const callExpr = options.hasBindings
|
|
309
|
+
? '__fn__(...args, this.env)'
|
|
310
|
+
: '__fn__(...args)';
|
|
311
|
+
lines.push(
|
|
312
|
+
'export default class extends WorkerEntrypoint {',
|
|
313
|
+
' execute(...args) {',
|
|
314
|
+
` const result = ${callExpr};`,
|
|
315
|
+
' if (result instanceof Promise) return result;',
|
|
316
|
+
' return result;',
|
|
317
|
+
' }',
|
|
318
|
+
'}',
|
|
319
|
+
);
|
|
320
|
+
return lines.join('\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Nimbus-scoped parallel dispatch over `env.LOADER`. Tasks are pure
|
|
325
|
+
* functions whose last argument is an `env` object containing the
|
|
326
|
+
* forwarded bindings (default: `{ SUPERVISOR }`).
|
|
327
|
+
*
|
|
328
|
+
* Typical use:
|
|
329
|
+
*
|
|
330
|
+
* const pool = new LoaderPool(env, ctx, {
|
|
331
|
+
* concurrency: 4,
|
|
332
|
+
* tag: 'npm-install',
|
|
333
|
+
* });
|
|
334
|
+
* const results = await pool.map(
|
|
335
|
+
* async (pkg, env) => env.SUPERVISOR.writeBatch(buildPayload(pkg)),
|
|
336
|
+
* toFetch,
|
|
337
|
+
* );
|
|
338
|
+
*/
|
|
339
|
+
export class LoaderPool {
|
|
340
|
+
private readonly loader: WorkerLoader;
|
|
341
|
+
/** The hosting actor, as the loader-ledger's per-DO key. */
|
|
342
|
+
private readonly ctx: DurableObjectState;
|
|
343
|
+
private readonly concurrency: number;
|
|
344
|
+
private readonly defaultTimeoutMs: number;
|
|
345
|
+
private readonly defaultRetries: number;
|
|
346
|
+
private readonly tag: string;
|
|
347
|
+
private readonly slotGenerations = new Map<number, number>();
|
|
348
|
+
private bindings: Record<string, unknown> | undefined;
|
|
349
|
+
|
|
350
|
+
private readonly preamble: string | undefined;
|
|
351
|
+
private readonly preambleHash: string;
|
|
352
|
+
/**
|
|
353
|
+
* WASM modules to ship in the LOADER `modules` map. See
|
|
354
|
+
* LoaderPoolOptions.wasmModules for the rationale. Stored in
|
|
355
|
+
* insertion order so the per-import preamble we generate matches
|
|
356
|
+
* across pool dispatches (cache-key stability).
|
|
357
|
+
*/
|
|
358
|
+
private readonly wasmModules: Array<{
|
|
359
|
+
/** Specifier path the worker imports from (e.g. 'esbuild.wasm'). */
|
|
360
|
+
name: string;
|
|
361
|
+
/** Identifier used inside the generated worker for both the static
|
|
362
|
+
* import binding and the globalThis exposure. Sanitised from `name`. */
|
|
363
|
+
id: string;
|
|
364
|
+
bytes: ArrayBuffer;
|
|
365
|
+
}>;
|
|
366
|
+
/** Hash of (name + byte length + first/last bytes) of every wasm
|
|
367
|
+
* module, folded into the loader cache key so changes invalidate
|
|
368
|
+
* warm slots. Hashing the FULL bytes would be O(20+ MiB) per dispatch
|
|
369
|
+
* and is unnecessary — wasm bytes are pinned at deploy time, the
|
|
370
|
+
* length+endpoints are a strong-enough fingerprint. */
|
|
371
|
+
private readonly wasmHash: string;
|
|
372
|
+
/**
|
|
373
|
+
* Short prefix of the owning DO's id, baked into the loader.get()
|
|
374
|
+
* cache key so warm isolates are scoped to ONE session. Without this,
|
|
375
|
+
* session A's pool and session B's pool (same `tag` + `fnHash`) share
|
|
376
|
+
* an isolate — which means B's writeBatch RPCs routed through A's
|
|
377
|
+
* env.SUPERVISOR binding (minted with A's doId at construction
|
|
378
|
+
* time). B's install reports success but the writes land in A's VFS,
|
|
379
|
+
* leaving B with only the git-clone seed files (~119 instead of ~1491).
|
|
380
|
+
* 12 chars is enough entropy for DO ids to collide-free per process.
|
|
381
|
+
*/
|
|
382
|
+
private readonly doIdShort: string;
|
|
383
|
+
|
|
384
|
+
constructor(
|
|
385
|
+
env: unknown,
|
|
386
|
+
ctx: DurableObjectState,
|
|
387
|
+
opts?: LoaderPoolOptions,
|
|
388
|
+
) {
|
|
389
|
+
// A host hands its whole env over; the binding is claimed here and the
|
|
390
|
+
// claim is checked on the next line.
|
|
391
|
+
const loader = (env as LoaderPoolEnv | null | undefined)?.LOADER;
|
|
392
|
+
if (!loader || typeof loader.get !== 'function') {
|
|
393
|
+
throw new BindingError(
|
|
394
|
+
'LoaderPool: env.LOADER binding missing or invalid. ' +
|
|
395
|
+
'Add a [[worker_loaders]] entry to wrangler.jsonc.',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
this.loader = loader;
|
|
399
|
+
this.ctx = ctx;
|
|
400
|
+
this.concurrency = Math.max(1, opts?.concurrency ?? 4);
|
|
401
|
+
this.defaultTimeoutMs = opts?.timeoutMs ?? 60_000;
|
|
402
|
+
this.defaultRetries = Math.max(0, opts?.retries ?? 0);
|
|
403
|
+
this.tag = opts?.tag ?? 'facet';
|
|
404
|
+
this.preamble = opts?.preamble;
|
|
405
|
+
// Include preamble in the cache-bucket key so changes to bundled helpers
|
|
406
|
+
// invalidate warm slots. Empty preamble → '0' suffix (stable).
|
|
407
|
+
this.preambleHash = this.preamble ? hashSource(this.preamble) : '0';
|
|
408
|
+
this.doIdShort = opts?.cacheScope === 'global'
|
|
409
|
+
? 'global'
|
|
410
|
+
: ctx.id.toString().slice(0, 12);
|
|
411
|
+
|
|
412
|
+
// Materialise the wasm-modules table. Sanitise each name into a
|
|
413
|
+
// valid JS identifier for the static import binding; key collisions
|
|
414
|
+
// (e.g. 'esbuild.wasm' and 'esbuild_wasm' both sanitise to
|
|
415
|
+
// 'esbuild_wasm') are rejected loudly because the generated worker
|
|
416
|
+
// would otherwise have duplicate imports. Order is preserved.
|
|
417
|
+
const wasmEntries: Array<{ name: string; id: string; bytes: ArrayBuffer }> = [];
|
|
418
|
+
const seenIds = new Set<string>();
|
|
419
|
+
if (opts?.wasmModules) {
|
|
420
|
+
for (const [name, bytes] of Object.entries(opts.wasmModules)) {
|
|
421
|
+
if (!(bytes instanceof ArrayBuffer)) {
|
|
422
|
+
// Reached only when a caller broke the declared option type, so the
|
|
423
|
+
// value is whatever it really was rather than the ArrayBuffer here.
|
|
424
|
+
const got = (bytes as { constructor?: { name?: string } } | null | undefined)?.constructor?.name;
|
|
425
|
+
throw new BindingError(
|
|
426
|
+
`LoaderPool: wasmModules['${name}'] must be ArrayBuffer ` +
|
|
427
|
+
`(got ${got || typeof bytes}).`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const id = name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^[^A-Za-z_]/, '_');
|
|
431
|
+
if (seenIds.has(id)) {
|
|
432
|
+
throw new BindingError(
|
|
433
|
+
`LoaderPool: wasmModules key '${name}' collides with another after ` +
|
|
434
|
+
`identifier-sanitisation (id='${id}'). Pick distinct module names.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
seenIds.add(id);
|
|
438
|
+
wasmEntries.push({ name, id, bytes });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.wasmModules = wasmEntries;
|
|
442
|
+
// Fingerprint: name + length + first/last byte of each module.
|
|
443
|
+
// Hashing 20+ MiB of wasm per dispatch would be wasteful; this
|
|
444
|
+
// fingerprint is bytes-stable for a given deployed bundle and only
|
|
445
|
+
// changes when the wasm itself changes (deploy-time event).
|
|
446
|
+
if (wasmEntries.length === 0) {
|
|
447
|
+
this.wasmHash = '0';
|
|
448
|
+
} else {
|
|
449
|
+
const fp = wasmEntries
|
|
450
|
+
.map((w) => {
|
|
451
|
+
const u = new Uint8Array(w.bytes);
|
|
452
|
+
const len = u.byteLength;
|
|
453
|
+
const first = len > 0 ? u[0] : 0;
|
|
454
|
+
const last = len > 0 ? u[len - 1] : 0;
|
|
455
|
+
return `${w.name}:${len}:${first}:${last}`;
|
|
456
|
+
})
|
|
457
|
+
.join('|');
|
|
458
|
+
this.wasmHash = hashSource(fp);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const bindings: Record<string, unknown> = { ...(opts?.extraBindings ?? {}) };
|
|
462
|
+
if (!opts?.omitSupervisor) {
|
|
463
|
+
const supervisorRpc = supervisorEntrypoint();
|
|
464
|
+
if (supervisorRpc) {
|
|
465
|
+
// INSTALL-HONESTY: peer-DO branch supplies coordinator's doId
|
|
466
|
+
// via supervisorDoIdOverride so SUPERVISOR.* RPCs route back
|
|
467
|
+
// to the user's session DO, not the peer DO. Default to the
|
|
468
|
+
// local ctx.id (single-DO callers and the in-DO in-DO fanout path).
|
|
469
|
+
const supDoId = opts?.supervisorDoIdOverride ?? ctx.id.toString();
|
|
470
|
+
bindings.SUPERVISOR = supervisorRpc({
|
|
471
|
+
props: { doId: supDoId, pid: opts?.supervisorPid ?? 0 },
|
|
472
|
+
});
|
|
473
|
+
} else {
|
|
474
|
+
// Supervisor entrypoint unavailable — running without ctx.exports
|
|
475
|
+
// (e.g. unit-test harness, or LOADER.load contexts where the
|
|
476
|
+
// bindings.SUPERVISOR auto-wire isn't set up). We still construct
|
|
477
|
+
// the pool but the facet will get env.SUPERVISOR === undefined.
|
|
478
|
+
// Callers that need SUPERVISOR should check availability before
|
|
479
|
+
// dispatch. (A facet that tries to call env.SUPERVISOR.writeBatch
|
|
480
|
+
// will throw a plain TypeError; that's the clearest failure mode.)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
this.bindings = Object.keys(bindings).length > 0 ? bindings : undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Effective concurrency used when no per-call override is supplied. */
|
|
487
|
+
get defaultConcurrency(): number {
|
|
488
|
+
return this.concurrency;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#resolve(opts?: LoaderCallOptions): ResolvedResilience {
|
|
492
|
+
return {
|
|
493
|
+
timeoutMs: Math.max(0, opts?.timeoutMs ?? this.defaultTimeoutMs),
|
|
494
|
+
retries: Math.max(0, opts?.retries ?? this.defaultRetries),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Materialise per-call wasm bytes into the {name,id,bytes} shape
|
|
500
|
+
* the import-build path expects. Mirrors the constructor's logic
|
|
501
|
+
* (identifier sanitisation + collision check) but ALSO rejects
|
|
502
|
+
* collisions with constructor-time entries.
|
|
503
|
+
*
|
|
504
|
+
* Returns [] when there are no per-call entries — that's the hot
|
|
505
|
+
* path (every existing pool dispatch).
|
|
506
|
+
*/
|
|
507
|
+
#materialisePerCallWasm(
|
|
508
|
+
perCall: Record<string, ArrayBuffer> | undefined,
|
|
509
|
+
): Array<{ name: string; id: string; bytes: ArrayBuffer }> {
|
|
510
|
+
if (!perCall) return [];
|
|
511
|
+
const out: Array<{ name: string; id: string; bytes: ArrayBuffer }> = [];
|
|
512
|
+
const ctorIds = new Set(this.wasmModules.map((w) => w.id));
|
|
513
|
+
const seen = new Set<string>();
|
|
514
|
+
for (const [name, bytes] of Object.entries(perCall)) {
|
|
515
|
+
if (!(bytes instanceof ArrayBuffer)) {
|
|
516
|
+
const got = (bytes as { constructor?: { name?: string } } | null | undefined)?.constructor?.name;
|
|
517
|
+
throw new BindingError(
|
|
518
|
+
`LoaderPool: per-call wasmModules['${name}'] must be ` +
|
|
519
|
+
`ArrayBuffer (got ${got || typeof bytes}).`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const id = name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^[^A-Za-z_]/, '_');
|
|
523
|
+
if (ctorIds.has(id)) {
|
|
524
|
+
throw new BindingError(
|
|
525
|
+
`LoaderPool: per-call wasmModules key '${name}' (sanitised ` +
|
|
526
|
+
`id='${id}') collides with a constructor-time wasm module. ` +
|
|
527
|
+
`Per-call modules cannot shadow pool-defaults. Pick a distinct name.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (seen.has(id)) {
|
|
531
|
+
throw new BindingError(
|
|
532
|
+
`LoaderPool: per-call wasmModules key '${name}' (sanitised ` +
|
|
533
|
+
`id='${id}') collides with another per-call key. Pick distinct names.`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
seen.add(id);
|
|
537
|
+
out.push({ name, id, bytes });
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Compute a stable fingerprint of a list of {name,bytes} entries.
|
|
544
|
+
* Returns '0' for the empty list (cache-key bytes-stable for the
|
|
545
|
+
* common no-per-call-wasm case). Same fingerprinting strategy as
|
|
546
|
+
* the constructor's `wasmHash`: name + length + first/last byte
|
|
547
|
+
* per module. Hashing all bytes would be O(20+ MiB) per dispatch
|
|
548
|
+
* for nothing — the length+endpoints fingerprint is a strong-
|
|
549
|
+
* enough discriminator and identical bytes produce identical
|
|
550
|
+
* fingerprints (warm reuse).
|
|
551
|
+
*
|
|
552
|
+
* Per-call wasm fingerprinting MUST be content-sensitive: when a user
|
|
553
|
+
* compiles two .c files (e.g. `clang a.c -o a` then `clang b.c -o b`)
|
|
554
|
+
* the resulting .wasm binaries may differ by only a few bytes deep
|
|
555
|
+
* inside the code section. The old fingerprint
|
|
556
|
+
* `name + len + first + last` collided for such cases, returning the
|
|
557
|
+
* same cache key and forcing a warm-isolate reuse that served the
|
|
558
|
+
* FIRST binary's WebAssembly.Module on the second dispatch (verified
|
|
559
|
+
* in prod: ./a and ./b were both 7572 bytes with identical first/last
|
|
560
|
+
* bytes, differing only at offset 651 — clang-state-fix wave repro).
|
|
561
|
+
*
|
|
562
|
+
* Fix: hash the ACTUAL bytes via djb2 over the full content. Per-call
|
|
563
|
+
* wasm is typically the user's compiled binary (KBs to a few MiB);
|
|
564
|
+
* djb2 of a few MiB takes microseconds on the supervisor side and
|
|
565
|
+
* runs once per dispatch (not per request — warm-reuse-on-match still
|
|
566
|
+
* works for the legitimate "same bytes" case). The savings of NOT
|
|
567
|
+
* hashing the wasm were marginal; the correctness cost was severe.
|
|
568
|
+
*/
|
|
569
|
+
#fingerprintWasm(
|
|
570
|
+
entries: Array<{ name: string; bytes: ArrayBuffer }>,
|
|
571
|
+
): string {
|
|
572
|
+
if (entries.length === 0) return '0';
|
|
573
|
+
const parts: string[] = [];
|
|
574
|
+
for (const w of entries) {
|
|
575
|
+
const u = new Uint8Array(w.bytes);
|
|
576
|
+
const len = u.byteLength;
|
|
577
|
+
// djb2 over the bytes. Faster than crypto.subtle.digest at small
|
|
578
|
+
// sizes, deterministic, and good enough for cache-key
|
|
579
|
+
// disambiguation (NOT cryptographic — the loader cache doesn't
|
|
580
|
+
// protect against malicious inputs).
|
|
581
|
+
let h = 5381;
|
|
582
|
+
for (let i = 0; i < len; i++) {
|
|
583
|
+
h = ((h << 5) + h + u[i]) | 0;
|
|
584
|
+
}
|
|
585
|
+
parts.push(`${w.name}:${len}:${(h >>> 0).toString(36)}`);
|
|
586
|
+
}
|
|
587
|
+
return hashSource(parts.join('|'));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Build the WorkerCode blob that the loader callback will return.
|
|
592
|
+
* Same bytes every time for a given function and slot, allowing workerd
|
|
593
|
+
* to reuse the isolate.
|
|
594
|
+
*
|
|
595
|
+
* Always prepends the ESBUILD_RUNTIME_SHIM so stringified functions that
|
|
596
|
+
* reference esbuild-emitted helpers (__name, __defProp, etc.) don't
|
|
597
|
+
* crash the facet with "__name is not defined". User preambles are
|
|
598
|
+
* appended below the shim.
|
|
599
|
+
*/
|
|
600
|
+
#buildCode(
|
|
601
|
+
fnSource: string,
|
|
602
|
+
perCallWasmEntries?: Array<{ name: string; id: string; bytes: ArrayBuffer }>,
|
|
603
|
+
) {
|
|
604
|
+
const workerOpts = {
|
|
605
|
+
compatibilityDate: CF_COMPAT_DATE,
|
|
606
|
+
compatibilityFlags: ['nodejs_compat'],
|
|
607
|
+
// Inherit parent network so the facet can reach registry.npmjs.org.
|
|
608
|
+
globalOutbound: undefined,
|
|
609
|
+
env: this.bindings,
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// ── WASM module imports ───────────────────────────────────────────
|
|
613
|
+
// Each entry in `wasmModules` (constructor-time + per-call) is
|
|
614
|
+
// registered in the LOADER's modules map (below) as
|
|
615
|
+
// `{ wasm: ArrayBuffer }`. workerd compiles each during the
|
|
616
|
+
// worker's module-load phase (eval permitted there) and the
|
|
617
|
+
// standard ESM import binding receives the resulting
|
|
618
|
+
// WebAssembly.Module. We expose them on `globalThis.__NIMBUS_WASM`
|
|
619
|
+
// so the user fn can read them at request time without having to
|
|
620
|
+
// re-import (the user fn is serialized via fn.toString and doesn't
|
|
621
|
+
// carry import statements).
|
|
622
|
+
//
|
|
623
|
+
// Per-call entries (passed via LoaderCallOptions.wasmModules
|
|
624
|
+
// — used by the wasm-runner shell command) are appended to the same
|
|
625
|
+
// table. Naming collision with constructor entries is rejected
|
|
626
|
+
// upstream in #materialisePerCallWasm so the import block here
|
|
627
|
+
// doesn't have to deduplicate.
|
|
628
|
+
const allWasmEntries = [
|
|
629
|
+
...this.wasmModules,
|
|
630
|
+
...(perCallWasmEntries ?? []),
|
|
631
|
+
];
|
|
632
|
+
const moduleSource = assembleLoaderWorkerModuleSource({
|
|
633
|
+
fnSource,
|
|
634
|
+
preamble: this.preamble,
|
|
635
|
+
wasmEntries: allWasmEntries,
|
|
636
|
+
hasBindings: this.bindings !== undefined,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Modules map: the entry worker.js source plus any wasm modules the
|
|
640
|
+
// pool was constructed with. Workerd parses the modules map at
|
|
641
|
+
// worker-load time and resolves the static `import` statements
|
|
642
|
+
// we generated above against this map. The wasm-shape entry
|
|
643
|
+
// (`{ wasm: ArrayBuffer }`) tells workerd to compile during the
|
|
644
|
+
// module-load phase — the only phase where wasm code generation
|
|
645
|
+
// is permitted in this deploy.
|
|
646
|
+
//
|
|
647
|
+
// Per-call entries are appended after constructor entries so the
|
|
648
|
+
// map order matches the import order in the generated worker.js
|
|
649
|
+
// (matters only for human-readable diffs; workerd doesn't care).
|
|
650
|
+
const modules: Record<string, ModuleContent> = { 'worker.js': moduleSource };
|
|
651
|
+
for (const w of allWasmEntries) {
|
|
652
|
+
modules[w.name] = { wasm: w.bytes };
|
|
653
|
+
}
|
|
654
|
+
assertModuleMapWithinCodeLimit(modules);
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
compatibilityDate: workerOpts.compatibilityDate,
|
|
658
|
+
compatibilityFlags: workerOpts.compatibilityFlags,
|
|
659
|
+
mainModule: 'worker.js',
|
|
660
|
+
modules,
|
|
661
|
+
env: workerOpts.env,
|
|
662
|
+
// globalOutbound: undefined = inherit parent network; omitting the key
|
|
663
|
+
// from the returned object has the same effect (codegen treats
|
|
664
|
+
// absence as inherit when the key is explicitly stated; here we keep
|
|
665
|
+
// it absent to match the cloudflare-parallel semantics).
|
|
666
|
+
} as const;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Dispatch a single task to the slot isolate. `slotIndex` picks which
|
|
671
|
+
* warm isolate services the call; callers round-robin slots themselves.
|
|
672
|
+
*/
|
|
673
|
+
async #dispatchSlot(
|
|
674
|
+
fnSource: string,
|
|
675
|
+
fnHash: string,
|
|
676
|
+
slotIndex: number,
|
|
677
|
+
args: unknown[],
|
|
678
|
+
resilience: ResolvedResilience,
|
|
679
|
+
perCallWasm?: Record<string, ArrayBuffer>,
|
|
680
|
+
): Promise<unknown> {
|
|
681
|
+
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
682
|
+
// with different bytes hit different slots (no cache poisoning).
|
|
683
|
+
// For the common case (no per-call wasm) the fingerprint is '0',
|
|
684
|
+
// which is bytes-stable so warm reuse is unaffected.
|
|
685
|
+
const perCallWasmEntries = this.#materialisePerCallWasm(perCallWasm);
|
|
686
|
+
const perCallWasmHash = this.#fingerprintWasm(perCallWasmEntries);
|
|
687
|
+
|
|
688
|
+
// Cache key includes the short DO id so warm isolates are scoped to
|
|
689
|
+
// ONE session. See the doIdShort field comment for why — without it,
|
|
690
|
+
// a later session's pool reuses the warm worker from a previous
|
|
691
|
+
// session (which still carries the old session's env.SUPERVISOR
|
|
692
|
+
// binding), and writeBatch RPCs land in the wrong DO's VFS.
|
|
693
|
+
const buildId = (generation: number): string =>
|
|
694
|
+
`nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:slot-${slotIndex}:g${generation}`;
|
|
695
|
+
let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
|
|
696
|
+
const code = this.#buildCode(fnSource, perCallWasmEntries);
|
|
697
|
+
|
|
698
|
+
// W5 Lever 5: record the dispatch so /api/_diag/memory shows the
|
|
699
|
+
// last-facet-id even on a hang or silent kill. Bounded — single
|
|
700
|
+
// slot updated on every dispatch.
|
|
701
|
+
try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
|
|
702
|
+
|
|
703
|
+
const runOnce = async (): Promise<unknown> => {
|
|
704
|
+
// loader.get() is synchronous from the caller's POV; the callback
|
|
705
|
+
// is only invoked on cache miss. We wrap the callback tightly so a
|
|
706
|
+
// retry doesn't rebuild workerCode — that's already stable here.
|
|
707
|
+
//
|
|
708
|
+
// The returned `stub` is the cached worker reference. We deliberately
|
|
709
|
+
// do NOT dispose it: loader.get() is designed for warm-slot reuse
|
|
710
|
+
// across dispatches (same `id` returns the same cached worker),
|
|
711
|
+
// and disposing would invalidate that cache.
|
|
712
|
+
//
|
|
713
|
+
// We USED to also dispose the per-dispatch `entrypoint` stub in a
|
|
714
|
+
// finally block here (added in 3c47b44 to prevent QueueState::ACTIVE
|
|
715
|
+
// during cold-start install). Empirically that broke dispatch on
|
|
716
|
+
// every slot after the first: once the finally ran
|
|
717
|
+
// `entrypoint[Symbol.dispose]()`, subsequent dispatches on the
|
|
718
|
+
// same cached slot hung — SupervisorRPC.writeBatch logged
|
|
719
|
+
// 'canceled', the DO-side _rpcWriteBatch completed OK, but the
|
|
720
|
+
// pool never saw the result and every task stalled to the 60s
|
|
721
|
+
// per-task timeout at 0/13 packages. Removing the per-dispatch
|
|
722
|
+
// dispose restored 13/13 in ~2.3s in dev.
|
|
723
|
+
//
|
|
724
|
+
// The pool-level dispose() method below (also from 3c47b44) is
|
|
725
|
+
// fine and stays — it only tears down the long-lived SUPERVISOR
|
|
726
|
+
// binding stub once the whole pool is done, which does NOT
|
|
727
|
+
// invalidate any in-flight slot's entrypoint reference.
|
|
728
|
+
const stub = this.loader.get(id, async () => code);
|
|
729
|
+
recordLoaderId(this.ctx, id);
|
|
730
|
+
const entrypoint = stub.getEntrypoint();
|
|
731
|
+
// Direct property call, awaited by this frame — bracketed, never
|
|
732
|
+
// wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
|
|
733
|
+
const endFetch = beginLoaderFetch(this.ctx);
|
|
734
|
+
try {
|
|
735
|
+
const out = await entrypoint.execute(...args);
|
|
736
|
+
return out;
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (err instanceof Error) {
|
|
739
|
+
throw new ExecutionError(err.message, err.stack);
|
|
740
|
+
}
|
|
741
|
+
throw new ExecutionError(String(err));
|
|
742
|
+
} finally {
|
|
743
|
+
endFetch();
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const maxAttempts = 1 + resilience.retries;
|
|
748
|
+
let lastError: Error | undefined;
|
|
749
|
+
let retriedCloneRefusal = false;
|
|
750
|
+
let attempt = 0;
|
|
751
|
+
while (attempt < maxAttempts) {
|
|
752
|
+
try {
|
|
753
|
+
if (resilience.timeoutMs > 0) {
|
|
754
|
+
// Race runOnce() against a settable timer. CRITICAL: clear
|
|
755
|
+
// the timer in a finally so the timer's reject closure (which
|
|
756
|
+
// transitively roots `args` — i.e. the per-task payload sent
|
|
757
|
+
// to the slot, including 28 MiB pre-bundle slices) doesn't
|
|
758
|
+
// hold its references for the full timeoutMs after the race
|
|
759
|
+
// settles.
|
|
760
|
+
//
|
|
761
|
+
// Before this fix: a facet OOM at t=0 left the slice rooted
|
|
762
|
+
// for the remaining timeoutMs (default 60s for pre-bundle).
|
|
763
|
+
// With concurrency=2, two consecutive OOMs could pin
|
|
764
|
+
// ~56 MiB of slice memory in the supervisor heap for a full
|
|
765
|
+
// minute — alongside an in-flight cirrus-real boot, that's
|
|
766
|
+
// enough to push a shared isolate over the 128 MiB cap.
|
|
767
|
+
// See plan in close-plan-2026-04-28.
|
|
768
|
+
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
769
|
+
try {
|
|
770
|
+
return await Promise.race([
|
|
771
|
+
runOnce(),
|
|
772
|
+
new Promise<never>((_, reject) => {
|
|
773
|
+
timerId = setTimeout(
|
|
774
|
+
() => reject(new TimeoutError(resilience.timeoutMs)),
|
|
775
|
+
resilience.timeoutMs,
|
|
776
|
+
);
|
|
777
|
+
}),
|
|
778
|
+
]);
|
|
779
|
+
} finally {
|
|
780
|
+
if (timerId !== undefined) clearTimeout(timerId);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return await runOnce();
|
|
784
|
+
} catch (err) {
|
|
785
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
786
|
+
const cause = classifyError(lastError);
|
|
787
|
+
// W5 Lever 5: classify + record. We push on EVERY failed
|
|
788
|
+
// attempt (not just the final retry-exhausted throw) so the
|
|
789
|
+
// ring captures transient SQLITE_NOMEM / clone-refused
|
|
790
|
+
// patterns that still ultimately succeed. Ring is bounded
|
|
791
|
+
// (50 entries) so noise is self-limiting.
|
|
792
|
+
try {
|
|
793
|
+
recordFailure({
|
|
794
|
+
at: Date.now(),
|
|
795
|
+
phase: 'rpc',
|
|
796
|
+
cause,
|
|
797
|
+
rssEstimateBytes: 0, heapUsedBytes: 0,
|
|
798
|
+
lruBytes: 0, inFlightBytes: 0,
|
|
799
|
+
lastRpcFrame: getLastRpcFrame(),
|
|
800
|
+
lastFacetId: { codeId: id, slotIndex, atMs: Date.now() },
|
|
801
|
+
message: lastError.message,
|
|
802
|
+
});
|
|
803
|
+
} catch { /* fail-soft */ }
|
|
804
|
+
if (cause === 'clone_refused' && !retriedCloneRefusal) {
|
|
805
|
+
retriedCloneRefusal = true;
|
|
806
|
+
const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
|
|
807
|
+
this.slotGenerations.set(slotIndex, generation);
|
|
808
|
+
id = buildId(generation);
|
|
809
|
+
try { setLastFacetId(id, slotIndex); } catch { /* best-effort */ }
|
|
810
|
+
// If the supervisor DO is stale, a newer loader still cannot
|
|
811
|
+
// deserialize back into it; only recycling that DO heals the
|
|
812
|
+
// reverse direction. This refresh targets the stale-loader case.
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (attempt < maxAttempts - 1) {
|
|
816
|
+
// 100 * 2^attempt, capped at 2s so retries don't compound waiting.
|
|
817
|
+
const delay = Math.min(2000, 100 * Math.pow(2, attempt));
|
|
818
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
819
|
+
}
|
|
820
|
+
attempt++;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// On the way out only, so retries do not stack the annotation: a cap hit
|
|
824
|
+
// carries the ledger — which ids hold slots, and that a keyed id never
|
|
825
|
+
// gives one back — instead of the platform's bare message.
|
|
826
|
+
const named = withDynamicWorkerCapNamed(this.ctx, lastError!);
|
|
827
|
+
if (maxAttempts > 1) {
|
|
828
|
+
throw new RetryExhaustedError(maxAttempts, named);
|
|
829
|
+
}
|
|
830
|
+
throw named;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#prepare(fn: Function): { fnSource: string; fnHash: string } {
|
|
834
|
+
const fnSource = serializeFunction(fn);
|
|
835
|
+
const fnHash = hashSource(fnSource);
|
|
836
|
+
return { fnSource, fnHash };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Run `fn` once with `arg` on a slot isolate. Returns the result or
|
|
841
|
+
* throws TimeoutError / RetryExhaustedError / ExecutionError.
|
|
842
|
+
*/
|
|
843
|
+
async submit<T, R>(
|
|
844
|
+
fn: FacetTaskFn<T, R>,
|
|
845
|
+
arg: T,
|
|
846
|
+
opts?: LoaderCallOptions,
|
|
847
|
+
): Promise<Awaited<R>> {
|
|
848
|
+
const { fnSource, fnHash } = this.#prepare(fn);
|
|
849
|
+
const resilience = this.#resolve(opts);
|
|
850
|
+
return (await this.#dispatchSlot(
|
|
851
|
+
fnSource,
|
|
852
|
+
fnHash,
|
|
853
|
+
0,
|
|
854
|
+
[arg],
|
|
855
|
+
resilience,
|
|
856
|
+
opts?.wasmModules,
|
|
857
|
+
)) as Awaited<R>;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Run `fn` on every item in `items`, at most `concurrency` at a time,
|
|
862
|
+
* pinned to stable slots so warm isolates are reused.
|
|
863
|
+
*
|
|
864
|
+
* Results are returned in input order. Failure handling per `onError`.
|
|
865
|
+
*/
|
|
866
|
+
async map<T, R>(
|
|
867
|
+
fn: FacetTaskFn<T, R>,
|
|
868
|
+
items: T[],
|
|
869
|
+
opts?: LoaderMapOptions,
|
|
870
|
+
): Promise<Array<Awaited<R> | null>> {
|
|
871
|
+
if (items.length === 0) return [];
|
|
872
|
+
|
|
873
|
+
const { fnSource, fnHash } = this.#prepare(fn);
|
|
874
|
+
return this.#mapInternal<T, R>(fnSource, fnHash, items, opts);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Same shape as `map`, but accepts a pre-serialized function source
|
|
879
|
+
* string instead of a live function reference. Used by
|
|
880
|
+
* `FanoutPool`'s peer-DO leg, where the function was already
|
|
881
|
+
* serialized on the coordinator side and forwarded over RPC.
|
|
882
|
+
*
|
|
883
|
+
* The fnSource MUST be the output of `serializeFunction(fn)`
|
|
884
|
+
* (typically forwarded directly from a coordinator RPC). Bytes-
|
|
885
|
+
* stable invariants:
|
|
886
|
+
* - `fnHash = hashSource(fnSource)` must be deterministic so
|
|
887
|
+
* warm slots are correctly keyed.
|
|
888
|
+
* - `fnSource` must NOT reference `this` — same rule as
|
|
889
|
+
* `serializeFunction`.
|
|
890
|
+
*
|
|
891
|
+
* No fn-validation runs here (it already ran on the coordinator);
|
|
892
|
+
* the peer trusts the caller to forward a valid serialization.
|
|
893
|
+
*/
|
|
894
|
+
async mapSource<T, R>(
|
|
895
|
+
fnSource: string,
|
|
896
|
+
items: T[],
|
|
897
|
+
opts?: LoaderMapOptions,
|
|
898
|
+
): Promise<Array<Awaited<R> | null>> {
|
|
899
|
+
if (items.length === 0) return [];
|
|
900
|
+
const fnHash = hashSource(fnSource);
|
|
901
|
+
return this.#mapInternal<T, R>(fnSource, fnHash, items, opts);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async #mapInternal<T, R>(
|
|
905
|
+
fnSource: string,
|
|
906
|
+
fnHash: string,
|
|
907
|
+
items: T[],
|
|
908
|
+
opts?: LoaderMapOptions,
|
|
909
|
+
): Promise<Array<Awaited<R> | null>> {
|
|
910
|
+
const resilience = this.#resolve(opts);
|
|
911
|
+
const concurrency = Math.max(
|
|
912
|
+
1,
|
|
913
|
+
Math.min(opts?.concurrency ?? this.concurrency, items.length),
|
|
914
|
+
);
|
|
915
|
+
const onError: 'throw' | 'null' | 'skip' = opts?.onError ?? 'throw';
|
|
916
|
+
|
|
917
|
+
type Settled =
|
|
918
|
+
| { ok: true; value: Awaited<R> }
|
|
919
|
+
| { ok: false; error: Error };
|
|
920
|
+
const settled: Settled[] = new Array(items.length);
|
|
921
|
+
let cursor = 0;
|
|
922
|
+
|
|
923
|
+
const runSlot = async (slotIndex: number): Promise<void> => {
|
|
924
|
+
while (true) {
|
|
925
|
+
const idx = cursor++;
|
|
926
|
+
if (idx >= items.length) return;
|
|
927
|
+
try {
|
|
928
|
+
const value = (await this.#dispatchSlot(
|
|
929
|
+
fnSource,
|
|
930
|
+
fnHash,
|
|
931
|
+
slotIndex,
|
|
932
|
+
[items[idx]],
|
|
933
|
+
resilience,
|
|
934
|
+
opts?.wasmModules,
|
|
935
|
+
)) as Awaited<R>;
|
|
936
|
+
settled[idx] = { ok: true, value };
|
|
937
|
+
} catch (err) {
|
|
938
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
939
|
+
if (onError === 'throw') throw error;
|
|
940
|
+
settled[idx] = { ok: false, error };
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
await Promise.all(
|
|
946
|
+
Array.from({ length: concurrency }, (_, slotIndex) => runSlot(slotIndex)),
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
if (onError === 'null') {
|
|
950
|
+
return settled.map((s) => (s.ok ? s.value : null));
|
|
951
|
+
}
|
|
952
|
+
if (onError === 'skip') {
|
|
953
|
+
return settled
|
|
954
|
+
.filter((s): s is { ok: true; value: Awaited<R> } => s.ok)
|
|
955
|
+
.map((s) => s.value);
|
|
956
|
+
}
|
|
957
|
+
// onError === 'throw' — all slots succeeded.
|
|
958
|
+
return settled.map((s) => (s as { ok: true; value: Awaited<R> }).value);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Release any RPC stubs held by the pool. Call this once the caller
|
|
963
|
+
* is done with the pool (post-`map`/`submit`) so the underlying
|
|
964
|
+
* stubs don't linger in workerd's deferred-destruction queue.
|
|
965
|
+
*
|
|
966
|
+
* Primary target: the SUPERVISOR binding stub we minted at
|
|
967
|
+
* construction time (via the registered supervisor entrypoint). It's
|
|
968
|
+
* a cross-isolate RPC stub — without explicit disposal it stays
|
|
969
|
+
* referenced until the parent isolate's event-handler context
|
|
970
|
+
* finishes, which during npm install means "until the whole install
|
|
971
|
+
* completes" — long enough to accumulate alongside other leaked
|
|
972
|
+
* stubs and trip the QueueState::ACTIVE fatal.
|
|
973
|
+
*
|
|
974
|
+
* Safe to call more than once; idempotent.
|
|
975
|
+
*/
|
|
976
|
+
dispose(): void {
|
|
977
|
+
if (!this.bindings) return;
|
|
978
|
+
for (const key of Object.keys(this.bindings)) {
|
|
979
|
+
disposeRpcResource(this.bindings[key]);
|
|
980
|
+
}
|
|
981
|
+
// Prevent double-dispose from re-running the loop.
|
|
982
|
+
this.bindings = undefined;
|
|
983
|
+
}
|
|
984
|
+
}
|