@nimbus-sh/worker 0.6.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.
Files changed (133) hide show
  1. package/README.md +2 -2
  2. package/dist/cirrus-npm-cjs.generated.d.ts +1 -1
  3. package/dist/cirrus-npm-cjs.generated.js +1 -1
  4. package/dist/facets/cirrus-real.d.ts.map +1 -1
  5. package/dist/facets/cirrus-real.js +6 -5
  6. package/dist/facets/compose.d.ts +148 -0
  7. package/dist/facets/compose.d.ts.map +1 -0
  8. package/dist/facets/compose.js +146 -0
  9. package/dist/facets/durable-images.d.ts +9 -5
  10. package/dist/facets/durable-images.d.ts.map +1 -1
  11. package/dist/facets/durable-images.js +17 -7
  12. package/dist/facets/esbuild-bundle-pool.d.ts +49 -0
  13. package/dist/facets/esbuild-bundle-pool.d.ts.map +1 -0
  14. package/dist/facets/esbuild-bundle-pool.js +122 -0
  15. package/dist/facets/exec-telemetry.d.ts +8 -0
  16. package/dist/facets/exec-telemetry.d.ts.map +1 -1
  17. package/dist/facets/manager.d.ts +240 -109
  18. package/dist/facets/manager.d.ts.map +1 -1
  19. package/dist/facets/manager.js +661 -337
  20. package/dist/facets/process.d.ts +20 -16
  21. package/dist/facets/process.d.ts.map +1 -1
  22. package/dist/facets/process.js +59 -28
  23. package/dist/facets/real-vite-fs-shim.js +2 -2
  24. package/dist/facets/vite-dev-server.d.ts +25 -47
  25. package/dist/facets/vite-dev-server.d.ts.map +1 -1
  26. package/dist/facets/vite-dev-server.js +185 -219
  27. package/dist/facets/wasm-image-digest.d.ts +25 -0
  28. package/dist/facets/wasm-image-digest.d.ts.map +1 -0
  29. package/dist/facets/wasm-image-digest.js +25 -0
  30. package/dist/facets/wasm-swap-registry.d.ts +51 -92
  31. package/dist/facets/wasm-swap-registry.d.ts.map +1 -1
  32. package/dist/facets/wasm-swap-registry.js +68 -227
  33. package/dist/git/network-facet.d.ts.map +1 -1
  34. package/dist/git/network-facet.js +6 -5
  35. package/dist/loaders/child-process/spawn-pool.d.ts +3 -2
  36. package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -1
  37. package/dist/loaders/child-process/spawn-pool.js +3 -2
  38. package/dist/loaders/generated-workers.d.ts +2 -2
  39. package/dist/loaders/generated-workers.d.ts.map +1 -1
  40. package/dist/loaders/generated-workers.js +3 -3
  41. package/dist/loaders/npm-resolve-preamble.d.ts +3 -6
  42. package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -1
  43. package/dist/loaders/npm-resolve-preamble.js +34 -108
  44. package/dist/node-shims-artifact.generated.js +3 -3
  45. package/dist/npm/install-batch-facet.d.ts +3 -2
  46. package/dist/npm/install-batch-facet.d.ts.map +1 -1
  47. package/dist/npm/install-batch-facet.js +45 -15
  48. package/dist/npm/installer.d.ts +22 -17
  49. package/dist/npm/installer.d.ts.map +1 -1
  50. package/dist/npm/installer.js +209 -193
  51. package/dist/npm/pre-bundle-facet.d.ts +1 -1
  52. package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
  53. package/dist/npm/pre-bundle-facet.js +1 -1
  54. package/dist/npm/resolve-facet.d.ts +6 -0
  55. package/dist/npm/resolve-facet.d.ts.map +1 -1
  56. package/dist/npm/resolve-one-facet.d.ts +21 -20
  57. package/dist/npm/resolve-one-facet.d.ts.map +1 -1
  58. package/dist/npm/resolve-one-facet.js +85 -84
  59. package/dist/npm/semver.d.ts +59 -0
  60. package/dist/npm/semver.d.ts.map +1 -0
  61. package/dist/npm/semver.js +226 -0
  62. package/dist/router/remote-api.js +31 -1
  63. package/dist/runtime/html-entrypoint.d.ts +7 -0
  64. package/dist/runtime/html-entrypoint.d.ts.map +1 -1
  65. package/dist/runtime/html-entrypoint.js +18 -2
  66. package/dist/runtime/node-runner.d.ts +3 -0
  67. package/dist/runtime/node-runner.d.ts.map +1 -1
  68. package/dist/runtime/node-runner.js +7 -0
  69. package/dist/runtime/node-shims.d.ts.map +1 -1
  70. package/dist/runtime/node-shims.js +305 -29
  71. package/dist/runtime/opencode-facet-runner.js +2 -2
  72. package/dist/session/helpers.d.ts +38 -0
  73. package/dist/session/helpers.d.ts.map +1 -1
  74. package/dist/session/helpers.js +76 -0
  75. package/dist/session/init-phases.d.ts +1 -1
  76. package/dist/session/init-phases.d.ts.map +1 -1
  77. package/dist/session/init-phases.js +2 -12
  78. package/dist/session/init.d.ts +15 -1
  79. package/dist/session/init.d.ts.map +1 -1
  80. package/dist/session/init.js +82 -603
  81. package/dist/session/nimbus-session.d.ts +56 -22
  82. package/dist/session/nimbus-session.d.ts.map +1 -1
  83. package/dist/session/nimbus-session.js +130 -101
  84. package/dist/session/npm-install-port.d.ts +16 -0
  85. package/dist/session/npm-install-port.d.ts.map +1 -0
  86. package/dist/session/npm-install-port.js +37 -0
  87. package/dist/session/port-capability.d.ts +44 -0
  88. package/dist/session/port-capability.d.ts.map +1 -1
  89. package/dist/session/port-capability.js +110 -0
  90. package/dist/session/programmatic.d.ts +31 -5
  91. package/dist/session/programmatic.d.ts.map +1 -1
  92. package/dist/session/programmatic.js +94 -68
  93. package/dist/session/routes.d.ts +30 -17
  94. package/dist/session/routes.d.ts.map +1 -1
  95. package/dist/session/routes.js +42 -131
  96. package/dist/session/rpc.d.ts +15 -15
  97. package/dist/session/rpc.d.ts.map +1 -1
  98. package/dist/session/rpc.js +97 -47
  99. package/dist/session/serving-port.d.ts +27 -0
  100. package/dist/session/serving-port.d.ts.map +1 -0
  101. package/dist/session/serving-port.js +27 -0
  102. package/dist/session/start-real-vite.d.ts +12 -0
  103. package/dist/session/start-real-vite.d.ts.map +1 -1
  104. package/dist/session/start-real-vite.js +4 -4
  105. package/dist/session/supervisor-op.d.ts +2 -2
  106. package/dist/session/supervisor-op.d.ts.map +1 -1
  107. package/dist/session/supervisor-op.js +13 -7
  108. package/dist/session/supervisor-rpc.d.ts +7 -7
  109. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  110. package/dist/session/supervisor-rpc.js +4 -0
  111. package/dist/session/vite-command.d.ts +18 -0
  112. package/dist/session/vite-command.d.ts.map +1 -0
  113. package/dist/session/vite-command.js +609 -0
  114. package/dist/session/ws.d.ts +61 -0
  115. package/dist/session/ws.d.ts.map +1 -1
  116. package/dist/session/ws.js +122 -12
  117. package/dist/shell/npm-bin-entrypoints.d.ts +0 -35
  118. package/dist/shell/npm-bin-entrypoints.d.ts.map +1 -1
  119. package/dist/shell/npm-bin-entrypoints.js +12 -85
  120. package/package.json +24 -4
  121. package/public/_assets/runtime/{node-shims-9e1380d9ee430fcc.js → node-shims-8f8c64edd2a1f0f5.js} +305 -29
  122. package/public/index.html +1 -1
  123. package/scripts/bundle-facet-workers.mjs +1 -1
  124. package/scripts/bundle-npm-cjs.mjs +2 -2
  125. package/dist/facets/on-demand-bundle-gate.d.ts +0 -65
  126. package/dist/facets/on-demand-bundle-gate.d.ts.map +0 -1
  127. package/dist/facets/on-demand-bundle-gate.js +0 -125
  128. package/dist/npm/install-args.d.ts +0 -21
  129. package/dist/npm/install-args.d.ts.map +0 -1
  130. package/dist/npm/install-args.js +0 -70
  131. package/dist/npm/npm-log.d.ts +0 -31
  132. package/dist/npm/npm-log.d.ts.map +0 -1
  133. package/dist/npm/npm-log.js +0 -51
@@ -21,14 +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';
24
+ import { stripLeadingSlashes, vfsPathExtension } from '@nimbus-sh/core/vfs/path.js';
25
25
  import { clearPortCapability, listPortReservations, readPortReservation, readPortReservationByOwner, releasePortReservation, restoreReservedPortCapability, } from '../session/port-capability.js';
26
26
  import { deriveResidentOwner } from './resident-identity.js';
27
27
  import { z } from 'zod/v4';
28
28
  import { RESIDENT_OWNER_KEY_PREFIX, DURABLE_IMAGES_KEY_PREFIX } from '../session/keys.js';
29
29
  import { PORT_CAPABILITY_KEY_PREFIX } from '../session/keys.js';
30
30
  import { unbindPublicPortCapability } from '../router/public-directory.js';
31
- import { prefetchForRequire } from '@nimbus-sh/core/runtime/require-resolver.js';
31
+ import { prefetchForRequire, ClosureBoundExceededError } from '@nimbus-sh/core/runtime/require-resolver.js';
32
32
  import { hasTopLevelModuleSyntax } from '@nimbus-sh/core/runtime/javascript-ast.js';
33
33
  import { bindImportMetaResolve, importMetaDefines } from '@nimbus-sh/core/runtime/import-meta-transform.js';
