@nimbus-sh/worker 0.5.0 → 0.7.0

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