@nimbus-sh/worker 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -43
- package/dist/_shared/preview-host.d.ts +33 -2
- package/dist/_shared/preview-host.d.ts.map +1 -1
- package/dist/_shared/preview-host.js +73 -8
- package/dist/_shared/session-router.d.ts +43 -0
- package/dist/_shared/session-router.d.ts.map +1 -1
- package/dist/_shared/session-router.js +59 -12
- package/dist/facets/durable-images.d.ts +59 -0
- package/dist/facets/durable-images.d.ts.map +1 -0
- package/dist/facets/durable-images.js +94 -0
- package/dist/facets/durable-slots.d.ts +45 -0
- package/dist/facets/durable-slots.d.ts.map +1 -0
- package/dist/facets/durable-slots.js +95 -0
- package/dist/facets/esbuild-transform.d.ts +10 -0
- package/dist/facets/esbuild-transform.d.ts.map +1 -0
- package/dist/facets/esbuild-transform.js +36 -0
- package/dist/facets/manager.d.ts +244 -6
- package/dist/facets/manager.d.ts.map +1 -1
- package/dist/facets/manager.js +901 -117
- package/dist/facets/resident-identity.d.ts +23 -0
- package/dist/facets/resident-identity.d.ts.map +1 -0
- package/dist/facets/resident-identity.js +31 -0
- package/dist/git/commands.d.ts +22 -1
- package/dist/git/commands.d.ts.map +1 -1
- package/dist/git/commands.js +429 -425
- package/dist/git-bundle.generated.d.ts +2 -2
- package/dist/git-bundle.generated.d.ts.map +1 -1
- package/dist/git-bundle.generated.js +3 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/node-shims-artifact.generated.js +3 -3
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +97 -3
- package/dist/router/public-directory-do.d.ts +17 -0
- package/dist/router/public-directory-do.d.ts.map +1 -0
- package/dist/router/public-directory-do.js +16 -0
- package/dist/router/public-directory.d.ts +79 -0
- package/dist/router/public-directory.d.ts.map +1 -0
- package/dist/router/public-directory.js +122 -0
- package/dist/router/remote-api.d.ts +27 -1
- package/dist/router/remote-api.d.ts.map +1 -1
- package/dist/router/remote-api.js +48 -2
- package/dist/runtime/cpython-resident.d.ts.map +1 -1
- package/dist/runtime/cpython-resident.js +3 -1
- package/dist/runtime/node-shims.d.ts +4 -1
- package/dist/runtime/node-shims.d.ts.map +1 -1
- package/dist/runtime/node-shims.js +324 -6
- package/dist/runtime/package-manager.d.ts +44 -1
- package/dist/runtime/package-manager.d.ts.map +1 -1
- package/dist/runtime/package-manager.js +168 -3
- package/dist/runtime/ruby-resident.d.ts.map +1 -1
- package/dist/runtime/ruby-resident.js +2 -0
- package/dist/session/agent.d.ts +11 -0
- package/dist/session/agent.d.ts.map +1 -1
- package/dist/session/agent.js +28 -1
- package/dist/session/helpers.js +1 -1
- package/dist/session/init-phases.d.ts +17 -23
- package/dist/session/init-phases.d.ts.map +1 -1
- package/dist/session/init-phases.js +17 -41
- package/dist/session/init.d.ts.map +1 -1
- package/dist/session/init.js +28 -15
- package/dist/session/keys.d.ts +12 -0
- package/dist/session/keys.d.ts.map +1 -1
- package/dist/session/keys.js +12 -0
- package/dist/session/nimbus-session.d.ts +63 -5
- package/dist/session/nimbus-session.d.ts.map +1 -1
- package/dist/session/nimbus-session.js +131 -17
- package/dist/session/port-capability.d.ts +141 -8
- package/dist/session/port-capability.d.ts.map +1 -1
- package/dist/session/port-capability.js +265 -8
- package/dist/session/programmatic.d.ts +145 -3
- package/dist/session/programmatic.d.ts.map +1 -1
- package/dist/session/programmatic.js +434 -12
- package/dist/session/routes.d.ts +26 -0
- package/dist/session/routes.d.ts.map +1 -1
- package/dist/session/routes.js +117 -25
- package/dist/session/rpc.d.ts +24 -1
- package/dist/session/rpc.d.ts.map +1 -1
- package/dist/session/rpc.js +65 -98
- package/dist/session/shell-socket.d.ts +76 -0
- package/dist/session/shell-socket.d.ts.map +1 -0
- package/dist/session/shell-socket.js +172 -0
- package/dist/session/start-real-vite.js +1 -1
- package/dist/session/supervisor-op.d.ts +42 -0
- package/dist/session/supervisor-op.d.ts.map +1 -0
- package/dist/session/supervisor-op.js +96 -0
- package/dist/session/supervisor-rpc.d.ts +6 -10
- package/dist/session/supervisor-rpc.d.ts.map +1 -1
- package/dist/session/supervisor-rpc.js +91 -71
- package/dist/session/ws.d.ts.map +1 -1
- package/dist/session/ws.js +19 -4
- package/package.json +4 -4
- package/public/_assets/runtime/{node-shims-fea906843f6d594e.js → node-shims-9e1380d9ee430fcc.js} +320 -5
- package/public/s/index.html +101 -5
package/dist/facets/manager.js
CHANGED
|
@@ -22,22 +22,29 @@ 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
24
|
import { vfsPathExtension } from '@nimbus-sh/core/vfs/path.js';
|
|
25
|
-
import { clearPortCapability } from '../session/port-capability.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';
|
|
29
|
+
import { PORT_CAPABILITY_KEY_PREFIX } from '../session/keys.js';
|
|
30
|
+
import { unbindPublicPortCapability } from '../router/public-directory.js';
|
|
26
31
|
import { prefetchForRequire } from '@nimbus-sh/core/runtime/require-resolver.js';
|
|
27
32
|
import { hasTopLevelModuleSyntax } from '@nimbus-sh/core/runtime/javascript-ast.js';
|
|
28
33
|
import { bindImportMetaResolve, importMetaDefines } from '@nimbus-sh/core/runtime/import-meta-transform.js';
|
|
29
34
|
import { recordFailure, getLastRpcFrame, getLastFacetId } from '@nimbus-sh/platform/oom-discriminator.js';
|
|
30
35
|
import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
|
|
31
|
-
import { TurnBudget, PacedWork, turnChunkMaxBytes } from '@nimbus-sh/fabric/turn-budget.js';
|
|
36
|
+
import { TurnBudget, PacedWork, turnChunkMaxBytes, withResolvers } from '@nimbus-sh/fabric/turn-budget.js';
|
|
32
37
|
import { onColdStart } from '@nimbus-sh/fabric/generation.js';
|
|
33
|
-
import { FencedWork, } from '@nimbus-sh/fabric/fenced-work.js';
|
|
34
|
-
import { EsbuildService } from '@nimbus-sh/core/runtime/esbuild-service.js';
|
|
38
|
+
import { FencedWork, FENCED_WORK_KEY_PREFIX, } from '@nimbus-sh/fabric/fenced-work.js';
|
|
39
|
+
import { EsbuildService, rewriteBundledEsmToCjs, } from '@nimbus-sh/core/runtime/esbuild-service.js';
|
|
35
40
|
import { isExecDiagEnabled, recordExecTelemetry } from './exec-telemetry.js';
|
|
36
41
|
import { disposeRpcResource, disposeRpcResources } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
37
42
|
import { sqliteWasmModuleEntry } from './opencode-staging.js';
|
|
38
43
|
import { ImageStore, } from '@nimbus-sh/fabric/image-store.js';
|
|
39
44
|
import { ProcessFabric, } from '@nimbus-sh/fabric/process-fabric.js';
|
|
40
|
-
import { createLoadedWorkerEntrypoint, getNimbusCtxExports, } from '@nimbus-sh/fabric/workerd-facet-host.js';
|
|
45
|
+
import { createLoadedWorkerEntrypoint, getNimbusCtxExports, deleteFacetStorage, } from '@nimbus-sh/fabric/workerd-facet-host.js';
|
|
46
|
+
import { acquireDurableFacetSlot, freeDurableFacetSlot, } from './durable-slots.js';
|
|
47
|
+
import { persistDurableWorkerImage, purgeDurableWorkerImages, } from './durable-images.js';
|
|
41
48
|
import { SQLITE_WASM_MODULE_NAME, } from '../runtime/opencode-facet-runner.js';
|
|
42
49
|
import { parsePortFromArgv, resolveLongRunningPort } from '@nimbus-sh/core/runtime/long-running-handle.js';
|
|
43
50
|
import { DEFAULT_FACET_BUNDLE_PROFILE, } from '@nimbus-sh/core/runtime/bundle-profile.js';
|
|
@@ -46,6 +53,7 @@ import { MAX_RPC_SAFE_PAYLOAD_BYTES } from '@nimbus-sh/platform/limits.js';
|
|
|
46
53
|
import { CRED_KERNEL } from '@nimbus-sh/core/runtime/os-contracts.js';
|
|
47
54
|
import { acquireSupervisorAllocation } from '@nimbus-sh/platform/heavy-alloc-coord.js';
|
|
48
55
|
import { prefetchBundleStart, prefetchBundleEnd, setPrefetchCacheBytes, } from '@nimbus-sh/platform/diag-counters.js';
|
|
56
|
+
const ISOLATED_ESM_TRANSFORM_MIN_BYTES = 512 * 1024;
|
|
49
57
|
/**
|
|
50
58
|
* Detect & restore a Uint8Array that's been JSON-mangled to a
|
|
51
59
|
* {"0":n,"1":n,...} object during the result-envelope round-trip.
|
|
@@ -120,6 +128,7 @@ function parseFacetManagerEnv(env) {
|
|
|
120
128
|
LOADER: loader,
|
|
121
129
|
ASSETS: assets,
|
|
122
130
|
NIMBUS_LAUNCH_CHUNK_BYTES: typeof chunkBytes === 'string' ? chunkBytes : undefined,
|
|
131
|
+
rawEnv: env,
|
|
123
132
|
};
|
|
124
133
|
}
|
|
125
134
|
/**
|
|
@@ -408,11 +417,11 @@ function __mkCompiledFn(code) {
|
|
|
408
417
|
code = __nl >= 0 ? code.slice(__nl + 1) : "";
|
|
409
418
|
}
|
|
410
419
|
function renameIfDeclared(name) {
|
|
411
|
-
const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "
|
|
420
|
+
const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "(?![$\\\\w])", "m");
|
|
412
421
|
return re.test(code) ? name + "__nimbus_unused" : name;
|
|
413
422
|
}
|
|
414
423
|
const baseParams = [
|
|
415
|
-
"exports", "require", "module",
|
|
424
|
+
"exports", renameIfDeclared("require"), "module",
|
|
416
425
|
renameIfDeclared("__filename"),
|
|
417
426
|
renameIfDeclared("__dirname"),
|
|
418
427
|
];
|
|
@@ -433,23 +442,10 @@ const __MODULE_VFS_MANIFEST = ${safeManifest};
|
|
|
433
442
|
const __MODULE_VFS_METADATA = ${safeMetadata};
|
|
434
443
|
const __compiledModules = new Map();
|
|
435
444
|
const __compileFailures = new Map();
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// .json is data; skip it (it loads via JSON.parse, not as a function).
|
|
441
|
-
const __base = __p.slice(__p.lastIndexOf("/") + 1);
|
|
442
|
-
const __dot = __base.lastIndexOf(".");
|
|
443
|
-
const __ext = __dot > 0 ? __base.slice(__dot) : "";
|
|
444
|
-
if (__ext === ".js" || __ext === ".mjs" || __ext === ".cjs" || __ext === "") {
|
|
445
|
-
if (typeof __c !== "string") continue;
|
|
446
|
-
try {
|
|
447
|
-
__compiledModules.set(__p, __mkCompiledFn(__c));
|
|
448
|
-
} catch (__e) {
|
|
449
|
-
__compileFailures.set(__p, __e && __e.message ? __e.message : String(__e));
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
445
|
+
// Precompile JS modules AND extensionless CJS entries (bin scripts and
|
|
446
|
+
// shims). workerd forbids new Function at request time, so anything not
|
|
447
|
+
// precompiled here surfaces the misleading "file was not pre-bundled".
|
|
448
|
+
${BUNDLE_PRECOMPILE_LOOP}
|
|
453
449
|
|
|
454
450
|
class __ProcessExit extends Error {
|
|
455
451
|
constructor(code) { super("process.exit(" + code + ")"); this.code = code; }
|
|
@@ -687,11 +683,11 @@ const __NimbusHostResponse = globalThis.Response;
|
|
|
687
683
|
|
|
688
684
|
function __mkCompiledFn(code) {
|
|
689
685
|
function renameIfDeclared(name) {
|
|
690
|
-
const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "
|
|
686
|
+
const re = new RegExp("(?:^|\\\\n|;)\\\\s*(?:const|let|var|function|class)\\\\s+" + name + "(?![$\\\\w])", "m");
|
|
691
687
|
return re.test(code) ? name + "__nimbus_unused" : name;
|
|
692
688
|
}
|
|
693
689
|
const baseParams = [
|
|
694
|
-
"exports", "require", "module",
|
|
690
|
+
"exports", renameIfDeclared("require"), "module",
|
|
695
691
|
renameIfDeclared("__filename"),
|
|
696
692
|
renameIfDeclared("__dirname"),
|
|
697
693
|
];
|
|
@@ -719,15 +715,7 @@ const __compileFailures = new Map();
|
|
|
719
715
|
// \`new Function\` throws "Code generation from strings disallowed" in the DO
|
|
720
716
|
// constructor and at request time — so the require closure must be compiled
|
|
721
717
|
// here, off the module map. That is why code cannot come from the store.
|
|
722
|
-
|
|
723
|
-
if (__p.endsWith(".js") || __p.endsWith(".mjs") || __p.endsWith(".cjs")) {
|
|
724
|
-
try {
|
|
725
|
-
__compiledModules.set(__p, __mkCompiledFn(__c));
|
|
726
|
-
} catch (__e) {
|
|
727
|
-
__compileFailures.set(__p, __e && __e.message ? __e.message : String(__e));
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
718
|
+
${BUNDLE_PRECOMPILE_LOOP}
|
|
731
719
|
|
|
732
720
|
${FACET_RESIDENT_STORE_SOURCE}
|
|
733
721
|
|
|
@@ -1342,8 +1330,10 @@ export function encodedBundleSize(bundle, manifest) {
|
|
|
1342
1330
|
function describeBundleCells(cells, limit = 8) {
|
|
1343
1331
|
const shown = [...cells].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
1344
1332
|
const rest = cells.length - shown.length;
|
|
1345
|
-
return shown.map(([path, bytes]) =>
|
|
1346
|
-
|
|
1333
|
+
return shown.map(([path, bytes]) => {
|
|
1334
|
+
const source = compiledCellPath(path);
|
|
1335
|
+
return source === null ? `${path} (${bytes} bytes)` : `${source} [compiled] (${bytes} bytes)`;
|
|
1336
|
+
}).join(', ') + (rest > 0 ? `, +${rest} more` : '');
|
|
1347
1337
|
}
|
|
1348
1338
|
/**
|
|
1349
1339
|
* hardening-r5: emit a JS expression that revives binary cells from base64
|
|
@@ -1729,7 +1719,7 @@ function buildManifest(vfs, cwd, scriptPath) {
|
|
|
1729
1719
|
return manifest;
|
|
1730
1720
|
}
|
|
1731
1721
|
function buildVfsMetadata(vfs, manifest, bundle) {
|
|
1732
|
-
const paths = new Set(Object.keys(bundle));
|
|
1722
|
+
const paths = new Set(Object.keys(bundle).filter((path) => compiledCellPath(path) === null));
|
|
1733
1723
|
for (const [directory, children] of Object.entries(manifest)) {
|
|
1734
1724
|
paths.add(directory);
|
|
1735
1725
|
for (const child of children) {
|
|
@@ -1803,6 +1793,16 @@ export function greedyAddMainEntries(vfs, cwd, bundle, budgetState) {
|
|
|
1803
1793
|
try {
|
|
1804
1794
|
if (!vfs.exists(stripped) || vfs.isDirectory(stripped))
|
|
1805
1795
|
return false;
|
|
1796
|
+
// This is a guess at what a program might require, and the same
|
|
1797
|
+
// per-file ceiling the entry-package walk applies bounds it: a
|
|
1798
|
+
// multi-MiB main entry is an alternative bundle (typescript's 8.69 MiB
|
|
1799
|
+
// `lib/typescript.js` beside the `lib/tsc.js` that actually runs), and
|
|
1800
|
+
// one guess must not spend a third of the budget — and a third of the
|
|
1801
|
+
// supervisor's headroom — on every invocation that never reads it.
|
|
1802
|
+
// Anything the program really requires arrives through the closure,
|
|
1803
|
+
// which is uncapped.
|
|
1804
|
+
if (vfs.lstat(stripped).size > BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES)
|
|
1805
|
+
return false;
|
|
1806
1806
|
// hardening-r5: preserve binary content as Uint8Array.
|
|
1807
1807
|
const content = _readBundleCell(vfs, stripped);
|
|
1808
1808
|
const cellLen = _bundleCellLength(content);
|
|
@@ -2530,18 +2530,22 @@ const RUNTIME_PACKAGE_EXCLUDED_FILE_SUFFIXES = [
|
|
|
2530
2530
|
'.webm',
|
|
2531
2531
|
];
|
|
2532
2532
|
/**
|
|
2533
|
-
* Per-file ceiling for the speculative
|
|
2533
|
+
* Per-file ceiling for the speculative passes over installed packages: the
|
|
2534
|
+
* entry-package walk (`addBinTargetSiblings`) and the main-entry oversample
|
|
2535
|
+
* (`greedyAddMainEntries`).
|
|
2534
2536
|
*
|
|
2535
2537
|
* Everything the entry package needs in order to *run* arrives through the
|
|
2536
|
-
* require closure, which is uncapped and never evicted.
|
|
2537
|
-
* to catch
|
|
2538
|
-
*
|
|
2539
|
-
*
|
|
2540
|
-
* than data.
|
|
2538
|
+
* require closure, which is uncapped and never evicted. These passes exist
|
|
2539
|
+
* only to catch what the static walker cannot see — data files, and modules
|
|
2540
|
+
* reached by a computed require — and those are small. Multi-MiB cells in a
|
|
2541
|
+
* package tree are overwhelmingly alternative bundles — typescript ships an
|
|
2542
|
+
* 8.69 MiB `lib/typescript.js` that `tsc` never reads — rather than data.
|
|
2541
2543
|
*
|
|
2542
2544
|
* So one speculative guess must not spend the budget every later invocation
|
|
2543
2545
|
* then carries: the same reasoning as `CWD_SNAPSHOT_MAX_FILE_BYTES`, applied
|
|
2544
|
-
* to the package tree.
|
|
2546
|
+
* to the package tree. A miss this causes is loud and self-repairing: the
|
|
2547
|
+
* facet reports the unstaged read and the next build stages it from the
|
|
2548
|
+
* residency ledger, which has no per-file rule.
|
|
2545
2549
|
*/
|
|
2546
2550
|
const BIN_PACKAGE_SPECULATIVE_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
2547
2551
|
function shouldIncludeBinPackageFile(pkgRoot, path, bundleProfile) {
|
|
@@ -2771,15 +2775,20 @@ function __cacheKey(src) {
|
|
|
2771
2775
|
*/
|
|
2772
2776
|
function _markBundleEsmAsFailed(bundle, reason) {
|
|
2773
2777
|
for (const path of Object.keys(bundle)) {
|
|
2778
|
+
if (compiledCellPath(path) !== null)
|
|
2779
|
+
continue;
|
|
2774
2780
|
if (!isBundleModuleCandidate(path))
|
|
2775
2781
|
continue;
|
|
2776
2782
|
const src = bundle[path];
|
|
2777
2783
|
if (typeof src !== 'string')
|
|
2778
2784
|
continue;
|
|
2779
|
-
|
|
2785
|
+
// A TypeScript source is never runnable as staged, so it always needs
|
|
2786
|
+
// the emit it cannot get; a JavaScript file only if it is ESM.
|
|
2787
|
+
const typescript = bundleTypescriptLoader(path) !== null;
|
|
2788
|
+
if (!typescript && !looksLikeEsm(src))
|
|
2780
2789
|
continue;
|
|
2781
2790
|
const escapedReason = JSON.stringify(`esbuild transform failed for ${path}: ${reason}`);
|
|
2782
|
-
bundle[path] =
|
|
2791
|
+
bundle[typescript ? compiledCellKey(path) : path] =
|
|
2783
2792
|
'// framework-fixes-F4 diagnostic shim — esbuild rejected the ESM transform\n' +
|
|
2784
2793
|
'(function () { throw new Error(' + escapedReason + '); })();\n';
|
|
2785
2794
|
}
|
|
@@ -2810,13 +2819,89 @@ export function isBundleModuleCandidate(path) {
|
|
|
2810
2819
|
* EXTENSION, where `.js` files transform on their content — `looksLikeEsm` is
|
|
2811
2820
|
* the right question for a file that is already valid JS either way, and the
|
|
2812
2821
|
* wrong one for a file that is never valid JS.
|
|
2822
|
+
*
|
|
2823
|
+
* A declaration file (`.d.ts`, `.d.mts`, `.d.cts`) is not a source: it has
|
|
2824
|
+
* no runtime form, nothing `require()`s one, and esbuild's output for it is
|
|
2825
|
+
* empty by definition. It is DATA — read by the program that ships it, which
|
|
2826
|
+
* is exactly typescript: `tsc` reads its own `lib/lib.*.d.ts` with
|
|
2827
|
+
* `readFileSync`, and every declaration it type-checks against comes from
|
|
2828
|
+
* those bytes. Transforming them handed the compiler an 811-byte license
|
|
2829
|
+
* comment where `lib.es5.d.ts` (217 KB) had been, and every global type was
|
|
2830
|
+
* gone. So a declaration file is left exactly as it was staged.
|
|
2813
2831
|
*/
|
|
2814
2832
|
export function bundleTypescriptLoader(path) {
|
|
2833
|
+
if (isTypescriptDeclarationFile(path))
|
|
2834
|
+
return null;
|
|
2815
2835
|
const ext = vfsPathExtension(path);
|
|
2816
2836
|
if (ext === '.tsx')
|
|
2817
2837
|
return 'tsx';
|
|
2818
2838
|
return ext === '.ts' || ext === '.mts' || ext === '.cts' ? 'ts' : null;
|
|
2819
2839
|
}
|
|
2840
|
+
/** `name.d.ts` / `name.d.mts` / `name.d.cts`, by TypeScript's own rule. */
|
|
2841
|
+
export function isTypescriptDeclarationFile(path) {
|
|
2842
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
2843
|
+
return /\.d\.[mc]?ts$/.test(base);
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Where a bundle carries the EXECUTABLE form of a cell whose staged bytes are
|
|
2847
|
+
* not JavaScript — a TypeScript source.
|
|
2848
|
+
*
|
|
2849
|
+
* A bundle cell is two things to the facet: what `readFileSync` returns for
|
|
2850
|
+
* the path, and what the startup pre-compile loop turns into the function
|
|
2851
|
+
* `require` runs. For a JavaScript file those are the same bytes, so the
|
|
2852
|
+
* ESM→CJS pass rewrites the cell in place and both readers see JavaScript.
|
|
2853
|
+
* For a TypeScript source they are NOT the same: the bytes a program reads
|
|
2854
|
+
* are the source (tsc compiling its project, a test runner, a linter), and
|
|
2855
|
+
* the bytes `require` needs are esbuild's emit. Rewriting the cell in place
|
|
2856
|
+
* handed tsc esbuild's CommonJS rendering of its own `src/index.ts` — it
|
|
2857
|
+
* compiled that, and reported errors on lines the file never had.
|
|
2858
|
+
*
|
|
2859
|
+
* So the source cell stays exactly as staged, and the compiled form travels
|
|
2860
|
+
* under this key: a NUL-framed prefix no filesystem path can contain, in the
|
|
2861
|
+
* same map, so it is sized, split, cached, released and shipped by every
|
|
2862
|
+
* mechanism the bundle already has. The facet's pre-compile loop takes these
|
|
2863
|
+
* cells OFF the map as it compiles them, keyed by the real path, before any
|
|
2864
|
+
* read can see them.
|
|
2865
|
+
*/
|
|
2866
|
+
const COMPILED_CELL_KEY_PREFIX = '\0compiled\0';
|
|
2867
|
+
export function compiledCellKey(path) {
|
|
2868
|
+
return COMPILED_CELL_KEY_PREFIX + path;
|
|
2869
|
+
}
|
|
2870
|
+
/** The source path a compiled-cell key stands for, or null for a plain path. */
|
|
2871
|
+
export function compiledCellPath(key) {
|
|
2872
|
+
return key.startsWith(COMPILED_CELL_KEY_PREFIX) ? key.slice(COMPILED_CELL_KEY_PREFIX.length) : null;
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* The pre-compile loop both generated facets run at module evaluation, the
|
|
2876
|
+
* only moment workerd lets a string become code. One definition so the two
|
|
2877
|
+
* facets cannot drift on what they compile: every JavaScript-shaped cell
|
|
2878
|
+
* (`.js`, `.mjs`, `.cjs` and the extensionless bin scripts) from its own
|
|
2879
|
+
* bytes, and every TypeScript source from its compiled cell — which is
|
|
2880
|
+
* removed from the bundle here, so a `readFileSync` of the source path
|
|
2881
|
+
* returns the source and a directory listing never shows the key.
|
|
2882
|
+
*/
|
|
2883
|
+
export const BUNDLE_PRECOMPILE_LOOP = `
|
|
2884
|
+
const __NIMBUS_COMPILED_PREFIX = ${JSON.stringify(COMPILED_CELL_KEY_PREFIX)};
|
|
2885
|
+
for (const [__p, __c] of Object.entries(__MODULE_VFS_BUNDLE)) {
|
|
2886
|
+
let __modulePath = __p;
|
|
2887
|
+
if (__p.startsWith(__NIMBUS_COMPILED_PREFIX)) {
|
|
2888
|
+
__modulePath = __p.slice(__NIMBUS_COMPILED_PREFIX.length);
|
|
2889
|
+
delete __MODULE_VFS_BUNDLE[__p];
|
|
2890
|
+
} else {
|
|
2891
|
+
// .json is data; skip it (it loads via JSON.parse, not as a function).
|
|
2892
|
+
const __base = __p.slice(__p.lastIndexOf("/") + 1);
|
|
2893
|
+
const __dot = __base.lastIndexOf(".");
|
|
2894
|
+
const __ext = __dot > 0 ? __base.slice(__dot) : "";
|
|
2895
|
+
if (__ext !== ".js" && __ext !== ".mjs" && __ext !== ".cjs" && __ext !== "") continue;
|
|
2896
|
+
}
|
|
2897
|
+
if (typeof __c !== "string") continue;
|
|
2898
|
+
try {
|
|
2899
|
+
__compiledModules.set(__modulePath, __mkCompiledFn(__c));
|
|
2900
|
+
} catch (__e) {
|
|
2901
|
+
__compileFailures.set(__modulePath, __e && __e.message ? __e.message : String(__e));
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
`;
|
|
2820
2905
|
/**
|
|
2821
2906
|
* Transform every ESM-shaped file in the bundle to CJS via esbuild.
|
|
2822
2907
|
* Mutates `bundle` in place. Errors are swallowed (the file is left as
|
|
@@ -2826,14 +2911,19 @@ export function bundleTypescriptLoader(path) {
|
|
|
2826
2911
|
* Candidate set is `isBundleModuleCandidate`; within it, files with no
|
|
2827
2912
|
* top-level import/export are already CJS-shaped and left alone.
|
|
2828
2913
|
*
|
|
2914
|
+
* A JavaScript cell is rewritten in place. A TypeScript source keeps its
|
|
2915
|
+
* bytes and gets a compiled cell beside it — see `compiledCellKey`.
|
|
2916
|
+
*
|
|
2829
2917
|
* Returns the count of files transformed (for diagnostics).
|
|
2830
2918
|
*/
|
|
2831
|
-
async function transformEsmInBundle(bundle, esbuild, pacer) {
|
|
2919
|
+
async function transformEsmInBundle(bundle, esbuild, pacer, isolatedTransform) {
|
|
2832
2920
|
let transformed = 0;
|
|
2833
2921
|
let failed = 0;
|
|
2834
2922
|
// Snapshot the keys first — esbuild calls await; never iterate-and-mutate.
|
|
2835
2923
|
const candidates = [];
|
|
2836
2924
|
for (const path of Object.keys(bundle)) {
|
|
2925
|
+
if (compiledCellPath(path) !== null)
|
|
2926
|
+
continue;
|
|
2837
2927
|
if (!isBundleModuleCandidate(path))
|
|
2838
2928
|
continue;
|
|
2839
2929
|
const src = bundle[path];
|
|
@@ -2841,14 +2931,19 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
|
|
|
2841
2931
|
// esbuild.transform expect strings.
|
|
2842
2932
|
if (typeof src !== 'string')
|
|
2843
2933
|
continue;
|
|
2844
|
-
if (bundleTypescriptLoader(path) === null
|
|
2845
|
-
|
|
2934
|
+
if (bundleTypescriptLoader(path) === null) {
|
|
2935
|
+
const esm = looksLikeEsm(src);
|
|
2936
|
+
if (!esm)
|
|
2937
|
+
continue;
|
|
2938
|
+
}
|
|
2846
2939
|
candidates.push(path);
|
|
2847
2940
|
}
|
|
2848
2941
|
for (const path of candidates) {
|
|
2849
2942
|
const src = bundle[path];
|
|
2850
2943
|
if (typeof src !== 'string')
|
|
2851
2944
|
continue;
|
|
2945
|
+
// A TypeScript source keeps its bytes; its emit lands beside it.
|
|
2946
|
+
const target = bundleTypescriptLoader(path) === null ? path : compiledCellKey(path);
|
|
2852
2947
|
// esbuild-wasm runs inside this isolate, so a transform is computation
|
|
2853
2948
|
// like any other pass — the await above it yields nothing on its own.
|
|
2854
2949
|
if (pacer)
|
|
@@ -2871,19 +2966,25 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
|
|
|
2871
2966
|
const key = __cacheKey(src + '\0' + absUrl);
|
|
2872
2967
|
const cached = __esmTransformCache.get(key);
|
|
2873
2968
|
if (cached) {
|
|
2874
|
-
bundle[
|
|
2969
|
+
bundle[target] = cached;
|
|
2875
2970
|
transformed++;
|
|
2876
2971
|
continue;
|
|
2877
2972
|
}
|
|
2878
2973
|
try {
|
|
2879
|
-
const
|
|
2974
|
+
const transformOptions = {
|
|
2880
2975
|
loader: bundleTypescriptLoader(path) ?? 'js',
|
|
2881
2976
|
format: 'cjs',
|
|
2882
2977
|
target: 'esnext',
|
|
2883
2978
|
define: importMetaDefines(absUrl),
|
|
2884
|
-
}
|
|
2979
|
+
};
|
|
2980
|
+
const bounded = transformOptions.loader === 'js' && src.length >= ISOLATED_ESM_TRANSFORM_MIN_BYTES
|
|
2981
|
+
? rewriteBundledEsmToCjs(src, absUrl)
|
|
2982
|
+
: null;
|
|
2983
|
+
const t = bounded ?? (isolatedTransform && src.length >= ISOLATED_ESM_TRANSFORM_MIN_BYTES
|
|
2984
|
+
? await isolatedTransform(src, transformOptions)
|
|
2985
|
+
: await esbuild.transform(src, transformOptions));
|
|
2885
2986
|
const code = bindImportMetaResolve(t.code, absUrl);
|
|
2886
|
-
bundle[
|
|
2987
|
+
bundle[target] = code;
|
|
2887
2988
|
__esmTransformCache.set(key, code);
|
|
2888
2989
|
transformed++;
|
|
2889
2990
|
}
|
|
@@ -2905,7 +3006,6 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
|
|
|
2905
3006
|
// __compiledModules, and the failure surfaces at require time
|
|
2906
3007
|
// with FULL diagnostic context. Net effect for users: the same
|
|
2907
3008
|
// upstream tool still fails, but they now see WHY.
|
|
2908
|
-
//
|
|
2909
3009
|
// Probe: tests/behavioral/npm-create/new/create-astro-diagnostic.mjs
|
|
2910
3010
|
// asserts the user-visible error contains "esbuild transform" so
|
|
2911
3011
|
// future diagnostic regressions are caught.
|
|
@@ -2916,7 +3016,7 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
|
|
|
2916
3016
|
// this so repeated submits don't re-run the same transform.
|
|
2917
3017
|
const diagnosticSrc = '// framework-fixes-F4 diagnostic shim — esbuild rejected the ESM transform\n' +
|
|
2918
3018
|
'(function () { throw new Error(' + escapedReason + '); })();\n';
|
|
2919
|
-
bundle[
|
|
3019
|
+
bundle[target] = diagnosticSrc;
|
|
2920
3020
|
__esmTransformCache.set(key, diagnosticSrc);
|
|
2921
3021
|
failed++;
|
|
2922
3022
|
}
|
|
@@ -2939,7 +3039,7 @@ async function transformEsmInBundle(bundle, esbuild, pacer) {
|
|
|
2939
3039
|
* behaviour for code paths that don't have esbuild handy).
|
|
2940
3040
|
*
|
|
2941
3041
|
*/
|
|
2942
|
-
export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer) {
|
|
3042
|
+
export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform) {
|
|
2943
3043
|
// This build accumulates raw VFS contents in the supervisor heap, and did it
|
|
2944
3044
|
// with nothing watching: the estimator read 9.4 MiB while these bytes were
|
|
2945
3045
|
// resetting the DO three times. Take the budget the enrichment passes are
|
|
@@ -2949,14 +3049,14 @@ export async function buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbui
|
|
|
2949
3049
|
const lease = await acquireSupervisorAllocation(VFS_BUNDLE_MAX_BYTES);
|
|
2950
3050
|
prefetchBundleStart(VFS_BUNDLE_MAX_BYTES);
|
|
2951
3051
|
try {
|
|
2952
|
-
return await _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile, observedReads, pacer);
|
|
3052
|
+
return await _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile, observedReads, pacer, isolatedTransform);
|
|
2953
3053
|
}
|
|
2954
3054
|
finally {
|
|
2955
3055
|
prefetchBundleEnd(VFS_BUNDLE_MAX_BYTES);
|
|
2956
3056
|
lease.release();
|
|
2957
3057
|
}
|
|
2958
3058
|
}
|
|
2959
|
-
async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer) {
|
|
3059
|
+
async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bundleProfile = DEFAULT_FACET_BUNDLE_PROFILE, observedReads, pacer, isolatedTransform) {
|
|
2960
3060
|
// Read the cursor BEFORE the walk: a mutation that lands while the bundle
|
|
2961
3061
|
// is being assembled must be reported as invalidated, not silently missed.
|
|
2962
3062
|
const cursor = { epoch: vfs.epoch, rev: vfs.revision() };
|
|
@@ -3071,7 +3171,7 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3071
3171
|
// "file was not pre-bundled" at request time.
|
|
3072
3172
|
if (esbuild) {
|
|
3073
3173
|
try {
|
|
3074
|
-
await transformEsmInBundle(bundle, esbuild, pacer);
|
|
3174
|
+
await transformEsmInBundle(bundle, esbuild, pacer, isolatedTransform);
|
|
3075
3175
|
// Recount bytes after the transform — CJS rebuilds can be larger
|
|
3076
3176
|
// OR smaller than the ESM source. We don't try to thread totalBytes
|
|
3077
3177
|
// through the transform because the eviction loop below recomputes
|
|
@@ -3110,8 +3210,9 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3110
3210
|
// went are named rather than silently dropped.
|
|
3111
3211
|
const size = encodedBundleSize(bundle, manifest);
|
|
3112
3212
|
if (size.bytes > BUNDLE_MAX_ENCODED_BYTES) {
|
|
3213
|
+
// A compiled cell goes with its source: required when the source is.
|
|
3113
3214
|
const evictable = Object.keys(bundle)
|
|
3114
|
-
.filter((path) => !requiredPaths.has(path))
|
|
3215
|
+
.filter((path) => !requiredPaths.has(compiledCellPath(path) ?? path))
|
|
3115
3216
|
.sort((a, b) => _bundleCellLength(bundle[b]) - _bundleCellLength(bundle[a]));
|
|
3116
3217
|
const evicted = [];
|
|
3117
3218
|
for (const k of evictable) {
|
|
@@ -3151,6 +3252,23 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3151
3252
|
};
|
|
3152
3253
|
}
|
|
3153
3254
|
const ROUTEABLE_PORT_ATTACH_TIMEOUT_MS = 1_000;
|
|
3255
|
+
/** A port request may wait this long for a durable app's re-drive to boot. */
|
|
3256
|
+
const DURABLE_ENSURE_BOOT_BUDGET_MS = 12_000;
|
|
3257
|
+
/** The env var a launch reads its restart policy from — set by startProcess({ restart }) and `nimbus start --restart`. */
|
|
3258
|
+
export const RESTART_POLICY_ENV = 'NIMBUS_RESTART';
|
|
3259
|
+
/** Backoff per spent FencedWork attempt; a healthy boot resets that existing budget. */
|
|
3260
|
+
const RESTART_BACKOFF_BASE_MS = 1_000;
|
|
3261
|
+
function residentRestartPolicy(env) {
|
|
3262
|
+
return env?.[RESTART_POLICY_ENV] === 'on-failure' ? 'on-failure' : 'never';
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* The durable owner a journal row serves: the row's stamped field for
|
|
3266
|
+
* reservation-claimed residents, falling back to the worker recipe's own
|
|
3267
|
+
* owner for rows written before the stamping rule landed.
|
|
3268
|
+
*/
|
|
3269
|
+
function residentOwner(record) {
|
|
3270
|
+
return record.owner ?? (record.recipe.kind === 'worker' ? record.recipe.owner : undefined);
|
|
3271
|
+
}
|
|
3154
3272
|
/** 'running' is the measured common case: the platform's reset strikes
|
|
3155
3273
|
* seconds after a launch settles, while the resident runs. */
|
|
3156
3274
|
function residentLaunchDoing(record) {
|
|
@@ -3189,7 +3307,7 @@ export class FacetManager {
|
|
|
3189
3307
|
* The resident-launch journal (fabric's fenced-work.ts): the durable
|
|
3190
3308
|
* record of every resident this session owes the user, and its recovery
|
|
3191
3309
|
* after an instance reset. This manager supplies what a launch IS — the
|
|
3192
|
-
*
|
|
3310
|
+
* recipe `_redrive` re-drives from — and how its loss is reported.
|
|
3193
3311
|
*/
|
|
3194
3312
|
launchJournal;
|
|
3195
3313
|
/**
|
|
@@ -3236,12 +3354,22 @@ export class FacetManager {
|
|
|
3236
3354
|
* prefetch cache above so a profile can only ever seed the bundle it was
|
|
3237
3355
|
* measured against.
|
|
3238
3356
|
*
|
|
3239
|
-
* Lifetime is the supervisor incarnation's, same as the cache — a restart
|
|
3240
3357
|
* costs one more loud failure and then relearns. Persisting it would be a
|
|
3241
3358
|
* schema and a migration bought with nothing the in-memory form does not
|
|
3242
3359
|
* already deliver for the case that matters: running the command again.
|
|
3243
3360
|
*/
|
|
3244
3361
|
residencyProfiles = new Map();
|
|
3362
|
+
/** In-flight request-driven durable-app ensures, single-flight per port. */
|
|
3363
|
+
ensureInflight = new Map();
|
|
3364
|
+
/** Per-pid chain of journal-row amendments; see `_amendRow`. */
|
|
3365
|
+
rowAmendments = new Map();
|
|
3366
|
+
/**
|
|
3367
|
+
* pid → the derived owner it duplicates: the second live instance of an
|
|
3368
|
+
* identity. Not journalled (nothing re-drives it), so this is the only
|
|
3369
|
+
* record of why `expose(pid)` refuses it.
|
|
3370
|
+
*/
|
|
3371
|
+
ephemeralPids = new Map();
|
|
3372
|
+
residentClaims = new Map();
|
|
3245
3373
|
static RESIDENCY_PROFILE_MAX_ENTRIES = 16;
|
|
3246
3374
|
/**
|
|
3247
3375
|
* A program that reads a directory of data files misses once per file, so
|
|
@@ -3271,7 +3399,7 @@ export class FacetManager {
|
|
|
3271
3399
|
this.launchJournal = new FencedWork(ctx.storage, {
|
|
3272
3400
|
generationBase: () => this.processes.pidBase,
|
|
3273
3401
|
waitUntil: (promise) => this.ctx.waitUntil(promise),
|
|
3274
|
-
redrive: (record, attempt) => this.
|
|
3402
|
+
redrive: (record, attempt) => this._redrive(record, attempt),
|
|
3275
3403
|
onRedrive: (record) => this.hooks.notify?.('\x1b[2m[nimbus: the session restarted while '
|
|
3276
3404
|
+ `"${record.command}" was ${residentLaunchDoing(record)} — restarting it]\x1b[0m\r\n`),
|
|
3277
3405
|
onAbandoned: (record) => this.hooks.notify?.('\x1b[2m[nimbus: the session restarted again while '
|
|
@@ -3292,9 +3420,100 @@ export class FacetManager {
|
|
|
3292
3420
|
// Rooted on waitUntil: the hook fires synchronously inside whatever turn
|
|
3293
3421
|
// ended the process, and the delete must not be a floating promise there.
|
|
3294
3422
|
this.processes.setOnTerminal((pid) => {
|
|
3295
|
-
this.ctx.waitUntil(this.
|
|
3423
|
+
this.ctx.waitUntil(this._onResidentTerminal(pid));
|
|
3296
3424
|
});
|
|
3297
3425
|
}
|
|
3426
|
+
/**
|
|
3427
|
+
* The process is over. Every end-of-life passes through here: a clean
|
|
3428
|
+
* exit, a kill, a timeout, a crash. Only one of them owes anything more
|
|
3429
|
+
* than the journal row's release — a crash under 'on-failure' is re-driven
|
|
3430
|
+
* from the row, after a backoff, while the row is still in storage so a
|
|
3431
|
+
* reset inside the backoff window recovers it like any other resident.
|
|
3432
|
+
*/
|
|
3433
|
+
async _onResidentTerminal(pid) {
|
|
3434
|
+
await this.rowAmendments.get(pid);
|
|
3435
|
+
this.ephemeralPids.delete(pid);
|
|
3436
|
+
await this._releaseResidentClaim(pid);
|
|
3437
|
+
if (!this.launchJournal.has(pid))
|
|
3438
|
+
return;
|
|
3439
|
+
const entry = this.processes.get(pid);
|
|
3440
|
+
const key = `${FENCED_WORK_KEY_PREFIX}${pid}`;
|
|
3441
|
+
const row = (await this.launchJournal.rows()).get(key);
|
|
3442
|
+
const crashed = entry?.state === 'exited' && (entry.exitCode ?? 0) !== 0;
|
|
3443
|
+
if (row === undefined || row.restart !== 'on-failure' || !crashed) {
|
|
3444
|
+
await this.launchJournal.release(pid);
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
const delayMs = RESTART_BACKOFF_BASE_MS * 2 ** row.attempt;
|
|
3448
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: "${row.command}" exited with code ${entry.exitCode} — `
|
|
3449
|
+
+ `restarting in ${delayMs / 1000}s (FencedWork attempt ${row.attempt + 1})]\x1b[0m\r\n`);
|
|
3450
|
+
await this.launchPump.nextTurn(Promise.resolve(), Date.now() + delayMs);
|
|
3451
|
+
// The process may have been removed or the session destroyed during the
|
|
3452
|
+
// backoff; a row that is gone is owed nothing.
|
|
3453
|
+
if (!(await this.launchJournal.rows()).has(key))
|
|
3454
|
+
return;
|
|
3455
|
+
await this.launchJournal.drive(key, row);
|
|
3456
|
+
}
|
|
3457
|
+
/** Claim identity AND write its recovery row in one serializable storage transaction. */
|
|
3458
|
+
async _claimResident(record) {
|
|
3459
|
+
const owner = residentOwner(record);
|
|
3460
|
+
if (owner === undefined)
|
|
3461
|
+
throw new Error('resident launch has no owner');
|
|
3462
|
+
const duplicate = await this.ctx.storage.transaction(async (txn) => {
|
|
3463
|
+
const key = `${RESIDENT_OWNER_KEY_PREFIX}${owner}`;
|
|
3464
|
+
const held = await txn.get(key);
|
|
3465
|
+
if (held !== undefined && held !== record.pid && held > this.processes.pidBase
|
|
3466
|
+
&& this.processes.get(held)?.state === 'running')
|
|
3467
|
+
return held;
|
|
3468
|
+
await txn.put(key, record.pid);
|
|
3469
|
+
await txn.put(`${FENCED_WORK_KEY_PREFIX}${record.pid}`, record);
|
|
3470
|
+
return null;
|
|
3471
|
+
});
|
|
3472
|
+
if (duplicate === null) {
|
|
3473
|
+
this.residentClaims.set(record.pid, owner);
|
|
3474
|
+
// Adopt into FencedWork's per-instance lifetime bookkeeping and cross
|
|
3475
|
+
// its sync barrier before booting. The winning row already exists.
|
|
3476
|
+
await this.launchJournal.journal(record);
|
|
3477
|
+
}
|
|
3478
|
+
return duplicate;
|
|
3479
|
+
}
|
|
3480
|
+
async _releaseResidentClaim(pid) {
|
|
3481
|
+
const owner = this.residentClaims.get(pid);
|
|
3482
|
+
if (owner === undefined)
|
|
3483
|
+
return;
|
|
3484
|
+
this.residentClaims.delete(pid);
|
|
3485
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
3486
|
+
const key = `${RESIDENT_OWNER_KEY_PREFIX}${owner}`;
|
|
3487
|
+
if (await txn.get(key) === pid)
|
|
3488
|
+
await txn.delete(key);
|
|
3489
|
+
});
|
|
3490
|
+
await this.ctx.storage.sync();
|
|
3491
|
+
}
|
|
3492
|
+
/**
|
|
3493
|
+
* Amend one journal row in place — port stamp, owner adoption, settle —
|
|
3494
|
+
* serialized per pid so two amendments in flight on the same row cannot
|
|
3495
|
+
* interleave their read and write and lose one another's fields.
|
|
3496
|
+
*/
|
|
3497
|
+
_amendRow(pid, amend) {
|
|
3498
|
+
const key = `${FENCED_WORK_KEY_PREFIX}${pid}`;
|
|
3499
|
+
const previous = this.rowAmendments.get(pid) ?? Promise.resolve(undefined);
|
|
3500
|
+
const next = previous.then(async () => {
|
|
3501
|
+
const row = (await this.launchJournal.rows()).get(key);
|
|
3502
|
+
if (row === undefined)
|
|
3503
|
+
return undefined;
|
|
3504
|
+
const amended = amend(row);
|
|
3505
|
+
if (amended !== row)
|
|
3506
|
+
await this.launchJournal.journal(amended);
|
|
3507
|
+
return amended;
|
|
3508
|
+
});
|
|
3509
|
+
const settled = next.then(() => undefined, () => undefined);
|
|
3510
|
+
this.rowAmendments.set(pid, settled);
|
|
3511
|
+
void settled.then(() => {
|
|
3512
|
+
if (this.rowAmendments.get(pid) === settled)
|
|
3513
|
+
this.rowAmendments.delete(pid);
|
|
3514
|
+
});
|
|
3515
|
+
return next;
|
|
3516
|
+
}
|
|
3298
3517
|
setVfs(vfs) { this.vfs = vfs; }
|
|
3299
3518
|
/**
|
|
3300
3519
|
* The env/ctx pair every loader-backed runtime builds its facet pools
|
|
@@ -3327,6 +3546,17 @@ export class FacetManager {
|
|
|
3327
3546
|
unlink: (path) => fs.unlink(path),
|
|
3328
3547
|
};
|
|
3329
3548
|
}
|
|
3549
|
+
/**
|
|
3550
|
+
* The kernel-scoped VFS the durable image store reads and writes through —
|
|
3551
|
+
* `.nimbus/images/<sha256>` is session kernel data, not user content.
|
|
3552
|
+
*/
|
|
3553
|
+
_imageVfs() {
|
|
3554
|
+
const vfs = this.vfs;
|
|
3555
|
+
if (!vfs) {
|
|
3556
|
+
throw new Error('Nimbus: a durable spawn needs a session filesystem to persist its launch image');
|
|
3557
|
+
}
|
|
3558
|
+
return vfs;
|
|
3559
|
+
}
|
|
3330
3560
|
/**
|
|
3331
3561
|
* W3.5 Fix B: hand the FacetManager a pre-warmed EsbuildService for
|
|
3332
3562
|
* the ESM→CJS bundle pre-pass. NimbusSession already lazy-creates one
|
|
@@ -3413,7 +3643,7 @@ export class FacetManager {
|
|
|
3413
3643
|
this.prefetchBundleCache.set(key, cached);
|
|
3414
3644
|
return { ...cached.vfsState, cacheHit: true, cacheRetained: true };
|
|
3415
3645
|
}
|
|
3416
|
-
const vfsState = await buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, this.esbuild || undefined, bundleProfile, this.residencyProfiles.get(key));
|
|
3646
|
+
const vfsState = await buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, this.esbuild || undefined, bundleProfile, this.residencyProfiles.get(key), undefined, this.hooks.transformLargeEsm);
|
|
3417
3647
|
vfsState.bundleKey = key;
|
|
3418
3648
|
vfsState.bundleSource = await buildFacetVfsBundleSource(vfsState.bundle, vfsState.bundleSideModulesRequired);
|
|
3419
3649
|
vfsState.serializedManifest = JSON.stringify(vfsState.manifest);
|
|
@@ -3624,12 +3854,7 @@ export class FacetManager {
|
|
|
3624
3854
|
// CJS before they hit the facet's `new Function` pre-compile loop.
|
|
3625
3855
|
// Lazy-create one if NimbusSession didn't share its own.
|
|
3626
3856
|
if (this.vfs && !this.esbuild) {
|
|
3627
|
-
|
|
3628
|
-
this.esbuild = new EsbuildService(this.vfs);
|
|
3629
|
-
}
|
|
3630
|
-
catch {
|
|
3631
|
-
this.esbuild = null;
|
|
3632
|
-
}
|
|
3857
|
+
this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
|
|
3633
3858
|
}
|
|
3634
3859
|
const diagOn = isExecDiagEnabled();
|
|
3635
3860
|
const __bundleStart = diagOn ? Date.now() : 0;
|
|
@@ -3947,7 +4172,7 @@ export class FacetManager {
|
|
|
3947
4172
|
this.imageStore.ensureDir();
|
|
3948
4173
|
const processVfs = this.vfs?.as(entry.cred);
|
|
3949
4174
|
const vfsState = processVfs
|
|
3950
|
-
? await buildPrefetchBundle(processVfs, undefined, opts.cwd, '', this.esbuild || undefined)
|
|
4175
|
+
? await buildPrefetchBundle(processVfs, undefined, opts.cwd, '', this.esbuild || undefined, DEFAULT_FACET_BUNDLE_PROFILE, undefined, undefined, this.hooks.transformLargeEsm)
|
|
3951
4176
|
: { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
|
|
3952
4177
|
const vfsBundle = _serializeBundleForFacet(vfsState.bundle);
|
|
3953
4178
|
assertStagedBundleFitsRpcPayload(vfsBundle, vfsState.bundle);
|
|
@@ -4371,6 +4596,21 @@ export class FacetManager {
|
|
|
4371
4596
|
return '(no output captured)';
|
|
4372
4597
|
}
|
|
4373
4598
|
}
|
|
4599
|
+
/**
|
|
4600
|
+
* A launch that fails before its process is running reports the same way
|
|
4601
|
+
* regardless of which phase failed: the pid is exited, the terminal event
|
|
4602
|
+
* recorded, and the session notified. Callers do their phase-specific
|
|
4603
|
+
* cleanup (ports, tracked RPC resources) first and pass a reason that names
|
|
4604
|
+
* the phase.
|
|
4605
|
+
*/
|
|
4606
|
+
_failLaunch(pid, reason) {
|
|
4607
|
+
this.processes.exit(pid, 1);
|
|
4608
|
+
this._w5RecordTermination(pid, 1, 'facet', reason);
|
|
4609
|
+
try {
|
|
4610
|
+
this.hooks.onExternalExit?.(pid, 1, reason);
|
|
4611
|
+
}
|
|
4612
|
+
catch { }
|
|
4613
|
+
}
|
|
4374
4614
|
/** Flush files written by the script back to the supervisor's VFS. */
|
|
4375
4615
|
_flushVfsWrites(result, pid) {
|
|
4376
4616
|
if (!this.vfs || !result.vfsWrites)
|
|
@@ -4418,6 +4658,42 @@ export class FacetManager {
|
|
|
4418
4658
|
clearTimeout(timer);
|
|
4419
4659
|
}
|
|
4420
4660
|
}
|
|
4661
|
+
/**
|
|
4662
|
+
* Re-drive a journalled launch after an instance reset. What the journal
|
|
4663
|
+
* row carries is the recipe and nothing else: env and credentials are never
|
|
4664
|
+
* written to storage, so a worker launch's are re-resolved by the embedder
|
|
4665
|
+
* through `hooks.resolveWorkerLaunch`.
|
|
4666
|
+
*/
|
|
4667
|
+
async _redrive(record, attempt) {
|
|
4668
|
+
const { recipe } = record;
|
|
4669
|
+
switch (recipe.kind) {
|
|
4670
|
+
case 'node': return this._spawnResident(recipe.code, recipe.opts, attempt);
|
|
4671
|
+
case 'worker': {
|
|
4672
|
+
// The embedder's resolver decides everything; only a self-owned
|
|
4673
|
+
// launch — nobody composed an embedder hook — falls through to the
|
|
4674
|
+
// session's image-store default.
|
|
4675
|
+
const resolve = recipe.resident
|
|
4676
|
+
? this.hooks.resolveWorkerLaunchFallback
|
|
4677
|
+
: this.hooks.resolveWorkerLaunch ?? this.hooks.resolveWorkerLaunchFallback;
|
|
4678
|
+
if (!resolve)
|
|
4679
|
+
throw new Error('resident launch journal holds a worker launch but no resolveWorkerLaunch hook is composed');
|
|
4680
|
+
const resolved = await resolve(recipe);
|
|
4681
|
+
if (resolved === null)
|
|
4682
|
+
return undefined;
|
|
4683
|
+
const { 'worker.js': workerCode, ...modules } = resolved.modules;
|
|
4684
|
+
if (workerCode === undefined)
|
|
4685
|
+
throw new Error('resolved worker launch carries no worker.js module');
|
|
4686
|
+
return this._spawnWorker(workerCode, record.command, recipe.cwd, {
|
|
4687
|
+
port: recipe.resident ? record.port : recipe.port > 0 ? recipe.port : undefined, modules, compatibilityDate: recipe.compatibilityDate,
|
|
4688
|
+
compatibilityFlags: recipe.compatibilityFlags, startArgs: resolved.startArgs ?? recipe.startArgs,
|
|
4689
|
+
resident: recipe.resident, restart: record.restart,
|
|
4690
|
+
env: resolved.env ?? undefined, globalOutbound: resolved.globalOutbound,
|
|
4691
|
+
vfsWasmModules: resolved.vfsWasmModules,
|
|
4692
|
+
durable: { owner: residentOwner(record) ?? recipe.owner, image: recipe.image },
|
|
4693
|
+
}, attempt);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4421
4697
|
/**
|
|
4422
4698
|
* Spawn a long-running Node process with the same shimmed require/fs/http
|
|
4423
4699
|
* environment used by foreground `node <script>` execution.
|
|
@@ -4503,17 +4779,89 @@ export class FacetManager {
|
|
|
4503
4779
|
// sessions kept dying. The row is finally released by the supervisor's
|
|
4504
4780
|
// terminal hook when the process ends. A re-drive owns its own process,
|
|
4505
4781
|
// so the caller's pid goes with the instance that had it.
|
|
4782
|
+
//
|
|
4783
|
+
// Every resident has an identity from the moment it is spawned: derived
|
|
4784
|
+
// from the working directory and argv (never the env), so the same
|
|
4785
|
+
// `node server.js` from the same directory is the same application
|
|
4786
|
+
// across restarts and resets. A resident that declares a port at spawn
|
|
4787
|
+
// which an EXPLICIT reservation holds (an embedder's ensureDurableApp)
|
|
4788
|
+
// adopts that reservation's owner instead — the landed contract: the
|
|
4789
|
+
// embedder declared the port, so whatever binds it is the application.
|
|
4790
|
+
//
|
|
4791
|
+
// A second live instance of the same derived identity is ephemeral: it
|
|
4792
|
+
// runs, but it is not the durable one — it takes no slot (the slot is
|
|
4793
|
+
// the identity's, and a live facet already holds it), claims no
|
|
4794
|
+
// reservation, and is not journalled, so nothing re-drives it and a
|
|
4795
|
+
// port it binds retires the previous occupant's capability like any
|
|
4796
|
+
// unrelated process would.
|
|
4797
|
+
const derivedOwner = await deriveResidentOwner(cwd, opts.argv ?? []);
|
|
4798
|
+
let owner = derivedOwner;
|
|
4799
|
+
if (opts.port !== undefined && opts.port > 0 && opts.port < 65536) {
|
|
4800
|
+
const declared = await readPortReservation(this.ctx, opts.port);
|
|
4801
|
+
if (declared !== null && declared.owner !== null && declared.kind === 'explicit') {
|
|
4802
|
+
owner = declared.owner;
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
const initial = {
|
|
4806
|
+
pid: entry.pid, command, attempt, phase: 'starting', owner,
|
|
4807
|
+
recipe: { kind: 'node', code, opts: { ...opts, skipSpawn: undefined, callerPid: undefined } },
|
|
4808
|
+
restart: residentRestartPolicy(opts.env),
|
|
4809
|
+
};
|
|
4810
|
+
let duplicateOf;
|
|
4811
|
+
try {
|
|
4812
|
+
duplicateOf = await this._claimResident(initial);
|
|
4813
|
+
this._assertLaunchStillOwned(entry.pid);
|
|
4814
|
+
}
|
|
4815
|
+
catch (e) {
|
|
4816
|
+
pacer.settle();
|
|
4817
|
+
await this._releaseResidentClaim(entry.pid);
|
|
4818
|
+
await this.launchJournal.release(entry.pid);
|
|
4819
|
+
this._failLaunch(entry.pid, 'long-running node launch failed: ' + errorMessage(e));
|
|
4820
|
+
throw e;
|
|
4821
|
+
}
|
|
4822
|
+
const ephemeral = duplicateOf !== null;
|
|
4823
|
+
// The reservation the identity holds, if any: its port is what `$PORT`
|
|
4824
|
+
// is set to and what the facet's durable slot is bound for, so the
|
|
4825
|
+
// resident's `ctx.storage` persists across resets the way a durable
|
|
4826
|
+
// worker's does.
|
|
4827
|
+
const held = ephemeral ? null : await readPortReservationByOwner(this.ctx, owner);
|
|
4828
|
+
let durableFacetName;
|
|
4829
|
+
let launchEnv;
|
|
4830
|
+
if (held !== null) {
|
|
4831
|
+
durableFacetName = await acquireDurableFacetSlot(this.ctx, owner);
|
|
4832
|
+
launchEnv = {
|
|
4833
|
+
PORT: String(held.port),
|
|
4834
|
+
NIMBUS_APP: held.reservation.name ?? owner,
|
|
4835
|
+
};
|
|
4836
|
+
}
|
|
4837
|
+
if (ephemeral) {
|
|
4838
|
+
this.ephemeralPids.set(entry.pid, owner);
|
|
4839
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: second instance of "${(opts.argv ?? []).join(' ') || command}" `
|
|
4840
|
+
+ `is not the durable one — pid ${duplicateOf} keeps the identity]\x1b[0m\r\n`);
|
|
4841
|
+
}
|
|
4506
4842
|
const record = {
|
|
4507
4843
|
pid: entry.pid,
|
|
4508
4844
|
command,
|
|
4509
|
-
code,
|
|
4510
|
-
opts: { ...opts, skipSpawn: undefined, callerPid: undefined },
|
|
4511
4845
|
attempt,
|
|
4512
4846
|
phase: 'starting',
|
|
4847
|
+
recipe: { kind: 'node', code, opts: { ...opts, skipSpawn: undefined, callerPid: undefined } },
|
|
4848
|
+
owner,
|
|
4849
|
+
...(held !== null ? { port: held.port, injectedPort: held.port } : {}),
|
|
4850
|
+
restart: residentRestartPolicy(opts.env),
|
|
4513
4851
|
};
|
|
4514
|
-
await this.launchJournal.journal(record);
|
|
4515
4852
|
try {
|
|
4516
|
-
|
|
4853
|
+
if (!ephemeral)
|
|
4854
|
+
await this.launchJournal.journal(record);
|
|
4855
|
+
}
|
|
4856
|
+
catch (e) {
|
|
4857
|
+
// A resident that cannot be journalled does not start; the failure is
|
|
4858
|
+
// reported the way a boot failure is.
|
|
4859
|
+
pacer.settle();
|
|
4860
|
+
this._failLaunch(entry.pid, 'long-running node launch failed: ' + errorMessage(e));
|
|
4861
|
+
throw e;
|
|
4862
|
+
}
|
|
4863
|
+
try {
|
|
4864
|
+
await this._residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv);
|
|
4517
4865
|
}
|
|
4518
4866
|
catch (e) {
|
|
4519
4867
|
await this.launchJournal.release(entry.pid);
|
|
@@ -4525,19 +4873,16 @@ export class FacetManager {
|
|
|
4525
4873
|
// Booted and running: the launch proved itself, so the resident starts
|
|
4526
4874
|
// its running life with a fresh re-drive budget. If the process already
|
|
4527
4875
|
// ended inside the launch body's own settlement, the terminal hook has
|
|
4528
|
-
// released the row — do not write it back.
|
|
4876
|
+
// released the row — do not write it back. Amended, not rewritten from
|
|
4877
|
+
// `record`: a port the program bound during its boot has already been
|
|
4878
|
+
// stamped onto the row, and the settle must not lose it.
|
|
4529
4879
|
if (this.launchJournal.has(entry.pid)) {
|
|
4530
|
-
await this.
|
|
4880
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, attempt: 0, phase: 'running' }));
|
|
4531
4881
|
}
|
|
4532
4882
|
}
|
|
4533
|
-
async _residentLaunchBody(entry, code, command, cwd, opts, pacer) {
|
|
4883
|
+
async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv) {
|
|
4534
4884
|
if (this.vfs && !this.esbuild) {
|
|
4535
|
-
|
|
4536
|
-
this.esbuild = new EsbuildService(this.vfs);
|
|
4537
|
-
}
|
|
4538
|
-
catch {
|
|
4539
|
-
this.esbuild = null;
|
|
4540
|
-
}
|
|
4885
|
+
this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
|
|
4541
4886
|
}
|
|
4542
4887
|
if (this.vfs)
|
|
4543
4888
|
this.imageStore.ensureDir();
|
|
@@ -4545,12 +4890,17 @@ export class FacetManager {
|
|
|
4545
4890
|
const __bundleStart = diagOn ? Date.now() : 0;
|
|
4546
4891
|
const processVfs = this.vfs?.as(entry.cred);
|
|
4547
4892
|
const vfsState = processVfs
|
|
4548
|
-
? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer)
|
|
4893
|
+
? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer, this.hooks.transformLargeEsm)
|
|
4549
4894
|
: { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
|
|
4550
4895
|
const bundleMs = diagOn ? Date.now() - __bundleStart : 0;
|
|
4896
|
+
// The launch-time overlay (`$PORT`, `$NIMBUS_APP` under a reservation)
|
|
4897
|
+
// rides on top of the recipe's env and is never journalled: it is
|
|
4898
|
+
// re-derived from the reservation on every launch, so a port the
|
|
4899
|
+
// application was given follows the reservation, not the recipe.
|
|
4900
|
+
const spawnEnv = launchEnv === undefined ? opts.env : { ...(opts.env || {}), ...launchEnv };
|
|
4551
4901
|
const processEnv = opts.attachedTty
|
|
4552
4902
|
? {
|
|
4553
|
-
...(
|
|
4903
|
+
...(spawnEnv || {}),
|
|
4554
4904
|
NIMBUS_ATTACHED_TTY: '1',
|
|
4555
4905
|
NIMBUS_CP_CHILD_PID: String(entry.pid),
|
|
4556
4906
|
TERM: opts.env?.TERM || 'xterm-256color',
|
|
@@ -4559,7 +4909,7 @@ export class FacetManager {
|
|
|
4559
4909
|
LINES: opts.env?.LINES || '24',
|
|
4560
4910
|
FORCE_COLOR: opts.env?.FORCE_COLOR || '1',
|
|
4561
4911
|
}
|
|
4562
|
-
:
|
|
4912
|
+
: spawnEnv;
|
|
4563
4913
|
const usesSqlite = bundleUsesNodeSqlite(code, vfsState.bundle);
|
|
4564
4914
|
const [sqliteModules, shims] = await Promise.all([
|
|
4565
4915
|
this.sqliteModuleEntry(usesSqlite),
|
|
@@ -4602,6 +4952,12 @@ export class FacetManager {
|
|
|
4602
4952
|
// life; the server/watch runner returns once it is up.
|
|
4603
4953
|
startContract: opts.attachedTty ? 'lifetime' : 'boot',
|
|
4604
4954
|
startArgs: { vfsCursor },
|
|
4955
|
+
// A resident whose declared port is reserved binds the owner's
|
|
4956
|
+
// durable slot — the same store a durable worker spawn takes — so the
|
|
4957
|
+
// reservation's durability reaches this process's storage too.
|
|
4958
|
+
...(durableFacetName !== undefined
|
|
4959
|
+
? { facet: { name: durableFacetName, durable: true } }
|
|
4960
|
+
: {}),
|
|
4605
4961
|
boot: {
|
|
4606
4962
|
kind: 'code',
|
|
4607
4963
|
code: {
|
|
@@ -4675,10 +5031,7 @@ export class FacetManager {
|
|
|
4675
5031
|
await handle.booted();
|
|
4676
5032
|
}
|
|
4677
5033
|
if (opts.port && opts.port > 0 && opts.port < 65536) {
|
|
4678
|
-
|
|
4679
|
-
// capability, so a URL handed out for that one cannot reach this one.
|
|
4680
|
-
await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, opts.port);
|
|
4681
|
-
this.portRegistry.register(opts.port, entry.pid);
|
|
5034
|
+
await this._registerResidentPort(entry.pid, opts.port);
|
|
4682
5035
|
}
|
|
4683
5036
|
}
|
|
4684
5037
|
catch (e) {
|
|
@@ -4687,13 +5040,7 @@ export class FacetManager {
|
|
|
4687
5040
|
this.releaseProcessRpcResources(entry.pid);
|
|
4688
5041
|
else
|
|
4689
5042
|
handle?.kill();
|
|
4690
|
-
this.
|
|
4691
|
-
const reason = 'long-running node boot failed: ' + errorMessage(e);
|
|
4692
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
4693
|
-
try {
|
|
4694
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
4695
|
-
}
|
|
4696
|
-
catch { }
|
|
5043
|
+
this._failLaunch(entry.pid, 'long-running node boot failed: ' + errorMessage(e));
|
|
4697
5044
|
throw e;
|
|
4698
5045
|
}
|
|
4699
5046
|
}
|
|
@@ -4711,6 +5058,13 @@ export class FacetManager {
|
|
|
4711
5058
|
* promise, so nothing polls the port to decide the process is up.
|
|
4712
5059
|
*/
|
|
4713
5060
|
async spawnWorker(workerCode, command, cwd, opts = {}) {
|
|
5061
|
+
return this._spawnWorker(workerCode, command, cwd, opts, 0);
|
|
5062
|
+
}
|
|
5063
|
+
/** `attempt` is the journal's re-drive budget, as `_spawnResident` carries it. */
|
|
5064
|
+
async _spawnWorker(workerCode, command, cwd, opts, attempt) {
|
|
5065
|
+
if (opts.resident && !opts.durable) {
|
|
5066
|
+
opts = { ...opts, durable: { owner: await deriveResidentOwner(cwd, opts.resident.argv) } };
|
|
5067
|
+
}
|
|
4714
5068
|
this.processes.reap();
|
|
4715
5069
|
const entry = this.processes.spawn(command, [], cwd);
|
|
4716
5070
|
// Stamp the process-table entry so /api/processes exposes this as a
|
|
@@ -4722,35 +5076,149 @@ export class FacetManager {
|
|
|
4722
5076
|
this.hooks.onSpawn?.(entry.pid, command, true);
|
|
4723
5077
|
}
|
|
4724
5078
|
catch { }
|
|
5079
|
+
const compatibilityDate = opts.compatibilityDate ?? CF_COMPAT_DATE;
|
|
5080
|
+
const compatibilityFlags = opts.compatibilityFlags || ['nodejs_compat'];
|
|
4725
5081
|
let handle;
|
|
4726
5082
|
let resourcesTracked = false;
|
|
5083
|
+
let record;
|
|
5084
|
+
let durableFacetName;
|
|
5085
|
+
let launchEnv;
|
|
4727
5086
|
try {
|
|
5087
|
+
if (opts.durable) {
|
|
5088
|
+
// A durable spawn on a declared port starts only when a reservation the
|
|
5089
|
+
// owner already holds is persisted. Validated before the journal row is
|
|
5090
|
+
// written and before the process boots, so a foreign or absent
|
|
5091
|
+
// reservation refuses the launch rather than stealing the exposure.
|
|
5092
|
+
if (!opts.resident && opts.port && opts.port > 0 && opts.port < 65536) {
|
|
5093
|
+
const preFlight = await readPortReservation(this.ctx, opts.port);
|
|
5094
|
+
if (preFlight === null || preFlight.owner !== opts.durable.owner) {
|
|
5095
|
+
throw new Error('port reservation conflict: durable worker does not own port ' + opts.port);
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5098
|
+
// A live outbound binding cannot be journalled — a reset would re-drive
|
|
5099
|
+
// the recipe with nothing to stand in for it. Only the EMBEDDER's
|
|
5100
|
+
// resolveWorkerLaunch can re-mint one: the session's fallback reads
|
|
5101
|
+
// the image store, which never carries a binding, so a durable spawn
|
|
5102
|
+
// carrying globalOutbound is refused where no embedder hook exists.
|
|
5103
|
+
if (opts.globalOutbound !== undefined && opts.globalOutbound !== null
|
|
5104
|
+
&& this.hooks.resolveWorkerLaunch === undefined) {
|
|
5105
|
+
throw new Error('Nimbus: a durable spawn carrying a live globalOutbound binding '
|
|
5106
|
+
+ 'cannot be journalled without an embedder resolveWorkerLaunch hook');
|
|
5107
|
+
}
|
|
5108
|
+
// Self-owned applications persist their launch inputs as image blobs —
|
|
5109
|
+
// a runner blob for worker.js, an application blob for modules + env —
|
|
5110
|
+
// under .nimbus/images/<sha256>, and the journal row names the digests
|
|
5111
|
+
// of what was written, never placeholder strings. An embedder-owned
|
|
5112
|
+
// spawn is given digests by its own bookkeeping instead.
|
|
5113
|
+
const image = opts.durable.image
|
|
5114
|
+
?? await persistDurableWorkerImage(this._imageVfs(), workerCode, {
|
|
5115
|
+
modules: opts.modules ?? {},
|
|
5116
|
+
...(opts.env !== undefined ? { env: opts.env } : {}),
|
|
5117
|
+
vfsWasmModules: opts.vfsWasmModules,
|
|
5118
|
+
startArgs: opts.startArgs,
|
|
5119
|
+
});
|
|
5120
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
5121
|
+
const key = `${DURABLE_IMAGES_KEY_PREFIX}${opts.durable.owner}`;
|
|
5122
|
+
const images = await txn.get(key) ?? [];
|
|
5123
|
+
if (!images.some((held) => held.runner === image.runner && held.application === image.application)) {
|
|
5124
|
+
await txn.put(key, [...images, image]);
|
|
5125
|
+
}
|
|
5126
|
+
});
|
|
5127
|
+
// Journalled before the launch's first byte of work, so a row a
|
|
5128
|
+
// later instance reads proves this process never ended.
|
|
5129
|
+
record = {
|
|
5130
|
+
pid: entry.pid,
|
|
5131
|
+
command,
|
|
5132
|
+
attempt,
|
|
5133
|
+
phase: 'starting',
|
|
5134
|
+
recipe: {
|
|
5135
|
+
kind: 'worker',
|
|
5136
|
+
owner: opts.durable.owner,
|
|
5137
|
+
image,
|
|
5138
|
+
port: opts.port ?? 0,
|
|
5139
|
+
cwd,
|
|
5140
|
+
compatibilityDate,
|
|
5141
|
+
compatibilityFlags,
|
|
5142
|
+
...(opts.durable.image && !opts.resident ? { startArgs: opts.startArgs } : {}),
|
|
5143
|
+
...(opts.resident ? { resident: opts.resident } : {}),
|
|
5144
|
+
},
|
|
5145
|
+
// The row-level fields `ensureDurableAppOnPort` reads — same values
|
|
5146
|
+
// the recipe carries, stamped so port/owner lookup never depends on
|
|
5147
|
+
// the recipe's kind.
|
|
5148
|
+
owner: opts.durable.owner,
|
|
5149
|
+
restart: opts.restart ?? 'never',
|
|
5150
|
+
...(opts.port !== undefined && opts.port > 0 ? { port: opts.port } : {}),
|
|
5151
|
+
};
|
|
5152
|
+
// The durable facet name is claimed once, ever, from DO storage — a
|
|
5153
|
+
// re-drive after a reset, an eviction's re-attach and a relaunch all
|
|
5154
|
+
// land on the same `app-slot-<n>`, which is the only thing that keeps
|
|
5155
|
+
// the retained SQLite bound to this application.
|
|
5156
|
+
const duplicate = await this._claimResident(record);
|
|
5157
|
+
if (duplicate !== null) {
|
|
5158
|
+
this.ephemeralPids.set(entry.pid, opts.durable.owner);
|
|
5159
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: second instance of "${command}" is not the durable one — pid ${duplicate} keeps the identity]\x1b[0m\r\n`);
|
|
5160
|
+
}
|
|
5161
|
+
else if (!opts.resident || await readPortReservationByOwner(this.ctx, opts.durable.owner)) {
|
|
5162
|
+
durableFacetName = await acquireDurableFacetSlot(this.ctx, opts.durable.owner);
|
|
5163
|
+
}
|
|
5164
|
+
if (duplicate === null) {
|
|
5165
|
+
const held = await readPortReservationByOwner(this.ctx, opts.durable.owner);
|
|
5166
|
+
if (held) {
|
|
5167
|
+
launchEnv = { PORT: String(held.port), NIMBUS_APP: held.reservation.name ?? opts.durable.owner };
|
|
5168
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, port: held.port, injectedPort: held.port }));
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
}
|
|
4728
5172
|
handle = await this._startResidentProcess(entry.pid, {
|
|
4729
5173
|
// These runners answer startProcess with a boot payload (listening
|
|
4730
5174
|
// port, or a completed non-server run) and stay resident after it.
|
|
4731
5175
|
startContract: 'boot',
|
|
4732
|
-
startArgs: opts.startArgs
|
|
5176
|
+
startArgs: opts.resident && opts.startArgs && typeof opts.startArgs === 'object'
|
|
5177
|
+
? { ...opts.startArgs, supervisorPid: entry.pid,
|
|
5178
|
+
...(launchEnv ? { userEnv: { ...z.record(z.string(), z.unknown()).parse(Reflect.get(opts.startArgs, 'userEnv') ?? {}), ...launchEnv } } : {}) }
|
|
5179
|
+
: opts.startArgs,
|
|
5180
|
+
...(durableFacetName !== undefined
|
|
5181
|
+
? { facet: { name: durableFacetName, durable: true } }
|
|
5182
|
+
: {}),
|
|
4733
5183
|
boot: {
|
|
4734
5184
|
kind: 'code',
|
|
4735
5185
|
code: {
|
|
4736
|
-
compatibilityDate
|
|
4737
|
-
compatibilityFlags
|
|
5186
|
+
compatibilityDate,
|
|
5187
|
+
compatibilityFlags,
|
|
4738
5188
|
mainModule: 'worker.js',
|
|
4739
5189
|
modules: { 'worker.js': workerCode, ...(opts.modules || {}) },
|
|
4740
5190
|
vfsWasmModules: opts.vfsWasmModules,
|
|
5191
|
+
...(opts.env !== undefined || launchEnv !== undefined ? { env: { ...opts.env, ...launchEnv } } : {}),
|
|
5192
|
+
...(opts.globalOutbound !== undefined ? { globalOutbound: opts.globalOutbound } : {}),
|
|
4741
5193
|
},
|
|
4742
5194
|
},
|
|
4743
5195
|
});
|
|
4744
5196
|
this.trackProcessRpcResources(entry.pid, [handle]);
|
|
4745
5197
|
resourcesTracked = true;
|
|
4746
5198
|
this.portRegistry.bindFacetStub(entry.pid, handle.routeTarget);
|
|
5199
|
+
const boot = await handle.booted();
|
|
5200
|
+
if (record && this.launchJournal.has(entry.pid)) {
|
|
5201
|
+
// Booted and running: the launch proved itself, so the resident
|
|
5202
|
+
// starts its running life with a fresh re-drive budget.
|
|
5203
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, attempt: 0, phase: 'running' }));
|
|
5204
|
+
}
|
|
4747
5205
|
if (opts.port && opts.port > 0 && opts.port < 65536) {
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
5206
|
+
if (opts.durable && !opts.resident) {
|
|
5207
|
+
// Re-read after the boot: a release or reassignment during it wins.
|
|
5208
|
+
// The durable launch registers only under a reservation it still
|
|
5209
|
+
// owns; it never clears or takes a foreign exposure.
|
|
5210
|
+
const reservation = await readPortReservation(this.ctx, opts.port);
|
|
5211
|
+
if (reservation === null || reservation.owner !== opts.durable.owner) {
|
|
5212
|
+
throw new Error('port reservation conflict: durable worker does not own port ' + opts.port);
|
|
5213
|
+
}
|
|
5214
|
+
// The owner's hold on the port survives the instance reset that
|
|
5215
|
+
// re-drove this launch, and preview URLs minted against it stay
|
|
5216
|
+
// valid: the durable capability is re-adopted through the
|
|
5217
|
+
// reservation's own path, gated on the stored owner.
|
|
5218
|
+
}
|
|
5219
|
+
await this._registerResidentPort(entry.pid, opts.port);
|
|
4752
5220
|
}
|
|
4753
|
-
return { pid: entry.pid, boot
|
|
5221
|
+
return { pid: entry.pid, boot };
|
|
4754
5222
|
}
|
|
4755
5223
|
catch (e) {
|
|
4756
5224
|
this.portRegistry.unregisterByPid(entry.pid);
|
|
@@ -4758,20 +5226,183 @@ export class FacetManager {
|
|
|
4758
5226
|
this.releaseProcessRpcResources(entry.pid);
|
|
4759
5227
|
else
|
|
4760
5228
|
handle?.kill();
|
|
4761
|
-
this.
|
|
4762
|
-
const reason = 'long-running worker boot failed: ' + errorMessage(e);
|
|
4763
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
4764
|
-
try {
|
|
4765
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
4766
|
-
}
|
|
4767
|
-
catch { }
|
|
5229
|
+
this._failLaunch(entry.pid, 'long-running worker boot failed: ' + errorMessage(e));
|
|
4768
5230
|
throw e;
|
|
4769
5231
|
}
|
|
4770
5232
|
}
|
|
5233
|
+
/**
|
|
5234
|
+
* A resident process announcing it bound `port`.
|
|
5235
|
+
*
|
|
5236
|
+
* The stamp is unconditional: the pid's journal row gets `{ port }`
|
|
5237
|
+
* whether or not anything reserved it, which is what lets
|
|
5238
|
+
* `ensureDurableAppOnPort` re-drive ANY resident a reset killed — the
|
|
5239
|
+
* scoped URLs need no capability, so this alone makes every server's
|
|
5240
|
+
* preview survive a reset on demand.
|
|
5241
|
+
*
|
|
5242
|
+
* The capability is bound to identity, not to the port. The stored
|
|
5243
|
+
* capability is re-adopted only when the row's owner IS the reservation's
|
|
5244
|
+
* owner; any other occupant — an unrelated server, a pid outside the
|
|
5245
|
+
* resident lifecycle, the ephemeral second instance of an identity —
|
|
5246
|
+
* retires it and mints fresh, so a shared link 404s rather than reaching
|
|
5247
|
+
* a program it was never handed out for. An EXPLICIT reservation (an
|
|
5248
|
+
* embedder's `ensureDurableApp`) is the one exception, and it is the
|
|
5249
|
+
* landed contract: the embedder declared the port, so the row adopts the
|
|
5250
|
+
* reservation's owner and the capability with it.
|
|
5251
|
+
*
|
|
5252
|
+
* Under an injected `$PORT`, a resident that binds a different port is
|
|
5253
|
+
* registered anyway — the server must not break — but the row records
|
|
5254
|
+
* the mismatch, so `apps.list` reports it as failed and the user is told.
|
|
5255
|
+
*/
|
|
5256
|
+
async _registerResidentPort(pid, port) {
|
|
5257
|
+
const reservation = await readPortReservation(this.ctx, port);
|
|
5258
|
+
let claimedBy;
|
|
5259
|
+
if (reservation?.owner && reservation.kind === 'explicit' && !this.ephemeralPids.has(pid)) {
|
|
5260
|
+
const owner = reservation.owner;
|
|
5261
|
+
const anotherLive = [...(await this.launchJournal.rows()).values()].some((row) => row.pid !== pid && residentOwner(row) === owner
|
|
5262
|
+
&& row.pid > this.processes.pidBase && this.processes.get(row.pid)?.state === 'running');
|
|
5263
|
+
if (!anotherLive)
|
|
5264
|
+
claimedBy = owner;
|
|
5265
|
+
}
|
|
5266
|
+
let newlyMismatched = false;
|
|
5267
|
+
const row = await this._amendRow(pid, (current) => {
|
|
5268
|
+
const owner = claimedBy ?? residentOwner(current);
|
|
5269
|
+
const injected = current.injectedPort;
|
|
5270
|
+
// Under an injected $PORT, the reserved port is the row's port for as
|
|
5271
|
+
// long as this pid holds it; a second, different binding is recorded
|
|
5272
|
+
// as the mismatch rather than displacing it.
|
|
5273
|
+
const holdsInjected = injected !== undefined && injected !== port
|
|
5274
|
+
&& this.portRegistry.get(injected)?.pid === pid;
|
|
5275
|
+
const stampedPort = holdsInjected ? injected : port;
|
|
5276
|
+
const portMismatch = injected !== undefined && injected !== port && !holdsInjected
|
|
5277
|
+
? { listened: port, reserved: injected }
|
|
5278
|
+
: undefined;
|
|
5279
|
+
const same = current.port === stampedPort && residentOwner(current) === owner
|
|
5280
|
+
&& (current.portMismatch?.listened === portMismatch?.listened)
|
|
5281
|
+
&& (current.portMismatch?.reserved === portMismatch?.reserved);
|
|
5282
|
+
if (same)
|
|
5283
|
+
return current;
|
|
5284
|
+
newlyMismatched = portMismatch !== undefined && current.portMismatch === undefined;
|
|
5285
|
+
const { portMismatch: _dropped, ...rest } = current;
|
|
5286
|
+
return {
|
|
5287
|
+
...rest,
|
|
5288
|
+
port: stampedPort,
|
|
5289
|
+
...(owner !== undefined ? { owner } : {}),
|
|
5290
|
+
...(portMismatch !== undefined ? { portMismatch } : {}),
|
|
5291
|
+
};
|
|
5292
|
+
});
|
|
5293
|
+
if (newlyMismatched && row?.portMismatch !== undefined) {
|
|
5294
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: "${row.command}" listened on ${port} but its reservation owns `
|
|
5295
|
+
+ `${row.portMismatch.reserved} ($PORT) — the app's URL will not reach it]\x1b[0m\r\n`);
|
|
5296
|
+
}
|
|
5297
|
+
const owner = row === undefined ? undefined : residentOwner(row);
|
|
5298
|
+
if (claimedBy !== undefined && row !== undefined && this.residentClaims.get(pid) !== claimedBy) {
|
|
5299
|
+
const previousOwner = this.residentClaims.get(pid);
|
|
5300
|
+
await this._releaseResidentClaim(pid);
|
|
5301
|
+
const duplicate = await this._claimResident(row);
|
|
5302
|
+
if (duplicate !== null) {
|
|
5303
|
+
this.ephemeralPids.set(pid, claimedBy);
|
|
5304
|
+
await this.launchJournal.release(pid);
|
|
5305
|
+
}
|
|
5306
|
+
else if (previousOwner && row.recipe.kind === 'worker') {
|
|
5307
|
+
// Adoption moves retained images with the identity, including the
|
|
5308
|
+
// index that survives a clean exit after the journal row is released.
|
|
5309
|
+
await this.ctx.storage.transaction(async (txn) => {
|
|
5310
|
+
const previousKey = `${DURABLE_IMAGES_KEY_PREFIX}${previousOwner}`;
|
|
5311
|
+
const nextKey = `${DURABLE_IMAGES_KEY_PREFIX}${claimedBy}`;
|
|
5312
|
+
const previous = await txn.get(previousKey) ?? [];
|
|
5313
|
+
const next = await txn.get(nextKey) ?? [];
|
|
5314
|
+
const combined = [...next];
|
|
5315
|
+
for (const image of previous) {
|
|
5316
|
+
if (!combined.some((held) => held.runner === image.runner && held.application === image.application))
|
|
5317
|
+
combined.push(image);
|
|
5318
|
+
}
|
|
5319
|
+
await txn.put(nextKey, combined);
|
|
5320
|
+
await txn.delete(previousKey);
|
|
5321
|
+
});
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
if (reservation !== null && reservation.owner !== null && owner === reservation.owner && !this.ephemeralPids.has(pid)) {
|
|
5325
|
+
this.portRegistry.register(port, pid);
|
|
5326
|
+
await restoreReservedPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port, reservation.owner);
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
// Any other occupant retires the previous one's capability — and its
|
|
5330
|
+
// directory row, so a public link goes dead rather than dangling — and
|
|
5331
|
+
// registers with a fresh one. The owner's reservation survives it.
|
|
5332
|
+
if (reservation?.owner) {
|
|
5333
|
+
this.hooks.notify?.(`\x1b[2m[nimbus: port ${port} belongs to ${reservation.owner}; pid ${pid} registers ephemeral (owner ${owner ?? 'none'})]\x1b[0m\r\n`);
|
|
5334
|
+
}
|
|
5335
|
+
if (reservation?.visibility === 'public' && reservation.capability !== null) {
|
|
5336
|
+
await unbindPublicPortCapability(this._publicDirectoryHost(), reservation.capability);
|
|
5337
|
+
}
|
|
5338
|
+
await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port);
|
|
5339
|
+
this.portRegistry.register(port, pid);
|
|
5340
|
+
}
|
|
5341
|
+
/** What a pid's journal row says about who it is; null for a pid without one. */
|
|
5342
|
+
async residentIdentity(pid) {
|
|
5343
|
+
const duplicated = this.ephemeralPids.get(pid);
|
|
5344
|
+
if (duplicated !== undefined)
|
|
5345
|
+
return { owner: duplicated, ephemeral: true, port: undefined };
|
|
5346
|
+
const row = (await this.launchJournal.rows()).get(`${FENCED_WORK_KEY_PREFIX}${pid}`);
|
|
5347
|
+
if (row === undefined)
|
|
5348
|
+
return null;
|
|
5349
|
+
return { owner: residentOwner(row), ephemeral: false, port: row.port };
|
|
5350
|
+
}
|
|
5351
|
+
/**
|
|
5352
|
+
* Every stamped identity — the reservations, and the journal rows that
|
|
5353
|
+
* carry an owner — folded one row per owner. A live pid in THIS instance
|
|
5354
|
+
* makes it running (or starting, until its launch settles and its port
|
|
5355
|
+
* is registered); a mismatch diagnostic makes it failed; everything else
|
|
5356
|
+
* is stopped, which for a row a reset left behind means re-drivable on
|
|
5357
|
+
* request.
|
|
5358
|
+
*/
|
|
5359
|
+
async listResidentApps() {
|
|
5360
|
+
const apps = new Map();
|
|
5361
|
+
for (const [port, reservation] of await listPortReservations(this.ctx)) {
|
|
5362
|
+
if (reservation.owner === null)
|
|
5363
|
+
continue;
|
|
5364
|
+
apps.set(reservation.owner, {
|
|
5365
|
+
owner: reservation.owner,
|
|
5366
|
+
name: reservation.name ?? null,
|
|
5367
|
+
port,
|
|
5368
|
+
pid: null,
|
|
5369
|
+
status: 'stopped',
|
|
5370
|
+
visibility: reservation.visibility,
|
|
5371
|
+
capability: reservation.capability,
|
|
5372
|
+
restart: 'never',
|
|
5373
|
+
diagnostic: null,
|
|
5374
|
+
});
|
|
5375
|
+
}
|
|
5376
|
+
for (const [, row] of await this.launchJournal.rows()) {
|
|
5377
|
+
const owner = residentOwner(row);
|
|
5378
|
+
if (owner === undefined)
|
|
5379
|
+
continue;
|
|
5380
|
+
const app = apps.get(owner) ?? {
|
|
5381
|
+
owner, name: null, port: null, pid: null, status: 'stopped',
|
|
5382
|
+
visibility: 'scoped', capability: null, restart: 'never', diagnostic: null,
|
|
5383
|
+
};
|
|
5384
|
+
if (app.port === null && row.port !== undefined && row.port > 0)
|
|
5385
|
+
app.port = row.port;
|
|
5386
|
+
if (row.restart !== undefined)
|
|
5387
|
+
app.restart = row.restart;
|
|
5388
|
+
const entry = this.processes.get(row.pid);
|
|
5389
|
+
const live = row.pid > this.processes.pidBase && entry?.state === 'running';
|
|
5390
|
+
if (live) {
|
|
5391
|
+
app.pid = row.pid;
|
|
5392
|
+
const bound = app.port !== null && this.portRegistry.get(app.port)?.pid === row.pid;
|
|
5393
|
+
app.status = row.phase === 'running' && bound ? 'running' : 'starting';
|
|
5394
|
+
}
|
|
5395
|
+
if (row.portMismatch !== undefined) {
|
|
5396
|
+
app.status = 'failed';
|
|
5397
|
+
app.diagnostic = `listened on ${row.portMismatch.listened}, owns ${row.portMismatch.reserved}`;
|
|
5398
|
+
}
|
|
5399
|
+
apps.set(owner, app);
|
|
5400
|
+
}
|
|
5401
|
+
return [...apps.values()];
|
|
5402
|
+
}
|
|
4771
5403
|
async registerPort(pid, port) {
|
|
4772
5404
|
if (port > 0 && port < 65536) {
|
|
4773
|
-
await
|
|
4774
|
-
this.portRegistry.register(port, pid);
|
|
5405
|
+
await this._registerResidentPort(pid, port);
|
|
4775
5406
|
}
|
|
4776
5407
|
}
|
|
4777
5408
|
waitForRouteablePorts(pid, timeoutMs = ROUTEABLE_PORT_ATTACH_TIMEOUT_MS) {
|
|
@@ -4809,5 +5440,158 @@ export class FacetManager {
|
|
|
4809
5440
|
this._teardownPairedServeFacet(pid);
|
|
4810
5441
|
return result;
|
|
4811
5442
|
}
|
|
5443
|
+
/**
|
|
5444
|
+
* Remove a durable application: the ONLY path that deletes durable facet
|
|
5445
|
+
* storage. Owner-checked by construction — `freeDurableFacetSlot` answers
|
|
5446
|
+
* only a slot the owner actually holds, and the reservation release refuses
|
|
5447
|
+
* a foreign owner's record — so another owner's name cannot be reached.
|
|
5448
|
+
*
|
|
5449
|
+
* One ordered teardown: the live process is killed first (a released
|
|
5450
|
+
* durable facet only aborts, so nothing else ends it), the port reservation
|
|
5451
|
+
* is released, the journal rows for the owner are purged (nothing is owed a
|
|
5452
|
+
* removed application), the facet's SQLite is deleted, and the slot row is
|
|
5453
|
+
* freed last — so a crash mid-removal leaves a name still claimed rather
|
|
5454
|
+
* than a store nobody can re-drive.
|
|
5455
|
+
*/
|
|
5456
|
+
async removeDurableApp(owner) {
|
|
5457
|
+
// Kill any live process this owner still has — a durable release aborts
|
|
5458
|
+
// without deleting, so the store outlives the process unless removal
|
|
5459
|
+
// ends it here.
|
|
5460
|
+
const journalRows = await this.ctx.storage.list({ prefix: 'resident-launch:' });
|
|
5461
|
+
const ownedPids = [];
|
|
5462
|
+
const ownedPorts = new Set();
|
|
5463
|
+
for (const [, record] of journalRows) {
|
|
5464
|
+
// Owner-stamped rows cover every kind the reservation claims — a node
|
|
5465
|
+
// resident that bound the port is as much the application as a durable
|
|
5466
|
+
// worker spawn is.
|
|
5467
|
+
if (residentOwner(record) !== owner)
|
|
5468
|
+
continue;
|
|
5469
|
+
ownedPids.push(record.pid);
|
|
5470
|
+
}
|
|
5471
|
+
for (const pid of ownedPids)
|
|
5472
|
+
this.kill(pid);
|
|
5473
|
+
// The reservation may outlive every journal row (spawn never finished
|
|
5474
|
+
// writing one), so scan the port records for the owner too.
|
|
5475
|
+
const portRows = await this.ctx.storage.list({ prefix: PORT_CAPABILITY_KEY_PREFIX });
|
|
5476
|
+
for (const [key, record] of portRows) {
|
|
5477
|
+
if (record?.owner === owner)
|
|
5478
|
+
ownedPorts.add(Number(key.slice(PORT_CAPABILITY_KEY_PREFIX.length)));
|
|
5479
|
+
// A public capability leaves the routing directory with the row —
|
|
5480
|
+
// before the reservation is released so a mid-removal crash leaves a
|
|
5481
|
+
// dead URL rather than a dangling route to this session.
|
|
5482
|
+
if (record?.owner === owner && record.visibility === 'public' && typeof record.capability === 'string') {
|
|
5483
|
+
await unbindPublicPortCapability(this._publicDirectoryHost(), record.capability);
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
for (const port of ownedPorts) {
|
|
5487
|
+
// A port stamp is not ownership. Release only a reservation that still
|
|
5488
|
+
// belongs to this owner, and never unregister somebody else's listener.
|
|
5489
|
+
if ((await readPortReservation(this.ctx, port))?.owner !== owner)
|
|
5490
|
+
continue;
|
|
5491
|
+
await releasePortReservation(this.ctx, { owner, port });
|
|
5492
|
+
const live = this.portRegistry.get(port);
|
|
5493
|
+
if (live && ownedPids.includes(live.pid))
|
|
5494
|
+
this.portRegistry.unregister(port);
|
|
5495
|
+
}
|
|
5496
|
+
const imageRows = await this.ctx.storage.list({ prefix: DURABLE_IMAGES_KEY_PREFIX });
|
|
5497
|
+
const ownedImages = [...(imageRows.get(`${DURABLE_IMAGES_KEY_PREFIX}${owner}`) ?? [])];
|
|
5498
|
+
const retainedImages = [...imageRows].filter(([key]) => key !== `${DURABLE_IMAGES_KEY_PREFIX}${owner}`).flatMap(([, images]) => images);
|
|
5499
|
+
for (const row of journalRows.values()) {
|
|
5500
|
+
if (row.recipe.kind !== 'worker')
|
|
5501
|
+
continue;
|
|
5502
|
+
(residentOwner(row) === owner ? ownedImages : retainedImages).push(row.recipe.image);
|
|
5503
|
+
}
|
|
5504
|
+
const imagesPurged = this.vfs ? purgeDurableWorkerImages(this.vfs, ownedImages, retainedImages) : 0;
|
|
5505
|
+
await this.ctx.storage.delete(`${DURABLE_IMAGES_KEY_PREFIX}${owner}`);
|
|
5506
|
+
await this.ctx.storage.delete(`${RESIDENT_OWNER_KEY_PREFIX}${owner}`);
|
|
5507
|
+
const purged = await this.launchJournal.purgeWhere((record) => residentOwner(record) === owner);
|
|
5508
|
+
const name = await freeDurableFacetSlot(this.ctx, owner, (slot) => deleteFacetStorage(this.ctx, slot));
|
|
5509
|
+
return name !== null || purged > 0 || ownedPorts.size > 0 || imagesPurged > 0;
|
|
5510
|
+
}
|
|
5511
|
+
/** The session-shaped view the public-directory helpers read env from. */
|
|
5512
|
+
_publicDirectoryHost() {
|
|
5513
|
+
return { env: this.env.rawEnv, ctx: this.ctx };
|
|
5514
|
+
}
|
|
5515
|
+
/**
|
|
5516
|
+
* Whether a request addressed to `port` can reach a durable application —
|
|
5517
|
+
* and, when the application is journaled but dead, drive its re-drive and
|
|
5518
|
+
* wait for the boot, bounded.
|
|
5519
|
+
*
|
|
5520
|
+
* The port request is the one surface a reset leaves dark: the alarm pump
|
|
5521
|
+
* re-drives journaled launches eventually, but a URL a user is holding
|
|
5522
|
+
* cannot wait for an alarm that may never fire. 'started' means a live
|
|
5523
|
+
* process owns the port now; 'absent' means nothing durable claims it
|
|
5524
|
+
* (the caller answers 502 as it always has); 'failed' means a re-drive
|
|
5525
|
+
* ran and lost, or outlived its bound — the caller answers 503 and lets
|
|
5526
|
+
* the page re-ask.
|
|
5527
|
+
*
|
|
5528
|
+
* Single-flight per port: parallel requests on a woken page share one
|
|
5529
|
+
* ensure, which shares the journal's per-row drive with recovery — a
|
|
5530
|
+
* request that lands mid-recovery waits on that boot, it never boots a
|
|
5531
|
+
* second process.
|
|
5532
|
+
*/
|
|
5533
|
+
ensureDurableAppOnPort(port) {
|
|
5534
|
+
let inflight = this.ensureInflight.get(port);
|
|
5535
|
+
if (inflight === undefined) {
|
|
5536
|
+
inflight = this._ensureDurableAppOnPort(port);
|
|
5537
|
+
this.ensureInflight.set(port, inflight);
|
|
5538
|
+
inflight.finally(() => {
|
|
5539
|
+
if (this.ensureInflight.get(port) === inflight)
|
|
5540
|
+
this.ensureInflight.delete(port);
|
|
5541
|
+
});
|
|
5542
|
+
}
|
|
5543
|
+
return inflight;
|
|
5544
|
+
}
|
|
5545
|
+
async _ensureDurableAppOnPort(port) {
|
|
5546
|
+
if (this.portRegistry.has(port))
|
|
5547
|
+
return 'started';
|
|
5548
|
+
// The journal row is what makes a port a launch this instance owes —
|
|
5549
|
+
// reservation or not: every resident's row is stamped with the port it
|
|
5550
|
+
// bound, so any of them re-drives on request. A port with no stamped
|
|
5551
|
+
// row (nothing ever bound it, or the app was removed and its rows
|
|
5552
|
+
// purged) answers absent — nothing here can bring back an application
|
|
5553
|
+
// with no recipe. When more than one row names the port, the
|
|
5554
|
+
// reservation's owner wins; otherwise the newest launch does.
|
|
5555
|
+
const reservation = await readPortReservation(this.ctx, port);
|
|
5556
|
+
const rows = await this.launchJournal.rows();
|
|
5557
|
+
const candidates = [...rows].filter(([, record]) => record.port === port);
|
|
5558
|
+
const ownerRow = reservation !== null && reservation.owner !== null
|
|
5559
|
+
? candidates.find(([, record]) => residentOwner(record) === reservation.owner)
|
|
5560
|
+
: undefined;
|
|
5561
|
+
const entry = ownerRow ?? candidates.sort(([, a], [, b]) => b.pid - a.pid)[0];
|
|
5562
|
+
if (entry === undefined)
|
|
5563
|
+
return 'absent';
|
|
5564
|
+
const [rowKey, record] = entry;
|
|
5565
|
+
if (record.pid > this.processes.pidBase) {
|
|
5566
|
+
// This instance's own row: the launch is already building — waiting
|
|
5567
|
+
// for its port registration is the entire ask, and driving the row
|
|
5568
|
+
// again would boot a second copy.
|
|
5569
|
+
return (await this._waitForPort(port, DURABLE_ENSURE_BOOT_BUDGET_MS))
|
|
5570
|
+
? 'started' : 'failed';
|
|
5571
|
+
}
|
|
5572
|
+
const { promise: boundHit, resolve: markBound } = withResolvers();
|
|
5573
|
+
setTimeout(() => markBound(true), DURABLE_ENSURE_BOOT_BUDGET_MS);
|
|
5574
|
+
const failed = await Promise.race([
|
|
5575
|
+
this.launchJournal.drive(rowKey, record),
|
|
5576
|
+
boundHit,
|
|
5577
|
+
]);
|
|
5578
|
+
if (failed)
|
|
5579
|
+
return 'failed';
|
|
5580
|
+
// A settled drive either owns the port or deliberately answered 'gone':
|
|
5581
|
+
// a resolver's null superseded the row, which is absent, not failed.
|
|
5582
|
+
return this.portRegistry.has(port) ? 'started' : 'absent';
|
|
5583
|
+
}
|
|
5584
|
+
/** Poll for a port registration the in-flight launch has not made yet. */
|
|
5585
|
+
async _waitForPort(port, budgetMs) {
|
|
5586
|
+
const deadline = Date.now() + budgetMs;
|
|
5587
|
+
while (Date.now() < deadline) {
|
|
5588
|
+
if (this.portRegistry.has(port))
|
|
5589
|
+
return true;
|
|
5590
|
+
const { promise: tick, resolve: tickDone } = withResolvers();
|
|
5591
|
+
setTimeout(tickDone, 50);
|
|
5592
|
+
await tick;
|
|
5593
|
+
}
|
|
5594
|
+
return this.portRegistry.has(port);
|
|
5595
|
+
}
|
|
4812
5596
|
get stats() { return this.processes.stats; }
|
|
4813
5597
|
}
|