34
34
  import { recordFailure, getLastRpcFrame, getLastFacetId } from '@nimbus-sh/platform/oom-discriminator.js';
@@ -48,10 +48,11 @@ import { persistDurableWorkerImage, purgeDurableWorkerImages, } from './durable-
48
48
  import { SQLITE_WASM_MODULE_NAME, } from '../runtime/opencode-facet-runner.js';
49
49
  import { parsePortFromArgv, resolveLongRunningPort } from '@nimbus-sh/core/runtime/long-running-handle.js';
50
50
  import { DEFAULT_FACET_BUNDLE_PROFILE, } from '@nimbus-sh/core/runtime/bundle-profile.js';
51
- import { BUNDLE_BUILD_DEADLINE_MS, CF_COMPAT_DATE, FACET_TIMEOUT_MS, 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';
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';
52
52
  import { MAX_RPC_SAFE_PAYLOAD_BYTES } from '@nimbus-sh/platform/limits.js';
53
53
  import { CRED_KERNEL } from '@nimbus-sh/core/runtime/os-contracts.js';
54
54
  import { acquireSupervisorAllocation } from '@nimbus-sh/platform/heavy-alloc-coord.js';
55
+ import { wasmImageDigest } from './wasm-image-digest.js';
55
56
  import { prefetchBundleStart, prefetchBundleEnd, setPrefetchCacheBytes, } from '@nimbus-sh/platform/diag-counters.js';
56
57
  const ISOLATED_ESM_TRANSFORM_MIN_BYTES = 512 * 1024;
57
58
  /**
@@ -131,34 +132,6 @@ function parseFacetManagerEnv(env) {
131
132
  rawEnv: env,
132
133
  };
133
134
  }
134
- /**
135
- * Reserve held back from a one-shot facet's lifetime so a program that runs
136
- * out of time is still alive to say so.
137
- *
138
- * A one-shot exec is ALREADY bounded: `_execWithTimeout` kills it at
139
- * FACET_TIMEOUT_MS with exit 124 and "[process killed: timeout after 30s]".
140
- * The entry drain must therefore not be a second, tighter, independent
141
- * timeout. Measured against a deployed Worker, floating async work of 5s /
142
- * 15s / 25s completes and 40s is killed by that outer bound at exactly 30s —
143
- * so the fixed 8s budget this used to carry was abandoning programs 22
144
- * seconds before anything actually required it.
145
- *
146
- * The drain therefore runs to the outer bound MINUS this reserve, which is
147
- * what buys the facet time to flush and report the honest "still in flight"
148
- * reason instead of the supervisor's generic kill. The reserve has to cover
149
- * the longest tail a facet can have after the drain: settling pending RPC,
150
- * writing back __vfsWrites (bounded by MAX_RPC_SAFE_PAYLOAD_BYTES; a 20 MiB
151
- * write-back measures ~1.5s), draining children, then reportExit.
152
- */
153
- export const ONE_SHOT_EXIT_RESERVE_MS = 3_000;
154
- /**
155
- * Budget used when the supervisor did not stamp an absolute deadline on the
156
- * payload. The deadline is the real bound — see `entryDeadlineAt` — because
157
- * it is measured from the supervisor's own timer rather than restarted when
158
- * the drain begins, so a slow module init cannot push the drain past the kill
159
- * and lose the honest message.
160
- */
161
- export const ONE_SHOT_ENTRY_DEADLINE_MS = FACET_TIMEOUT_MS - ONE_SHOT_EXIT_RESERVE_MS;
162
135
  /**
163
136
  * How long a RESIDENT facet settles its startup before answering its boot
164
137
  * call. It keeps running afterwards, so this is not a lifetime decision: the
@@ -174,8 +147,11 @@ export const RESIDENT_BOOT_SETTLE_MS = 1000;
174
147
  * servers, requests in flight. A promise is not a handle: a program whose
175
148
  * last act leaves `new Promise(() => {})` unsettled prints its output and
176
149
  * exits 0. Counting unsettled promises as work was a real divergence from
177
- * that — such a program burned the whole facet lifetime and was then
178
- * 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.
179
155
  *
180
156
  * Three kinds of handle, each owned by the shim that creates them:
181
157
  *
@@ -188,13 +164,6 @@ export const RESIDENT_BOOT_SETTLE_MS = 1000;
188
164
  * - listening SERVERS (`__portRegistry`), open until the program closes
189
165
  * them.
190
166
  *
191
- * The bound is a REAL wall-clock deadline, armed as a timer rather than
192
- * compared against `Date.now()`: a `setTimeout(0)` turn in workerd costs
193
- * ~5µs, so the pass budget this loop used to carry (50k) expired after
194
- * ~150ms and silently overrode every longer deadline the callers declared —
195
- * anything slower than that, including an ordinary network fetch, was
196
- * abandoned mid-flight and reported as a clean exit.
197
- *
198
167
  * The loop subscribes to the exit promise ONCE — a per-pass
199
168
  * `exitPromise.then()` allocates a promise every iteration — and yields
200
169
  * through the raw setTimeout so its own ticks don't inflate the timer count
@@ -231,16 +200,21 @@ async function __nimbusRunEventLoop(__countHandles, __exitPromise, __deadlineMs,
231
200
  ? globalThis.__nimbusRawClearTimeout
232
201
  : globalThis.clearTimeout;
233
202
  let __expired = false;
234
- const __deadline = __rawSetTimeout(() => { __expired = true; }, __deadlineMs);
235
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;
236
210
  while (!__exited && !__expired && (__pass < __minPasses || __countHandles() > 0)) {
237
211
  // The warm-up passes give a settling microtask chain its turns and cost
238
212
  // ~5µs each; past them the loop is waiting on wall-clock work, where
239
- // spinning at 0ms would burn the isolate's CPU for the whole deadline.
213
+ // spinning at 0ms would burn the isolate's CPU indefinitely.
240
214
  await new Promise((resolve) => __rawSetTimeout(resolve, __pass < __minPasses ? 0 : 1));
241
215
  __pass++;
242
216
  }
243
- try { __rawClearTimeout(__deadline); } catch {}
217
+ if (__deadline !== null) { try { __rawClearTimeout(__deadline); } catch {} }
244
218
  // \`pending\` is what the caller reports when it gives up: a one-shot program
245
219
  // still holding a handle did NOT finish, and exiting 0 would claim it did.
246
220
  return { passes: __pass, pending: __exited ? 0 : __countHandles() };
@@ -395,7 +369,7 @@ const SQLITE_FACET_IMPORT = `import __nimbusSqliteWasmModule from "${SQLITE_WASM
395
369
  /**
396
370
  * Generate one-shot runtime code with a plain fetch handler.
397
371
  */
398
- export async function generateEntrypointCode(userCode, vfsState, usesSqlite, shims) {
372
+ export async function generateEntrypointCode(userCode, vfsState, usesSqlite, shims, wasmImports = []) {
399
373
  const safeCode = JSON.stringify(userCode);
400
374
  const bundleSource = await facetVfsBundleSourceFor(vfsState);
401
375
  const safeManifest = vfsState.serializedManifest ?? JSON.stringify(vfsState.manifest);
@@ -405,6 +379,7 @@ export async function generateEntrypointCode(userCode, vfsState, usesSqlite, shi
405
379
  ${bundleSource.imports}
406
380
  ${REAL_NODE_IMPORTS}
407
381
  ${usesSqlite ? SQLITE_FACET_IMPORT : ''}
382
+ ${facetWasmImportsSource(wasmImports)}
408
383
  const USER_CODE = ${safeCode};
409
384
  const __NimbusHostResponse = globalThis.Response;
410
385
 
@@ -454,20 +429,16 @@ class __ProcessExit extends Error {
454
429
  export default {
455
430
  async fetch(request, workerEnv) {
456
431
  const args = await request.json();
457
- const { argv, env, cwd: _cwd, filename, dirname, stdin, captureOutput, cred, diag: __diag, entryDeadlineAt, vfsCursor } = args;
432
+ const { argv, env, cwd: _cwd, filename, dirname, stdin, captureOutput, cred, diag: __diag, vfsCursor } = args;
458
433
  // Per invocation, not per module: this body is cached on
459
434
  // hash(code + bundle + manifest) and reused by any session whose snapshot
460
435
  // hashes the same, and epochs are per supervisor incarnation.
461
436
  const __MODULE_VFS_CURSOR = vfsCursor || null;
462
437
  ${VFS_CURSOR_SEED_SOURCE}
463
- // What is left of this facet's lifetime, measured from the supervisor's
464
- // own timeout timer rather than from whenever the drain happens to start
465
- // — a slow module init must not be able to push the drain past the kill
466
- // and lose the honest reason. Same shape as the git network facet's
467
- // phaseDeadline.
468
- const __entryBudgetMs = Number.isFinite(entryDeadlineAt)
469
- ? Math.max(0, Number(entryDeadlineAt) - Date.now())
470
- : ${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;
471
442
  let __drainPasses = 0;
472
443
  const __vfsBundle = __MODULE_VFS_BUNDLE;
473
444
  const __vfsManifest = __MODULE_VFS_MANIFEST;
@@ -486,11 +457,12 @@ ${VFS_CURSOR_SEED_SOURCE}
486
457
  };
487
458
  let __rpcWriteChain = Promise.resolve();
488
459
  let __rpcWriteCount = 0;
489
- const __queueRpcWrite = (method, s) => {
460
+ // The relay carries bytes (see "Process output is bytes" in the shims).
461
+ const __queueRpcWrite = (method, bytes) => {
490
462
  __rpcWriteCount++;
491
463
  const __task = __rpcWriteChain
492
- .then(() => __supervisor[method](s))
493
- .catch((e) => __onRpcDrop(s.length, e));
464
+ .then(() => __supervisor[method](bytes))
465
+ .catch((e) => __onRpcDrop(bytes.byteLength, e));
494
466
  __rpcWriteChain = __task.then(() => {}, () => {});
495
467
  __pendingIO.push(__task);
496
468
  };
@@ -509,13 +481,13 @@ ${RESIDENCY_MISS_REPORT}
509
481
 
510
482
  // Override console AND process.stdout/stderr for live SUPERVISOR streaming
511
483
  if (__supervisor && !captureOutput) {
512
- __consoleMod.log = (...a) => { const s = __utilMod.format(...a) + "\\n"; stdout += s; __queueRpcWrite("stdout", s); };
513
- __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)); };
514
486
  __consoleMod.warn = __consoleMod.error;
515
487
  __consoleMod.info = __consoleMod.log;
516
488
  __consoleMod.debug = __consoleMod.log;
517
- __processMod.stdout.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const s = String(d); stdout += s; __queueRpcWrite("stdout", s); if (typeof cb === "function") queueMicrotask(cb); return true; };
518
- __processMod.stderr.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const s = String(d); stderr += s; __queueRpcWrite("stderr", s); if (typeof cb === "function") queueMicrotask(cb); return true; };
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; };
519
491
  }
520
492
 
521
493
  try { globalThis.console = __consoleMod; } catch {}
@@ -546,18 +518,7 @@ ${RESIDENCY_MISS_REPORT}
546
518
  const __drain = await __nimbusRunEntrypointToExit(__entryResult, __entryBudgetMs);
547
519
  __drainPasses = __drain.passes;
548
520
  if (__nimbusProcessExitCode !== null) exitCode = __nimbusProcessExitCode;
549
- // This facet's lifetime IS the event loop, so a handle still open when
550
- // the deadline passes is work that will never run. Reporting exit 0
551
- // there is the silent-truncation failure: name the limit and fail.
552
- else if (__drain.pending > 0) {
553
- const __why = "node: reached the ${FACET_TIMEOUT_MS / 1000}s facet lifetime limit with " +
554
- __drain.pending + " operation(s) still in flight; the rest of the program did not run. " +
555
- "A program that needs longer than ${FACET_TIMEOUT_MS / 1000}s has to run as a " +
556
- "long-running process (node --watch, or a server), which is not bound by it.\\n";
557
- stderr += __why;
558
- exitCode = 1;
559
- if (__supervisor && !captureOutput) __queueRpcWrite("stderr", __why);
560
- }
521
+
561
522
  if (__nimbusLiveStdinPump && !__nimbusAttachedTty) await __nimbusLiveStdinPump;
562
523
  } catch (e) {
563
524
  if (e instanceof __ProcessExit) { exitCode = e.code; }
@@ -566,7 +527,7 @@ ${RESIDENCY_MISS_REPORT}
566
527
  stderr += trace + "\\n";
567
528
  exitCode = 1;
568
529
  if (__supervisor && !captureOutput) {
569
- try { __pendingIO.push(__supervisor.stderr(trace + "\\n").catch((e2) => __onRpcDrop((trace || "").length + 1, e2))); } catch {}
530
+ try { const __traceBytes = __nimbusOutEnc.encode(trace + "\\n"); __pendingIO.push(__supervisor.stderr(__traceBytes).catch((e2) => __onRpcDrop(__traceBytes.byteLength, e2))); } catch {}
570
531
  }
571
532
  }
572
533
  }
