@nimbus-sh/worker 0.5.0 → 0.7.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 +2 -2
- package/dist/_shared/preview-host.d.ts +26 -8
- package/dist/_shared/preview-host.d.ts.map +1 -1
- package/dist/_shared/preview-host.js +51 -11
- package/dist/cirrus-npm-cjs.generated.d.ts +1 -1
- package/dist/cirrus-npm-cjs.generated.js +1 -1
- package/dist/facets/cirrus-real.d.ts.map +1 -1
- package/dist/facets/cirrus-real.js +6 -5
- package/dist/facets/compose.d.ts +148 -0
- package/dist/facets/compose.d.ts.map +1 -0
- package/dist/facets/compose.js +146 -0
- package/dist/facets/durable-images.d.ts +18 -5
- package/dist/facets/durable-images.d.ts.map +1 -1
- package/dist/facets/durable-images.js +36 -7
- package/dist/facets/durable-slots.d.ts +1 -1
- package/dist/facets/durable-slots.d.ts.map +1 -1
- package/dist/facets/durable-slots.js +2 -1
- package/dist/facets/esbuild-bundle-pool.d.ts +49 -0
- package/dist/facets/esbuild-bundle-pool.d.ts.map +1 -0
- package/dist/facets/esbuild-bundle-pool.js +122 -0
- package/dist/facets/exec-telemetry.d.ts +8 -0
- package/dist/facets/exec-telemetry.d.ts.map +1 -1
- package/dist/facets/manager.d.ts +337 -123
- package/dist/facets/manager.d.ts.map +1 -1
- package/dist/facets/manager.js +1100 -449
- package/dist/facets/process.d.ts +20 -16
- package/dist/facets/process.d.ts.map +1 -1
- package/dist/facets/process.js +59 -28
- package/dist/facets/real-vite-fs-shim.js +2 -2
- package/dist/facets/resident-identity.d.ts +23 -0
- package/dist/facets/resident-identity.d.ts.map +1 -0
- package/dist/facets/resident-identity.js +31 -0
- package/dist/facets/vite-dev-server.d.ts +25 -47
- package/dist/facets/vite-dev-server.d.ts.map +1 -1
- package/dist/facets/vite-dev-server.js +185 -219
- package/dist/facets/wasm-image-digest.d.ts +25 -0
- package/dist/facets/wasm-image-digest.d.ts.map +1 -0
- package/dist/facets/wasm-image-digest.js +25 -0
- package/dist/facets/wasm-swap-registry.d.ts +51 -92
- package/dist/facets/wasm-swap-registry.d.ts.map +1 -1
- package/dist/facets/wasm-swap-registry.js +68 -227
- package/dist/git/commands.d.ts +0 -1
- package/dist/git/commands.d.ts.map +1 -1
- package/dist/git/commands.js +0 -3
- package/dist/git/network-facet.d.ts.map +1 -1
- package/dist/git/network-facet.js +6 -5
- package/dist/loaders/child-process/spawn-pool.d.ts +3 -2
- package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -1
- package/dist/loaders/child-process/spawn-pool.js +3 -2
- package/dist/loaders/generated-workers.d.ts +2 -2
- package/dist/loaders/generated-workers.d.ts.map +1 -1
- package/dist/loaders/generated-workers.js +3 -3
- package/dist/loaders/npm-resolve-preamble.d.ts +3 -6
- package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -1
- package/dist/loaders/npm-resolve-preamble.js +34 -108
- package/dist/node-shims-artifact.generated.js +3 -3
- package/dist/npm/install-batch-facet.d.ts +3 -2
- package/dist/npm/install-batch-facet.d.ts.map +1 -1
- package/dist/npm/install-batch-facet.js +45 -15
- package/dist/npm/installer.d.ts +22 -17
- package/dist/npm/installer.d.ts.map +1 -1
- package/dist/npm/installer.js +209 -193
- package/dist/npm/pre-bundle-facet.d.ts +1 -1
- package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
- package/dist/npm/pre-bundle-facet.js +1 -1
- package/dist/npm/resolve-facet.d.ts +6 -0
- package/dist/npm/resolve-facet.d.ts.map +1 -1
- package/dist/npm/resolve-one-facet.d.ts +21 -20
- package/dist/npm/resolve-one-facet.d.ts.map +1 -1
- package/dist/npm/resolve-one-facet.js +85 -84
- package/dist/npm/semver.d.ts +59 -0
- package/dist/npm/semver.d.ts.map +1 -0
- package/dist/npm/semver.js +226 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +47 -10
- package/dist/router/public-directory.d.ts +5 -1
- package/dist/router/public-directory.d.ts.map +1 -1
- package/dist/router/public-directory.js +9 -2
- package/dist/router/remote-api.d.ts +18 -0
- package/dist/router/remote-api.d.ts.map +1 -1
- package/dist/router/remote-api.js +64 -5
- package/dist/runtime/cpython-resident.d.ts.map +1 -1
- package/dist/runtime/cpython-resident.js +3 -1
- package/dist/runtime/html-entrypoint.d.ts +7 -0
- package/dist/runtime/html-entrypoint.d.ts.map +1 -1
- package/dist/runtime/html-entrypoint.js +18 -2
- package/dist/runtime/node-runner.d.ts +3 -0
- package/dist/runtime/node-runner.d.ts.map +1 -1
- package/dist/runtime/node-runner.js +7 -0
- package/dist/runtime/node-shims.d.ts.map +1 -1
- package/dist/runtime/node-shims.js +305 -29
- package/dist/runtime/opencode-facet-runner.js +2 -2
- package/dist/runtime/package-manager.d.ts +44 -1
- package/dist/runtime/package-manager.d.ts.map +1 -1
- package/dist/runtime/package-manager.js +168 -3
- package/dist/runtime/ruby-resident.d.ts.map +1 -1
- package/dist/runtime/ruby-resident.js +2 -0
- package/dist/session/agent.d.ts.map +1 -1
- package/dist/session/agent.js +28 -1
- package/dist/session/helpers.d.ts +38 -0
- package/dist/session/helpers.d.ts.map +1 -1
- package/dist/session/helpers.js +76 -0
- package/dist/session/init-phases.d.ts +1 -1
- package/dist/session/init-phases.d.ts.map +1 -1
- package/dist/session/init-phases.js +2 -12
- package/dist/session/init.d.ts +15 -1
- package/dist/session/init.d.ts.map +1 -1
- package/dist/session/init.js +91 -603
- package/dist/session/keys.d.ts +4 -0
- package/dist/session/keys.d.ts.map +1 -1
- package/dist/session/keys.js +4 -0
- package/dist/session/nimbus-session.d.ts +90 -23
- package/dist/session/nimbus-session.d.ts.map +1 -1
- package/dist/session/nimbus-session.js +200 -110
- package/dist/session/npm-install-port.d.ts +16 -0
- package/dist/session/npm-install-port.d.ts.map +1 -0
- package/dist/session/npm-install-port.js +37 -0
- package/dist/session/port-capability.d.ts +90 -0
- package/dist/session/port-capability.d.ts.map +1 -1
- package/dist/session/port-capability.js +219 -9
- package/dist/session/programmatic.d.ts +125 -5
- package/dist/session/programmatic.d.ts.map +1 -1
- package/dist/session/programmatic.js +439 -89
- package/dist/session/routes.d.ts +39 -17
- package/dist/session/routes.d.ts.map +1 -1
- package/dist/session/routes.js +67 -131
- package/dist/session/rpc.d.ts +39 -16
- package/dist/session/rpc.d.ts.map +1 -1
- package/dist/session/rpc.js +137 -130
- package/dist/session/serving-port.d.ts +27 -0
- package/dist/session/serving-port.d.ts.map +1 -0
- package/dist/session/serving-port.js +27 -0
- package/dist/session/start-real-vite.d.ts +12 -0
- package/dist/session/start-real-vite.d.ts.map +1 -1
- package/dist/session/start-real-vite.js +4 -4
- package/dist/session/supervisor-op.d.ts +41 -4
- package/dist/session/supervisor-op.d.ts.map +1 -1
- package/dist/session/supervisor-op.js +101 -73
- package/dist/session/supervisor-rpc.d.ts +7 -7
- package/dist/session/supervisor-rpc.d.ts.map +1 -1
- package/dist/session/supervisor-rpc.js +6 -3
- package/dist/session/vite-command.d.ts +18 -0
- package/dist/session/vite-command.d.ts.map +1 -0
- package/dist/session/vite-command.js +609 -0
- package/dist/session/ws.d.ts +61 -0
- package/dist/session/ws.d.ts.map +1 -1
- package/dist/session/ws.js +122 -12
- package/dist/shell/npm-bin-entrypoints.d.ts +0 -35
- package/dist/shell/npm-bin-entrypoints.d.ts.map +1 -1
- package/dist/shell/npm-bin-entrypoints.js +12 -85
- package/package.json +24 -4
- package/public/_assets/runtime/{node-shims-9e1380d9ee430fcc.js → node-shims-8f8c64edd2a1f0f5.js} +305 -29
- package/public/index.html +1 -1
- package/scripts/bundle-facet-workers.mjs +1 -1
- package/scripts/bundle-npm-cjs.mjs +2 -2
- package/dist/facets/on-demand-bundle-gate.d.ts +0 -65
- package/dist/facets/on-demand-bundle-gate.d.ts.map +0 -1
- package/dist/facets/on-demand-bundle-gate.js +0 -125
- package/dist/npm/install-args.d.ts +0 -21
- package/dist/npm/install-args.d.ts.map +0 -1
- package/dist/npm/install-args.js +0 -70
- package/dist/npm/npm-log.d.ts +0 -31
- package/dist/npm/npm-log.d.ts.map +0 -1
- package/dist/npm/npm-log.js +0 -51
package/dist/facets/manager.js
CHANGED
|
@@ -21,11 +21,14 @@ import { getRealNodeImportsCode } from '@nimbus-sh/core/_shared/real-node-import
|
|
|
21
21
|
import { FACET_RESIDENT_STORE_SOURCE } from '../vfs/facet-resident-store.js';
|
|
22
22
|
import { VFS_CURSOR_SEED_SOURCE, serializeFacetVfsCursor, } from '@nimbus-sh/core/_shared/facet-vfs-cursor.js';
|
|
23
23
|
import { VFS_WRITE_LEDGER_SOURCE } from '@nimbus-sh/core/_shared/vfs-write-ledger.js';
|
|
24
|
-
import { vfsPathExtension } from '@nimbus-sh/core/vfs/path.js';
|
|
25
|
-
import { clearPortCapability, readPortReservation, releasePortReservation, restoreReservedPortCapability } from '../session/port-capability.js';
|
|
24
|
+
import { stripLeadingSlashes, vfsPathExtension } from '@nimbus-sh/core/vfs/path.js';
|
|
25
|
+
import { clearPortCapability, listPortReservations, readPortReservation, readPortReservationByOwner, releasePortReservation, restoreReservedPortCapability, } from '../session/port-capability.js';
|
|
26
|
+
import { deriveResidentOwner } from './resident-identity.js';
|
|
27
|
+
import { z } from 'zod/v4';
|
|
28
|
+
import { RESIDENT_OWNER_KEY_PREFIX, DURABLE_IMAGES_KEY_PREFIX } from '../session/keys.js';
|
|
26
29
|
import { PORT_CAPABILITY_KEY_PREFIX } from '../session/keys.js';
|
|
27
30
|
import { unbindPublicPortCapability } from '../router/public-directory.js';
|
|
28
|
-
import { prefetchForRequire } from '@nimbus-sh/core/runtime/require-resolver.js';
|
|
31
|
+
import { prefetchForRequire, ClosureBoundExceededError } from '@nimbus-sh/core/runtime/require-resolver.js';
|
|
29
32
|
import { hasTopLevelModuleSyntax } from '@nimbus-sh/core/runtime/javascript-ast.js';
|
|
30
33
|
import { bindImportMetaResolve, importMetaDefines } from '@nimbus-sh/core/runtime/import-meta-transform.js';
|
|
31
34
|
import { recordFailure, getLastRpcFrame, getLastFacetId } from '@nimbus-sh/platform/oom-discriminator.js';
|
|
@@ -41,14 +44,15 @@ import { ImageStore, } from '@nimbus-sh/fabric/image-store.js';
|
|
|
41
44
|
import { ProcessFabric, } from '@nimbus-sh/fabric/process-fabric.js';
|
|
42
45
|
import { createLoadedWorkerEntrypoint, getNimbusCtxExports, deleteFacetStorage, } from '@nimbus-sh/fabric/workerd-facet-host.js';
|
|
43
46
|
import { acquireDurableFacetSlot, freeDurableFacetSlot, } from './durable-slots.js';
|
|
44
|
-
import { persistDurableWorkerImage, } from './durable-images.js';
|
|
47
|
+
import { persistDurableWorkerImage, purgeDurableWorkerImages, } from './durable-images.js';
|
|
45
48
|
import { SQLITE_WASM_MODULE_NAME, } from '../runtime/opencode-facet-runner.js';
|
|
46
49
|
import { parsePortFromArgv, resolveLongRunningPort } from '@nimbus-sh/core/runtime/long-running-handle.js';
|
|
47
50
|
import { DEFAULT_FACET_BUNDLE_PROFILE, } from '@nimbus-sh/core/runtime/bundle-profile.js';
|
|
48
|
-
import {
|
|
51
|
+
import { CF_COMPAT_DATE, VFS_BUNDLE_MAX_FILES, VFS_BUNDLE_MAX_BYTES, CWD_SNAPSHOT_MAX_FILE_BYTES, BUNDLE_MAX_ENCODED_BYTES, PREFETCH_CACHE_MAX_BYTES, } from '@nimbus-sh/core/constants.js';
|
|
49
52
|
import { MAX_RPC_SAFE_PAYLOAD_BYTES } from '@nimbus-sh/platform/limits.js';
|
|
50
53
|
import { CRED_KERNEL } from '@nimbus-sh/core/runtime/os-contracts.js';
|
|
51
54
|
import { acquireSupervisorAllocation } from '@nimbus-sh/platform/heavy-alloc-coord.js';
|
|
55
|
+
import { wasmImageDigest } from './wasm-image-digest.js';
|
|
52
56
|
import { prefetchBundleStart, prefetchBundleEnd, setPrefetchCacheBytes, } from '@nimbus-sh/platform/diag-counters.js';
|
|
53
57
|
const ISOLATED_ESM_TRANSFORM_MIN_BYTES = 512 * 1024;
|
|
54
58
|
/**
|
|
@@ -128,34 +132,6 @@ function parseFacetManagerEnv(env) {
|
|
|
128
132
|
rawEnv: env,
|
|
129
133
|
};
|
|
130
134
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Reserve held back from a one-shot facet's lifetime so a program that runs
|
|
133
|
-
* out of time is still alive to say so.
|
|
134
|
-
*
|
|
135
|
-
* A one-shot exec is ALREADY bounded: `_execWithTimeout` kills it at
|
|
136
|
-
* FACET_TIMEOUT_MS with exit 124 and "[process killed: timeout after 30s]".
|
|
137
|
-
* The entry drain must therefore not be a second, tighter, independent
|
|
138
|
-
* timeout. Measured against a deployed Worker, floating async work of 5s /
|
|
139
|
-
* 15s / 25s completes and 40s is killed by that outer bound at exactly 30s —
|
|
140
|
-
* so the fixed 8s budget this used to carry was abandoning programs 22
|
|
141
|
-
* seconds before anything actually required it.
|
|
142
|
-
*
|
|
143
|
-
* The drain therefore runs to the outer bound MINUS this reserve, which is
|
|
144
|
-
* what buys the facet time to flush and report the honest "still in flight"
|
|
145
|
-
* reason instead of the supervisor's generic kill. The reserve has to cover
|
|
146
|
-
* the longest tail a facet can have after the drain: settling pending RPC,
|
|
147
|
-
* writing back __vfsWrites (bounded by MAX_RPC_SAFE_PAYLOAD_BYTES; a 20 MiB
|
|
148
|
-
* write-back measures ~1.5s), draining children, then reportExit.
|
|
149
|
-
*/
|
|
150
|
-
export const ONE_SHOT_EXIT_RESERVE_MS = 3_000;
|
|
151
|
-
/**
|
|
152
|
-
* Budget used when the supervisor did not stamp an absolute deadline on the
|
|
153
|
-
* payload. The deadline is the real bound — see `entryDeadlineAt` — because
|
|
154
|
-
* it is measured from the supervisor's own timer rather than restarted when
|
|
155
|
-
* the drain begins, so a slow module init cannot push the drain past the kill
|
|
156
|
-
* and lose the honest message.
|
|
157
|
-
*/
|
|
158
|
-
export const ONE_SHOT_ENTRY_DEADLINE_MS = FACET_TIMEOUT_MS - ONE_SHOT_EXIT_RESERVE_MS;
|
|
159
135
|
/**
|
|
160
136
|
* How long a RESIDENT facet settles its startup before answering its boot
|
|
161
137
|
* call. It keeps running afterwards, so this is not a lifetime decision: the
|
|
@@ -171,8 +147,11 @@ export const RESIDENT_BOOT_SETTLE_MS = 1000;
|
|
|
171
147
|
* servers, requests in flight. A promise is not a handle: a program whose
|
|
172
148
|
* last act leaves `new Promise(() => {})` unsettled prints its output and
|
|
173
149
|
* exits 0. Counting unsettled promises as work was a real divergence from
|
|
174
|
-
* that — such a program
|
|
175
|
-
* reported as having not finished.
|
|
150
|
+
* that — such a program's drain used to end early and it was
|
|
151
|
+
* reported as having not finished. A user-invoked program runs this loop
|
|
152
|
+
* with NO deadline — Node itself has no wall-clock kill; the only endings
|
|
153
|
+
* are the program's exit and a signal. Callers that still pass a finite
|
|
154
|
+
* deadline (the resident boot settle) arm the expiry timer.
|
|
176
155
|
*
|
|
177
156
|
* Three kinds of handle, each owned by the shim that creates them:
|
|
178
157
|
*
|
|
@@ -185,13 +164,6 @@ export const RESIDENT_BOOT_SETTLE_MS = 1000;
|
|
|
185
164
|
* - listening SERVERS (`__portRegistry`), open until the program closes
|
|
186
165
|
* them.
|
|
187
166
|
*
|
|
188
|
-
* The bound is a REAL wall-clock deadline, armed as a timer rather than
|
|
189
|
-
* compared against `Date.now()`: a `setTimeout(0)` turn in workerd costs
|
|
190
|
-
* ~5µs, so the pass budget this loop used to carry (50k) expired after
|
|
191
|
-
* ~150ms and silently overrode every longer deadline the callers declared —
|
|
192
|
-
* anything slower than that, including an ordinary network fetch, was
|
|
193
|
-
* abandoned mid-flight and reported as a clean exit.
|
|
194
|
-
*
|
|
195
167
|
* The loop subscribes to the exit promise ONCE — a per-pass
|
|
196
168
|
* `exitPromise.then()` allocates a promise every iteration — and yields
|
|
197
169
|
* through the raw setTimeout so its own ticks don't inflate the timer count
|
|
@@ -228,16 +200,21 @@ async function __nimbusRunEventLoop(__countHandles, __exitPromise, __deadlineMs,
|
|
|
228
200
|
? globalThis.__nimbusRawClearTimeout
|
|
229
201
|
: globalThis.clearTimeout;
|
|
230
202
|
let __expired = false;
|
|
231
|
-
const __deadline = __rawSetTimeout(() => { __expired = true; }, __deadlineMs);
|
|
232
203
|
let __pass = 0;
|
|
204
|
+
// A user-invoked program runs until it exits or is killed — there is no
|
|
205
|
+
// wall-clock deadline, so no expiry timer is armed at all. (Callers that
|
|
206
|
+
// still pass a finite deadline get the timer for compatibility.)
|
|
207
|
+
const __deadline = Number.isFinite(__deadlineMs)
|
|
208
|
+
? __rawSetTimeout(() => { __expired = true; }, __deadlineMs)
|
|
209
|
+
: null;
|
|
233
210
|
while (!__exited && !__expired && (__pass < __minPasses || __countHandles() > 0)) {
|
|
234
211
|
// The warm-up passes give a settling microtask chain its turns and cost
|
|
235
212
|
// ~5µs each; past them the loop is waiting on wall-clock work, where
|
|
236
|
-
// spinning at 0ms would burn the isolate's CPU
|
|
213
|
+
// spinning at 0ms would burn the isolate's CPU indefinitely.
|
|
237
214
|
await new Promise((resolve) => __rawSetTimeout(resolve, __pass < __minPasses ? 0 : 1));
|
|
238
215
|
__pass++;
|
|
239
216
|
}
|
|
240
|
-
try { __rawClearTimeout(__deadline); } catch {}
|
|
217
|
+
if (__deadline !== null) { try { __rawClearTimeout(__deadline); } catch {} }
|
|
241
218
|
// \`pending\` is what the caller reports when it gives up: a one-shot program
|
|
242
219
|
// still holding a handle did NOT finish, and exiting 0 would claim it did.
|
|
243
220
|
return { passes: __pass, pending: __exited ? 0 : __countHandles() };
|
|
@@ -392,7 +369,7 @@ const SQLITE_FACET_IMPORT = `import __nimbusSqliteWasmModule from "${SQLITE_WASM
|
|
|
392
369
|
/**
|
|
393
370
|
* Generate one-shot runtime code with a plain fetch handler.
|
|
394
371
|
*/
|
|
395
|
-
export async function generateEntrypointCode(userCode, vfsState, usesSqlite, shims) {
|
|
372
|
+
export async function generateEntrypointCode(userCode, vfsState, usesSqlite, shims, wasmImports = []) {
|
|
396
373
|
const safeCode = JSON.stringify(userCode);
|
|
397
374
|
const bundleSource = await facetVfsBundleSourceFor(vfsState);
|
|
398
375
|
const safeManifest = vfsState.serializedManifest ?? JSON.stringify(vfsState.manifest);
|
|
@@ -402,6 +379,7 @@ export async function generateEntrypointCode(userCode, vfsState, usesSqlite, shi
|
|
|
402
379
|
${bundleSource.imports}
|
|
403
380
|
${REAL_NODE_IMPORTS}
|
|
404
381
|
${usesSqlite ? SQLITE_FACET_IMPORT : ''}
|
|
382
|
+
${facetWasmImportsSource(wasmImports)}
|
|
405
383
|
const USER_CODE = ${safeCode};
|
|
406
384
|
const __NimbusHostResponse = globalThis.Response;
|
|
407
385
|
|
|
@@ -451,20 +429,16 @@ class __ProcessExit extends Error {
|
|
|
451
429
|
export default {
|
|
452
430
|
async fetch(request, workerEnv) {
|
|
453
431
|
const args = await request.json();
|
|
454
|
-
const { argv, env, cwd: _cwd, filename, dirname, stdin, captureOutput, cred, diag: __diag,
|
|
432
|
+
const { argv, env, cwd: _cwd, filename, dirname, stdin, captureOutput, cred, diag: __diag, vfsCursor } = args;
|
|
455
433
|
// Per invocation, not per module: this body is cached on
|
|
456
434
|
// hash(code + bundle + manifest) and reused by any session whose snapshot
|
|
457
435
|
// hashes the same, and epochs are per supervisor incarnation.
|
|
458
436
|
const __MODULE_VFS_CURSOR = vfsCursor || null;
|
|
459
437
|
${VFS_CURSOR_SEED_SOURCE}
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
// phaseDeadline.
|
|
465
|
-
const __entryBudgetMs = Number.isFinite(entryDeadlineAt)
|
|
466
|
-
? Math.max(0, Number(entryDeadlineAt) - Date.now())
|
|
467
|
-
: ${ONE_SHOT_ENTRY_DEADLINE_MS};
|
|
438
|
+
// A user-invoked program has no wall-clock lifetime: the drain runs the
|
|
439
|
+
// entrypoint's event loop until Node would exit, and only a kill (Ctrl-C)
|
|
440
|
+
// ends it early.
|
|
441
|
+
const __entryBudgetMs = Infinity;
|
|
468
442
|
let __drainPasses = 0;
|
|
469
443
|
const __vfsBundle = __MODULE_VFS_BUNDLE;
|
|
470
444
|
const __vfsManifest = __MODULE_VFS_MANIFEST;
|
|
@@ -483,11 +457,12 @@ ${VFS_CURSOR_SEED_SOURCE}
|
|
|
483
457
|
};
|
|
484
458
|
let __rpcWriteChain = Promise.resolve();
|
|
485
459
|
let __rpcWriteCount = 0;
|
|
486
|
-
|
|
460
|
+
// The relay carries bytes (see "Process output is bytes" in the shims).
|
|
461
|
+
const __queueRpcWrite = (method, bytes) => {
|
|
487
462
|
__rpcWriteCount++;
|
|
488
463
|
const __task = __rpcWriteChain
|
|
489
|
-
.then(() => __supervisor[method](
|
|
490
|
-
.catch((e) => __onRpcDrop(
|
|
464
|
+
.then(() => __supervisor[method](bytes))
|
|
465
|
+
.catch((e) => __onRpcDrop(bytes.byteLength, e));
|
|
491
466
|
__rpcWriteChain = __task.then(() => {}, () => {});
|
|
492
467
|
__pendingIO.push(__task);
|
|
493
468
|
};
|
|
@@ -506,13 +481,13 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
506
481
|
|
|
507
482
|
// Override console AND process.stdout/stderr for live SUPERVISOR streaming
|
|
508
483
|
if (__supervisor && !captureOutput) {
|
|
509
|
-
__consoleMod.log = (...a) => { const s = __utilMod.format(...a) + "\\n"; stdout += s; __queueRpcWrite("stdout", s); };
|
|
510
|
-
__consoleMod.error = (...a) => { const s = __utilMod.format(...a) + "\\n"; stderr += s; __queueRpcWrite("stderr", s); };
|
|
484
|
+
__consoleMod.log = (...a) => { const s = __utilMod.format(...a) + "\\n"; stdout += s; __queueRpcWrite("stdout", __nimbusOutEnc.encode(s)); };
|
|
485
|
+
__consoleMod.error = (...a) => { const s = __utilMod.format(...a) + "\\n"; stderr += s; __queueRpcWrite("stderr", __nimbusOutEnc.encode(s)); };
|
|
511
486
|
__consoleMod.warn = __consoleMod.error;
|
|
512
487
|
__consoleMod.info = __consoleMod.log;
|
|
513
488
|
__consoleMod.debug = __consoleMod.log;
|
|
514
|
-
__processMod.stdout.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const
|
|
515
|
-
__processMod.stderr.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const
|
|
489
|
+
__processMod.stdout.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const b = __nimbusOutBytes(d, enc); stdout += __nimbusOutText("stdout", b); __queueRpcWrite("stdout", b); if (typeof cb === "function") queueMicrotask(cb); return true; };
|
|
490
|
+
__processMod.stderr.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const b = __nimbusOutBytes(d, enc); stderr += __nimbusOutText("stderr", b); __queueRpcWrite("stderr", b); if (typeof cb === "function") queueMicrotask(cb); return true; };
|
|
516
491
|
}
|
|
517
492
|
|
|
518
493
|
try { globalThis.console = __consoleMod; } catch {}
|
|
@@ -543,18 +518,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
543
518
|
const __drain = await __nimbusRunEntrypointToExit(__entryResult, __entryBudgetMs);
|
|
544
519
|
__drainPasses = __drain.passes;
|
|
545
520
|
if (__nimbusProcessExitCode !== null) exitCode = __nimbusProcessExitCode;
|
|
546
|
-
|
|
547
|
-
// the deadline passes is work that will never run. Reporting exit 0
|
|
548
|
-
// there is the silent-truncation failure: name the limit and fail.
|
|
549
|
-
else if (__drain.pending > 0) {
|
|
550
|
-
const __why = "node: reached the ${FACET_TIMEOUT_MS / 1000}s facet lifetime limit with " +
|
|
551
|
-
__drain.pending + " operation(s) still in flight; the rest of the program did not run. " +
|
|
552
|
-
"A program that needs longer than ${FACET_TIMEOUT_MS / 1000}s has to run as a " +
|
|
553
|
-
"long-running process (node --watch, or a server), which is not bound by it.\\n";
|
|
554
|
-
stderr += __why;
|
|
555
|
-
exitCode = 1;
|
|
556
|
-
if (__supervisor && !captureOutput) __queueRpcWrite("stderr", __why);
|
|
557
|
-
}
|
|
521
|
+
|
|
558
522
|
if (__nimbusLiveStdinPump && !__nimbusAttachedTty) await __nimbusLiveStdinPump;
|
|
559
523
|
} catch (e) {
|
|
560
524
|
if (e instanceof __ProcessExit) { exitCode = e.code; }
|
|
@@ -563,7 +527,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
563
527
|
stderr += trace + "\\n";
|
|
564
528
|
exitCode = 1;
|
|
565
529
|
if (__supervisor && !captureOutput) {
|
|
566
|
-
try {
|
|
530
|
+
try { const __traceBytes = __nimbusOutEnc.encode(trace + "\\n"); __pendingIO.push(__supervisor.stderr(__traceBytes).catch((e2) => __onRpcDrop(__traceBytes.byteLength, e2))); } catch {}
|
|
567
531
|
}
|
|
568
532
|
}
|
|
569
533
|
}
|
|
@@ -589,7 +553,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
589
553
|
if (__residencyReport) {
|
|
590
554
|
stderr += __residencyReport;
|
|
591
555
|
if (exitCode === 0) exitCode = 1;
|
|
592
|
-
if (__supervisor && !captureOutput) __queueRpcWrite("stderr", __residencyReport);
|
|
556
|
+
if (__supervisor && !captureOutput) __queueRpcWrite("stderr", __nimbusOutEnc.encode(__residencyReport));
|
|
593
557
|
}
|
|
594
558
|
|
|
595
559
|
await __drainPendingIO();
|
|
@@ -603,7 +567,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
603
567
|
exitCode = 1;
|
|
604
568
|
if (!captureOutput) {
|
|
605
569
|
try {
|
|
606
|
-
await __supervisor.stderr(trace + "\\n");
|
|
570
|
+
await __supervisor.stderr(__nimbusOutEnc.encode(trace + "\\n"));
|
|
607
571
|
} catch {}
|
|
608
572
|
}
|
|
609
573
|
}
|
|
@@ -653,6 +617,61 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
653
617
|
* compiled user entry is booted once and the exported entrypoint keeps
|
|
654
618
|
* serving HTTP requests from the shimmed http.Server registry.
|
|
655
619
|
*/
|
|
620
|
+
/**
|
|
621
|
+
* Hand a record's entries to a consumer one at a time, releasing each as it
|
|
622
|
+
* goes, so the record stops being a second holder of the whole set.
|
|
623
|
+
*
|
|
624
|
+
* The image store takes its images as a sequence for exactly this: a launch's
|
|
625
|
+
* module text is the largest thing the coordinator assembles, and holding it
|
|
626
|
+
* in two places at once is what the 128 MiB isolate could not survive.
|
|
627
|
+
*/
|
|
628
|
+
function* drainSources(sources) {
|
|
629
|
+
for (const name of Object.keys(sources)) {
|
|
630
|
+
const source = sources[name];
|
|
631
|
+
delete sources[name];
|
|
632
|
+
yield [name, source];
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** The module-map name a precompiled wasm image travels under. */
|
|
636
|
+
export function facetWasmModuleName(index) {
|
|
637
|
+
return `__nimbus_wasm_${index}.wasm`;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* The wasm imports one launch stages: the images its options name, then
|
|
641
|
+
* every image the closure walk recorded (FacetVfsState.wasmImages) that the
|
|
642
|
+
* options did not already name by path. One member per path; the closure's
|
|
643
|
+
* record supplies the digest an option without one lacks.
|
|
644
|
+
*/
|
|
645
|
+
export function facetWasmImports(named, closure) {
|
|
646
|
+
const byPath = new Map();
|
|
647
|
+
for (const image of named)
|
|
648
|
+
byPath.set(image.vfsPath, image);
|
|
649
|
+
for (const image of closure) {
|
|
650
|
+
const seen = byPath.get(image.vfsPath);
|
|
651
|
+
if (seen === undefined)
|
|
652
|
+
byPath.set(image.vfsPath, image);
|
|
653
|
+
else if (seen.digest === undefined)
|
|
654
|
+
byPath.set(image.vfsPath, { ...seen, digest: image.digest });
|
|
655
|
+
}
|
|
656
|
+
return [...byPath.values()].map((image, index) => ({ ...image, moduleName: facetWasmModuleName(index) }));
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The static imports that compile a launch's wasm images at module eval and
|
|
660
|
+
* park them by VFS path for the node-shims WebAssembly seam.
|
|
661
|
+
*/
|
|
662
|
+
function facetWasmImportsSource(wasmImports) {
|
|
663
|
+
if (wasmImports.length === 0)
|
|
664
|
+
return '';
|
|
665
|
+
const lines = wasmImports.map((entry, index) => `import __nimbusWasm${index} from ${JSON.stringify(entry.moduleName)};`);
|
|
666
|
+
const entries = wasmImports.map((entry, index) => `[${JSON.stringify(stripLeadingSlashes(entry.vfsPath))}, __nimbusWasm${index}]`);
|
|
667
|
+
const byDigest = wasmImports
|
|
668
|
+
.map((entry, index) => (entry.digest === undefined
|
|
669
|
+
? null
|
|
670
|
+
: `[${JSON.stringify(entry.digest)}, __nimbusWasm${index}]`))
|
|
671
|
+
.filter((line) => line !== null);
|
|
672
|
+
return `${lines.join('\n')}\nglobalThis.__nimbusPrecompiledWasm = new Map([${entries.join(', ')}]);`
|
|
673
|
+
+ `\nglobalThis.__nimbusPrecompiledWasmByDigest = new Map([${byDigest.join(', ')}]);`;
|
|
674
|
+
}
|
|
656
675
|
export async function generateLongRunningNodeCode(userCode, vfsState, opts, usesSqlite, shims, pacer) {
|
|
657
676
|
const safeCode = JSON.stringify(userCode);
|
|
658
677
|
const safeArgs = JSON.stringify({
|
|
@@ -666,14 +685,15 @@ export async function generateLongRunningNodeCode(userCode, vfsState, opts, uses
|
|
|
666
685
|
cred: opts.cred,
|
|
667
686
|
});
|
|
668
687
|
const bundleSource = await facetVfsBundleSourceFor(vfsState, pacer);
|
|
669
|
-
const safeManifest = JSON.stringify(vfsState.manifest);
|
|
670
|
-
const safeMetadata = JSON.stringify(vfsState.metadata);
|
|
688
|
+
const safeManifest = vfsState.serializedManifest ?? JSON.stringify(vfsState.manifest);
|
|
689
|
+
const safeMetadata = vfsState.serializedMetadata ?? JSON.stringify(vfsState.metadata);
|
|
671
690
|
return {
|
|
672
691
|
code: `
|
|
673
692
|
${bundleSource.imports}
|
|
674
693
|
import { DurableObject } from "cloudflare:workers";
|
|
675
694
|
${REAL_NODE_IMPORTS}
|
|
676
695
|
${usesSqlite ? SQLITE_FACET_IMPORT : ''}
|
|
696
|
+
${facetWasmImportsSource(opts.wasmImports ?? [])}
|
|
677
697
|
const USER_CODE = ${safeCode};
|
|
678
698
|
const __NIMBUS_ARGS = ${safeArgs};
|
|
679
699
|
const __NimbusHostResponse = globalThis.Response;
|
|
@@ -829,11 +849,12 @@ ${VFS_CURSOR_SEED_SOURCE}
|
|
|
829
849
|
};
|
|
830
850
|
let __rpcWriteChain = Promise.resolve();
|
|
831
851
|
let __rpcWriteCount = 0;
|
|
832
|
-
|
|
852
|
+
// The relay carries bytes (see "Process output is bytes" in the shims).
|
|
853
|
+
const __queueRpcWrite = (method, bytes) => {
|
|
833
854
|
__rpcWriteCount++;
|
|
834
855
|
const __task = __rpcWriteChain
|
|
835
|
-
.then(() => __supervisor[method](
|
|
836
|
-
.catch((e) => __onRpcDrop(
|
|
856
|
+
.then(() => __supervisor[method](bytes))
|
|
857
|
+
.catch((e) => __onRpcDrop(bytes.byteLength, e));
|
|
837
858
|
__rpcWriteChain = __task.then(() => {}, () => {});
|
|
838
859
|
__pendingIO.push(__task);
|
|
839
860
|
};
|
|
@@ -851,13 +872,13 @@ ${ENTRYPOINT_EVENT_LOOP}
|
|
|
851
872
|
${RESIDENCY_MISS_REPORT}
|
|
852
873
|
|
|
853
874
|
if (__supervisor && !captureOutput) {
|
|
854
|
-
__consoleMod.log = (...a) => { const s = __utilMod.format(...a) + "\\n"; stdout += s; __queueRpcWrite("stdout", s); };
|
|
855
|
-
__consoleMod.error = (...a) => { const s = __utilMod.format(...a) + "\\n"; stderr += s; __queueRpcWrite("stderr", s); };
|
|
875
|
+
__consoleMod.log = (...a) => { const s = __utilMod.format(...a) + "\\n"; stdout += s; __queueRpcWrite("stdout", __nimbusOutEnc.encode(s)); };
|
|
876
|
+
__consoleMod.error = (...a) => { const s = __utilMod.format(...a) + "\\n"; stderr += s; __queueRpcWrite("stderr", __nimbusOutEnc.encode(s)); };
|
|
856
877
|
__consoleMod.warn = __consoleMod.error;
|
|
857
878
|
__consoleMod.info = __consoleMod.log;
|
|
858
879
|
__consoleMod.debug = __consoleMod.log;
|
|
859
|
-
__processMod.stdout.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const
|
|
860
|
-
__processMod.stderr.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const
|
|
880
|
+
__processMod.stdout.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const b = __nimbusOutBytes(d, enc); stdout += __nimbusOutText("stdout", b); __queueRpcWrite("stdout", b); if (typeof cb === "function") queueMicrotask(cb); return true; };
|
|
881
|
+
__processMod.stderr.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const b = __nimbusOutBytes(d, enc); stderr += __nimbusOutText("stderr", b); __queueRpcWrite("stderr", b); if (typeof cb === "function") queueMicrotask(cb); return true; };
|
|
861
882
|
}
|
|
862
883
|
|
|
863
884
|
try { globalThis.console = __consoleMod; } catch {}
|
|
@@ -911,7 +932,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
911
932
|
stderr += trace + "\\n";
|
|
912
933
|
exitCode = 1;
|
|
913
934
|
if (__supervisor && !captureOutput) {
|
|
914
|
-
try {
|
|
935
|
+
try { const __traceBytes = __nimbusOutEnc.encode(trace + "\\n"); __pendingIO.push(__supervisor.stderr(__traceBytes).catch((e2) => __onRpcDrop(__traceBytes.byteLength, e2))); } catch {}
|
|
915
936
|
}
|
|
916
937
|
}
|
|
917
938
|
}
|
|
@@ -940,7 +961,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
940
961
|
if (__residencyReport) {
|
|
941
962
|
stderr += __residencyReport;
|
|
942
963
|
if (Number(code ?? 0) === 0) code = 1;
|
|
943
|
-
try { await __supervisor.stderr(__residencyReport); } catch {}
|
|
964
|
+
try { await __supervisor.stderr(__nimbusOutEnc.encode(__residencyReport)); } catch {}
|
|
944
965
|
}
|
|
945
966
|
await __supervisor.reportExit(code, reason || "");
|
|
946
967
|
__nimbusProcessExitReported = true;
|
|
@@ -949,7 +970,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
949
970
|
const trace = (e && e.stack) || (e && e.message) || String(e);
|
|
950
971
|
stderr += trace + "\\n";
|
|
951
972
|
if (__supervisor) {
|
|
952
|
-
try { await __supervisor.stderr(trace + "\\n"); } catch {}
|
|
973
|
+
try { await __supervisor.stderr(__nimbusOutEnc.encode(trace + "\\n")); } catch {}
|
|
953
974
|
await __nimbusReportFinalExit(1, trace + "\\n");
|
|
954
975
|
}
|
|
955
976
|
};
|
|
@@ -998,7 +1019,7 @@ ${RESIDENCY_MISS_REPORT}
|
|
|
998
1019
|
const tail = "[orphan output: " + __rpcDrops + " dropped RPC write(s), ~" +
|
|
999
1020
|
__rpcDropBytes + " bytes lost" +
|
|
1000
1021
|
(__rpcLastError ? "; last error: " + __rpcLastError : "") + "]\\n";
|
|
1001
|
-
try { await __supervisor.stderr(tail); } catch {}
|
|
1022
|
+
try { await __supervisor.stderr(__nimbusOutEnc.encode(tail)); } catch {}
|
|
1002
1023
|
}
|
|
1003
1024
|
if (exitCode !== 0) {
|
|
1004
1025
|
if (__supervisor && !__nimbusProcessExitReported) {
|
|
@@ -1054,16 +1075,15 @@ export class NimbusProcess extends DurableObject {
|
|
|
1054
1075
|
* `bundleSource`, `serializedManifest` and `serializedMetadata` are total
|
|
1055
1076
|
* encodings of `bundle`, `manifest` and `metadata` — no caller can distinguish
|
|
1056
1077
|
* a state carrying both from one carrying only the serialized halves, because
|
|
1057
|
-
*
|
|
1078
|
+
* both facet generators read the serialized halves and nothing else does.
|
|
1058
1079
|
* Holding both doubles the cost of a cached entry for its whole lifetime, and
|
|
1059
1080
|
* that lifetime spans execs.
|
|
1060
1081
|
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
* (`
|
|
1066
|
-
* goes through here.
|
|
1082
|
+
* Applied by `_buildProcessBundle` to every state it builds, whether or not
|
|
1083
|
+
* the entry turns out small enough to retain: the launch being served reads
|
|
1084
|
+
* the serialized forms too. `_stageOpencodeFacet` builds its own uncached
|
|
1085
|
+
* state and genuinely re-reads the raw cells
|
|
1086
|
+
* (`assertStagedBundleFitsRpcPayload`); it does not go through here.
|
|
1067
1087
|
*/
|
|
1068
1088
|
export function releaseSerializedSources(vfsState) {
|
|
1069
1089
|
if (vfsState.bundleSource)
|
|
@@ -1073,31 +1093,6 @@ export function releaseSerializedSources(vfsState) {
|
|
|
1073
1093
|
if (vfsState.serializedMetadata !== undefined)
|
|
1074
1094
|
vfsState.metadata = {};
|
|
1075
1095
|
}
|
|
1076
|
-
/**
|
|
1077
|
-
* Drop everything a resident launch has finished reading, in place.
|
|
1078
|
-
*
|
|
1079
|
-
* The one-shot path releases its map at LOADER.load; this is the same policy
|
|
1080
|
-
* for the path `spawnNode` takes, which is the path every attached-TTY npm bin
|
|
1081
|
-
* takes — how a real agentic CLI starts. The generated source is a total
|
|
1082
|
-
* encoding of the cells, the manifest and the metadata, and the only thing the
|
|
1083
|
-
* rest of a launch reads off the state is `cursor`. Everything else is a second
|
|
1084
|
-
* copy of the largest thing this DO builds — 22.9 MB for pi — held for exactly
|
|
1085
|
-
* as long as the facet takes to boot on it.
|
|
1086
|
-
*
|
|
1087
|
-
* Holding it reset the session isolate with exceededMemory, and an isolate
|
|
1088
|
-
* reset tears the terminal WebSocket down with no exit frame: the dead screen
|
|
1089
|
-
* reading "[process terminal closed]".
|
|
1090
|
-
*
|
|
1091
|
-
* An emptied state can still generate a map — it would just generate one with
|
|
1092
|
-
* no program in it — so this marks the state instead of trusting callers to
|
|
1093
|
-
* stop.
|
|
1094
|
-
*/
|
|
1095
|
-
export function releaseResidentLaunchSources(vfsState) {
|
|
1096
|
-
vfsState.bundle = {};
|
|
1097
|
-
vfsState.manifest = {};
|
|
1098
|
-
vfsState.metadata = {};
|
|
1099
|
-
vfsState.generatedSourcesReleased = true;
|
|
1100
|
-
}
|
|
1101
1096
|
/**
|
|
1102
1097
|
* Drop the serialized forms once a module map has been generated from them.
|
|
1103
1098
|
*
|
|
@@ -1105,12 +1100,19 @@ export function releaseResidentLaunchSources(vfsState) {
|
|
|
1105
1100
|
* bundle expression, the manifest and the metadata is inside it. Holding them
|
|
1106
1101
|
* afterwards keeps a second copy of the largest thing this DO builds alive for
|
|
1107
1102
|
* as long as the facet runs — for pi, 22.7 MB across the ~20 s window in which
|
|
1108
|
-
* the isolate was being reset.
|
|
1103
|
+
* the isolate was being reset. For the one-shot path that window is the run;
|
|
1104
|
+
* for a resident launch — the path every attached-TTY npm bin takes, how a
|
|
1105
|
+
* real agentic CLI starts — it is the boot, and holding the copy across it
|
|
1106
|
+
* reset the session isolate with exceededMemory, which tears the terminal
|
|
1107
|
+
* WebSocket down with no exit frame: the dead screen reading "[process
|
|
1108
|
+
* terminal closed]".
|
|
1109
1109
|
*
|
|
1110
1110
|
* Only for a state the prefetch cache refused. A retained entry's serialized
|
|
1111
|
-
* forms ARE the entry, and a later
|
|
1111
|
+
* forms ARE the entry, and a later launch is served from them. An emptied
|
|
1112
|
+
* state could still generate a map — one with no program in it — so this
|
|
1113
|
+
* marks the state instead of trusting callers to stop.
|
|
1112
1114
|
*/
|
|
1113
|
-
function releaseGeneratedSources(vfsState) {
|
|
1115
|
+
export function releaseGeneratedSources(vfsState) {
|
|
1114
1116
|
vfsState.bundleSource = undefined;
|
|
1115
1117
|
vfsState.serializedManifest = undefined;
|
|
1116
1118
|
vfsState.serializedMetadata = undefined;
|
|
@@ -1163,6 +1165,29 @@ function _readBundleCell(vfs, path) {
|
|
|
1163
1165
|
function _bundleCellLength(cell) {
|
|
1164
1166
|
return typeof cell === 'string' ? cell.length : cell.byteLength;
|
|
1165
1167
|
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Pre-read admission check for a bundle candidate: refuse BEFORE reading
|
|
1170
|
+
* when the file's on-disk size already exceeds the remaining bundle budget
|
|
1171
|
+
* or the caller's per-file ceiling. Without this every helper read a file
|
|
1172
|
+
* in full, then discarded it when the post-read `cellLen` check tripped —
|
|
1173
|
+
* on trees with several 5–10 MiB artifacts that read alone was the memory
|
|
1174
|
+
* pressure that reset the isolate.
|
|
1175
|
+
*
|
|
1176
|
+
* `perFileCeiling` is the pass's own cap (e.g. CWD_SNAPSHOT_MAX_FILE_BYTES,
|
|
1177
|
+
* BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES); omit it for budget-only checks.
|
|
1178
|
+
*/
|
|
1179
|
+
function _bundleAdmits(vfs, path, budgetState, perFileCeiling) {
|
|
1180
|
+
let size;
|
|
1181
|
+
try {
|
|
1182
|
+
size = vfs.lstat(path).size;
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
if (perFileCeiling !== undefined && size > perFileCeiling)
|
|
1188
|
+
return false;
|
|
1189
|
+
return budgetState.totalBytes + size <= VFS_BUNDLE_MAX_BYTES;
|
|
1190
|
+
}
|
|
1166
1191
|
/** What a bundle currently weighs, and so what one more pass over it costs. */
|
|
1167
1192
|
function _bundleWeight(bundle) {
|
|
1168
1193
|
let weight = 0;
|
|
@@ -1767,12 +1792,16 @@ function addUnreadableDenialCells(vfs, bundle, metadata) {
|
|
|
1767
1792
|
* computed-path requires). Bounded to package.json + 1 main-entry file
|
|
1768
1793
|
* per package — sub-agent §Q3 quantified the worst-case cumulative
|
|
1769
1794
|
* budget impact (~322 KiB for fastify, ~1.7 MiB for ts-jest).
|
|
1795
|
+
*
|
|
1796
|
+
* `requiredPaths` is the static require closure. A package the closure
|
|
1797
|
+
* already reached — but reached only through a SUBPATH — has its main entry
|
|
1798
|
+
* skipped; see `mainIsSpeculative`.
|
|
1770
1799
|
*/
|
|
1771
1800
|
// can verify the hash-chunk + shared/ oversample directly. Pre-X.5-C this
|
|
1772
1801
|
// was a file-local helper. Adding the named export is a pure surface
|
|
1773
1802
|
// addition — no callers other than buildPrefetchBundle (same file) and
|
|
1774
1803
|
// the new probe.
|
|
1775
|
-
export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
|
|
1804
|
+
export function greedyAddMainEntries(vfs, cwd, bundle, budgetState, requiredPaths = new Set()) {
|
|
1776
1805
|
let added = 0;
|
|
1777
1806
|
const cwdStripped = cwd.replace(/^\/+/, '');
|
|
1778
1807
|
const nmDir = cwdStripped + '/node_modules';
|
|
@@ -1798,7 +1827,16 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
|
|
|
1798
1827
|
// supervisor's headroom — on every invocation that never reads it.
|
|
1799
1828
|
// Anything the program really requires arrives through the closure,
|
|
1800
1829
|
// which is uncapped.
|
|
1801
|
-
if (vfs
|
|
1830
|
+
if (!_bundleAdmits(vfs, stripped, budgetState, BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES))
|
|
1831
|
+
return false;
|
|
1832
|
+
// A guess must not be a native binary. Nothing in a Workers isolate can
|
|
1833
|
+
// load a `.node` addon or a `.exe` — the ABI policy classifies them
|
|
1834
|
+
// native-unsupported and the installer says so at install time — so
|
|
1835
|
+
// admitting one spends the budget on bytes no code path can reach.
|
|
1836
|
+
// Measured: freeing 1.3 MiB of rollup let `@napi-rs/lzma-linux-x64-gnu`'s
|
|
1837
|
+
// 1,445,448-byte `.node` in, which the size guard had been evicting.
|
|
1838
|
+
// Only the guess is filtered; a path the closure requires is untouched.
|
|
1839
|
+
if (stripped.endsWith('.node') || stripped.endsWith('.exe'))
|
|
1802
1840
|
return false;
|
|
1803
1841
|
// hardening-r5: preserve binary content as Uint8Array.
|
|
1804
1842
|
const content = _readBundleCell(vfs, stripped);
|
|
@@ -1835,8 +1873,42 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
|
|
|
1835
1873
|
collectExportLeaves(node[k], out);
|
|
1836
1874
|
}
|
|
1837
1875
|
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Whether guessing at `pkgDir`'s main entry is still a guess.
|
|
1878
|
+
*
|
|
1879
|
+
* It is, for a package nothing required: that is what this pass exists for,
|
|
1880
|
+
* and a dynamic `require(variable)` leaves no edge to follow. It is NOT for
|
|
1881
|
+
* a package the closure reached only through a subpath export — there the
|
|
1882
|
+
* program has told us exactly which corner of the package it uses, and the
|
|
1883
|
+
* main entry is a different graph that no require reaches. Adding it anyway
|
|
1884
|
+
* is how `rollup/parseAst` (7.9 KB of binding) dragged in rollup's whole
|
|
1885
|
+
* bundler: `dist/shared/rollup.js` at 941 KB and, through the `module`
|
|
1886
|
+
* candidate, `dist/es/shared/node-entry.js` at 951 KB — measured together
|
|
1887
|
+
* as 46.5% of a real-vite snapshot, reached by no require in it.
|
|
1888
|
+
*
|
|
1889
|
+
* A package that loads its own main from a subpath at runtime does so
|
|
1890
|
+
* through a static edge, so it is in the closure and unaffected.
|
|
1891
|
+
*/
|
|
1892
|
+
function mainIsSpeculative(pkgDir) {
|
|
1893
|
+
const prefix = pkgDir.replace(/^\/+/, '') + '/';
|
|
1894
|
+
let reached = false;
|
|
1895
|
+
for (const path of requiredPaths) {
|
|
1896
|
+
if (!path.startsWith(prefix))
|
|
1897
|
+
continue;
|
|
1898
|
+
// package.json alone is resolution metadata, not a use of the package.
|
|
1899
|
+
if (path === prefix + 'package.json')
|
|
1900
|
+
continue;
|
|
1901
|
+
reached = true;
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
return !reached;
|
|
1905
|
+
}
|
|
1838
1906
|
function addPkgEntry(pkgDir) {
|
|
1839
1907
|
addOne(pkgDir + '/package.json');
|
|
1908
|
+
// A package the closure reached keeps exactly what it reached; only an
|
|
1909
|
+
// unreached one gets the guess below.
|
|
1910
|
+
if (!mainIsSpeculative(pkgDir))
|
|
1911
|
+
return;
|
|
1840
1912
|
let meta;
|
|
1841
1913
|
try {
|
|
1842
1914
|
meta = JSON.parse(vfs.readFileString(pkgDir + '/package.json'));
|
|
@@ -1936,27 +2008,87 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
|
|
|
1936
2008
|
}
|
|
1937
2009
|
}
|
|
1938
2010
|
}
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2011
|
+
for (const pkgDir of speculativePackageDirs(vfs, cwdStripped, bundle))
|
|
2012
|
+
addPkgEntry(pkgDir);
|
|
2013
|
+
return { added };
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* The packages a computed `require(name)` inside the program can plausibly
|
|
2017
|
+
* name: the project root's own runtime `dependencies`, plus every package
|
|
2018
|
+
* ONE `dependencies` hop from a package that already owns a file in the
|
|
2019
|
+
* static closure. Never devDependencies, never a second hop.
|
|
2020
|
+
*
|
|
2021
|
+
* Unbounded, the greedy oversample read every installed package's main:
|
|
2022
|
+
* for `node -e "import('got')"` in got's repo — a one-file static closure —
|
|
2023
|
+
* that was 1,526 files / 10.9 MB from 706 packages, which every later pass
|
|
2024
|
+
* re-scanned and esbuild-wasm transformed, and the exec path's 20 s bundle
|
|
2025
|
+
* deadline fired on a program that reads none of it. A bound that followed
|
|
2026
|
+
* dependency edges from the project's devDependencies reached all 772 of
|
|
2027
|
+
* them (measured), because a library repo's dev toolchain reaches the whole
|
|
2028
|
+
* tree. Computed requires almost always target a declared runtime
|
|
2029
|
+
* dependency of the package doing the requiring, so the bound is one hop
|
|
2030
|
+
* over `dependencies` only. Directories, sorted for a stable bundle.
|
|
2031
|
+
*/
|
|
2032
|
+
export function speculativePackageDirs(vfs, cwdStripped, bundle) {
|
|
2033
|
+
const runtimeDeps = (pkgJsonPath) => {
|
|
2034
|
+
try {
|
|
2035
|
+
const meta = JSON.parse(vfs.readFileString(pkgJsonPath));
|
|
2036
|
+
const names = new Set();
|
|
2037
|
+
for (const field of ['dependencies', 'optionalDependencies']) {
|
|
2038
|
+
const deps = meta?.[field];
|
|
2039
|
+
if (deps && typeof deps === 'object')
|
|
2040
|
+
for (const name of Object.keys(deps))
|
|
2041
|
+
names.add(name);
|
|
1955
2042
|
}
|
|
2043
|
+
return [...names];
|
|
2044
|
+
}
|
|
2045
|
+
catch {
|
|
2046
|
+
return [];
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
2049
|
+
// The package that owns a bundle file: the last node_modules segment.
|
|
2050
|
+
const ownerOf = (path) => {
|
|
2051
|
+
const idx = path.lastIndexOf('/node_modules/');
|
|
2052
|
+
if (idx === -1)
|
|
2053
|
+
return null;
|
|
2054
|
+
const segs = path.slice(idx + '/node_modules/'.length).split('/');
|
|
2055
|
+
const name = segs[0]?.startsWith('@') ? segs.slice(0, 2).join('/') : segs[0];
|
|
2056
|
+
return name ? path.slice(0, idx + '/node_modules/'.length) + name : null;
|
|
2057
|
+
};
|
|
2058
|
+
// Resolve a bare name the way require does from `fromDir`: the nearest
|
|
2059
|
+
// node_modules up the tree that has it.
|
|
2060
|
+
const resolveDir = (name, fromDir) => {
|
|
2061
|
+
let dir = fromDir;
|
|
2062
|
+
for (;;) {
|
|
2063
|
+
const candidate = dir + '/node_modules/' + name;
|
|
2064
|
+
if (vfs.exists(candidate + '/package.json'))
|
|
2065
|
+
return candidate;
|
|
2066
|
+
const idx = dir.lastIndexOf('/');
|
|
2067
|
+
if (idx <= 0)
|
|
2068
|
+
return null;
|
|
2069
|
+
dir = dir.slice(0, idx);
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
const reached = new Set();
|
|
2073
|
+
const hop = (fromDir) => {
|
|
2074
|
+
for (const name of runtimeDeps(fromDir + '/package.json')) {
|
|
2075
|
+
const dir = resolveDir(name, fromDir);
|
|
2076
|
+
if (dir !== null)
|
|
2077
|
+
reached.add(dir);
|
|
1956
2078
|
}
|
|
2079
|
+
};
|
|
2080
|
+
hop(cwdStripped);
|
|
2081
|
+
const owners = new Set();
|
|
2082
|
+
for (const path of Object.keys(bundle)) {
|
|
2083
|
+
const owner = ownerOf(path);
|
|
2084
|
+
if (owner !== null)
|
|
2085
|
+
owners.add(owner);
|
|
1957
2086
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
2087
|
+
for (const owner of owners) {
|
|
2088
|
+
reached.add(owner);
|
|
2089
|
+
hop(owner);
|
|
2090
|
+
}
|
|
2091
|
+
return [...reached].sort();
|
|
1960
2092
|
}
|
|
1961
2093
|
/**
|
|
1962
2094
|
* X.5-Z3: scan every JS source already in `bundle` for static
|
|
@@ -2026,6 +2158,8 @@ export function addStaticReadFileAssets(vfs, cwd, bundle, budgetState) {
|
|
|
2026
2158
|
try {
|
|
2027
2159
|
if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
|
|
2028
2160
|
return false;
|
|
2161
|
+
if (!_bundleAdmits(vfs, stripped, budgetState))
|
|
2162
|
+
return false;
|
|
2029
2163
|
// hardening-r5: preserve binary content as Uint8Array.
|
|
2030
2164
|
const content = _readBundleCell(vfs, stripped);
|
|
2031
2165
|
const cellLen = _bundleCellLength(content);
|
|
@@ -2199,6 +2333,8 @@ export function addStaticReadFileDotfilesAndCompiled(vfs, cwd, bundle, budgetSta
|
|
|
2199
2333
|
try {
|
|
2200
2334
|
if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
|
|
2201
2335
|
return false;
|
|
2336
|
+
if (!_bundleAdmits(vfs, stripped, budgetState))
|
|
2337
|
+
return false;
|
|
2202
2338
|
// hardening-r5: preserve binary content as Uint8Array.
|
|
2203
2339
|
const content = _readBundleCell(vfs, stripped);
|
|
2204
2340
|
const cellLen = _bundleCellLength(content);
|
|
@@ -2304,7 +2440,7 @@ export function addStaticReadFileDotfilesAndCompiled(vfs, cwd, bundle, budgetSta
|
|
|
2304
2440
|
*/
|
|
2305
2441
|
export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundleProfile) {
|
|
2306
2442
|
if (!scriptPath)
|
|
2307
|
-
return { added: 0 };
|
|
2443
|
+
return { added: 0, wasmPaths: [] };
|
|
2308
2444
|
const stripped = scriptPath.replace(/^\/+/, '');
|
|
2309
2445
|
// Find the *innermost* node_modules/<pkg> root. Handles scoped
|
|
2310
2446
|
// packages (`@org/name`) too.
|
|
@@ -2317,11 +2453,11 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
|
|
|
2317
2453
|
}
|
|
2318
2454
|
}
|
|
2319
2455
|
if (nmIdx < 0)
|
|
2320
|
-
return { added: 0 };
|
|
2456
|
+
return { added: 0, wasmPaths: [] };
|
|
2321
2457
|
const isScoped = segs[nmIdx + 1]?.startsWith('@');
|
|
2322
2458
|
const pkgEnd = isScoped ? nmIdx + 3 : nmIdx + 2;
|
|
2323
2459
|
if (pkgEnd > segs.length)
|
|
2324
|
-
return { added: 0 };
|
|
2460
|
+
return { added: 0, wasmPaths: [] };
|
|
2325
2461
|
const pkgRoot = segs.slice(0, pkgEnd).join('/');
|
|
2326
2462
|
// npm-create-fix wave (2026-05-12): scaffold profile needs this cap
|
|
2327
2463
|
// high enough to cover
|
|
@@ -2347,6 +2483,10 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
|
|
|
2347
2483
|
// an unread multi-MiB cell costs a stat rather than a transfer.
|
|
2348
2484
|
let visited = 0;
|
|
2349
2485
|
const candidates = [];
|
|
2486
|
+
// Every wasm image under the package, whatever its size: an image rides in
|
|
2487
|
+
// the module map by path and is compiled by the loader, so the bundle's
|
|
2488
|
+
// per-file cap does not apply to it (see FacetVfsState.wasmImages).
|
|
2489
|
+
const wasmPaths = [];
|
|
2350
2490
|
const queue = [pkgRoot];
|
|
2351
2491
|
while (queue.length > 0 && visited < MAX_PKG_FILES) {
|
|
2352
2492
|
const dir = queue.shift();
|
|
@@ -2376,6 +2516,8 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
|
|
|
2376
2516
|
// to it) or outside this profile's package-data policy.
|
|
2377
2517
|
if (!shouldIncludeBinPackageFile(pkgRoot, child, bundleProfile))
|
|
2378
2518
|
continue;
|
|
2519
|
+
if (child.endsWith('.wasm'))
|
|
2520
|
+
wasmPaths.push(child);
|
|
2379
2521
|
if (bundle[child] !== undefined)
|
|
2380
2522
|
continue;
|
|
2381
2523
|
let size;
|
|
@@ -2406,6 +2548,8 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
|
|
|
2406
2548
|
break;
|
|
2407
2549
|
if (budgetState.totalBytes >= VFS_BUNDLE_MAX_BYTES)
|
|
2408
2550
|
break;
|
|
2551
|
+
if (candidate.size > VFS_BUNDLE_MAX_BYTES - budgetState.totalBytes)
|
|
2552
|
+
continue;
|
|
2409
2553
|
// hardening-r5: preserve binary content as Uint8Array.
|
|
2410
2554
|
let content;
|
|
2411
2555
|
try {
|
|
@@ -2424,7 +2568,40 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
|
|
|
2424
2568
|
budgetState.fileCount++;
|
|
2425
2569
|
added++;
|
|
2426
2570
|
}
|
|
2427
|
-
return { added };
|
|
2571
|
+
return { added, wasmPaths };
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* The wasm images a program's closure holds, by path and content digest.
|
|
2575
|
+
*
|
|
2576
|
+
* Two sources, one record: a `.wasm` cell the walk already staged (digested
|
|
2577
|
+
* from the cell, no second read), and a `.wasm` file the bin-package pass
|
|
2578
|
+
* saw but did not stage because it is over the bundle's per-file cap —
|
|
2579
|
+
* esbuild-wasm's 11.9 MiB image is the motivating one. A launch stages each
|
|
2580
|
+
* as a module-map member the loader compiles, registered under both keys,
|
|
2581
|
+
* so the program's own `new WebAssembly.Module(bytes)` is answered from the
|
|
2582
|
+
* map whether it read the bytes by path or carried them inline. A file that
|
|
2583
|
+
* cannot be read is left out; the seam's refusal names the module later.
|
|
2584
|
+
*/
|
|
2585
|
+
export function collectClosureWasmImages(vfs, bundle, unstagedPaths) {
|
|
2586
|
+
const byPath = new Map();
|
|
2587
|
+
for (const [path, cell] of Object.entries(bundle)) {
|
|
2588
|
+
if (!path.endsWith('.wasm') || !(cell instanceof Uint8Array))
|
|
2589
|
+
continue;
|
|
2590
|
+
byPath.set(path, { vfsPath: '/' + stripLeadingSlashes(path), digest: wasmImageDigest(cell) });
|
|
2591
|
+
}
|
|
2592
|
+
for (const path of unstagedPaths) {
|
|
2593
|
+
if (byPath.has(path))
|
|
2594
|
+
continue;
|
|
2595
|
+
let bytes;
|
|
2596
|
+
try {
|
|
2597
|
+
bytes = vfs.readFile(path);
|
|
2598
|
+
}
|
|
2599
|
+
catch {
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
byPath.set(path, { vfsPath: '/' + stripLeadingSlashes(path), digest: wasmImageDigest(bytes) });
|
|
2603
|
+
}
|
|
2604
|
+
return [...byPath.values()];
|
|
2428
2605
|
}
|
|
2429
2606
|
/**
|
|
2430
2607
|
* Stage the paths an earlier run of the same entry read synchronously and did
|
|
@@ -2608,13 +2785,8 @@ function addCwdProjectFiles(vfs, cwd, bundle, budgetState) {
|
|
|
2608
2785
|
// Skip an oversized file before reading it: this walk guesses at what
|
|
2609
2786
|
// the program might read, and one guess must not spend the budget (or
|
|
2610
2787
|
// the supervisor's headroom) that every later invocation then carries.
|
|
2611
|
-
|
|
2612
|
-
if (vfs.lstat(child).size > CWD_SNAPSHOT_MAX_FILE_BYTES)
|
|
2613
|
-
continue;
|
|
2614
|
-
}
|
|
2615
|
-
catch {
|
|
2788
|
+
if (!_bundleAdmits(vfs, child, budgetState, CWD_SNAPSHOT_MAX_FILE_BYTES))
|
|
2616
2789
|
continue;
|
|
2617
|
-
}
|
|
2618
2790
|
let content;
|
|
2619
2791
|
try {
|
|
2620
2792
|
content = _readBundleCell(vfs, child);
|
|
@@ -2684,6 +2856,8 @@ function addEntryAbsPathReads(vfs, entryCode, bundle, budgetState) {
|
|
|
2684
2856
|
try {
|
|
2685
2857
|
if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
|
|
2686
2858
|
return;
|
|
2859
|
+
if (!_bundleAdmits(vfs, stripped, budgetState))
|
|
2860
|
+
return;
|
|
2687
2861
|
// hardening-r5: preserve binary content as Uint8Array.
|
|
2688
2862
|
const content = _readBundleCell(vfs, stripped);
|
|
2689
2863
|
const cellLen = _bundleCellLength(content);
|
|
@@ -3036,7 +3210,7 @@ async function transformEsmInBundle(bundle, esbuild, pacer, isolatedTransform) {
|
|
|
3036
3210
|
* behaviour for code paths that don't have esbuild handy).
|
|
3037
3211
|
*
|
|
3038
3212
|
*/
|
|
3039
|
-
export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform) {
|
|
3213
|
+
export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform, maxBundleBytes) {
|
|
3040
3214
|
// This build accumulates raw VFS contents in the supervisor heap, and did it
|
|
3041
3215
|
// with nothing watching: the estimator read 9.4 MiB while these bytes were
|
|
3042
3216
|
// resetting the DO three times. Take the budget the enrichment passes are
|
|
@@ -3046,19 +3220,25 @@ export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbui
|
|
|
3046
3220
|
const lease = await acquireSupervisorAllocation(VFS_BUNDLE_MAX_BYTES);
|
|
3047
3221
|
prefetchBundleStart(VFS_BUNDLE_MAX_BYTES);
|
|
3048
3222
|
try {
|
|
3049
|
-
return await _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile, observedReads, pacer, isolatedTransform);
|
|
3223
|
+
return await _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile, observedReads, pacer, isolatedTransform, maxBundleBytes);
|
|
3050
3224
|
}
|
|
3051
3225
|
finally {
|
|
3052
3226
|
prefetchBundleEnd(VFS_BUNDLE_MAX_BYTES);
|
|
3053
3227
|
lease.release();
|
|
3054
3228
|
}
|
|
3055
3229
|
}
|
|
3056
|
-
async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform) {
|
|
3230
|
+
async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform, maxBundleBytes = VFS_BUNDLE_MAX_BYTES) {
|
|
3057
3231
|
// Read the cursor BEFORE the walk: a mutation that lands while the bundle
|
|
3058
3232
|
// is being assembled must be reported as invalidated, not silently missed.
|
|
3059
3233
|
const cursor = { epoch: vfs.epoch, rev: vfs.revision() };
|
|
3060
3234
|
// 1. Static reachable-set walk from entry.
|
|
3061
|
-
const prefetch = prefetchForRequire(vfs, entryCode || '', cwd, scriptPath);
|
|
3235
|
+
const prefetch = prefetchForRequire(vfs, entryCode || '', cwd, scriptPath, maxBundleBytes);
|
|
3236
|
+
if ('kind' in prefetch) {
|
|
3237
|
+
// A required closure larger than the bound can never launch as a
|
|
3238
|
+
// snapshot. Surface it as the process's own failure rather than a
|
|
3239
|
+
// build error: the caller maps it to exit 1 + stderr.
|
|
3240
|
+
throw new ClosureBoundExceededError(prefetch);
|
|
3241
|
+
}
|
|
3062
3242
|
const bundle = { ...prefetch.bundle };
|
|
3063
3243
|
const requiredPaths = new Set(Object.keys(prefetch.bundle));
|
|
3064
3244
|
let truncated = false;
|
|
@@ -3089,7 +3269,7 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3089
3269
|
// Catches dynamic-require / `bindings()` / plugin-loader cases the
|
|
3090
3270
|
// regex prefetch misses. Its budget is independent from the complete
|
|
3091
3271
|
// static require closure, which is correctness-critical.
|
|
3092
|
-
const greedy = greedyAddMainEntries(vfs, cwd, bundle, budgetState);
|
|
3272
|
+
const greedy = greedyAddMainEntries(vfs, cwd, bundle, budgetState, requiredPaths);
|
|
3093
3273
|
await paceAfterPass();
|
|
3094
3274
|
// 2.25 X.5-Z3: static-readFileSync asset prefetch. Scans every
|
|
3095
3275
|
// bundle .js/.mjs/.cjs source for the canonical jsdom shape:
|
|
@@ -3122,7 +3302,6 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3122
3302
|
// as data, etc.) without needing a per-pkg whitelist.
|
|
3123
3303
|
// No-op when entry isn't inside node_modules.
|
|
3124
3304
|
const binSiblingAdd = addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundleProfile);
|
|
3125
|
-
void binSiblingAdd;
|
|
3126
3305
|
await paceAfterPass();
|
|
3127
3306
|
// 2.34 project-data snapshot: sync Node fs cannot await the
|
|
3128
3307
|
// supervisor. Include a bounded snapshot of the current working tree
|
|
@@ -3238,6 +3417,7 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3238
3417
|
const bundleSideModulesRequired = size.bytes > BUNDLE_MAX_ENCODED_BYTES;
|
|
3239
3418
|
// Suppress lint: `greedy.added` is observed only via diagnostics.
|
|
3240
3419
|
void greedy;
|
|
3420
|
+
const wasmImages = collectClosureWasmImages(vfs, bundle, binSiblingAdd.wasmPaths);
|
|
3241
3421
|
return {
|
|
3242
3422
|
bundle,
|
|
3243
3423
|
manifest,
|
|
@@ -3246,11 +3426,21 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3246
3426
|
reachableCount: fileCount,
|
|
3247
3427
|
truncated,
|
|
3248
3428
|
bundleSideModulesRequired,
|
|
3429
|
+
...(wasmImages.length > 0 ? { wasmImages } : {}),
|
|
3249
3430
|
};
|
|
3250
3431
|
}
|
|
3432
|
+
/** The main module name a worker launch boots from unless told otherwise. */
|
|
3433
|
+
export const DEFAULT_WORKER_MAIN_MODULE = 'worker.js';
|
|
3251
3434
|
const ROUTEABLE_PORT_ATTACH_TIMEOUT_MS = 1_000;
|
|
3252
3435
|
/** A port request may wait this long for a durable app's re-drive to boot. */
|
|
3253
3436
|
const DURABLE_ENSURE_BOOT_BUDGET_MS = 12_000;
|
|
3437
|
+
/** The env var a launch reads its restart policy from — set by startProcess({ restart }) and `nimbus start --restart`. */
|
|
3438
|
+
export const RESTART_POLICY_ENV = 'NIMBUS_RESTART';
|
|
3439
|
+
/** Backoff per spent FencedWork attempt; a healthy boot resets that existing budget. */
|
|
3440
|
+
const RESTART_BACKOFF_BASE_MS = 1_000;
|
|
3441
|
+
function residentRestartPolicy(env) {
|
|
3442
|
+
return env?.[RESTART_POLICY_ENV] === 'on-failure' ? 'on-failure' : 'never';
|
|
3443
|
+
}
|
|
3254
3444
|
/**
|
|
3255
3445
|
* The durable owner a journal row serves: the row's stamped field for
|
|
3256
3446
|
* reservation-claimed residents, falling back to the worker recipe's own
|
|
@@ -3306,7 +3496,6 @@ export class FacetManager {
|
|
|
3306
3496
|
* journal recovery rides the first pump.
|
|
3307
3497
|
*/
|
|
3308
3498
|
launchPump;
|
|
3309
|
-
timedOutProcessIds = new Set();
|
|
3310
3499
|
// attach-pid → serve-pid: the resident serve facet a bare-`opencode` dual
|
|
3311
3500
|
// spawn created as an OS-child of the attach TUI. When the attach process
|
|
3312
3501
|
// exits (reported / killed), its serve facet is torn down with it.
|
|
@@ -3351,6 +3540,15 @@ export class FacetManager {
|
|
|
3351
3540
|
residencyProfiles = new Map();
|
|
3352
3541
|
/** In-flight request-driven durable-app ensures, single-flight per port. */
|
|
3353
3542
|
ensureInflight = new Map();
|
|
3543
|
+
/** Per-pid chain of journal-row amendments; see `_amendRow`. */
|
|
3544
|
+
rowAmendments = new Map();
|
|
3545
|
+
/**
|
|
3546
|
+
* pid → the derived owner it duplicates: the second live instance of an
|
|
3547
|
+
* identity. Not journalled (nothing re-drives it), so this is the only
|
|
3548
|
+
* record of why `expose(pid)` refuses it.
|
|
3549
|
+
*/
|
|
3550
|
+
ephemeralPids = new Map();
|
|
3551
|
+
residentClaims = new Map();
|
|
3354
3552
|
static RESIDENCY_PROFILE_MAX_ENTRIES = 16;
|
|
3355
3553
|
/**
|
|
3356
3554
|
* A program that reads a directory of data files misses once per file, so
|
|
@@ -3401,9 +3599,100 @@ export class FacetManager {
|
|
|
3401
3599
|
// Rooted on waitUntil: the hook fires synchronously inside whatever turn
|
|
3402
3600
|
// ended the process, and the delete must not be a floating promise there.
|
|
3403
3601
|
this.processes.setOnTerminal((pid) => {
|
|
3404
|
-
this.ctx.waitUntil(this.
|
|
3602
|
+
this.ctx.waitUntil(this._onResidentTerminal(pid));
|
|
3405
3603
|
});
|
|
3406
3604
|
}
|
|
3605
|
+
/**
|
|
3606
|
+
* The process is over. Every end-of-life passes through here: a clean
|
|
3607
|
+
* exit, a kill, a timeout, a crash. Only one of them owes anything more
|
|
3608
|
+
* than the journal row's release — a crash under 'on-failure' is re-driven
|
|
3609
|
+
* from the row, after a backoff, while the row is still in storage so a
|
|
3610
|
+
* reset inside the backoff window recovers it like any other resident.
|
|
3611
|
+
*/
|
|
3612
|
+
async _onResidentTerminal(pid) {
|
|
3613
|
+
await this.rowAmendments.get(pid);
|
|
3614
|
+
this.ephemeralPids.delete(pid);
|
|
3615
|
+
await this._releaseResidentClaim(pid);
|
|
3616
|
+
if (!this.launchJournal.has(pid))
|
|
3617
|
+
return;
|
|
3618
|
+
const entry = this.processes.get(pid);
|
|
3619
|
+
const key = `${FENCED_WORK_KEY_PREFIX}${pid}`;
|
|
3620
|
+
const row = (await this.launchJournal.rows()).get(key);
|
|
3621
|
+
const crashed = entry?.state === 'exited' && (entry.exitCode ?? 0) !== 0;
|
|
3622
|
+
if (row === undefined || row.restart !== 'on-failure' || !crashed) {
|
|
3623
|
+
await this.launchJournal.release(pid);
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
const delayMs = RESTART_BACKOFF_BASE_MS * 2 ** row.attempt;
|
|
3627
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: "${row.command}" exited with code ${entry.exitCode} — `
|
|
3628
|
+
+ `restarting in ${delayMs / 1000}s (FencedWork attempt ${row.attempt + 1})]\x1b[0m\r\n`);
|
|
3629
|
+
await this.launchPump.nextTurn(Promise.resolve(), Date.now() + delayMs);
|
|
3630
|
+
// The process may have been removed or the session destroyed during the
|
|
3631
|
+
// backoff; a row that is gone is owed nothing.
|
|
3632
|
+
if (!(await this.launchJournal.rows()).has(key))
|
|
3633
|
+
return;
|
|
3634
|
+
await this.launchJournal.drive(key, row);
|
|
3635
|
+
}
|
|
3636
|
+
/** Claim identity AND write its recovery row in one serializable storage transaction. */
|
|
3637
|
+
async _claimResident(record) {
|
|
3638
|
+
const owner = residentOwner(record);
|
|
3639
|
+
if (owner === undefined)
|
|
3640
|
+
throw new Error('resident launch has no owner');
|
|
3641
|
+
const duplicate = await this.ctx.storage.transaction(async (txn) => {
|
|
3642
|
+
const key = `${RESIDENT_OWNER_KEY_PREFIX}${owner}`;
|
|
3643
|
+
const held = await txn.get(key);
|
|
3644
|
+
if (held !== undefined && held !== record.pid && held > this.processes.pidBase
|
|
3645
|
+
&& this.processes.get(held)?.state === 'running')
|
|
3646
|
+
return held;
|
|
3647
|
+
await txn.put(key, record.pid);
|
|
3648
|
+
await txn.put(`${FENCED_WORK_KEY_PREFIX}${record.pid}`, record);
|
|
3649
|
+
return null;
|
|
3650
|
+
});
|
|
3651
|
+
if (duplicate === null) {
|
|
3652
|
+
this.residentClaims.set(record.pid, owner);
|
|
3653
|
+
// Adopt into FencedWork's per-instance lifetime bookkeeping and cross
|
|
3654
|
+
// its sync barrier before booting. The winning row already exists.
|
|
3655
|
+
await this.launchJournal.journal(record);
|
|
3656
|
+
}
|
|
3657
|
+
return duplicate;
|
|
3658
|
+
}
|
|
3659
|
+
async _releaseResidentClaim(pid) {
|
|
3660
|
+
const owner = this.residentClaims.get(pid);
|
|
3661
|
+
if (owner === undefined)
|
|
3662
|
+
return;
|
|
3663
|
+
this.residentClaims.delete(pid);
|
|
3664
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
3665
|
+
const key = `${RESIDENT_OWNER_KEY_PREFIX}${owner}`;
|
|
3666
|
+
if (await txn.get(key) === pid)
|
|
3667
|
+
await txn.delete(key);
|
|
3668
|
+
});
|
|
3669
|
+
await this.ctx.storage.sync();
|
|
3670
|
+
}
|
|
3671
|
+
/**
|
|
3672
|
+
* Amend one journal row in place — port stamp, owner adoption, settle —
|
|
3673
|
+
* serialized per pid so two amendments in flight on the same row cannot
|
|
3674
|
+
* interleave their read and write and lose one another's fields.
|
|
3675
|
+
*/
|
|
3676
|
+
_amendRow(pid, amend) {
|
|
3677
|
+
const key = `${FENCED_WORK_KEY_PREFIX}${pid}`;
|
|
3678
|
+
const previous = this.rowAmendments.get(pid) ?? Promise.resolve(undefined);
|
|
3679
|
+
const next = previous.then(async () => {
|
|
3680
|
+
const row = (await this.launchJournal.rows()).get(key);
|
|
3681
|
+
if (row === undefined)
|
|
3682
|
+
return undefined;
|
|
3683
|
+
const amended = amend(row);
|
|
3684
|
+
if (amended !== row)
|
|
3685
|
+
await this.launchJournal.journal(amended);
|
|
3686
|
+
return amended;
|
|
3687
|
+
});
|
|
3688
|
+
const settled = next.then(() => undefined, () => undefined);
|
|
3689
|
+
this.rowAmendments.set(pid, settled);
|
|
3690
|
+
void settled.then(() => {
|
|
3691
|
+
if (this.rowAmendments.get(pid) === settled)
|
|
3692
|
+
this.rowAmendments.delete(pid);
|
|
3693
|
+
});
|
|
3694
|
+
return next;
|
|
3695
|
+
}
|
|
3407
3696
|
setVfs(vfs) { this.vfs = vfs; }
|
|
3408
3697
|
/**
|
|
3409
3698
|
* The env/ctx pair every loader-backed runtime builds its facet pools
|
|
@@ -3454,61 +3743,92 @@ export class FacetManager {
|
|
|
3454
3743
|
*/
|
|
3455
3744
|
setEsbuildService(esbuild) { this.esbuild = esbuild; }
|
|
3456
3745
|
/**
|
|
3457
|
-
*
|
|
3458
|
-
*
|
|
3459
|
-
*
|
|
3460
|
-
* pass + source construction. See `prefetchBundleCache` for the
|
|
3461
|
-
* correctness argument behind the conservative global-revision watermark.
|
|
3462
|
-
*
|
|
3463
|
-
* The bundle source and manifest are computed once on the miss path and
|
|
3464
|
-
* stored so subsequent hits skip rebuilding them too.
|
|
3746
|
+
* The pacer every launch is built under: the session's alarm-driven turn
|
|
3747
|
+
* pump, the deployment's chunk bound, and the one check a suspended launch
|
|
3748
|
+
* makes when it resumes — that the process it is building for still exists.
|
|
3465
3749
|
*/
|
|
3750
|
+
_launchPacer(pid) {
|
|
3751
|
+
return new TurnBudget(this.launchPump, turnChunkMaxBytes(this.env), () => this._assertLaunchStillOwned(pid));
|
|
3752
|
+
}
|
|
3466
3753
|
/**
|
|
3467
|
-
*
|
|
3754
|
+
* Assemble the filesystem bundle a process boots on, across as many
|
|
3755
|
+
* Durable Object turns as it takes.
|
|
3756
|
+
*
|
|
3757
|
+
* The one builder for every Node process this manager starts. A one-shot
|
|
3758
|
+
* exec and a resident launch used to own two copies of this: exec's was
|
|
3759
|
+
* memoized behind the prefetch cache and raced a wall-clock deadline in a
|
|
3760
|
+
* single turn; the resident's was paged with a TurnBudget and never cached.
|
|
3761
|
+
* Two paths, one job — and a tree large enough to page on one path hit the
|
|
3762
|
+
* deadline on the other, failing every `node -e` in it with "assembling the
|
|
3763
|
+
* filesystem bundle … exceeded". What the two callers genuinely differ in is
|
|
3764
|
+
* the entry, the working directory and the process they build for; that is
|
|
3765
|
+
* all they supply. Everything else — the revision-keyed cache and its stale
|
|
3766
|
+
* eviction, the residency profile a previous miss learned, the reachable-set
|
|
3767
|
+
* walk and its enrichment passes, the ESM→CJS transform, the manifest and
|
|
3768
|
+
* metadata, the module-map serialization with its side-module split, the
|
|
3769
|
+
* `node:sqlite` answer, and the release of the raw cells once they are
|
|
3770
|
+
* serialized — happens here, once, and yields the turn whenever a chunk's
|
|
3771
|
+
* worth of it has been done.
|
|
3772
|
+
*
|
|
3773
|
+
* There is no deadline. A launch that spans turns costs turns, not a held
|
|
3774
|
+
* thread, so a large tree is not a defect to be reported at N seconds; the
|
|
3775
|
+
* pacer's `stillWanted` check is what ends a build nothing will use — a
|
|
3776
|
+
* process killed while its build was suspended throws from the next resume,
|
|
3777
|
+
* and the caller reports that as it reports any other launch failure.
|
|
3468
3778
|
*
|
|
3469
|
-
* The
|
|
3470
|
-
*
|
|
3471
|
-
*
|
|
3472
|
-
*
|
|
3473
|
-
*
|
|
3474
|
-
*
|
|
3779
|
+
* The returned state carries its serialized forms (`bundleSource`,
|
|
3780
|
+
* `serializedManifest`, `serializedMetadata`) and has already released the
|
|
3781
|
+
* raw ones: `generateEntrypointCode` and `generateLongRunningNodeCode` read
|
|
3782
|
+
* the serialized forms and nothing else. A state the cache retained belongs
|
|
3783
|
+
* to the cache — a caller must not release its serialized forms either
|
|
3784
|
+
* (`cacheRetained` says which); one the cache refused belongs to the caller
|
|
3785
|
+
* alone, and `releaseGeneratedSources` drops it once a map is generated.
|
|
3475
3786
|
*
|
|
3476
|
-
*
|
|
3477
|
-
*
|
|
3478
|
-
* it awaits the VFS walk and the esbuild ESM→CJS pass — so a stall at any
|
|
3479
|
-
* of those points is caught and reported here. A stall inside ONE
|
|
3480
|
-
* synchronous stretch is not: a JS stack that never yields cannot be raced
|
|
3481
|
-
* by anything in the same isolate, so serializing a multi-megabyte bundle
|
|
3482
|
-
* in a single pass still wedges, and the deadline fires only once the stack
|
|
3483
|
-
* finally unwinds. That class needs the work bounded at its INPUT rather
|
|
3484
|
-
* than timed at its edge, which is a separate change; this one converts
|
|
3485
|
-
* every interruptible stall from a silent wedge into a loud failure, and
|
|
3486
|
-
* makes the remaining class the only one left to explain.
|
|
3787
|
+
* A session without a filesystem gets an empty state: there is nothing to
|
|
3788
|
+
* stage and nothing to yield for.
|
|
3487
3789
|
*/
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3790
|
+
/**
|
|
3791
|
+
* The closure's wasm images as module-map members for a one-shot facet,
|
|
3792
|
+
* read by path under the process's own credential. An image that cannot
|
|
3793
|
+
* be read is left out; the program's compile then meets the seam's own
|
|
3794
|
+
* refusal, which names the module.
|
|
3795
|
+
*/
|
|
3796
|
+
_wasmModulesByValue(entry, wasmImports) {
|
|
3797
|
+
const modules = {};
|
|
3798
|
+
if (!this.vfs || wasmImports.length === 0)
|
|
3799
|
+
return modules;
|
|
3800
|
+
const vfs = this.vfs.as(entry.cred);
|
|
3801
|
+
for (const image of wasmImports) {
|
|
3802
|
+
let bytes;
|
|
3803
|
+
try {
|
|
3804
|
+
bytes = vfs.readFile(stripLeadingSlashes(image.vfsPath));
|
|
3805
|
+
}
|
|
3806
|
+
catch {
|
|
3807
|
+
continue;
|
|
3808
|
+
}
|
|
3809
|
+
modules[image.moduleName] = {
|
|
3810
|
+
wasm: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
3811
|
+
};
|
|
3507
3812
|
}
|
|
3813
|
+
return modules;
|
|
3508
3814
|
}
|
|
3509
|
-
async
|
|
3510
|
-
|
|
3511
|
-
|
|
3815
|
+
async _buildProcessBundle(entry, spec, pacer) {
|
|
3816
|
+
if (!this.vfs) {
|
|
3817
|
+
return { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
|
|
3818
|
+
}
|
|
3819
|
+
// W3.5 Fix B: thread an EsbuildService into buildPrefetchBundle so ESM
|
|
3820
|
+
// source files (e.g. tldts/dist/es6/index.js, @remix-run/react,
|
|
3821
|
+
// @tailwindcss/vite, react-remove-scroll, astro) get transformed to CJS
|
|
3822
|
+
// before they hit the facet's `new Function` pre-compile loop. Lazy-create
|
|
3823
|
+
// one if NimbusSession didn't share its own.
|
|
3824
|
+
if (!this.esbuild)
|
|
3825
|
+
this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
|
|
3826
|
+
this.imageStore.ensureDir();
|
|
3827
|
+
const vfs = this.vfs.as(entry.cred);
|
|
3828
|
+
const { cred } = entry;
|
|
3829
|
+
const profile = spec.bundleProfile ?? DEFAULT_FACET_BUNDLE_PROFILE;
|
|
3830
|
+
const credKey = `${cred.uid}:${cred.gid}:${cred.groups.join(',')}`;
|
|
3831
|
+
const key = `${profile}\x00${credKey}\x00${spec.cwd}\x00${spec.scriptPath ?? ''}\x00${_fnv1a(spec.entryCode)}`;
|
|
3512
3832
|
const revision = vfs.revision();
|
|
3513
3833
|
// An entry built at an older revision can never be SERVED again — the
|
|
3514
3834
|
// lookup below requires an exact match — so from the first write after it
|
|
@@ -3517,11 +3837,11 @@ export class FacetManager {
|
|
|
3517
3837
|
// replaces it allocates, so the stale filesystem graph and the new one
|
|
3518
3838
|
// never co-reside.
|
|
3519
3839
|
let evictedStale = false;
|
|
3520
|
-
for (const [staleKey,
|
|
3521
|
-
if (
|
|
3840
|
+
for (const [staleKey, stale] of this.prefetchBundleCache) {
|
|
3841
|
+
if (stale.revision === revision)
|
|
3522
3842
|
continue;
|
|
3523
3843
|
this.prefetchBundleCache.delete(staleKey);
|
|
3524
|
-
this.prefetchCacheBytes -=
|
|
3844
|
+
this.prefetchCacheBytes -= stale.bytes;
|
|
3525
3845
|
evictedStale = true;
|
|
3526
3846
|
}
|
|
3527
3847
|
if (evictedStale)
|
|
@@ -3533,24 +3853,26 @@ export class FacetManager {
|
|
|
3533
3853
|
this.prefetchBundleCache.set(key, cached);
|
|
3534
3854
|
return { ...cached.vfsState, cacheHit: true, cacheRetained: true };
|
|
3535
3855
|
}
|
|
3536
|
-
const vfsState = await buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, this.esbuild
|
|
3856
|
+
const vfsState = await buildPrefetchBundle(vfs, spec.scriptPath, spec.cwd, spec.entryCode, this.esbuild, profile, this.residencyProfiles.get(key), pacer, this.hooks.transformLargeEsm);
|
|
3537
3857
|
vfsState.bundleKey = key;
|
|
3538
|
-
vfsState.bundleSource = await buildFacetVfsBundleSource(vfsState.bundle, vfsState.bundleSideModulesRequired);
|
|
3858
|
+
vfsState.bundleSource = await buildFacetVfsBundleSource(vfsState.bundle, vfsState.bundleSideModulesRequired, pacer);
|
|
3539
3859
|
vfsState.serializedManifest = JSON.stringify(vfsState.manifest);
|
|
3540
3860
|
vfsState.serializedMetadata = JSON.stringify(vfsState.metadata);
|
|
3861
|
+
// Two more serializations of the same weight as the passes before them.
|
|
3862
|
+
await pacer.spend(vfsState.serializedManifest.length + vfsState.serializedMetadata.length);
|
|
3541
3863
|
// The only consumer of the raw cells past this point is a single boolean,
|
|
3542
3864
|
// so answer it now rather than hold ~17 MB (pi) to answer it later.
|
|
3543
|
-
vfsState.usesNodeSqlite = bundleUsesNodeSqlite(entryCode, vfsState.bundle);
|
|
3865
|
+
vfsState.usesNodeSqlite = bundleUsesNodeSqlite(spec.entryCode, vfsState.bundle);
|
|
3544
3866
|
vfsState.cacheHit = false;
|
|
3545
3867
|
// Serialization is total: bundleSource/serializedManifest/serializedMetadata
|
|
3546
|
-
// carry every byte the raw cells and objects do, and
|
|
3547
|
-
//
|
|
3548
|
-
//
|
|
3549
|
-
//
|
|
3550
|
-
//
|
|
3551
|
-
//
|
|
3552
|
-
//
|
|
3553
|
-
//
|
|
3868
|
+
// carry every byte the raw cells and objects do, and both generators read
|
|
3869
|
+
// only the serialized forms. Retaining both doubled what an entry costs
|
|
3870
|
+
// for its whole lifetime — measured for pi at 502af77, per entry: raw
|
|
3871
|
+
// 17,253,610 + source 18,262,324 + manifest 600,060 + metadata 3,841,244
|
|
3872
|
+
// = 39,957,238 B, of which the raw halves are 21,694,914 B held to answer
|
|
3873
|
+
// `usesNodeSqlite`. Dropping them is a pure release: nothing downstream of
|
|
3874
|
+
// this method reads them, and a cache MISS rebuilds from the VFS rather
|
|
3875
|
+
// than from anything discarded here.
|
|
3554
3876
|
//
|
|
3555
3877
|
// Released BEFORE admission so the byte bound prices what the entry costs
|
|
3556
3878
|
// from here on, not the peak it passed through on the way in.
|
|
@@ -3713,7 +4035,7 @@ export class FacetManager {
|
|
|
3713
4035
|
if (opts.skipSpawn && opts.callerPid != null) {
|
|
3714
4036
|
// The caller already allocated the PID via the supervisor
|
|
3715
4037
|
// (with their own user-facing label). Look up the full entry
|
|
3716
|
-
// from the table —
|
|
4038
|
+
// from the table — the exec path needs the canonical
|
|
3717
4039
|
// ProcessEntry shape. Do NOT reap() either: reaping would
|
|
3718
4040
|
// clear the caller's just-spawned entry because its startTime
|
|
3719
4041
|
// is recent (< 60s) but reap() ALSO drops 'running' entries
|
|
@@ -3738,30 +4060,62 @@ export class FacetManager {
|
|
|
3738
4060
|
catch { }
|
|
3739
4061
|
}
|
|
3740
4062
|
}
|
|
3741
|
-
// W3.5 Fix B: thread an EsbuildService into buildPrefetchBundle so
|
|
3742
|
-
// ESM source files (e.g. tldts/dist/es6/index.js, @remix-run/react,
|
|
3743
|
-
// @tailwindcss/vite, react-remove-scroll, astro) get transformed to
|
|
3744
|
-
// CJS before they hit the facet's `new Function` pre-compile loop.
|
|
3745
|
-
// Lazy-create one if NimbusSession didn't share its own.
|
|
3746
|
-
if (this.vfs && !this.esbuild) {
|
|
3747
|
-
this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
|
|
3748
|
-
}
|
|
3749
4063
|
const diagOn = isExecDiagEnabled();
|
|
3750
4064
|
const __bundleStart = diagOn ? Date.now() : 0;
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
4065
|
+
// Paced like a resident launch: a tree too large for one turn costs
|
|
4066
|
+
// turns, and the pacer's stillWanted check ends a build whose process was
|
|
4067
|
+
// killed while it was suspended. The pacer is settled in the finally
|
|
4068
|
+
// below and not before, because the invocation that granted the last
|
|
4069
|
+
// chunk awaits `chunkEnded` (PacedWork.pump) and has to stay the one
|
|
4070
|
+
// that owns the run: settling at the end of the build would release that
|
|
4071
|
+
// turn with the facet still to load and run on it.
|
|
4072
|
+
const pacer = this._launchPacer(entry.pid);
|
|
4073
|
+
let vfsState;
|
|
4074
|
+
try {
|
|
4075
|
+
vfsState = await this._buildProcessBundle(entry, { scriptPath: opts.filename, cwd: opts.cwd || '/home/user', entryCode: code, bundleProfile: opts.bundleProfile }, pacer);
|
|
4076
|
+
}
|
|
4077
|
+
catch (err) {
|
|
4078
|
+
// The require closure that cannot fit the snapshot bound is the
|
|
4079
|
+
// process's own answer, not a build failure: exit 1 with a stderr
|
|
4080
|
+
// that names the entry, the staged bytes at the stop, the bound,
|
|
4081
|
+
// and the remedy — the same shape the got/next guards print.
|
|
4082
|
+
pacer.settle();
|
|
4083
|
+
if (err instanceof ClosureBoundExceededError) {
|
|
4084
|
+
const o = err.outcome;
|
|
4085
|
+
const mib = (n) => `${(n / 1048576).toFixed(1)} MiB`;
|
|
4086
|
+
const stderr = `require closure for ${o.entry} exceeds the snapshot bound: ` +
|
|
4087
|
+
`${mib(o.bytesSeen)} staged when ${o.lastPath} crossed the ` +
|
|
4088
|
+
`${mib(o.bound)} bound; the process was not started.\n` +
|
|
4089
|
+
`Run it as a server or split the entry; there is no flag to bypass.`;
|
|
4090
|
+
if (this.processes.get(entry.pid)?.state === 'running') {
|
|
4091
|
+
this._failLaunch(entry.pid, stderr);
|
|
4092
|
+
}
|
|
4093
|
+
return { exitCode: 1, stdout: '', stderr };
|
|
4094
|
+
}
|
|
4095
|
+
// A failed build is thrown to the caller exactly as before. What must
|
|
4096
|
+
// not be left behind is the process entry: it was spawned above and
|
|
4097
|
+
// would otherwise sit 'running' forever for a process that never
|
|
4098
|
+
// started. A build ended by a kill finds its entry already exited and
|
|
4099
|
+
// reports nothing twice.
|
|
4100
|
+
if (this.processes.get(entry.pid)?.state === 'running') {
|
|
4101
|
+
this._failLaunch(entry.pid, `assembling the filesystem bundle for \`${command}\` failed: ${errorMessage(err)}`);
|
|
4102
|
+
}
|
|
4103
|
+
throw err;
|
|
4104
|
+
}
|
|
3758
4105
|
const bundleMs = diagOn ? Date.now() - __bundleStart : 0;
|
|
3759
4106
|
const diagSink = diagOn
|
|
3760
4107
|
? { loadMs: 0, runMs: 0, moduleMapBytes: 0, bundleBytes: 0, manifestBytes: 0, metadataBytes: 0 }
|
|
3761
4108
|
: undefined;
|
|
3762
4109
|
const abortController = new AbortController();
|
|
4110
|
+
// Ctrl-C / kill on a user program has to end the in-flight run, not just
|
|
4111
|
+
// mark the table row: the runOnce request carries this signal.
|
|
4112
|
+
this.processes.setTerminator(entry.pid, () => abortController.abort());
|
|
4113
|
+
// A shell Ctrl+C arrives as an abort on the command's signal — it is the
|
|
4114
|
+
// same kill, forwarded onto the controller the runOnce request carries.
|
|
4115
|
+
const onShellAbort = () => abortController.abort();
|
|
4116
|
+
opts.signal?.addEventListener('abort', onShellAbort, { once: true });
|
|
3763
4117
|
try {
|
|
3764
|
-
const result = await this.
|
|
4118
|
+
const result = await this._execViaLoader(code, opts, entry, vfsState, abortController.signal, diagSink);
|
|
3765
4119
|
this._flushVfsWrites(result, entry.pid);
|
|
3766
4120
|
this._recordResidencyMisses(vfsState.bundleKey, result.residencyMisses);
|
|
3767
4121
|
this.processes.exit(entry.pid, result.exitCode);
|
|
@@ -3782,6 +4136,7 @@ export class FacetManager {
|
|
|
3782
4136
|
rpcWrites: result.diag?.rpcWrites ?? 0,
|
|
3783
4137
|
fsRpcReads: result.diag?.fsRpcReads ?? 0,
|
|
3784
4138
|
cacheHit: vfsState.cacheHit ?? false,
|
|
4139
|
+
turns: pacer.chunks,
|
|
3785
4140
|
exitCode: result.exitCode,
|
|
3786
4141
|
at: Date.now(),
|
|
3787
4142
|
});
|
|
@@ -3789,31 +4144,27 @@ export class FacetManager {
|
|
|
3789
4144
|
return result;
|
|
3790
4145
|
}
|
|
3791
4146
|
catch (err) {
|
|
3792
|
-
//
|
|
3793
|
-
//
|
|
3794
|
-
//
|
|
3795
|
-
//
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
4147
|
+
// The abort controller firing means this run ended BY a kill — the
|
|
4148
|
+
// shell's Ctrl+C or `kill <pid>`. The process was not a crash and the
|
|
4149
|
+
// abort text is not its stderr: mark it killed and hand back 130 (the
|
|
4150
|
+
// shell's signalled status) with nothing for the terminal to print.
|
|
4151
|
+
if (abortController.signal.aborted) {
|
|
4152
|
+
this.processes.kill(entry.pid);
|
|
4153
|
+
return { exitCode: 130, stdout: '', stderr: '' };
|
|
4154
|
+
}
|
|
4155
|
+
const exitCode = 1;
|
|
4156
|
+
const reason = `runtime worker error: ${errorMessage(err)}`;
|
|
3800
4157
|
this.processes.exit(entry.pid, exitCode);
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
// store marks exit AND the tabs-UI structured event fires. The
|
|
3805
|
-
// timeout path already called onExternalExit from the timeout
|
|
3806
|
-
// handler; _reportExternalExit's getExit() guard dedupes.
|
|
3807
|
-
if (!timedOut) {
|
|
3808
|
-
try {
|
|
3809
|
-
this.hooks.onExternalExit?.(entry.pid, exitCode, reason);
|
|
3810
|
-
}
|
|
3811
|
-
catch { }
|
|
4158
|
+
this._w5RecordTermination(entry.pid, exitCode, 'runtime-worker', reason);
|
|
4159
|
+
try {
|
|
4160
|
+
this.hooks.onExternalExit?.(entry.pid, exitCode, reason);
|
|
3812
4161
|
}
|
|
4162
|
+
catch { }
|
|
3813
4163
|
return { exitCode, stdout: '', stderr: errorMessage(err) };
|
|
3814
4164
|
}
|
|
3815
4165
|
finally {
|
|
3816
|
-
|
|
4166
|
+
opts.signal?.removeEventListener('abort', onShellAbort);
|
|
4167
|
+
pacer.settle();
|
|
3817
4168
|
}
|
|
3818
4169
|
}
|
|
3819
4170
|
/**
|
|
@@ -3853,7 +4204,7 @@ export class FacetManager {
|
|
|
3853
4204
|
}
|
|
3854
4205
|
// ── One-shot dynamic Worker entrypoint ────────────────────────────────
|
|
3855
4206
|
async _execViaLoader(code, opts, entry, vfsState, signal, diagSink) {
|
|
3856
|
-
// Answered by
|
|
4207
|
+
// Answered by _buildProcessBundle while the raw cells were still in
|
|
3857
4208
|
// hand; re-deriving it here is what forced them to be retained.
|
|
3858
4209
|
const usesSqlite = vfsState.usesNodeSqlite ?? bundleUsesNodeSqlite(code, vfsState.bundle);
|
|
3859
4210
|
const [sqliteModules, shims] = await Promise.all([
|
|
@@ -3873,13 +4224,6 @@ export class FacetManager {
|
|
|
3873
4224
|
stdin: opts.stdin || '',
|
|
3874
4225
|
captureOutput: !!opts.captureOutput,
|
|
3875
4226
|
cred: { ...entry.cred, groups: [...entry.cred.groups] },
|
|
3876
|
-
// Absolute wall-clock instant the entry drain must stop at, derived
|
|
3877
|
-
// from the same FACET_TIMEOUT_MS the supervisor's kill timer uses. It
|
|
3878
|
-
// is stamped here rather than computed facet-side so the budget tracks
|
|
3879
|
-
// the real remaining lifetime; everything still to happen before the
|
|
3880
|
-
// facet runs (module map build, LOADER.load, the RPC hop) only makes
|
|
3881
|
-
// this earlier than the kill, which is the safe direction.
|
|
3882
|
-
entryDeadlineAt: Date.now() + FACET_TIMEOUT_MS - ONE_SHOT_EXIT_RESERVE_MS,
|
|
3883
4227
|
vfsCursor: vfsState.cursor,
|
|
3884
4228
|
...(diagSink ? { diag: true } : {}),
|
|
3885
4229
|
});
|
|
@@ -3893,16 +4237,21 @@ export class FacetManager {
|
|
|
3893
4237
|
// here rather than named by the enclosing frame, and the serialized
|
|
3894
4238
|
// forms it was built from are released unless the prefetch cache is
|
|
3895
4239
|
// keeping them. Otherwise the coordinator carries a second full copy
|
|
3896
|
-
// of the program for the whole
|
|
3897
|
-
//
|
|
4240
|
+
// of the program for the whole run the facet then performs,
|
|
4241
|
+
// which is the window the isolate was being reset in.
|
|
3898
4242
|
code: async () => {
|
|
3899
|
-
|
|
4243
|
+
// A one-shot has no disk reader at load, so the closure's wasm
|
|
4244
|
+
// images ride by value: read here, inside the scope that holds
|
|
4245
|
+
// the map, and compiled by the loader like the sqlite sidecar.
|
|
4246
|
+
const wasmImports = facetWasmImports([], vfsState.wasmImages ?? []);
|
|
4247
|
+
const wasmModules = this._wasmModulesByValue(entry, wasmImports);
|
|
4248
|
+
const generatedWorker = await generateEntrypointCode(code, vfsState, usesSqlite, shims, wasmImports);
|
|
3900
4249
|
if (diagSink) {
|
|
3901
4250
|
diagSink.moduleMapBytes = _encodedSourceBytes(generatedWorker.code);
|
|
3902
4251
|
for (const source of Object.values(generatedWorker.modules)) {
|
|
3903
4252
|
diagSink.moduleMapBytes += _encodedSourceBytes(source);
|
|
3904
4253
|
}
|
|
3905
|
-
for (const m of Object.values(sqliteModules)) {
|
|
4254
|
+
for (const m of [...Object.values(sqliteModules), ...Object.values(wasmModules)]) {
|
|
3906
4255
|
diagSink.moduleMapBytes += m.wasm.byteLength;
|
|
3907
4256
|
}
|
|
3908
4257
|
// Read before the release below, which is the last moment the
|
|
@@ -3922,7 +4271,7 @@ export class FacetManager {
|
|
|
3922
4271
|
compatibilityDate: CF_COMPAT_DATE,
|
|
3923
4272
|
compatibilityFlags: ['nodejs_compat', 'nodejs_compat_v2'],
|
|
3924
4273
|
mainModule: 'runner.js',
|
|
3925
|
-
modules: { 'runner.js': generatedWorker.code, ...generatedWorker.modules, ...sqliteModules },
|
|
4274
|
+
modules: { 'runner.js': generatedWorker.code, ...generatedWorker.modules, ...sqliteModules, ...wasmModules },
|
|
3926
4275
|
};
|
|
3927
4276
|
},
|
|
3928
4277
|
request: new Request('http://nimbus-runtime.local/run', {
|
|
@@ -4330,7 +4679,7 @@ export class FacetManager {
|
|
|
4330
4679
|
_assertLaunchStillOwned(pid) {
|
|
4331
4680
|
if (this.processes.get(pid)?.state === 'running')
|
|
4332
4681
|
return;
|
|
4333
|
-
throw new Error(`Nimbus:
|
|
4682
|
+
throw new Error(`Nimbus: the launch for pid ${pid} was cancelled while it was suspended`);
|
|
4334
4683
|
}
|
|
4335
4684
|
/**
|
|
4336
4685
|
* The one way this manager boots a resident process. Every resident process
|
|
@@ -4486,6 +4835,21 @@ export class FacetManager {
|
|
|
4486
4835
|
return '(no output captured)';
|
|
4487
4836
|
}
|
|
4488
4837
|
}
|
|
4838
|
+
/**
|
|
4839
|
+
* A launch that fails before its process is running reports the same way
|
|
4840
|
+
* regardless of which phase failed: the pid is exited, the terminal event
|
|
4841
|
+
* recorded, and the session notified. Callers do their phase-specific
|
|
4842
|
+
* cleanup (ports, tracked RPC resources) first and pass a reason that names
|
|
4843
|
+
* the phase.
|
|
4844
|
+
*/
|
|
4845
|
+
_failLaunch(pid, reason) {
|
|
4846
|
+
this.processes.exit(pid, 1);
|
|
4847
|
+
this._w5RecordTermination(pid, 1, 'facet', reason);
|
|
4848
|
+
try {
|
|
4849
|
+
this.hooks.onExternalExit?.(pid, 1, reason);
|
|
4850
|
+
}
|
|
4851
|
+
catch { }
|
|
4852
|
+
}
|
|
4489
4853
|
/** Flush files written by the script back to the supervisor's VFS. */
|
|
4490
4854
|
_flushVfsWrites(result, pid) {
|
|
4491
4855
|
if (!this.vfs || !result.vfsWrites)
|
|
@@ -4505,34 +4869,6 @@ export class FacetManager {
|
|
|
4505
4869
|
vfs.writeFile(path, restored);
|
|
4506
4870
|
}
|
|
4507
4871
|
}
|
|
4508
|
-
/** Execution timeout. */
|
|
4509
|
-
async _execWithTimeout(promise, entry, abort) {
|
|
4510
|
-
let timer;
|
|
4511
|
-
const timeout = new Promise((_, reject) => {
|
|
4512
|
-
timer = setTimeout(() => {
|
|
4513
|
-
this.timedOutProcessIds.add(entry.pid);
|
|
4514
|
-
abort();
|
|
4515
|
-
// The process cannot report exit after the request is cancelled, so
|
|
4516
|
-
// notify the session explicitly.
|
|
4517
|
-
try {
|
|
4518
|
-
this.hooks.onExternalExit?.(entry.pid, 124, // conventional timeout exit code
|
|
4519
|
-
`timeout after ${FACET_TIMEOUT_MS / 1000}s`);
|
|
4520
|
-
}
|
|
4521
|
-
catch { }
|
|
4522
|
-
reject(new Error(`Process timed out after ${FACET_TIMEOUT_MS / 1000}s`));
|
|
4523
|
-
}, FACET_TIMEOUT_MS);
|
|
4524
|
-
});
|
|
4525
|
-
// Always clear the timer; otherwise a successful run would still
|
|
4526
|
-
// trigger the timeout callback at FACET_TIMEOUT_MS, spuriously
|
|
4527
|
-
// marking the exit code as 124.
|
|
4528
|
-
try {
|
|
4529
|
-
return await Promise.race([promise, timeout]);
|
|
4530
|
-
}
|
|
4531
|
-
finally {
|
|
4532
|
-
if (timer !== undefined)
|
|
4533
|
-
clearTimeout(timer);
|
|
4534
|
-
}
|
|
4535
|
-
}
|
|
4536
4872
|
/**
|
|
4537
4873
|
* Re-drive a journalled launch after an instance reset. What the journal
|
|
4538
4874
|
* row carries is the recipe and nothing else: env and credentials are never
|
|
@@ -4544,24 +4880,36 @@ export class FacetManager {
|
|
|
4544
4880
|
switch (recipe.kind) {
|
|
4545
4881
|
case 'node': return this._spawnResident(recipe.code, recipe.opts, attempt);
|
|
4546
4882
|
case 'worker': {
|
|
4547
|
-
//
|
|
4548
|
-
//
|
|
4549
|
-
//
|
|
4550
|
-
|
|
4883
|
+
// Which resolver a worker recipe re-drives through is decided by
|
|
4884
|
+
// `recipe.resident`, and it is not a flag: it is the interpreter
|
|
4885
|
+
// image (`{ runtime, argv }`) of a python/ruby socket server the
|
|
4886
|
+
// SESSION launched for itself. Only those recipes take the
|
|
4887
|
+
// image-store fallback, because the session persisted that image and
|
|
4888
|
+
// no embedder was ever asked about the launch. Every other worker
|
|
4889
|
+
// recipe — an embedder's `spawnWorker`, durable or not — re-drives
|
|
4890
|
+
// through the embedder's `resolveWorkerLaunch`, and falls back to the
|
|
4891
|
+
// image store only when no embedder hook is composed at all.
|
|
4892
|
+
const resolve = recipe.resident
|
|
4893
|
+
? this.hooks.resolveWorkerLaunchFallback
|
|
4894
|
+
: this.hooks.resolveWorkerLaunch ?? this.hooks.resolveWorkerLaunchFallback;
|
|
4551
4895
|
if (!resolve)
|
|
4552
4896
|
throw new Error('resident launch journal holds a worker launch but no resolveWorkerLaunch hook is composed');
|
|
4553
4897
|
const resolved = await resolve(recipe);
|
|
4554
4898
|
if (resolved === null)
|
|
4555
4899
|
return undefined;
|
|
4556
|
-
const
|
|
4900
|
+
const mainModule = resolved.mainModule ?? recipe.mainModule ?? DEFAULT_WORKER_MAIN_MODULE;
|
|
4901
|
+
const { [mainModule]: workerCode, ...modules } = resolved.modules;
|
|
4557
4902
|
if (workerCode === undefined)
|
|
4558
|
-
throw new Error(
|
|
4903
|
+
throw new Error(`resolved worker launch carries no ${mainModule} main module`);
|
|
4559
4904
|
return this._spawnWorker(workerCode, record.command, recipe.cwd, {
|
|
4560
|
-
port: recipe.port > 0 ? recipe.port : undefined, modules, compatibilityDate: recipe.compatibilityDate,
|
|
4561
|
-
compatibilityFlags: recipe.compatibilityFlags, startArgs: recipe.startArgs,
|
|
4905
|
+
port: recipe.resident ? record.port : recipe.port > 0 ? recipe.port : undefined, modules, compatibilityDate: recipe.compatibilityDate,
|
|
4906
|
+
compatibilityFlags: recipe.compatibilityFlags, startArgs: resolved.startArgs ?? recipe.startArgs,
|
|
4907
|
+
resident: recipe.resident, restart: record.restart,
|
|
4562
4908
|
env: resolved.env ?? undefined, globalOutbound: resolved.globalOutbound,
|
|
4563
4909
|
vfsWasmModules: resolved.vfsWasmModules,
|
|
4564
|
-
|
|
4910
|
+
vfsTextModules: resolved.vfsTextModules,
|
|
4911
|
+
mainModule,
|
|
4912
|
+
durable: { owner: residentOwner(record) ?? recipe.owner, image: recipe.image },
|
|
4565
4913
|
}, attempt);
|
|
4566
4914
|
}
|
|
4567
4915
|
}
|
|
@@ -4641,7 +4989,7 @@ export class FacetManager {
|
|
|
4641
4989
|
*/
|
|
4642
4990
|
async _runResidentLaunch(entry, code, command, opts, attempt) {
|
|
4643
4991
|
const cwd = opts.cwd || '/home/user';
|
|
4644
|
-
const pacer =
|
|
4992
|
+
const pacer = this._launchPacer(entry.pid);
|
|
4645
4993
|
// Journalled before the first byte of work. A launch that FAILS deletes
|
|
4646
4994
|
// its row on the way out — its process has already been exited and the
|
|
4647
4995
|
// user notified, so there is nothing left to owe. A launch that SETTLES
|
|
@@ -4652,50 +5000,88 @@ export class FacetManager {
|
|
|
4652
5000
|
// terminal hook when the process ends. A re-drive owns its own process,
|
|
4653
5001
|
// so the caller's pid goes with the instance that had it.
|
|
4654
5002
|
//
|
|
4655
|
-
//
|
|
4656
|
-
//
|
|
4657
|
-
//
|
|
4658
|
-
//
|
|
4659
|
-
//
|
|
4660
|
-
//
|
|
4661
|
-
// the
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
5003
|
+
// Every resident has an identity from the moment it is spawned: derived
|
|
5004
|
+
// from the working directory and argv (never the env), so the same
|
|
5005
|
+
// `node server.js` from the same directory is the same application
|
|
5006
|
+
// across restarts and resets. A resident that declares a port at spawn
|
|
5007
|
+
// which an EXPLICIT reservation holds (an embedder's ensureDurableApp)
|
|
5008
|
+
// adopts that reservation's owner instead — the landed contract: the
|
|
5009
|
+
// embedder declared the port, so whatever binds it is the application.
|
|
5010
|
+
//
|
|
5011
|
+
// A second live instance of the same derived identity is ephemeral: it
|
|
5012
|
+
// runs, but it is not the durable one — it takes no slot (the slot is
|
|
5013
|
+
// the identity's, and a live facet already holds it), claims no
|
|
5014
|
+
// reservation, and is not journalled, so nothing re-drives it and a
|
|
5015
|
+
// port it binds retires the previous occupant's capability like any
|
|
5016
|
+
// unrelated process would.
|
|
5017
|
+
const derivedOwner = await deriveResidentOwner(cwd, opts.argv ?? []);
|
|
5018
|
+
let owner = derivedOwner;
|
|
4665
5019
|
if (opts.port !== undefined && opts.port > 0 && opts.port < 65536) {
|
|
4666
|
-
const
|
|
4667
|
-
if (
|
|
4668
|
-
|
|
4669
|
-
durablePort = opts.port;
|
|
4670
|
-
durableFacetName = await acquireDurableFacetSlot(this.ctx, durableOwner);
|
|
5020
|
+
const declared = await readPortReservation(this.ctx, opts.port);
|
|
5021
|
+
if (declared !== null && declared.owner !== null && declared.kind === 'explicit') {
|
|
5022
|
+
owner = declared.owner;
|
|
4671
5023
|
}
|
|
4672
5024
|
}
|
|
5025
|
+
const initial = {
|
|
5026
|
+
pid: entry.pid, command, attempt, phase: 'starting', owner,
|
|
5027
|
+
recipe: { kind: 'node', code, opts: { ...opts, skipSpawn: undefined, callerPid: undefined } },
|
|
5028
|
+
restart: residentRestartPolicy(opts.env),
|
|
5029
|
+
};
|
|
5030
|
+
let duplicateOf;
|
|
5031
|
+
try {
|
|
5032
|
+
duplicateOf = await this._claimResident(initial);
|
|
5033
|
+
this._assertLaunchStillOwned(entry.pid);
|
|
5034
|
+
}
|
|
5035
|
+
catch (e) {
|
|
5036
|
+
pacer.settle();
|
|
5037
|
+
await this._releaseResidentClaim(entry.pid);
|
|
5038
|
+
await this.launchJournal.release(entry.pid);
|
|
5039
|
+
this._failLaunch(entry.pid, 'long-running node launch failed: ' + errorMessage(e));
|
|
5040
|
+
throw e;
|
|
5041
|
+
}
|
|
5042
|
+
const ephemeral = duplicateOf !== null;
|
|
5043
|
+
// The reservation the identity holds, if any: its port is what `$PORT`
|
|
5044
|
+
// is set to and what the facet's durable slot is bound for, so the
|
|
5045
|
+
// resident's `ctx.storage` persists across resets the way a durable
|
|
5046
|
+
// worker's does.
|
|
5047
|
+
const held = ephemeral ? null : await readPortReservationByOwner(this.ctx, owner);
|
|
5048
|
+
let durableFacetName;
|
|
5049
|
+
let launchEnv;
|
|
5050
|
+
if (held !== null) {
|
|
5051
|
+
durableFacetName = await acquireDurableFacetSlot(this.ctx, owner);
|
|
5052
|
+
launchEnv = {
|
|
5053
|
+
PORT: String(held.port),
|
|
5054
|
+
NIMBUS_APP: held.reservation.name ?? owner,
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
if (ephemeral) {
|
|
5058
|
+
this.ephemeralPids.set(entry.pid, owner);
|
|
5059
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: second instance of "${(opts.argv ?? []).join(' ') || command}" `
|
|
5060
|
+
+ `is not the durable one — pid ${duplicateOf} keeps the identity]\x1b[0m\r\n`);
|
|
5061
|
+
}
|
|
4673
5062
|
const record = {
|
|
4674
5063
|
pid: entry.pid,
|
|
4675
5064
|
command,
|
|
4676
5065
|
attempt,
|
|
4677
5066
|
phase: 'starting',
|
|
4678
5067
|
recipe: { kind: 'node', code, opts: { ...opts, skipSpawn: undefined, callerPid: undefined } },
|
|
4679
|
-
|
|
5068
|
+
owner,
|
|
5069
|
+
...(held !== null ? { port: held.port, injectedPort: held.port } : {}),
|
|
5070
|
+
restart: residentRestartPolicy(opts.env),
|
|
4680
5071
|
};
|
|
4681
5072
|
try {
|
|
4682
|
-
|
|
5073
|
+
if (!ephemeral)
|
|
5074
|
+
await this.launchJournal.journal(record);
|
|
4683
5075
|
}
|
|
4684
5076
|
catch (e) {
|
|
4685
5077
|
// A resident that cannot be journalled does not start; the failure is
|
|
4686
5078
|
// reported the way a boot failure is.
|
|
4687
5079
|
pacer.settle();
|
|
4688
|
-
this.
|
|
4689
|
-
const reason = 'long-running node launch failed: ' + errorMessage(e);
|
|
4690
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
4691
|
-
try {
|
|
4692
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
4693
|
-
}
|
|
4694
|
-
catch { }
|
|
5080
|
+
this._failLaunch(entry.pid, 'long-running node launch failed: ' + errorMessage(e));
|
|
4695
5081
|
throw e;
|
|
4696
5082
|
}
|
|
4697
5083
|
try {
|
|
4698
|
-
await this._residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName);
|
|
5084
|
+
await this._residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv);
|
|
4699
5085
|
}
|
|
4700
5086
|
catch (e) {
|
|
4701
5087
|
await this.launchJournal.release(entry.pid);
|
|
@@ -4707,27 +5093,26 @@ export class FacetManager {
|
|
|
4707
5093
|
// Booted and running: the launch proved itself, so the resident starts
|
|
4708
5094
|
// its running life with a fresh re-drive budget. If the process already
|
|
4709
5095
|
// ended inside the launch body's own settlement, the terminal hook has
|
|
4710
|
-
// released the row — do not write it back.
|
|
5096
|
+
// released the row — do not write it back. Amended, not rewritten from
|
|
5097
|
+
// `record`: a port the program bound during its boot has already been
|
|
5098
|
+
// stamped onto the row, and the settle must not lose it.
|
|
4711
5099
|
if (this.launchJournal.has(entry.pid)) {
|
|
4712
|
-
await this.
|
|
5100
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, attempt: 0, phase: 'running' }));
|
|
4713
5101
|
}
|
|
4714
5102
|
}
|
|
4715
|
-
async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName) {
|
|
4716
|
-
if (this.vfs && !this.esbuild) {
|
|
4717
|
-
this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
|
|
4718
|
-
}
|
|
4719
|
-
if (this.vfs)
|
|
4720
|
-
this.imageStore.ensureDir();
|
|
5103
|
+
async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv) {
|
|
4721
5104
|
const diagOn = isExecDiagEnabled();
|
|
4722
5105
|
const __bundleStart = diagOn ? Date.now() : 0;
|
|
4723
|
-
const
|
|
4724
|
-
const vfsState = processVfs
|
|
4725
|
-
? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer, this.hooks.transformLargeEsm)
|
|
4726
|
-
: { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
|
|
5106
|
+
const vfsState = await this._buildProcessBundle(entry, { scriptPath: opts.filename, cwd, entryCode: code, bundleProfile: opts.bundleProfile }, pacer);
|
|
4727
5107
|
const bundleMs = diagOn ? Date.now() - __bundleStart : 0;
|
|
5108
|
+
// The launch-time overlay (`$PORT`, `$NIMBUS_APP` under a reservation)
|
|
5109
|
+
// rides on top of the recipe's env and is never journalled: it is
|
|
5110
|
+
// re-derived from the reservation on every launch, so a port the
|
|
5111
|
+
// application was given follows the reservation, not the recipe.
|
|
5112
|
+
const spawnEnv = launchEnv === undefined ? opts.env : { ...(opts.env || {}), ...launchEnv };
|
|
4728
5113
|
const processEnv = opts.attachedTty
|
|
4729
5114
|
? {
|
|
4730
|
-
...(
|
|
5115
|
+
...(spawnEnv || {}),
|
|
4731
5116
|
NIMBUS_ATTACHED_TTY: '1',
|
|
4732
5117
|
NIMBUS_CP_CHILD_PID: String(entry.pid),
|
|
4733
5118
|
TERM: opts.env?.TERM || 'xterm-256color',
|
|
@@ -4736,13 +5121,16 @@ export class FacetManager {
|
|
|
4736
5121
|
LINES: opts.env?.LINES || '24',
|
|
4737
5122
|
FORCE_COLOR: opts.env?.FORCE_COLOR || '1',
|
|
4738
5123
|
}
|
|
4739
|
-
:
|
|
4740
|
-
|
|
5124
|
+
: spawnEnv;
|
|
5125
|
+
// Answered by _buildProcessBundle while the raw cells were still in hand.
|
|
5126
|
+
const usesSqlite = vfsState.usesNodeSqlite ?? bundleUsesNodeSqlite(code, vfsState.bundle);
|
|
4741
5127
|
const [sqliteModules, shims] = await Promise.all([
|
|
4742
5128
|
this.sqliteModuleEntry(usesSqlite),
|
|
4743
5129
|
fetchNodeShimsCode(this.env),
|
|
4744
5130
|
]);
|
|
4745
|
-
|
|
5131
|
+
// Each image is read by path when the facet loads, never by value here.
|
|
5132
|
+
const wasmImports = facetWasmImports([], vfsState.wasmImages ?? []);
|
|
5133
|
+
let generatedWorker = await generateLongRunningNodeCode(code, vfsState, { ...opts, env: processEnv, cred: entry.cred, wasmImports }, usesSqlite, shims, pacer);
|
|
4746
5134
|
// Sized here, while the map is still in hand. Reading these after the load
|
|
4747
5135
|
// would itself be what keeps the map alive, and the whole point of the
|
|
4748
5136
|
// scoping below is that nothing does.
|
|
@@ -4754,21 +5142,31 @@ export class FacetManager {
|
|
|
4754
5142
|
}
|
|
4755
5143
|
moduleMapBytes = _encodedSourceBytes(generatedWorker.code) + bundleBytes;
|
|
4756
5144
|
}
|
|
4757
|
-
const manifestBytes = diagOn ? _encodedSourceBytes(
|
|
4758
|
-
const metadataBytes = diagOn ? _encodedSourceBytes(
|
|
5145
|
+
const manifestBytes = diagOn ? _encodedSourceBytes(vfsState.serializedManifest ?? '') : 0;
|
|
5146
|
+
const metadataBytes = diagOn ? _encodedSourceBytes(vfsState.serializedMetadata ?? '') : 0;
|
|
4759
5147
|
const cacheHit = vfsState.cacheHit ?? false;
|
|
4760
5148
|
const vfsCursor = vfsState.cursor;
|
|
4761
|
-
|
|
5149
|
+
// The map is generated; the state's only remaining job is its cursor.
|
|
5150
|
+
// Released here, before the boot — releasing afterwards keeps the copy
|
|
5151
|
+
// alive for exactly the window that was resetting the isolate. A state
|
|
5152
|
+
// the cache retained is the cache's to keep, and is served to the next
|
|
5153
|
+
// launch of the same entry.
|
|
5154
|
+
if (!vfsState.cacheRetained)
|
|
5155
|
+
releaseGeneratedSources(vfsState);
|
|
4762
5156
|
let handle;
|
|
4763
5157
|
let resourcesTracked = false;
|
|
4764
5158
|
try {
|
|
4765
|
-
|
|
5159
|
+
// Assembled as one record of the SAME string objects the generator
|
|
5160
|
+
// produced — no copy — and then handed over: this frame drops its own
|
|
5161
|
+
// reference before materialize runs, and the sequence below drops each
|
|
5162
|
+
// source as the store takes it, so one image's text is resident rather
|
|
5163
|
+
// than every image's twice over.
|
|
5164
|
+
const sources = {
|
|
4766
5165
|
'worker.js': generatedWorker.code,
|
|
4767
5166
|
...generatedWorker.modules,
|
|
4768
|
-
}
|
|
4769
|
-
// The image store has taken it; this frame must not be what holds the
|
|
4770
|
-
// only other copy while the facet boots.
|
|
5167
|
+
};
|
|
4771
5168
|
generatedWorker = undefined;
|
|
5169
|
+
const vfsTextModules = await this.imageStore.materialize(entry.pid, drainSources(sources), pacer);
|
|
4772
5170
|
// Last gate before the facet exists. A launch now spans many turns, so
|
|
4773
5171
|
// a kill can land anywhere inside it; booting a process the table has
|
|
4774
5172
|
// already exited would leave a facet nothing owns, running against a
|
|
@@ -4819,6 +5217,7 @@ export class FacetManager {
|
|
|
4819
5217
|
rpcWrites: 0,
|
|
4820
5218
|
fsRpcReads: 0,
|
|
4821
5219
|
cacheHit,
|
|
5220
|
+
turns: pacer.chunks,
|
|
4822
5221
|
exitCode: 0,
|
|
4823
5222
|
at: Date.now(),
|
|
4824
5223
|
});
|
|
@@ -4862,26 +5261,43 @@ export class FacetManager {
|
|
|
4862
5261
|
}
|
|
4863
5262
|
}
|
|
4864
5263
|
catch (e) {
|
|
5264
|
+
// A program classified as long-running that ended on its own during
|
|
5265
|
+
// its boot — `json-server --version` prints and exits 0 — reported its
|
|
5266
|
+
// exit through the supervisor, which released the facet and rejected
|
|
5267
|
+
// the boot handshake with 'resident process released'. That is a
|
|
5268
|
+
// completed run, not a failed launch: the process table already holds
|
|
5269
|
+
// its real exit code, and the caller reports that code.
|
|
4865
5270
|
this.portRegistry.unregisterByPid(entry.pid);
|
|
4866
5271
|
if (resourcesTracked)
|
|
4867
5272
|
this.releaseProcessRpcResources(entry.pid);
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
4873
|
-
try {
|
|
4874
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
4875
|
-
}
|
|
4876
|
-
catch { }
|
|
5273
|
+
if (this.processExitCode(entry.pid) !== null)
|
|
5274
|
+
return;
|
|
5275
|
+
handle?.kill();
|
|
5276
|
+
this._failLaunch(entry.pid, 'long-running node boot failed: ' + errorMessage(e));
|
|
4877
5277
|
throw e;
|
|
4878
5278
|
}
|
|
4879
5279
|
}
|
|
4880
5280
|
/**
|
|
4881
|
-
*
|
|
5281
|
+
* The exit code of a launched process that has already ended, or null
|
|
5282
|
+
* while it runs. What a caller that started a resident reads to tell a
|
|
5283
|
+
* server that is up from a program that finished during its boot.
|
|
5284
|
+
*/
|
|
5285
|
+
processExitCode(pid) {
|
|
5286
|
+
const entry = this.processes.get(pid);
|
|
5287
|
+
if (entry === undefined || entry.state === 'running')
|
|
5288
|
+
return null;
|
|
5289
|
+
return entry.exitCode ?? 0;
|
|
5290
|
+
}
|
|
5291
|
+
/**
|
|
5292
|
+
* Spawn a long-running dynamic Worker, boot it, and return its boot payload
|
|
5293
|
+
* beside the pid and the process's own inbound facet.
|
|
4882
5294
|
*
|
|
4883
5295
|
* The shared primitive for any runtime that serves over
|
|
4884
|
-
* handleHttpRequest(Request) — the python and ruby socket servers today
|
|
5296
|
+
* handleHttpRequest(Request) — the python and ruby socket servers today —
|
|
5297
|
+
* and for an embedder's own Worker-class program: `workerCode` boots as
|
|
5298
|
+
* `opts.mainModule` (default `worker.js`), `opts.modules` ride inline,
|
|
5299
|
+
* `opts.vfsTextModules` and `opts.vfsWasmModules` are read by path when the
|
|
5300
|
+
* facet loads.
|
|
4885
5301
|
*
|
|
4886
5302
|
* The interpreter image it carries is the memory that should not sit in the
|
|
4887
5303
|
* session's own isolate — ruby's interpreter+stdlib alone is 34.3 MiB — and
|
|
@@ -4889,14 +5305,24 @@ export class FacetManager {
|
|
|
4889
5305
|
* no readiness coupling back into the session: the runner answers
|
|
4890
5306
|
* startProcess with its boot payload and the caller waits on that one
|
|
4891
5307
|
* promise, so nothing polls the port to decide the process is up.
|
|
5308
|
+
*
|
|
5309
|
+
* The returned `facet` is bound to the resident handle's route target — the
|
|
5310
|
+
* same target a registered port routes to — so a port-less process can be
|
|
5311
|
+
* invoked directly. It has no release: `kill(pid)` is the one lifecycle
|
|
5312
|
+
* owner, and the facet is dead once the pid is.
|
|
4892
5313
|
*/
|
|
4893
5314
|
async spawnWorker(workerCode, command, cwd, opts = {}) {
|
|
4894
5315
|
return this._spawnWorker(workerCode, command, cwd, opts, 0);
|
|
4895
5316
|
}
|
|
4896
5317
|
/** `attempt` is the journal's re-drive budget, as `_spawnResident` carries it. */
|
|
4897
5318
|
async _spawnWorker(workerCode, command, cwd, opts, attempt) {
|
|
5319
|
+
if (opts.resident && !opts.durable) {
|
|
5320
|
+
opts = { ...opts, durable: { owner: await deriveResidentOwner(cwd, opts.resident.argv) } };
|
|
5321
|
+
}
|
|
4898
5322
|
this.processes.reap();
|
|
4899
|
-
|
|
5323
|
+
// The table entry carries the same argv the identity is derived from, so
|
|
5324
|
+
// a runtime resident reads the same way through either path.
|
|
5325
|
+
const entry = this.processes.spawn(command, opts.resident?.argv ?? [], cwd);
|
|
4900
5326
|
// Stamp the process-table entry so /api/processes exposes this as a
|
|
4901
5327
|
// long-running process.
|
|
4902
5328
|
this.processes.setLongRunning(entry.pid);
|
|
@@ -4908,17 +5334,22 @@ export class FacetManager {
|
|
|
4908
5334
|
catch { }
|
|
4909
5335
|
const compatibilityDate = opts.compatibilityDate ?? CF_COMPAT_DATE;
|
|
4910
5336
|
const compatibilityFlags = opts.compatibilityFlags || ['nodejs_compat'];
|
|
5337
|
+
const mainModule = opts.mainModule ?? DEFAULT_WORKER_MAIN_MODULE;
|
|
5338
|
+
if (opts.modules && Object.hasOwn(opts.modules, mainModule)) {
|
|
5339
|
+
throw new Error(`Nimbus: spawnWorker main module '${mainModule}' is also an inline module; workerCode is the main module`);
|
|
5340
|
+
}
|
|
4911
5341
|
let handle;
|
|
4912
5342
|
let resourcesTracked = false;
|
|
4913
5343
|
let record;
|
|
4914
5344
|
let durableFacetName;
|
|
5345
|
+
let launchEnv;
|
|
4915
5346
|
try {
|
|
4916
5347
|
if (opts.durable) {
|
|
4917
5348
|
// A durable spawn on a declared port starts only when a reservation the
|
|
4918
5349
|
// owner already holds is persisted. Validated before the journal row is
|
|
4919
5350
|
// written and before the process boots, so a foreign or absent
|
|
4920
5351
|
// reservation refuses the launch rather than stealing the exposure.
|
|
4921
|
-
if (opts.port && opts.port > 0 && opts.port < 65536) {
|
|
5352
|
+
if (!opts.resident && opts.port && opts.port > 0 && opts.port < 65536) {
|
|
4922
5353
|
const preFlight = await readPortReservation(this.ctx, opts.port);
|
|
4923
5354
|
if (preFlight === null || preFlight.owner !== opts.durable.owner) {
|
|
4924
5355
|
throw new Error('port reservation conflict: durable worker does not own port ' + opts.port);
|
|
@@ -4944,7 +5375,17 @@ export class FacetManager {
|
|
|
4944
5375
|
modules: opts.modules ?? {},
|
|
4945
5376
|
...(opts.env !== undefined ? { env: opts.env } : {}),
|
|
4946
5377
|
vfsWasmModules: opts.vfsWasmModules,
|
|
5378
|
+
vfsTextModules: opts.vfsTextModules,
|
|
5379
|
+
...(opts.mainModule !== undefined ? { mainModule: opts.mainModule } : {}),
|
|
5380
|
+
startArgs: opts.startArgs,
|
|
4947
5381
|
});
|
|
5382
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
5383
|
+
const key = `${DURABLE_IMAGES_KEY_PREFIX}${opts.durable.owner}`;
|
|
5384
|
+
const images = await txn.get(key) ?? [];
|
|
5385
|
+
if (!images.some((held) => held.runner === image.runner && held.application === image.application)) {
|
|
5386
|
+
await txn.put(key, [...images, image]);
|
|
5387
|
+
}
|
|
5388
|
+
});
|
|
4948
5389
|
// Journalled before the launch's first byte of work, so a row a
|
|
4949
5390
|
// later instance reads proves this process never ended.
|
|
4950
5391
|
record = {
|
|
@@ -4960,27 +5401,45 @@ export class FacetManager {
|
|
|
4960
5401
|
cwd,
|
|
4961
5402
|
compatibilityDate,
|
|
4962
5403
|
compatibilityFlags,
|
|
4963
|
-
startArgs: opts.startArgs,
|
|
5404
|
+
...(opts.durable.image && !opts.resident ? { startArgs: opts.startArgs } : {}),
|
|
5405
|
+
...(opts.resident ? { resident: opts.resident } : {}),
|
|
5406
|
+
...(opts.mainModule !== undefined ? { mainModule: opts.mainModule } : {}),
|
|
4964
5407
|
},
|
|
4965
5408
|
// The row-level fields `ensureDurableAppOnPort` reads — same values
|
|
4966
5409
|
// the recipe carries, stamped so port/owner lookup never depends on
|
|
4967
5410
|
// the recipe's kind.
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
5411
|
+
owner: opts.durable.owner,
|
|
5412
|
+
restart: opts.restart ?? 'never',
|
|
5413
|
+
...(opts.port !== undefined && opts.port > 0 ? { port: opts.port } : {}),
|
|
4971
5414
|
};
|
|
4972
5415
|
// The durable facet name is claimed once, ever, from DO storage — a
|
|
4973
5416
|
// re-drive after a reset, an eviction's re-attach and a relaunch all
|
|
4974
5417
|
// land on the same `app-slot-<n>`, which is the only thing that keeps
|
|
4975
5418
|
// the retained SQLite bound to this application.
|
|
4976
|
-
|
|
4977
|
-
|
|
5419
|
+
const duplicate = await this._claimResident(record);
|
|
5420
|
+
if (duplicate !== null) {
|
|
5421
|
+
this.ephemeralPids.set(entry.pid, opts.durable.owner);
|
|
5422
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: second instance of "${command}" is not the durable one — pid ${duplicate} keeps the identity]\x1b[0m\r\n`);
|
|
5423
|
+
}
|
|
5424
|
+
else if (!opts.resident || await readPortReservationByOwner(this.ctx, opts.durable.owner)) {
|
|
5425
|
+
durableFacetName = await acquireDurableFacetSlot(this.ctx, opts.durable.owner);
|
|
5426
|
+
}
|
|
5427
|
+
if (duplicate === null) {
|
|
5428
|
+
const held = await readPortReservationByOwner(this.ctx, opts.durable.owner);
|
|
5429
|
+
if (held) {
|
|
5430
|
+
launchEnv = { PORT: String(held.port), NIMBUS_APP: held.reservation.name ?? opts.durable.owner };
|
|
5431
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, port: held.port, injectedPort: held.port }));
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
4978
5434
|
}
|
|
4979
5435
|
handle = await this._startResidentProcess(entry.pid, {
|
|
4980
5436
|
// These runners answer startProcess with a boot payload (listening
|
|
4981
5437
|
// port, or a completed non-server run) and stay resident after it.
|
|
4982
5438
|
startContract: 'boot',
|
|
4983
|
-
startArgs: opts.startArgs
|
|
5439
|
+
startArgs: opts.resident && opts.startArgs && typeof opts.startArgs === 'object'
|
|
5440
|
+
? { ...opts.startArgs, supervisorPid: entry.pid,
|
|
5441
|
+
...(launchEnv ? { userEnv: { ...z.record(z.string(), z.unknown()).parse(Reflect.get(opts.startArgs, 'userEnv') ?? {}), ...launchEnv } } : {}) }
|
|
5442
|
+
: opts.startArgs,
|
|
4984
5443
|
...(durableFacetName !== undefined
|
|
4985
5444
|
? { facet: { name: durableFacetName, durable: true } }
|
|
4986
5445
|
: {}),
|
|
@@ -4989,10 +5448,11 @@ export class FacetManager {
|
|
|
4989
5448
|
code: {
|
|
4990
5449
|
compatibilityDate,
|
|
4991
5450
|
compatibilityFlags,
|
|
4992
|
-
mainModule
|
|
4993
|
-
modules: {
|
|
5451
|
+
mainModule,
|
|
5452
|
+
modules: { ...(opts.modules || {}), [mainModule]: workerCode },
|
|
4994
5453
|
vfsWasmModules: opts.vfsWasmModules,
|
|
4995
|
-
...(opts.
|
|
5454
|
+
...(opts.vfsTextModules !== undefined ? { vfsTextModules: opts.vfsTextModules } : {}),
|
|
5455
|
+
...(opts.env !== undefined || launchEnv !== undefined ? { env: { ...opts.env, ...launchEnv } } : {}),
|
|
4996
5456
|
...(opts.globalOutbound !== undefined ? { globalOutbound: opts.globalOutbound } : {}),
|
|
4997
5457
|
},
|
|
4998
5458
|
},
|
|
@@ -5001,13 +5461,13 @@ export class FacetManager {
|
|
|
5001
5461
|
resourcesTracked = true;
|
|
5002
5462
|
this.portRegistry.bindFacetStub(entry.pid, handle.routeTarget);
|
|
5003
5463
|
const boot = await handle.booted();
|
|
5004
|
-
if (
|
|
5464
|
+
if (record && this.launchJournal.has(entry.pid)) {
|
|
5005
5465
|
// Booted and running: the launch proved itself, so the resident
|
|
5006
5466
|
// starts its running life with a fresh re-drive budget.
|
|
5007
|
-
await this.
|
|
5467
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, attempt: 0, phase: 'running' }));
|
|
5008
5468
|
}
|
|
5009
5469
|
if (opts.port && opts.port > 0 && opts.port < 65536) {
|
|
5010
|
-
if (opts.durable) {
|
|
5470
|
+
if (opts.durable && !opts.resident) {
|
|
5011
5471
|
// Re-read after the boot: a release or reassignment during it wins.
|
|
5012
5472
|
// The durable launch registers only under a reservation it still
|
|
5013
5473
|
// owns; it never clears or takes a foreign exposure.
|
|
@@ -5019,17 +5479,23 @@ export class FacetManager {
|
|
|
5019
5479
|
// re-drove this launch, and preview URLs minted against it stay
|
|
5020
5480
|
// valid: the durable capability is re-adopted through the
|
|
5021
5481
|
// reservation's own path, gated on the stored owner.
|
|
5022
|
-
this.portRegistry.register(opts.port, entry.pid);
|
|
5023
|
-
await restoreReservedPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port, opts.durable.owner);
|
|
5024
|
-
}
|
|
5025
|
-
else {
|
|
5026
|
-
// A new process on a port retires the previous occupant's preview
|
|
5027
|
-
// capability, so a URL handed out for that one cannot reach this one.
|
|
5028
|
-
await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port);
|
|
5029
|
-
this.portRegistry.register(opts.port, entry.pid);
|
|
5030
5482
|
}
|
|
5483
|
+
await this._registerResidentPort(entry.pid, opts.port);
|
|
5031
5484
|
}
|
|
5032
|
-
|
|
5485
|
+
const target = handle.routeTarget;
|
|
5486
|
+
return {
|
|
5487
|
+
pid: entry.pid,
|
|
5488
|
+
boot,
|
|
5489
|
+
facet: {
|
|
5490
|
+
fetch: (request) => target.handleHttpRequest(request),
|
|
5491
|
+
connect: async (request) => {
|
|
5492
|
+
if (typeof target.handleWebSocketRequest !== 'function') {
|
|
5493
|
+
throw new Error(`Nimbus: worker pid ${entry.pid} accepts no WebSocket upgrades`);
|
|
5494
|
+
}
|
|
5495
|
+
return target.handleWebSocketRequest(request);
|
|
5496
|
+
},
|
|
5497
|
+
},
|
|
5498
|
+
};
|
|
5033
5499
|
}
|
|
5034
5500
|
catch (e) {
|
|
5035
5501
|
this.portRegistry.unregisterByPid(entry.pid);
|
|
@@ -5037,51 +5503,227 @@ export class FacetManager {
|
|
|
5037
5503
|
this.releaseProcessRpcResources(entry.pid);
|
|
5038
5504
|
else
|
|
5039
5505
|
handle?.kill();
|
|
5040
|
-
this.
|
|
5041
|
-
const reason = 'long-running worker boot failed: ' + errorMessage(e);
|
|
5042
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
5043
|
-
try {
|
|
5044
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
5045
|
-
}
|
|
5046
|
-
catch { }
|
|
5506
|
+
this._failLaunch(entry.pid, 'long-running worker boot failed: ' + errorMessage(e));
|
|
5047
5507
|
throw e;
|
|
5048
5508
|
}
|
|
5049
5509
|
}
|
|
5050
5510
|
/**
|
|
5051
|
-
* A resident process announcing it bound `port`.
|
|
5052
|
-
*
|
|
5053
|
-
*
|
|
5054
|
-
*
|
|
5055
|
-
*
|
|
5056
|
-
*
|
|
5057
|
-
*
|
|
5511
|
+
* A resident process announcing it bound `port`.
|
|
5512
|
+
*
|
|
5513
|
+
* The stamp is unconditional: the pid's journal row gets `{ port }`
|
|
5514
|
+
* whether or not anything reserved it, which is what lets
|
|
5515
|
+
* `ensureDurableAppOnPort` re-drive ANY resident a reset killed — the
|
|
5516
|
+
* scoped URLs need no capability, so this alone makes every server's
|
|
5517
|
+
* preview survive a reset on demand.
|
|
5518
|
+
*
|
|
5519
|
+
* The capability is bound to identity, not to the port. The stored
|
|
5520
|
+
* capability is re-adopted only when the row's owner IS the reservation's
|
|
5521
|
+
* owner; any other occupant — an unrelated server, a pid outside the
|
|
5522
|
+
* resident lifecycle, the ephemeral second instance of an identity —
|
|
5523
|
+
* retires it and mints fresh, so a shared link 404s rather than reaching
|
|
5524
|
+
* a program it was never handed out for. An EXPLICIT reservation (an
|
|
5525
|
+
* embedder's `ensureDurableApp`) is the one exception, and it is the
|
|
5526
|
+
* landed contract: the embedder declared the port, so the row adopts the
|
|
5527
|
+
* reservation's owner and the capability with it.
|
|
5058
5528
|
*
|
|
5059
|
-
*
|
|
5060
|
-
*
|
|
5061
|
-
*
|
|
5062
|
-
* owner. An accidental port reuse inside one session therefore cannot
|
|
5063
|
-
* inherit the public capability; same-session processes are one trust
|
|
5064
|
-
* domain, so this is hygiene, not a security boundary.
|
|
5529
|
+
* Under an injected `$PORT`, a resident that binds a different port is
|
|
5530
|
+
* registered anyway — the server must not break — but the row records
|
|
5531
|
+
* the mismatch, so `apps.list` reports it as failed and the user is told.
|
|
5065
5532
|
*/
|
|
5066
5533
|
async _registerResidentPort(pid, port) {
|
|
5067
5534
|
const reservation = await readPortReservation(this.ctx, port);
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5535
|
+
let claimedBy;
|
|
5536
|
+
if (reservation?.owner && reservation.kind === 'explicit' && !this.ephemeralPids.has(pid)) {
|
|
5537
|
+
const owner = reservation.owner;
|
|
5538
|
+
const anotherLive = [...(await this.launchJournal.rows()).values()].some((row) => row.pid !== pid && residentOwner(row) === owner
|
|
5539
|
+
&& row.pid > this.processes.pidBase && this.processes.get(row.pid)?.state === 'running');
|
|
5540
|
+
if (!anotherLive)
|
|
5541
|
+
claimedBy = owner;
|
|
5542
|
+
}
|
|
5543
|
+
let newlyMismatched = false;
|
|
5544
|
+
const row = await this._amendRow(pid, (current) => {
|
|
5545
|
+
const owner = claimedBy ?? residentOwner(current);
|
|
5546
|
+
const injected = current.injectedPort;
|
|
5547
|
+
// Under an injected $PORT, the reserved port is the row's port for as
|
|
5548
|
+
// long as this pid holds it; a second, different binding is recorded
|
|
5549
|
+
// as the mismatch rather than displacing it.
|
|
5550
|
+
const holdsInjected = injected !== undefined && injected !== port
|
|
5551
|
+
&& this.portRegistry.get(injected)?.pid === pid;
|
|
5552
|
+
const stampedPort = holdsInjected ? injected : port;
|
|
5553
|
+
const portMismatch = injected !== undefined && injected !== port && !holdsInjected
|
|
5554
|
+
? { listened: port, reserved: injected }
|
|
5555
|
+
: undefined;
|
|
5556
|
+
const same = current.port === stampedPort && residentOwner(current) === owner
|
|
5557
|
+
&& (current.portMismatch?.listened === portMismatch?.listened)
|
|
5558
|
+
&& (current.portMismatch?.reserved === portMismatch?.reserved);
|
|
5559
|
+
if (same)
|
|
5560
|
+
return current;
|
|
5561
|
+
newlyMismatched = portMismatch !== undefined && current.portMismatch === undefined;
|
|
5562
|
+
const { portMismatch: _dropped, ...rest } = current;
|
|
5563
|
+
return {
|
|
5564
|
+
...rest,
|
|
5565
|
+
port: stampedPort,
|
|
5566
|
+
...(owner !== undefined ? { owner } : {}),
|
|
5567
|
+
...(portMismatch !== undefined ? { portMismatch } : {}),
|
|
5568
|
+
};
|
|
5569
|
+
});
|
|
5570
|
+
if (newlyMismatched && row?.portMismatch !== undefined) {
|
|
5571
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: "${row.command}" listened on ${port} but its reservation owns `
|
|
5572
|
+
+ `${row.portMismatch.reserved} ($PORT) — the app's URL will not reach it]\x1b[0m\r\n`);
|
|
5573
|
+
}
|
|
5574
|
+
// A pid nothing journalled — the in-process dev servers, a builtin's
|
|
5575
|
+
// adopted wrapper pid — still has an identity: the process table's. It
|
|
5576
|
+
// re-adopts a reservation it owns exactly as a resident does; the one
|
|
5577
|
+
// thing it cannot do is adopt an EXPLICIT owner, because there is no row
|
|
5578
|
+
// to stamp the adoption on, so it registers ephemeral on that port.
|
|
5579
|
+
const owner = row === undefined
|
|
5580
|
+
? (await this.residentIdentity(pid))?.owner
|
|
5581
|
+
: residentOwner(row);
|
|
5582
|
+
if (claimedBy !== undefined && row !== undefined && this.residentClaims.get(pid) !== claimedBy) {
|
|
5583
|
+
const previousOwner = this.residentClaims.get(pid);
|
|
5584
|
+
await this._releaseResidentClaim(pid);
|
|
5585
|
+
const duplicate = await this._claimResident(row);
|
|
5586
|
+
if (duplicate !== null) {
|
|
5587
|
+
this.ephemeralPids.set(pid, claimedBy);
|
|
5588
|
+
await this.launchJournal.release(pid);
|
|
5074
5589
|
}
|
|
5590
|
+
else if (previousOwner && row.recipe.kind === 'worker') {
|
|
5591
|
+
// Adoption moves retained images with the identity, including the
|
|
5592
|
+
// index that survives a clean exit after the journal row is released.
|
|
5593
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
5594
|
+
const previousKey = `${DURABLE_IMAGES_KEY_PREFIX}${previousOwner}`;
|
|
5595
|
+
const nextKey = `${DURABLE_IMAGES_KEY_PREFIX}${claimedBy}`;
|
|
5596
|
+
const previous = await txn.get(previousKey) ?? [];
|
|
5597
|
+
const next = await txn.get(nextKey) ?? [];
|
|
5598
|
+
const combined = [...next];
|
|
5599
|
+
for (const image of previous) {
|
|
5600
|
+
if (!combined.some((held) => held.runner === image.runner && held.application === image.application))
|
|
5601
|
+
combined.push(image);
|
|
5602
|
+
}
|
|
5603
|
+
await txn.put(nextKey, combined);
|
|
5604
|
+
await txn.delete(previousKey);
|
|
5605
|
+
});
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
if (reservation !== null && reservation.owner !== null && owner === reservation.owner && !this.ephemeralPids.has(pid)) {
|
|
5075
5609
|
this.portRegistry.register(port, pid);
|
|
5076
5610
|
await restoreReservedPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port, reservation.owner);
|
|
5077
5611
|
return;
|
|
5078
5612
|
}
|
|
5079
|
-
//
|
|
5080
|
-
//
|
|
5081
|
-
//
|
|
5613
|
+
// Any other occupant retires the previous one's capability — and its
|
|
5614
|
+
// directory row, so a public link goes dead rather than dangling — and
|
|
5615
|
+
// registers with a fresh one. The owner's reservation survives it.
|
|
5616
|
+
if (reservation?.owner) {
|
|
5617
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: port ${port} belongs to ${reservation.owner}; pid ${pid} registers ephemeral (owner ${owner ?? 'none'})]\x1b[0m\r\n`);
|
|
5618
|
+
}
|
|
5619
|
+
if (reservation?.visibility === 'public' && reservation.capability !== null) {
|
|
5620
|
+
await unbindPublicPortCapability(this._publicDirectoryHost(), reservation.capability);
|
|
5621
|
+
}
|
|
5082
5622
|
await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port);
|
|
5083
5623
|
this.portRegistry.register(port, pid);
|
|
5084
5624
|
}
|
|
5625
|
+
/**
|
|
5626
|
+
* Who a pid is. One resolver, in precedence order: the ephemeral-duplicate
|
|
5627
|
+
* mark, the journal row (the owner a launch this manager made was stamped
|
|
5628
|
+
* with — derived from the launch's own cwd+argv, or adopted from an
|
|
5629
|
+
* explicit reservation), and finally the process table. The table knows
|
|
5630
|
+
* cwd and argv for every pid, so a serving process nothing journalled — the
|
|
5631
|
+
* in-process Vite dev server, real-vite, a staged artifact, any wrapper pid
|
|
5632
|
+
* a builtin adopted — has the same derived identity shape as a resident
|
|
5633
|
+
* and answers to the same app verbs. It just cannot be re-driven after a
|
|
5634
|
+
* reset: only a journal row carries a recipe. Null only for a pid that is
|
|
5635
|
+
* neither journalled nor running.
|
|
5636
|
+
*/
|
|
5637
|
+
async residentIdentity(pid) {
|
|
5638
|
+
const duplicated = this.ephemeralPids.get(pid);
|
|
5639
|
+
if (duplicated !== undefined)
|
|
5640
|
+
return { owner: duplicated, ephemeral: true, port: undefined };
|
|
5641
|
+
const row = (await this.launchJournal.rows()).get(`${FENCED_WORK_KEY_PREFIX}${pid}`);
|
|
5642
|
+
if (row !== undefined)
|
|
5643
|
+
return { owner: residentOwner(row), ephemeral: false, port: row.port };
|
|
5644
|
+
const entry = this.processes.get(pid);
|
|
5645
|
+
if (entry === undefined || entry.state !== 'running')
|
|
5646
|
+
return null;
|
|
5647
|
+
return {
|
|
5648
|
+
owner: await deriveResidentOwner(entry.cwd, entry.argv),
|
|
5649
|
+
ephemeral: false,
|
|
5650
|
+
port: this.portRegistry.getAll().find((live) => live.pid === pid)?.port,
|
|
5651
|
+
};
|
|
5652
|
+
}
|
|
5653
|
+
/**
|
|
5654
|
+
* Every stamped identity — the reservations, and the journal rows that
|
|
5655
|
+
* carry an owner — folded one row per owner. A live pid in THIS instance
|
|
5656
|
+
* makes it running (or starting, until its launch settles and its port
|
|
5657
|
+
* is registered); a mismatch diagnostic makes it failed; everything else
|
|
5658
|
+
* is stopped, which for a row a reset left behind means re-drivable on
|
|
5659
|
+
* request.
|
|
5660
|
+
*/
|
|
5661
|
+
async listResidentApps() {
|
|
5662
|
+
const apps = new Map();
|
|
5663
|
+
for (const [port, reservation] of await listPortReservations(this.ctx)) {
|
|
5664
|
+
if (reservation.owner === null)
|
|
5665
|
+
continue;
|
|
5666
|
+
apps.set(reservation.owner, {
|
|
5667
|
+
owner: reservation.owner,
|
|
5668
|
+
name: reservation.name ?? null,
|
|
5669
|
+
port,
|
|
5670
|
+
pid: null,
|
|
5671
|
+
status: 'stopped',
|
|
5672
|
+
visibility: reservation.visibility,
|
|
5673
|
+
capability: reservation.capability,
|
|
5674
|
+
restart: 'never',
|
|
5675
|
+
diagnostic: null,
|
|
5676
|
+
});
|
|
5677
|
+
}
|
|
5678
|
+
for (const [, row] of await this.launchJournal.rows()) {
|
|
5679
|
+
const owner = residentOwner(row);
|
|
5680
|
+
if (owner === undefined)
|
|
5681
|
+
continue;
|
|
5682
|
+
const app = apps.get(owner) ?? {
|
|
5683
|
+
owner, name: null, port: null, pid: null, status: 'stopped',
|
|
5684
|
+
visibility: 'scoped', capability: null, restart: 'never', diagnostic: null,
|
|
5685
|
+
};
|
|
5686
|
+
if (app.port === null && row.port !== undefined && row.port > 0)
|
|
5687
|
+
app.port = row.port;
|
|
5688
|
+
if (row.restart !== undefined)
|
|
5689
|
+
app.restart = row.restart;
|
|
5690
|
+
const entry = this.processes.get(row.pid);
|
|
5691
|
+
const live = row.pid > this.processes.pidBase && entry?.state === 'running';
|
|
5692
|
+
if (live) {
|
|
5693
|
+
app.pid = row.pid;
|
|
5694
|
+
const bound = app.port !== null && this.portRegistry.get(app.port)?.pid === row.pid;
|
|
5695
|
+
app.status = row.phase === 'running' && bound ? 'running' : 'starting';
|
|
5696
|
+
}
|
|
5697
|
+
if (row.portMismatch !== undefined) {
|
|
5698
|
+
app.status = 'failed';
|
|
5699
|
+
app.diagnostic = `listened on ${row.portMismatch.listened}, owns ${row.portMismatch.reserved}`;
|
|
5700
|
+
}
|
|
5701
|
+
apps.set(owner, app);
|
|
5702
|
+
}
|
|
5703
|
+
// Every process serving a port is an application whether or not a
|
|
5704
|
+
// journal row backs it — the identity comes from the same resolver the
|
|
5705
|
+
// verbs use, so what `list` shows is what `expose` will bind. A row-backed
|
|
5706
|
+
// app already carries its pid from above; this only adds the servers
|
|
5707
|
+
// nothing journalled (the dev servers, adopted wrapper pids).
|
|
5708
|
+
for (const live of this.portRegistry.getAll()) {
|
|
5709
|
+
const identity = await this.residentIdentity(live.pid);
|
|
5710
|
+
if (identity === null || identity.ephemeral || identity.owner === undefined)
|
|
5711
|
+
continue;
|
|
5712
|
+
const app = apps.get(identity.owner) ?? {
|
|
5713
|
+
owner: identity.owner, name: null, port: null, pid: null, status: 'stopped',
|
|
5714
|
+
visibility: 'scoped', capability: null, restart: 'never', diagnostic: null,
|
|
5715
|
+
};
|
|
5716
|
+
if (app.pid !== null)
|
|
5717
|
+
continue;
|
|
5718
|
+
app.pid = live.pid;
|
|
5719
|
+
if (app.port === null)
|
|
5720
|
+
app.port = live.port;
|
|
5721
|
+
if (app.status === 'stopped')
|
|
5722
|
+
app.status = 'running';
|
|
5723
|
+
apps.set(identity.owner, app);
|
|
5724
|
+
}
|
|
5725
|
+
return [...apps.values()];
|
|
5726
|
+
}
|
|
5085
5727
|
async registerPort(pid, port) {
|
|
5086
5728
|
if (port > 0 && port < 65536) {
|
|
5087
5729
|
await this._registerResidentPort(pid, port);
|
|
@@ -5149,9 +5791,6 @@ export class FacetManager {
|
|
|
5149
5791
|
if (residentOwner(record) !== owner)
|
|
5150
5792
|
continue;
|
|
5151
5793
|
ownedPids.push(record.pid);
|
|
5152
|
-
const rowPort = record.port ?? (record.recipe.kind === 'worker' ? record.recipe.port : 0);
|
|
5153
|
-
if (rowPort > 0)
|
|
5154
|
-
ownedPorts.add(rowPort);
|
|
5155
5794
|
}
|
|
5156
5795
|
for (const pid of ownedPids)
|
|
5157
5796
|
this.kill(pid);
|
|
@@ -5169,18 +5808,29 @@ export class FacetManager {
|
|
|
5169
5808
|
}
|
|
5170
5809
|
}
|
|
5171
5810
|
for (const port of ownedPorts) {
|
|
5811
|
+
// A port stamp is not ownership. Release only a reservation that still
|
|
5812
|
+
// belongs to this owner, and never unregister somebody else's listener.
|
|
5813
|
+
if ((await readPortReservation(this.ctx, port))?.owner !== owner)
|
|
5814
|
+
continue;
|
|
5172
5815
|
await releasePortReservation(this.ctx, { owner, port });
|
|
5173
|
-
this.portRegistry.
|
|
5816
|
+
const live = this.portRegistry.get(port);
|
|
5817
|
+
if (live && ownedPids.includes(live.pid))
|
|
5818
|
+
this.portRegistry.unregister(port);
|
|
5819
|
+
}
|
|
5820
|
+
const imageRows = await this.ctx.storage.list({ prefix: DURABLE_IMAGES_KEY_PREFIX });
|
|
5821
|
+
const ownedImages = [...(imageRows.get(`${DURABLE_IMAGES_KEY_PREFIX}${owner}`) ?? [])];
|
|
5822
|
+
const retainedImages = [...imageRows].filter(([key]) => key !== `${DURABLE_IMAGES_KEY_PREFIX}${owner}`).flatMap(([, images]) => images);
|
|
5823
|
+
for (const row of journalRows.values()) {
|
|
5824
|
+
if (row.recipe.kind !== 'worker')
|
|
5825
|
+
continue;
|
|
5826
|
+
(residentOwner(row) === owner ? ownedImages : retainedImages).push(row.recipe.image);
|
|
5174
5827
|
}
|
|
5828
|
+
const imagesPurged = this.vfs ? purgeDurableWorkerImages(this.vfs, ownedImages, retainedImages) : 0;
|
|
5829
|
+
await this.ctx.storage.delete(`${DURABLE_IMAGES_KEY_PREFIX}${owner}`);
|
|
5830
|
+
await this.ctx.storage.delete(`${RESIDENT_OWNER_KEY_PREFIX}${owner}`);
|
|
5175
5831
|
const purged = await this.launchJournal.purgeWhere((record) => residentOwner(record) === owner);
|
|
5176
|
-
const name = await freeDurableFacetSlot(this.ctx, owner);
|
|
5177
|
-
|
|
5178
|
-
try {
|
|
5179
|
-
deleteFacetStorage(this.ctx, name);
|
|
5180
|
-
}
|
|
5181
|
-
catch { /* already gone */ }
|
|
5182
|
-
}
|
|
5183
|
-
return name !== null || purged > 0 || ownedPorts.size > 0;
|
|
5832
|
+
const name = await freeDurableFacetSlot(this.ctx, owner, (slot) => deleteFacetStorage(this.ctx, slot));
|
|
5833
|
+
return name !== null || purged > 0 || ownedPorts.size > 0 || imagesPurged > 0;
|
|
5184
5834
|
}
|
|
5185
5835
|
/** The session-shaped view the public-directory helpers read env from. */
|
|
5186
5836
|
_publicDirectoryHost() {
|
|
@@ -5219,19 +5869,20 @@ export class FacetManager {
|
|
|
5219
5869
|
async _ensureDurableAppOnPort(port) {
|
|
5220
5870
|
if (this.portRegistry.has(port))
|
|
5221
5871
|
return 'started';
|
|
5222
|
-
// The
|
|
5223
|
-
//
|
|
5224
|
-
//
|
|
5225
|
-
//
|
|
5226
|
-
//
|
|
5872
|
+
// The journal row is what makes a port a launch this instance owes —
|
|
5873
|
+
// reservation or not: every resident's row is stamped with the port it
|
|
5874
|
+
// bound, so any of them re-drives on request. A port with no stamped
|
|
5875
|
+
// row (nothing ever bound it, or the app was removed and its rows
|
|
5876
|
+
// purged) answers absent — nothing here can bring back an application
|
|
5877
|
+
// with no recipe. When more than one row names the port, the
|
|
5878
|
+
// reservation's owner wins; otherwise the newest launch does.
|
|
5227
5879
|
const reservation = await readPortReservation(this.ctx, port);
|
|
5228
|
-
if (reservation === null || reservation.owner === null)
|
|
5229
|
-
return 'absent';
|
|
5230
5880
|
const rows = await this.launchJournal.rows();
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5881
|
+
const candidates = [...rows].filter(([, record]) => record.port === port);
|
|
5882
|
+
const ownerRow = reservation !== null && reservation.owner !== null
|
|
5883
|
+
? candidates.find(([, record]) => residentOwner(record) === reservation.owner)
|
|
5884
|
+
: undefined;
|
|
5885
|
+
const entry = ownerRow ?? candidates.sort(([, a], [, b]) => b.pid - a.pid)[0];
|
|
5235
5886
|
if (entry === undefined)
|
|
5236
5887
|
return 'absent';
|
|
5237
5888
|
const [rowKey, record] = entry;
|