@@ -592,7 +553,7 @@ ${RESIDENCY_MISS_REPORT}
592
553
  if (__residencyReport) {
593
554
  stderr += __residencyReport;
594
555
  if (exitCode === 0) exitCode = 1;
595
- if (__supervisor && !captureOutput) __queueRpcWrite("stderr", __residencyReport);
556
+ if (__supervisor && !captureOutput) __queueRpcWrite("stderr", __nimbusOutEnc.encode(__residencyReport));
596
557
  }
597
558
 
598
559
  await __drainPendingIO();
@@ -606,7 +567,7 @@ ${RESIDENCY_MISS_REPORT}
606
567
  exitCode = 1;
607
568
  if (!captureOutput) {
608
569
  try {
609
- await __supervisor.stderr(trace + "\\n");
570
+ await __supervisor.stderr(__nimbusOutEnc.encode(trace + "\\n"));
610
571
  } catch {}
611
572
  }
612
573
  }
@@ -656,6 +617,61 @@ ${RESIDENCY_MISS_REPORT}
656
617
  * compiled user entry is booted once and the exported entrypoint keeps
657
618
  * serving HTTP requests from the shimmed http.Server registry.
658
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
+ }
659
675
  export async function generateLongRunningNodeCode(userCode, vfsState, opts, usesSqlite, shims, pacer) {
660
676
  const safeCode = JSON.stringify(userCode);
661
677
  const safeArgs = JSON.stringify({
@@ -669,14 +685,15 @@ export async function generateLongRunningNodeCode(userCode, vfsState, opts, uses
669
685
  cred: opts.cred,
670
686
  });
671
687
  const bundleSource = await facetVfsBundleSourceFor(vfsState, pacer);
672
- const safeManifest = JSON.stringify(vfsState.manifest);
673
- const safeMetadata = JSON.stringify(vfsState.metadata);
688
+ const safeManifest = vfsState.serializedManifest ?? JSON.stringify(vfsState.manifest);
689
+ const safeMetadata = vfsState.serializedMetadata ?? JSON.stringify(vfsState.metadata);
674
690
  return {
675
691
  code: `
676
692
  ${bundleSource.imports}
677
693
  import { DurableObject } from "cloudflare:workers";
678
694
  ${REAL_NODE_IMPORTS}
679
695
  ${usesSqlite ? SQLITE_FACET_IMPORT : ''}
696
+ ${facetWasmImportsSource(opts.wasmImports ?? [])}
680
697
  const USER_CODE = ${safeCode};
681
698
  const __NIMBUS_ARGS = ${safeArgs};
682
699
  const __NimbusHostResponse = globalThis.Response;
@@ -832,11 +849,12 @@ ${VFS_CURSOR_SEED_SOURCE}
832
849
  };
833
850
  let __rpcWriteChain = Promise.resolve();
834
851
  let __rpcWriteCount = 0;
835
- const __queueRpcWrite = (method, s) => {
852
+ // The relay carries bytes (see "Process output is bytes" in the shims).
853
+ const __queueRpcWrite = (method, bytes) => {
836
854
  __rpcWriteCount++;
837
855
  const __task = __rpcWriteChain
838
- .then(() => __supervisor[method](s))
839
- .catch((e) => __onRpcDrop(s.length, e));
856
+ .then(() => __supervisor[method](bytes))
857
+ .catch((e) => __onRpcDrop(bytes.byteLength, e));
840
858
  __rpcWriteChain = __task.then(() => {}, () => {});
841
859
  __pendingIO.push(__task);
842
860
  };
@@ -854,13 +872,13 @@ ${ENTRYPOINT_EVENT_LOOP}
854
872
  ${RESIDENCY_MISS_REPORT}
855
873
 
856
874
  if (__supervisor && !captureOutput) {
857
- __consoleMod.log = (...a) => { const s = __utilMod.format(...a) + "\\n"; stdout += s; __queueRpcWrite("stdout", s); };
858
- __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)); };
859
877
  __consoleMod.warn = __consoleMod.error;
860
878
  __consoleMod.info = __consoleMod.log;
861
879
  __consoleMod.debug = __consoleMod.log;
862
- __processMod.stdout.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const s = String(d); stdout += s; __queueRpcWrite("stdout", s); if (typeof cb === "function") queueMicrotask(cb); return true; };
863
- __processMod.stderr.write = (d, enc, cb) => { if (typeof enc === "function") cb = enc; const s = String(d); stderr += s; __queueRpcWrite("stderr", s); if (typeof cb === "function") queueMicrotask(cb); return true; };
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; };
864
882
  }
865
883
 
866
884
  try { globalThis.console = __consoleMod; } catch {}
@@ -914,7 +932,7 @@ ${RESIDENCY_MISS_REPORT}
914
932
  stderr += trace + "\\n";
915
933
  exitCode = 1;
916
934
  if (__supervisor && !captureOutput) {
917
- try { __pendingIO.push(__supervisor.stderr(trace + "\\n").catch((e2) => __onRpcDrop((trace || "").length + 1, e2))); } catch {}
935
+ try { const __traceBytes = __nimbusOutEnc.encode(trace + "\\n"); __pendingIO.push(__supervisor.stderr(__traceBytes).catch((e2) => __onRpcDrop(__traceBytes.byteLength, e2))); } catch {}
918
936
  }
919
937
  }
920
938
  }
@@ -943,7 +961,7 @@ ${RESIDENCY_MISS_REPORT}
943
961
  if (__residencyReport) {
944
962
  stderr += __residencyReport;
945
963
  if (Number(code ?? 0) === 0) code = 1;
946
- try { await __supervisor.stderr(__residencyReport); } catch {}
964
+ try { await __supervisor.stderr(__nimbusOutEnc.encode(__residencyReport)); } catch {}
947
965
  }
948
966
  await __supervisor.reportExit(code, reason || "");
949
967
  __nimbusProcessExitReported = true;
@@ -952,7 +970,7 @@ ${RESIDENCY_MISS_REPORT}
952
970
  const trace = (e && e.stack) || (e && e.message) || String(e);
953
971
  stderr += trace + "\\n";
954
972
  if (__supervisor) {
955
- try { await __supervisor.stderr(trace + "\\n"); } catch {}
973
+ try { await __supervisor.stderr(__nimbusOutEnc.encode(trace + "\\n")); } catch {}
956
974
  await __nimbusReportFinalExit(1, trace + "\\n");
957
975
  }
958
976
  };
@@ -1001,7 +1019,7 @@ ${RESIDENCY_MISS_REPORT}
1001
1019
  const tail = "[orphan output: " + __rpcDrops + " dropped RPC write(s), ~" +
1002
1020
  __rpcDropBytes + " bytes lost" +
1003
1021
  (__rpcLastError ? "; last error: " + __rpcLastError : "") + "]\\n";
1004
- try { await __supervisor.stderr(tail); } catch {}
1022
+ try { await __supervisor.stderr(__nimbusOutEnc.encode(tail)); } catch {}
1005
1023
  }
1006
1024
  if (exitCode !== 0) {
1007
1025
  if (__supervisor && !__nimbusProcessExitReported) {
@@ -1057,16 +1075,15 @@ export class NimbusProcess extends DurableObject {
1057
1075
  * `bundleSource`, `serializedManifest` and `serializedMetadata` are total
1058
1076
  * encodings of `bundle`, `manifest` and `metadata` — no caller can distinguish
1059
1077
  * a state carrying both from one carrying only the serialized halves, because
1060
- * `generateEntrypointCode` reads the serialized halves and nothing else does.
1078
+ * both facet generators read the serialized halves and nothing else does.
1061
1079
  * Holding both doubles the cost of a cached entry for its whole lifetime, and
1062
1080
  * that lifetime spans execs.
1063
1081
  *
1064
- * Only for states on the one-shot cached path, and applied there whether or
1065
- * not the entry turns out small enough to retain: the invocation being served
1066
- * reads the serialized forms too. `spawnNode` and `_stageOpencodeFacet` build
1067
- * their own uncached states and genuinely re-read the raw cells
1068
- * (`_serializeBundleForFacet`, `assertStagedBundleFitsRpcPayload`); neither
1069
- * 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.
1070
1087
  */
1071
1088
  export function releaseSerializedSources(vfsState) {
1072
1089
  if (vfsState.bundleSource)
@@ -1076,31 +1093,6 @@ export function releaseSerializedSources(vfsState) {
1076
1093
  if (vfsState.serializedMetadata !== undefined)
1077
1094
  vfsState.metadata = {};
1078
1095
  }
1079
- /**
1080
- * Drop everything a resident launch has finished reading, in place.
1081
- *
1082
- * The one-shot path releases its map at LOADER.load; this is the same policy
1083
- * for the path `spawnNode` takes, which is the path every attached-TTY npm bin
1084
- * takes — how a real agentic CLI starts. The generated source is a total
1085
- * encoding of the cells, the manifest and the metadata, and the only thing the
1086
- * rest of a launch reads off the state is `cursor`. Everything else is a second
1087
- * copy of the largest thing this DO builds — 22.9 MB for pi — held for exactly
1088
- * as long as the facet takes to boot on it.
1089
- *
1090
- * Holding it reset the session isolate with exceededMemory, and an isolate
1091
- * reset tears the terminal WebSocket down with no exit frame: the dead screen
1092
- * reading "[process terminal closed]".
1093
- *
1094
- * An emptied state can still generate a map — it would just generate one with
1095
- * no program in it — so this marks the state instead of trusting callers to
1096
- * stop.
1097
- */
1098
- export function releaseResidentLaunchSources(vfsState) {
1099
- vfsState.bundle = {};
1100
- vfsState.manifest = {};
1101
- vfsState.metadata = {};
1102
- vfsState.generatedSourcesReleased = true;
1103
- }
1104
1096
  /**
1105
1097
  * Drop the serialized forms once a module map has been generated from them.
1106
1098
  *
@@ -1108,12 +1100,19 @@ export function releaseResidentLaunchSources(vfsState) {
1108
1100
  * bundle expression, the manifest and the metadata is inside it. Holding them
1109
1101
  * afterwards keeps a second copy of the largest thing this DO builds alive for
1110
1102
  * as long as the facet runs — for pi, 22.7 MB across the ~20 s window in which
1111
- * 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]".
1112
1109
  *
1113
1110
  * Only for a state the prefetch cache refused. A retained entry's serialized
1114
- * forms ARE the entry, and a later exec is served from them.
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.
1115
1114
  */
1116
- function releaseGeneratedSources(vfsState) {
1115
+ export function releaseGeneratedSources(vfsState) {
1117
1116
  vfsState.bundleSource = undefined;
1118
1117
  vfsState.serializedManifest = undefined;
1119
1118
  vfsState.serializedMetadata = undefined;
@@ -1166,6 +1165,29 @@ function _readBundleCell(vfs, path) {
1166
1165
  function _bundleCellLength(cell) {
1167
1166
  return typeof cell === 'string' ? cell.length : cell.byteLength;
1168
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
+ }
1169
1191
  /** What a bundle currently weighs, and so what one more pass over it costs. */
1170
1192
  function _bundleWeight(bundle) {
1171
1193
  let weight = 0;
@@ -1770,12 +1792,16 @@ function addUnreadableDenialCells(vfs, bundle, metadata) {
1770
1792
  * computed-path requires). Bounded to package.json + 1 main-entry file
1771
1793
  * per package — sub-agent §Q3 quantified the worst-case cumulative
1772
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`.
1773
1799
  */
1774
1800
  // can verify the hash-chunk + shared/ oversample directly. Pre-X.5-C this
1775
1801
  // was a file-local helper. Adding the named export is a pure surface
1776
1802
  // addition — no callers other than buildPrefetchBundle (same file) and
1777
1803
  // the new probe.
1778
- export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
1804
+ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState, requiredPaths = new Set()) {
1779
1805
  let added = 0;
1780
1806
  const cwdStripped = cwd.replace(/^\/+/, '');
1781
1807
  const nmDir = cwdStripped + '/node_modules';
@@ -1801,7 +1827,16 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
1801
1827
  // supervisor's headroom — on every invocation that never reads it.
1802
1828
  // Anything the program really requires arrives through the closure,
1803
1829
  // which is uncapped.
1804
- if (vfs.lstat(stripped).size > BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES)
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'))
1805
1840
  return false;
1806
1841
  // hardening-r5: preserve binary content as Uint8Array.
1807
1842
  const content = _readBundleCell(vfs, stripped);
@@ -1838,8 +1873,42 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
1838
1873
  collectExportLeaves(node[k], out);
1839
1874
  }
1840
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
+ }
1841
1906
  function addPkgEntry(pkgDir) {
1842
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;
1843
1912
  let meta;
1844
1913
  try {
1845
1914
  meta = JSON.parse(vfs.readFileString(pkgDir + '/package.json'));
@@ -1939,27 +2008,87 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
1939
2008
  }
1940
2009
  }
1941
2010
  }
1942
- try {
1943
- for (const pkg of vfs.readdir(nmDir)) {
1944
- if (pkg.type !== 'directory')
1945
- continue;
1946
- const pkgDir = nmDir + '/' + pkg.name;
1947
- if (pkg.name.startsWith('@')) {
1948
- try {
1949
- for (const sub of vfs.readdir(pkgDir)) {
1950
- if (sub.type === 'directory')
1951
- addPkgEntry(pkgDir + '/' + sub.name);
1952
- }
1953
- }
1954
- catch { /* ignore */ }
1955
- }
1956
- else {
1957
- addPkgEntry(pkgDir);
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);
1958
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);
1959
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);
1960
2086
  }
1961
- catch { /* ignore */ }
1962
- return { added };
2087
+ for (const owner of owners) {
2088
+ reached.add(owner);
2089
+ hop(owner);
2090
+ }
2091
+ return [...reached].sort();
1963
2092
  }
1964
2093
  /**
1965
2094
  * X.5-Z3: scan every JS source already in `bundle` for static
@@ -2029,6 +2158,8 @@ export function addStaticReadFileAssets(vfs, cwd, bundle, budgetState) {
2029
2158
  try {
2030
2159
  if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
2031
2160
  return false;
2161
+ if (!_bundleAdmits(vfs, stripped, budgetState))
2162
+ return false;
2032
2163
  // hardening-r5: preserve binary content as Uint8Array.
2033
2164
  const content = _readBundleCell(vfs, stripped);
2034
2165
  const cellLen = _bundleCellLength(content);
@@ -2202,6 +2333,8 @@ export function addStaticReadFileDotfilesAndCompiled(vfs, cwd, bundle, budgetSta
2202
2333
  try {
2203
2334
  if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
2204
2335
  return false;
2336
+ if (!_bundleAdmits(vfs, stripped, budgetState))
2337
+ return false;
2205
2338
  // hardening-r5: preserve binary content as Uint8Array.
2206
2339
  const content = _readBundleCell(vfs, stripped);
2207
2340
  const cellLen = _bundleCellLength(content);
@@ -2307,7 +2440,7 @@ export function addStaticReadFileDotfilesAndCompiled(vfs, cwd, bundle, budgetSta
2307
2440
  */
2308
2441
  export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundleProfile) {
2309
2442
  if (!scriptPath)
2310
- return { added: 0 };
2443
+ return { added: 0, wasmPaths: [] };
2311
2444
  const stripped = scriptPath.replace(/^\/+/, '');
2312
2445
  // Find the *innermost* node_modules/<pkg> root. Handles scoped
2313
2446
  // packages (`@org/name`) too.
@@ -2320,11 +2453,11 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
2320
2453
  }
2321
2454
  }
2322
2455
  if (nmIdx < 0)
2323
- return { added: 0 };
2456
+ return { added: 0, wasmPaths: [] };
2324
2457
  const isScoped = segs[nmIdx + 1]?.startsWith('@');
2325
2458
  const pkgEnd = isScoped ? nmIdx + 3 : nmIdx + 2;
2326
2459
  if (pkgEnd > segs.length)
2327
- return { added: 0 };
2460
+ return { added: 0, wasmPaths: [] };
2328
2461
  const pkgRoot = segs.slice(0, pkgEnd).join('/');
2329
2462
  // npm-create-fix wave (2026-05-12): scaffold profile needs this cap
2330
2463
  // high enough to cover
@@ -2350,6 +2483,10 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
2350
2483
  // an unread multi-MiB cell costs a stat rather than a transfer.
2351
2484
  let visited = 0;
2352
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 = [];
2353
2490
  const queue = [pkgRoot];
2354
2491
  while (queue.length > 0 && visited < MAX_PKG_FILES) {
2355
2492
  const dir = queue.shift();
@@ -2379,6 +2516,8 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
2379
2516
  // to it) or outside this profile's package-data policy.
2380
2517
  if (!shouldIncludeBinPackageFile(pkgRoot, child, bundleProfile))
2381
2518
  continue;
2519
+ if (child.endsWith('.wasm'))
2520
+ wasmPaths.push(child);
2382
2521
  if (bundle[child] !== undefined)
2383
2522
  continue;
2384
2523
  let size;
@@ -2409,6 +2548,8 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
2409
2548
  break;
2410
2549
  if (budgetState.totalBytes >= VFS_BUNDLE_MAX_BYTES)
2411
2550
  break;
2551
+ if (candidate.size > VFS_BUNDLE_MAX_BYTES - budgetState.totalBytes)
2552
+ continue;
2412
2553
  // hardening-r5: preserve binary content as Uint8Array.
2413
2554
  let content;
2414
2555
  try {
@@ -2427,7 +2568,40 @@ export function addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundl
2427
2568
  budgetState.fileCount++;
2428
2569
  added++;
2429
2570
  }
2430
- 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()];
2431
2605
  }
2432
2606
  /**
2433
2607
  * Stage the paths an earlier run of the same entry read synchronously and did
@@ -2611,13 +2785,8 @@ function addCwdProjectFiles(vfs, cwd, bundle, budgetState) {
2611
2785
  // Skip an oversized file before reading it: this walk guesses at what
2612
2786
  // the program might read, and one guess must not spend the budget (or
2613
2787
  // the supervisor's headroom) that every later invocation then carries.
2614
- try {
2615
- if (vfs.lstat(child).size > CWD_SNAPSHOT_MAX_FILE_BYTES)
2616
- continue;
2617
- }
2618
- catch {
2788
+ if (!_bundleAdmits(vfs, child, budgetState, CWD_SNAPSHOT_MAX_FILE_BYTES))
2619
2789
  continue;
2620
- }
2621
2790
  let content;
2622
2791
  try {
2623
2792
  content = _readBundleCell(vfs, child);
@@ -2687,6 +2856,8 @@ function addEntryAbsPathReads(vfs, entryCode, bundle, budgetState) {
2687
2856
  try {
2688
2857
  if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
2689
2858
  return;
2859
+ if (!_bundleAdmits(vfs, stripped, budgetState))
2860
+ return;
2690
2861
  // hardening-r5: preserve binary content as Uint8Array.
2691
2862
  const content = _readBundleCell(vfs, stripped);
2692
2863
  const cellLen = _bundleCellLength(content);
@@ -3039,7 +3210,7 @@ async function transformEsmInBundle(bundle, esbuild, pacer, isolatedTransform) {
3039
3210
  * behaviour for code paths that don't have esbuild handy).
3040
3211
  *
3041
3212
  */
3042
- 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) {
3043
3214
  // This build accumulates raw VFS contents in the supervisor heap, and did it
3044
3215
  // with nothing watching: the estimator read 9.4 MiB while these bytes were
3045
3216
  // resetting the DO three times. Take the budget the enrichment passes are
@@ -3049,19 +3220,25 @@ export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbui
3049
3220
  const lease = await acquireSupervisorAllocation(VFS_BUNDLE_MAX_BYTES);
3050
3221
  prefetchBundleStart(VFS_BUNDLE_MAX_BYTES);
3051
3222
  try {
3052
- 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);
3053
3224
  }
3054
3225
  finally {
3055
3226
  prefetchBundleEnd(VFS_BUNDLE_MAX_BYTES);
3056
3227
  lease.release();
3057
3228
  }
3058
3229
  }
3059
- 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) {
3060
3231
  // Read the cursor BEFORE the walk: a mutation that lands while the bundle
3061
3232
  // is being assembled must be reported as invalidated, not silently missed.
3062
3233
  const cursor = { epoch: vfs.epoch, rev: vfs.revision() };
3063
3234
  // 1. Static reachable-set walk from entry.
3064
- 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
+ }
3065
3242
  const bundle = { ...prefetch.bundle };
3066
3243
  const requiredPaths = new Set(Object.keys(prefetch.bundle));
3067
3244
  let truncated = false;
@@ -3092,7 +3269,7 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3092
3269
  // Catches dynamic-require / `bindings()` / plugin-loader cases the
3093
3270
  // regex prefetch misses. Its budget is independent from the complete
3094
3271
  // static require closure, which is correctness-critical.
3095
- const greedy = greedyAddMainEntries(vfs, cwd, bundle, budgetState);
3272
+ const greedy = greedyAddMainEntries(vfs, cwd, bundle, budgetState, requiredPaths);
3096
3273
  await paceAfterPass();
3097
3274
  // 2.25 X.5-Z3: static-readFileSync asset prefetch. Scans every
3098
3275
  // bundle .js/.mjs/.cjs source for the canonical jsdom shape:
@@ -3125,7 +3302,6 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3125
3302
  // as data, etc.) without needing a per-pkg whitelist.
3126
3303
  // No-op when entry isn't inside node_modules.
3127
3304
  const binSiblingAdd = addBinTargetSiblings(vfs, scriptPath, bundle, budgetState, bundleProfile);
3128
- void binSiblingAdd;
3129
3305
  await paceAfterPass();
3130
3306
  // 2.34 project-data snapshot: sync Node fs cannot await the
3131
3307
  // supervisor. Include a bounded snapshot of the current working tree
@@ -3241,6 +3417,7 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3241
3417
  const bundleSideModulesRequired = size.bytes > BUNDLE_MAX_ENCODED_BYTES;
3242
3418
  // Suppress lint: `greedy.added` is observed only via diagnostics.
3243
3419
  void greedy;
3420
+ const wasmImages = collectClosureWasmImages(vfs, bundle, binSiblingAdd.wasmPaths);
3244
3421
  return {
3245
3422
  bundle,
3246
3423
  manifest,
@@ -3249,8 +3426,11 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
3249
3426
  reachableCount: fileCount,
3250
3427
  truncated,
3251
3428
  bundleSideModulesRequired,
3429
+ ...(wasmImages.length > 0 ? { wasmImages } : {}),
3252
3430
  };
3253
3431
  }
3432
+ /** The main module name a worker launch boots from unless told otherwise. */
3433
+ export const DEFAULT_WORKER_MAIN_MODULE = 'worker.js';
3254
3434
  const ROUTEABLE_PORT_ATTACH_TIMEOUT_MS = 1_000;
3255
3435
  /** A port request may wait this long for a durable app's re-drive to boot. */
3256
3436
  const DURABLE_ENSURE_BOOT_BUDGET_MS = 12_000;
@@ -3316,7 +3496,6 @@ export class FacetManager {
3316
3496
  * journal recovery rides the first pump.
3317
3497
  */
3318
3498
  launchPump;
3319
- timedOutProcessIds = new Set();
3320
3499
  // attach-pid → serve-pid: the resident serve facet a bare-`opencode` dual
3321
3500
  // spawn created as an OS-child of the attach TUI. When the attach process
3322
3501
  // exits (reported / killed), its serve facet is torn down with it.
@@ -3564,61 +3743,92 @@ export class FacetManager {
3564
3743
  */
3565
3744
  setEsbuildService(esbuild) { this.esbuild = esbuild; }
3566
3745
  /**
3567
- * buildPrefetchBundle wrapped in a global-revision-keyed cache. On a hit
3568
- * (same key AND the VFS hasn't been mutated since) it returns the memoized
3569
- * bundle + pre-built facet source, skipping the full VFS walk + esbuild
3570
- * pass + source construction. See `prefetchBundleCache` for the
3571
- * correctness argument behind the conservative global-revision watermark.
3572
- *
3573
- * The bundle source and manifest are computed once on the miss path and
3574
- * 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.
3575
3749
  */
3750
+ _launchPacer(pid) {
3751
+ return new TurnBudget(this.launchPump, turnChunkMaxBytes(this.env), () => this._assertLaunchStillOwned(pid));
3752
+ }
3576
3753
  /**
3577
- * Bound the prefetch build, so a cache miss cannot be a silent hang.
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.
3578
3772
  *
3579
- * The build was awaited entirely OUTSIDE `_execWithTimeout`, which wraps
3580
- * only `_execViaLoader`. So every timeout in the system — the 30 s facet
3581
- * bound, the 60 s bin-dispatch bound — sat downstream of a step that could
3582
- * take arbitrarily long, and a heavy build wedged the Durable Object with
3583
- * nothing able to report it. Observed as a terminal that goes quiet and
3584
- * never returns, with no exit record for the process.
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.
3585
3778
  *
3586
- * WHAT THIS CAN AND CANNOT CATCH, stated plainly because the difference
3587
- * decides whether a given hang is fixed by it. The build is asynchronous —
3588
- * it awaits the VFS walk and the esbuild ESM→CJS pass — so a stall at any
3589
- * of those points is caught and reported here. A stall inside ONE
3590
- * synchronous stretch is not: a JS stack that never yields cannot be raced
3591
- * by anything in the same isolate, so serializing a multi-megabyte bundle
3592
- * in a single pass still wedges, and the deadline fires only once the stack
3593
- * finally unwinds. That class needs the work bounded at its INPUT rather
3594
- * than timed at its edge, which is a separate change; this one converts
3595
- * every interruptible stall from a silent wedge into a loud failure, and
3596
- * makes the remaining class the only one left to explain.
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.
3786
+ *
3787
+ * A session without a filesystem gets an empty state: there is nothing to
3788
+ * stage and nothing to yield for.
3597
3789
  */
3598
- async _withBundleBuildDeadline(build, command) {
3599
- let timer;
3600
- try {
3601
- return await Promise.race([
3602
- build,
3603
- new Promise((_resolve, reject) => {
3604
- timer = setTimeout(() => reject(new Error(`Nimbus: assembling the filesystem bundle for \`${command}\` exceeded `
3605
- + `${BUNDLE_BUILD_DEADLINE_MS}ms. The process was not started. This is the `
3606
- + 'bundle build, not the program: it runs in the session Durable Object, so '
3607
- + 'it is reported rather than allowed to wedge the session.')), BUNDLE_BUILD_DEADLINE_MS);
3608
- }),
3609
- ]);
3610
- }
3611
- finally {
3612
- if (timer !== undefined)
3613
- clearTimeout(timer);
3614
- // The build keeps running after a timeout — nothing here can interrupt
3615
- // it — so its rejection must not surface later as an unhandled one.
3616
- build.catch(() => { });
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
+ };
3617
3812
  }
3813
+ return modules;
3618
3814
  }
3619
- async _buildPrefetchBundleCached(vfs, scriptPath, cwd, entryCode, credKey, bundleProfile) {
3620
- const profile = bundleProfile ?? DEFAULT_FACET_BUNDLE_PROFILE;
3621
- const key = `${profile}\x00${credKey}\x00${cwd}\x00${scriptPath ?? ''}\x00${_fnv1a(entryCode)}`;
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)}`;
3622
3832
  const revision = vfs.revision();
3623
3833
  // An entry built at an older revision can never be SERVED again — the
3624
3834
  // lookup below requires an exact match — so from the first write after it
@@ -3627,11 +3837,11 @@ export class FacetManager {
3627
3837
  // replaces it allocates, so the stale filesystem graph and the new one
3628
3838
  // never co-reside.
3629
3839
  let evictedStale = false;
3630
- for (const [staleKey, entry] of this.prefetchBundleCache) {
3631
- if (entry.revision === revision)
3840
+ for (const [staleKey, stale] of this.prefetchBundleCache) {
3841
+ if (stale.revision === revision)
3632
3842
  continue;
3633
3843
  this.prefetchBundleCache.delete(staleKey);
3634
- this.prefetchCacheBytes -= entry.bytes;
3844
+ this.prefetchCacheBytes -= stale.bytes;
3635
3845
  evictedStale = true;
3636
3846
  }
3637
3847
  if (evictedStale)
@@ -3643,24 +3853,26 @@ export class FacetManager {
3643
3853
  this.prefetchBundleCache.set(key, cached);
3644
3854
  return { ...cached.vfsState, cacheHit: true, cacheRetained: true };
3645
3855
  }
3646
- const vfsState = await buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, this.esbuild || undefined, bundleProfile, this.residencyProfiles.get(key), undefined, this.hooks.transformLargeEsm);
3856
+ const vfsState = await buildPrefetchBundle(vfs, spec.scriptPath, spec.cwd, spec.entryCode, this.esbuild, profile, this.residencyProfiles.get(key), pacer, this.hooks.transformLargeEsm);
3647
3857
  vfsState.bundleKey = key;
3648
- vfsState.bundleSource = await buildFacetVfsBundleSource(vfsState.bundle, vfsState.bundleSideModulesRequired);
3858
+ vfsState.bundleSource = await buildFacetVfsBundleSource(vfsState.bundle, vfsState.bundleSideModulesRequired, pacer);
3649
3859
  vfsState.serializedManifest = JSON.stringify(vfsState.manifest);
3650
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);
3651
3863
  // The only consumer of the raw cells past this point is a single boolean,
3652
3864
  // so answer it now rather than hold ~17 MB (pi) to answer it later.
3653
- vfsState.usesNodeSqlite = bundleUsesNodeSqlite(entryCode, vfsState.bundle);
3865
+ vfsState.usesNodeSqlite = bundleUsesNodeSqlite(spec.entryCode, vfsState.bundle);
3654
3866
  vfsState.cacheHit = false;
3655
3867
  // Serialization is total: bundleSource/serializedManifest/serializedMetadata
3656
- // carry every byte the raw cells and objects do, and generateEntrypointCode
3657
- // reads only the serialized forms. Retaining both doubled what an entry
3658
- // costs for its whole lifetime — measured for pi at 502af77, per entry:
3659
- // raw 17,253,610 + source 18,262,324 + manifest 600,060 + metadata
3660
- // 3,841,244 = 39,957,238 B, of which the raw halves are 21,694,914 B held
3661
- // to answer `usesNodeSqlite`. Dropping them is a pure release: nothing
3662
- // downstream of this method reads them, and a cache MISS rebuilds from the
3663
- // VFS rather than from anything discarded here.
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.
3664
3876
  //
3665
3877
  // Released BEFORE admission so the byte bound prices what the entry costs
3666
3878
  // from here on, not the peak it passed through on the way in.
@@ -3823,7 +4035,7 @@ export class FacetManager {
3823
4035
  if (opts.skipSpawn && opts.callerPid != null) {
3824
4036
  // The caller already allocated the PID via the supervisor
3825
4037
  // (with their own user-facing label). Look up the full entry
3826
- // from the table — _execWithTimeout etc. need the canonical
4038
+ // from the table — the exec path needs the canonical
3827
4039
  // ProcessEntry shape. Do NOT reap() either: reaping would
3828
4040
  // clear the caller's just-spawned entry because its startTime
3829
4041
  // is recent (< 60s) but reap() ALSO drops 'running' entries
@@ -3848,30 +4060,62 @@ export class FacetManager {
3848
4060
  catch { }
3849
4061
  }
3850
4062
  }
3851
- // W3.5 Fix B: thread an EsbuildService into buildPrefetchBundle so
3852
- // ESM source files (e.g. tldts/dist/es6/index.js, @remix-run/react,
3853
- // @tailwindcss/vite, react-remove-scroll, astro) get transformed to
3854
- // CJS before they hit the facet's `new Function` pre-compile loop.
3855
- // Lazy-create one if NimbusSession didn't share its own.
3856
- if (this.vfs && !this.esbuild) {
3857
- this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
3858
- }
3859
4063
  const diagOn = isExecDiagEnabled();
3860
4064
  const __bundleStart = diagOn ? Date.now() : 0;
3861
- if (this.vfs)
3862
- this.imageStore.ensureDir();
3863
- const processVfs = this.vfs?.as(entry.cred);
3864
- const credKey = `${entry.cred.uid}:${entry.cred.gid}:${entry.cred.groups.join(',')}`;
3865
- const vfsState = processVfs
3866
- ? await this._withBundleBuildDeadline(this._buildPrefetchBundleCached(processVfs, opts.filename, opts.cwd || '/home/user', code, credKey, opts.bundleProfile), command)
3867
- : { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
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
+ }
3868
4105
  const bundleMs = diagOn ? Date.now() - __bundleStart : 0;
3869
4106
  const diagSink = diagOn
3870
4107
  ? { loadMs: 0, runMs: 0, moduleMapBytes: 0, bundleBytes: 0, manifestBytes: 0, metadataBytes: 0 }
3871
4108
  : undefined;
3872
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 });
3873
4117
  try {
3874
- const result = await this._execWithTimeout(this._execViaLoader(code, opts, entry, vfsState, abortController.signal, diagSink), entry, () => abortController.abort());
4118
+ const result = await this._execViaLoader(code, opts, entry, vfsState, abortController.signal, diagSink);
3875
4119
  this._flushVfsWrites(result, entry.pid);
3876
4120
  this._recordResidencyMisses(vfsState.bundleKey, result.residencyMisses);
3877
4121
  this.processes.exit(entry.pid, result.exitCode);
@@ -3892,6 +4136,7 @@ export class FacetManager {
3892
4136
  rpcWrites: result.diag?.rpcWrites ?? 0,
3893
4137
  fsRpcReads: result.diag?.fsRpcReads ?? 0,
3894
4138
  cacheHit: vfsState.cacheHit ?? false,
4139
+ turns: pacer.chunks,
3895
4140
  exitCode: result.exitCode,
3896
4141
  at: Date.now(),
3897
4142
  });
@@ -3899,31 +4144,27 @@ export class FacetManager {
3899
4144
  return result;
3900
4145
  }
3901
4146
  catch (err) {
3902
- // If the timeout already fired, it already called onExternalExit
3903
- // with code 124 and reason "timeout…". Don't clobber that with a
3904
- // generic exit code 1. (_reportExternalExit's guard separately
3905
- // prevents double-dump; this stops ProcessTable from showing a
3906
- // different exit code than the ring buffer's footer.)
3907
- const timedOut = this.timedOutProcessIds.has(entry.pid);
3908
- const exitCode = timedOut ? 124 : 1;
3909
- const reason = timedOut ? 'timeout' : `runtime worker error: ${errorMessage(err)}`;
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)}`;
3910
4157
  this.processes.exit(entry.pid, exitCode);
3911
- // W5 Lever 5: ring entry on every catch-path exit.
3912
- this._w5RecordTermination(entry.pid, exitCode, timedOut ? 'rpc' : 'runtime-worker', reason);
3913
- // Non-timeout failure: route through external-exit so the log
3914
- // store marks exit AND the tabs-UI structured event fires. The
3915
- // timeout path already called onExternalExit from the timeout
3916
- // handler; _reportExternalExit's getExit() guard dedupes.
3917
- if (!timedOut) {
3918
- try {
3919
- this.hooks.onExternalExit?.(entry.pid, exitCode, reason);
3920
- }
3921
- catch { }
4158
+ this._w5RecordTermination(entry.pid, exitCode, 'runtime-worker', reason);
4159
+ try {
4160
+ this.hooks.onExternalExit?.(entry.pid, exitCode, reason);
3922
4161
  }
4162
+ catch { }
3923
4163
  return { exitCode, stdout: '', stderr: errorMessage(err) };
3924
4164
  }
3925
4165
  finally {
3926
- this.timedOutProcessIds.delete(entry.pid);
4166
+ opts.signal?.removeEventListener('abort', onShellAbort);
4167
+ pacer.settle();
3927
4168
  }
3928
4169
  }
3929
4170
  /**
@@ -3963,7 +4204,7 @@ export class FacetManager {
3963
4204
  }
3964
4205
  // ── One-shot dynamic Worker entrypoint ────────────────────────────────
3965
4206
  async _execViaLoader(code, opts, entry, vfsState, signal, diagSink) {
3966
- // Answered by _buildPrefetchBundleCached while the raw cells were still in
4207
+ // Answered by _buildProcessBundle while the raw cells were still in
3967
4208
  // hand; re-deriving it here is what forced them to be retained.
3968
4209
  const usesSqlite = vfsState.usesNodeSqlite ?? bundleUsesNodeSqlite(code, vfsState.bundle);
3969
4210
  const [sqliteModules, shims] = await Promise.all([
@@ -3983,13 +4224,6 @@ export class FacetManager {
3983
4224
  stdin: opts.stdin || '',
3984
4225
  captureOutput: !!opts.captureOutput,
3985
4226
  cred: { ...entry.cred, groups: [...entry.cred.groups] },
3986
- // Absolute wall-clock instant the entry drain must stop at, derived
3987
- // from the same FACET_TIMEOUT_MS the supervisor's kill timer uses. It
3988
- // is stamped here rather than computed facet-side so the budget tracks
3989
- // the real remaining lifetime; everything still to happen before the
3990
- // facet runs (module map build, LOADER.load, the RPC hop) only makes
3991
- // this earlier than the kill, which is the safe direction.
3992
- entryDeadlineAt: Date.now() + FACET_TIMEOUT_MS - ONE_SHOT_EXIT_RESERVE_MS,
3993
4227
  vfsCursor: vfsState.cursor,
3994
4228
  ...(diagSink ? { diag: true } : {}),
3995
4229
  });
@@ -4003,16 +4237,21 @@ export class FacetManager {
4003
4237
  // here rather than named by the enclosing frame, and the serialized
4004
4238
  // forms it was built from are released unless the prefetch cache is
4005
4239
  // keeping them. Otherwise the coordinator carries a second full copy
4006
- // of the program for the whole FACET_TIMEOUT_MS the facet then runs
4007
- // for, which is the window the isolate was being reset in.
4240
+ // of the program for the whole run the facet then performs,
4241
+ // which is the window the isolate was being reset in.
4008
4242
  code: async () => {
4009
- const generatedWorker = await generateEntrypointCode(code, vfsState, usesSqlite, shims);
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);
4010
4249
  if (diagSink) {
4011
4250
  diagSink.moduleMapBytes = _encodedSourceBytes(generatedWorker.code);
4012
4251
  for (const source of Object.values(generatedWorker.modules)) {
4013
4252
  diagSink.moduleMapBytes += _encodedSourceBytes(source);
4014
4253
  }
4015
- for (const m of Object.values(sqliteModules)) {
4254
+ for (const m of [...Object.values(sqliteModules), ...Object.values(wasmModules)]) {
4016
4255
  diagSink.moduleMapBytes += m.wasm.byteLength;
4017
4256
  }
4018
4257
  // Read before the release below, which is the last moment the
@@ -4032,7 +4271,7 @@ export class FacetManager {
4032
4271
  compatibilityDate: CF_COMPAT_DATE,
4033
4272
  compatibilityFlags: ['nodejs_compat', 'nodejs_compat_v2'],
4034
4273
  mainModule: 'runner.js',
4035
- modules: { 'runner.js': generatedWorker.code, ...generatedWorker.modules, ...sqliteModules },
4274
+ modules: { 'runner.js': generatedWorker.code, ...generatedWorker.modules, ...sqliteModules, ...wasmModules },
4036
4275
  };
4037
4276
  },
4038
4277
  request: new Request('http://nimbus-runtime.local/run', {
@@ -4440,7 +4679,7 @@ export class FacetManager {
4440
4679
  _assertLaunchStillOwned(pid) {
4441
4680
  if (this.processes.get(pid)?.state === 'running')
4442
4681
  return;
4443
- throw new Error(`Nimbus: resident launch for pid ${pid} was cancelled while it was suspended`);
4682
+ throw new Error(`Nimbus: the launch for pid ${pid} was cancelled while it was suspended`);
4444
4683
  }
4445
4684
  /**
4446
4685
  * The one way this manager boots a resident process. Every resident process
@@ -4630,34 +4869,6 @@ export class FacetManager {
4630
4869
  vfs.writeFile(path, restored);
4631
4870
  }
4632
4871
  }
4633
- /** Execution timeout. */
4634
- async _execWithTimeout(promise, entry, abort) {
4635
- let timer;
4636
- const timeout = new Promise((_, reject) => {
4637
- timer = setTimeout(() => {
4638
- this.timedOutProcessIds.add(entry.pid);
4639
- abort();
4640
- // The process cannot report exit after the request is cancelled, so
4641
- // notify the session explicitly.
4642
- try {
4643
- this.hooks.onExternalExit?.(entry.pid, 124, // conventional timeout exit code
4644
- `timeout after ${FACET_TIMEOUT_MS / 1000}s`);
4645
- }
4646
- catch { }
4647
- reject(new Error(`Process timed out after ${FACET_TIMEOUT_MS / 1000}s`));
4648
- }, FACET_TIMEOUT_MS);
4649
- });
4650
- // Always clear the timer; otherwise a successful run would still
4651
- // trigger the timeout callback at FACET_TIMEOUT_MS, spuriously
4652
- // marking the exit code as 124.
4653
- try {
4654
- return await Promise.race([promise, timeout]);
4655
- }
4656
- finally {
4657
- if (timer !== undefined)
4658
- clearTimeout(timer);
4659
- }
4660
- }
4661
4872
  /**
4662
4873
  * Re-drive a journalled launch after an instance reset. What the journal
4663
4874
  * row carries is the recipe and nothing else: env and credentials are never
@@ -4669,9 +4880,15 @@ export class FacetManager {
4669
4880
  switch (recipe.kind) {
4670
4881
  case 'node': return this._spawnResident(recipe.code, recipe.opts, attempt);
4671
4882
  case 'worker': {
4672
- // The embedder's resolver decides everything; only a self-owned
4673
- // launch — nobody composed an embedder hook — falls through to the
4674
- // session's image-store default.
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.
4675
4892
  const resolve = recipe.resident
4676
4893
  ? this.hooks.resolveWorkerLaunchFallback
4677
4894
  : this.hooks.resolveWorkerLaunch ?? this.hooks.resolveWorkerLaunchFallback;
@@ -4680,15 +4897,18 @@ export class FacetManager {
4680
4897
  const resolved = await resolve(recipe);
4681
4898
  if (resolved === null)
4682
4899
  return undefined;
4683
- const { 'worker.js': workerCode, ...modules } = resolved.modules;
4900
+ const mainModule = resolved.mainModule ?? recipe.mainModule ?? DEFAULT_WORKER_MAIN_MODULE;
4901
+ const { [mainModule]: workerCode, ...modules } = resolved.modules;
4684
4902
  if (workerCode === undefined)
4685
- throw new Error('resolved worker launch carries no worker.js module');
4903
+ throw new Error(`resolved worker launch carries no ${mainModule} main module`);
4686
4904
  return this._spawnWorker(workerCode, record.command, recipe.cwd, {
4687
4905
  port: recipe.resident ? record.port : recipe.port > 0 ? recipe.port : undefined, modules, compatibilityDate: recipe.compatibilityDate,
4688
4906
  compatibilityFlags: recipe.compatibilityFlags, startArgs: resolved.startArgs ?? recipe.startArgs,
4689
4907
  resident: recipe.resident, restart: record.restart,
4690
4908
  env: resolved.env ?? undefined, globalOutbound: resolved.globalOutbound,
4691
4909
  vfsWasmModules: resolved.vfsWasmModules,
4910
+ vfsTextModules: resolved.vfsTextModules,
4911
+ mainModule,
4692
4912
  durable: { owner: residentOwner(record) ?? recipe.owner, image: recipe.image },
4693
4913
  }, attempt);
4694
4914
  }
@@ -4769,7 +4989,7 @@ export class FacetManager {
4769
4989
  */
4770
4990
  async _runResidentLaunch(entry, code, command, opts, attempt) {
4771
4991
  const cwd = opts.cwd || '/home/user';
4772
- const pacer = new TurnBudget(this.launchPump, turnChunkMaxBytes(this.env), () => this._assertLaunchStillOwned(entry.pid));
4992
+ const pacer = this._launchPacer(entry.pid);
4773
4993
  // Journalled before the first byte of work. A launch that FAILS deletes
4774
4994
  // its row on the way out — its process has already been exited and the
4775
4995
  // user notified, so there is nothing left to owe. A launch that SETTLES
@@ -4881,17 +5101,9 @@ export class FacetManager {
4881
5101
  }
4882
5102
  }
4883
5103
  async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv) {
4884
- if (this.vfs && !this.esbuild) {
4885
- this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
4886
- }
4887
- if (this.vfs)
4888
- this.imageStore.ensureDir();
4889
5104
  const diagOn = isExecDiagEnabled();
4890
5105
  const __bundleStart = diagOn ? Date.now() : 0;
4891
- const processVfs = this.vfs?.as(entry.cred);
4892
- const vfsState = processVfs
4893
- ? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer, this.hooks.transformLargeEsm)
4894
- : { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
5106
+ const vfsState = await this._buildProcessBundle(entry, { scriptPath: opts.filename, cwd, entryCode: code, bundleProfile: opts.bundleProfile }, pacer);
4895
5107
  const bundleMs = diagOn ? Date.now() - __bundleStart : 0;
4896
5108
  // The launch-time overlay (`$PORT`, `$NIMBUS_APP` under a reservation)
4897
5109
  // rides on top of the recipe's env and is never journalled: it is
@@ -4910,12 +5122,15 @@ export class FacetManager {
4910
5122
  FORCE_COLOR: opts.env?.FORCE_COLOR || '1',
4911
5123
  }
4912
5124
  : spawnEnv;
4913
- const usesSqlite = bundleUsesNodeSqlite(code, vfsState.bundle);
5125
+ // Answered by _buildProcessBundle while the raw cells were still in hand.
5126
+ const usesSqlite = vfsState.usesNodeSqlite ?? bundleUsesNodeSqlite(code, vfsState.bundle);
4914
5127
  const [sqliteModules, shims] = await Promise.all([
4915
5128
  this.sqliteModuleEntry(usesSqlite),
4916
5129
  fetchNodeShimsCode(this.env),
4917
5130
  ]);
4918
- let generatedWorker = await generateLongRunningNodeCode(code, vfsState, { ...opts, env: processEnv, cred: entry.cred }, usesSqlite, shims, pacer);
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);
4919
5134
  // Sized here, while the map is still in hand. Reading these after the load
4920
5135
  // would itself be what keeps the map alive, and the whole point of the
4921
5136
  // scoping below is that nothing does.
@@ -4927,21 +5142,31 @@ export class FacetManager {
4927
5142
  }
4928
5143
  moduleMapBytes = _encodedSourceBytes(generatedWorker.code) + bundleBytes;
4929
5144
  }
4930
- const manifestBytes = diagOn ? _encodedSourceBytes(JSON.stringify(vfsState.manifest)) : 0;
4931
- const metadataBytes = diagOn ? _encodedSourceBytes(JSON.stringify(vfsState.metadata)) : 0;
5145
+ const manifestBytes = diagOn ? _encodedSourceBytes(vfsState.serializedManifest ?? '') : 0;
5146
+ const metadataBytes = diagOn ? _encodedSourceBytes(vfsState.serializedMetadata ?? '') : 0;
4932
5147
  const cacheHit = vfsState.cacheHit ?? false;
4933
5148
  const vfsCursor = vfsState.cursor;
4934
- releaseResidentLaunchSources(vfsState);
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);
4935
5156
  let handle;
4936
5157
  let resourcesTracked = false;
4937
5158
  try {
4938
- const vfsTextModules = await this.imageStore.materialize(entry.pid, {
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 = {
4939
5165
  'worker.js': generatedWorker.code,
4940
5166
  ...generatedWorker.modules,
4941
- }, pacer);
4942
- // The image store has taken it; this frame must not be what holds the
4943
- // only other copy while the facet boots.
5167
+ };
4944
5168
  generatedWorker = undefined;
5169
+ const vfsTextModules = await this.imageStore.materialize(entry.pid, drainSources(sources), pacer);
4945
5170
  // Last gate before the facet exists. A launch now spans many turns, so
4946
5171
  // a kill can land anywhere inside it; booting a process the table has
4947
5172
  // already exited would leave a facet nothing owns, running against a
@@ -4992,6 +5217,7 @@ export class FacetManager {
4992
5217
  rpcWrites: 0,
4993
5218
  fsRpcReads: 0,
4994
5219
  cacheHit,
5220
+ turns: pacer.chunks,
4995
5221
  exitCode: 0,
4996
5222
  at: Date.now(),
4997
5223
  });
@@ -5035,20 +5261,43 @@ export class FacetManager {
5035
5261
  }
5036
5262
  }
5037
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.
5038
5270
  this.portRegistry.unregisterByPid(entry.pid);
5039
5271
  if (resourcesTracked)
5040
5272
  this.releaseProcessRpcResources(entry.pid);
5041
- else
5042
- handle?.kill();
5273
+ if (this.processExitCode(entry.pid) !== null)
5274
+ return;
5275
+ handle?.kill();
5043
5276
  this._failLaunch(entry.pid, 'long-running node boot failed: ' + errorMessage(e));
5044
5277
  throw e;
5045
5278
  }
5046
5279
  }
5047
5280
  /**
5048
- * Spawn a long-running dynamic Worker, boot it, and return its boot payload.
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.
5049
5294
  *
5050
5295
  * The shared primitive for any runtime that serves over
5051
- * 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.
5052
5301
  *
5053
5302
  * The interpreter image it carries is the memory that should not sit in the
5054
5303
  * session's own isolate — ruby's interpreter+stdlib alone is 34.3 MiB — and
@@ -5056,6 +5305,11 @@ export class FacetManager {
5056
5305
  * no readiness coupling back into the session: the runner answers
5057
5306
  * startProcess with its boot payload and the caller waits on that one
5058
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.
5059
5313
  */
5060
5314
  async spawnWorker(workerCode, command, cwd, opts = {}) {
5061
5315
  return this._spawnWorker(workerCode, command, cwd, opts, 0);
@@ -5066,7 +5320,9 @@ export class FacetManager {
5066
5320
  opts = { ...opts, durable: { owner: await deriveResidentOwner(cwd, opts.resident.argv) } };
5067
5321
  }
5068
5322
  this.processes.reap();
5069
- const entry = this.processes.spawn(command, [], cwd);
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);
5070
5326
  // Stamp the process-table entry so /api/processes exposes this as a
5071
5327
  // long-running process.
5072
5328
  this.processes.setLongRunning(entry.pid);
@@ -5078,6 +5334,10 @@ export class FacetManager {
5078
5334
  catch { }
5079
5335
  const compatibilityDate = opts.compatibilityDate ?? CF_COMPAT_DATE;
5080
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
+ }
5081
5341
  let handle;
5082
5342
  let resourcesTracked = false;
5083
5343
  let record;
@@ -5115,6 +5375,8 @@ export class FacetManager {
5115
5375
  modules: opts.modules ?? {},
5116
5376
  ...(opts.env !== undefined ? { env: opts.env } : {}),
5117
5377
  vfsWasmModules: opts.vfsWasmModules,
5378
+ vfsTextModules: opts.vfsTextModules,
5379
+ ...(opts.mainModule !== undefined ? { mainModule: opts.mainModule } : {}),
5118
5380
  startArgs: opts.startArgs,
5119
5381
  });
5120
5382
  await this.ctx.storage.transaction(async (txn) => {
@@ -5141,6 +5403,7 @@ export class FacetManager {
5141
5403
  compatibilityFlags,
5142
5404
  ...(opts.durable.image && !opts.resident ? { startArgs: opts.startArgs } : {}),
5143
5405
  ...(opts.resident ? { resident: opts.resident } : {}),
5406
+ ...(opts.mainModule !== undefined ? { mainModule: opts.mainModule } : {}),
5144
5407
  },
5145
5408
  // The row-level fields `ensureDurableAppOnPort` reads — same values
5146
5409
  // the recipe carries, stamped so port/owner lookup never depends on
@@ -5185,9 +5448,10 @@ export class FacetManager {
5185
5448
  code: {
5186
5449
  compatibilityDate,
5187
5450
  compatibilityFlags,
5188
- mainModule: 'worker.js',
5189
- modules: { 'worker.js': workerCode, ...(opts.modules || {}) },
5451
+ mainModule,
5452
+ modules: { ...(opts.modules || {}), [mainModule]: workerCode },
5190
5453
  vfsWasmModules: opts.vfsWasmModules,
5454
+ ...(opts.vfsTextModules !== undefined ? { vfsTextModules: opts.vfsTextModules } : {}),
5191
5455
  ...(opts.env !== undefined || launchEnv !== undefined ? { env: { ...opts.env, ...launchEnv } } : {}),
5192
5456
  ...(opts.globalOutbound !== undefined ? { globalOutbound: opts.globalOutbound } : {}),
5193
5457
  },
@@ -5218,7 +5482,20 @@ export class FacetManager {
5218
5482
  }
5219
5483
  await this._registerResidentPort(entry.pid, opts.port);
5220
5484
  }
5221
- return { pid: entry.pid, boot };
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
+ };
5222
5499
  }
5223
5500
  catch (e) {
5224
5501
  this.portRegistry.unregisterByPid(entry.pid);
@@ -5294,7 +5571,14 @@ export class FacetManager {
5294
5571
  this.hooks.notify?.(`\x1b[2m[nimbus: "${row.command}" listened on ${port} but its reservation owns `
5295
5572
  + `${row.portMismatch.reserved} ($PORT) — the app's URL will not reach it]\x1b[0m\r\n`);
5296
5573
  }
5297
- const owner = row === undefined ? undefined : residentOwner(row);
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);
5298
5582
  if (claimedBy !== undefined && row !== undefined && this.residentClaims.get(pid) !== claimedBy) {
5299
5583
  const previousOwner = this.residentClaims.get(pid);
5300
5584
  await this._releaseResidentClaim(pid);
@@ -5338,15 +5622,33 @@ export class FacetManager {
5338
5622
  await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port);
5339
5623
  this.portRegistry.register(port, pid);
5340
5624
  }
5341
- /** What a pid's journal row says about who it is; null for a pid without one. */
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
+ */
5342
5637
  async residentIdentity(pid) {
5343
5638
  const duplicated = this.ephemeralPids.get(pid);
5344
5639
  if (duplicated !== undefined)
5345
5640
  return { owner: duplicated, ephemeral: true, port: undefined };
5346
5641
  const row = (await this.launchJournal.rows()).get(`${FENCED_WORK_KEY_PREFIX}${pid}`);
5347
- if (row === undefined)
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')
5348
5646
  return null;
5349
- return { owner: residentOwner(row), ephemeral: false, port: row.port };
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
+ };
5350
5652
  }
5351
5653
  /**
5352
5654
  * Every stamped identity — the reservations, and the journal rows that
@@ -5398,6 +5700,28 @@ export class FacetManager {
5398
5700
  }
5399
5701
  apps.set(owner, app);
5400
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
+ }
5401
5725
  return [...apps.values()];
5402
5726
  }
5403
5727
  async registerPort(pid, port) {