@nimbus-sh/worker 0.5.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/dist/_shared/preview-host.d.ts +26 -8
- package/dist/_shared/preview-host.d.ts.map +1 -1
- package/dist/_shared/preview-host.js +51 -11
- package/dist/facets/durable-images.d.ts +9 -0
- package/dist/facets/durable-images.d.ts.map +1 -1
- package/dist/facets/durable-images.js +20 -1
- package/dist/facets/durable-slots.d.ts +1 -1
- package/dist/facets/durable-slots.d.ts.map +1 -1
- package/dist/facets/durable-slots.js +2 -1
- package/dist/facets/manager.d.ts +98 -15
- package/dist/facets/manager.d.ts.map +1 -1
- package/dist/facets/manager.js +443 -116
- 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 +0 -1
- package/dist/git/commands.d.ts.map +1 -1
- package/dist/git/commands.js +0 -3
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +47 -10
- package/dist/router/public-directory.d.ts +5 -1
- package/dist/router/public-directory.d.ts.map +1 -1
- package/dist/router/public-directory.js +9 -2
- package/dist/router/remote-api.d.ts +18 -0
- package/dist/router/remote-api.d.ts.map +1 -1
- package/dist/router/remote-api.js +33 -4
- package/dist/runtime/cpython-resident.d.ts.map +1 -1
- package/dist/runtime/cpython-resident.js +3 -1
- package/dist/runtime/package-manager.d.ts +44 -1
- package/dist/runtime/package-manager.d.ts.map +1 -1
- package/dist/runtime/package-manager.js +168 -3
- package/dist/runtime/ruby-resident.d.ts.map +1 -1
- package/dist/runtime/ruby-resident.js +2 -0
- package/dist/session/agent.d.ts.map +1 -1
- package/dist/session/agent.js +28 -1
- package/dist/session/init.d.ts.map +1 -1
- package/dist/session/init.js +9 -0
- package/dist/session/keys.d.ts +4 -0
- package/dist/session/keys.d.ts.map +1 -1
- package/dist/session/keys.js +4 -0
- package/dist/session/nimbus-session.d.ts +34 -1
- package/dist/session/nimbus-session.d.ts.map +1 -1
- package/dist/session/nimbus-session.js +71 -10
- package/dist/session/port-capability.d.ts +46 -0
- package/dist/session/port-capability.d.ts.map +1 -1
- package/dist/session/port-capability.js +109 -9
- package/dist/session/programmatic.d.ts +95 -1
- package/dist/session/programmatic.d.ts.map +1 -1
- package/dist/session/programmatic.js +350 -26
- package/dist/session/routes.d.ts +9 -0
- package/dist/session/routes.d.ts.map +1 -1
- package/dist/session/routes.js +27 -2
- package/dist/session/rpc.d.ts +24 -1
- package/dist/session/rpc.d.ts.map +1 -1
- package/dist/session/rpc.js +51 -94
- package/dist/session/supervisor-op.d.ts +41 -4
- package/dist/session/supervisor-op.d.ts.map +1 -1
- package/dist/session/supervisor-op.js +95 -73
- package/dist/session/supervisor-rpc.d.ts.map +1 -1
- package/dist/session/supervisor-rpc.js +2 -3
- package/package.json +4 -4
package/dist/facets/manager.js
CHANGED
|
@@ -22,7 +22,10 @@ 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, readPortReservation, releasePortReservation, restoreReservedPortCapability } 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';
|
|
26
29
|
import { PORT_CAPABILITY_KEY_PREFIX } from '../session/keys.js';
|
|
27
30
|
import { unbindPublicPortCapability } from '../router/public-directory.js';
|
|
28
31
|
import { prefetchForRequire } from '@nimbus-sh/core/runtime/require-resolver.js';
|
|
@@ -41,7 +44,7 @@ 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';
|
|
@@ -3251,6 +3254,13 @@ async function _buildPrefetchBundle(vfs, scriptPath, cwd, entryCode, esbuild, bu
|
|
|
3251
3254
|
const ROUTEABLE_PORT_ATTACH_TIMEOUT_MS = 1_000;
|
|
3252
3255
|
/** A port request may wait this long for a durable app's re-drive to boot. */
|
|
3253
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
|
+
}
|
|
3254
3264
|
/**
|
|
3255
3265
|
* The durable owner a journal row serves: the row's stamped field for
|
|
3256
3266
|
* reservation-claimed residents, falling back to the worker recipe's own
|
|
@@ -3351,6 +3361,15 @@ export class FacetManager {
|
|
|
3351
3361
|
residencyProfiles = new Map();
|
|
3352
3362
|
/** In-flight request-driven durable-app ensures, single-flight per port. */
|
|
3353
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();
|
|
3354
3373
|
static RESIDENCY_PROFILE_MAX_ENTRIES = 16;
|
|
3355
3374
|
/**
|
|
3356
3375
|
* A program that reads a directory of data files misses once per file, so
|
|
@@ -3401,9 +3420,100 @@ export class FacetManager {
|
|
|
3401
3420
|
// Rooted on waitUntil: the hook fires synchronously inside whatever turn
|
|
3402
3421
|
// ended the process, and the delete must not be a floating promise there.
|
|
3403
3422
|
this.processes.setOnTerminal((pid) => {
|
|
3404
|
-
this.ctx.waitUntil(this.
|
|
3423
|
+
this.ctx.waitUntil(this._onResidentTerminal(pid));
|
|
3405
3424
|
});
|
|
3406
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
|
+
}
|
|
3407
3517
|
setVfs(vfs) { this.vfs = vfs; }
|
|
3408
3518
|
/**
|
|
3409
3519
|
* The env/ctx pair every loader-backed runtime builds its facet pools
|
|
@@ -4486,6 +4596,21 @@ export class FacetManager {
|
|
|
4486
4596
|
return '(no output captured)';
|
|
4487
4597
|
}
|
|
4488
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
|
+
}
|
|
4489
4614
|
/** Flush files written by the script back to the supervisor's VFS. */
|
|
4490
4615
|
_flushVfsWrites(result, pid) {
|
|
4491
4616
|
if (!this.vfs || !result.vfsWrites)
|
|
@@ -4547,7 +4672,9 @@ export class FacetManager {
|
|
|
4547
4672
|
// The embedder's resolver decides everything; only a self-owned
|
|
4548
4673
|
// launch — nobody composed an embedder hook — falls through to the
|
|
4549
4674
|
// session's image-store default.
|
|
4550
|
-
const resolve =
|
|
4675
|
+
const resolve = recipe.resident
|
|
4676
|
+
? this.hooks.resolveWorkerLaunchFallback
|
|
4677
|
+
: this.hooks.resolveWorkerLaunch ?? this.hooks.resolveWorkerLaunchFallback;
|
|
4551
4678
|
if (!resolve)
|
|
4552
4679
|
throw new Error('resident launch journal holds a worker launch but no resolveWorkerLaunch hook is composed');
|
|
4553
4680
|
const resolved = await resolve(recipe);
|
|
@@ -4557,11 +4684,12 @@ export class FacetManager {
|
|
|
4557
4684
|
if (workerCode === undefined)
|
|
4558
4685
|
throw new Error('resolved worker launch carries no worker.js module');
|
|
4559
4686
|
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,
|
|
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,
|
|
4562
4690
|
env: resolved.env ?? undefined, globalOutbound: resolved.globalOutbound,
|
|
4563
4691
|
vfsWasmModules: resolved.vfsWasmModules,
|
|
4564
|
-
durable: { owner: recipe.owner, image: recipe.image },
|
|
4692
|
+
durable: { owner: residentOwner(record) ?? recipe.owner, image: recipe.image },
|
|
4565
4693
|
}, attempt);
|
|
4566
4694
|
}
|
|
4567
4695
|
}
|
|
@@ -4652,50 +4780,88 @@ export class FacetManager {
|
|
|
4652
4780
|
// terminal hook when the process ends. A re-drive owns its own process,
|
|
4653
4781
|
// so the caller's pid goes with the instance that had it.
|
|
4654
4782
|
//
|
|
4655
|
-
//
|
|
4656
|
-
//
|
|
4657
|
-
//
|
|
4658
|
-
//
|
|
4659
|
-
//
|
|
4660
|
-
//
|
|
4661
|
-
// the
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
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;
|
|
4665
4799
|
if (opts.port !== undefined && opts.port > 0 && opts.port < 65536) {
|
|
4666
|
-
const
|
|
4667
|
-
if (
|
|
4668
|
-
|
|
4669
|
-
durablePort = opts.port;
|
|
4670
|
-
durableFacetName = await acquireDurableFacetSlot(this.ctx, durableOwner);
|
|
4800
|
+
const declared = await readPortReservation(this.ctx, opts.port);
|
|
4801
|
+
if (declared !== null && declared.owner !== null && declared.kind === 'explicit') {
|
|
4802
|
+
owner = declared.owner;
|
|
4671
4803
|
}
|
|
4672
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
|
+
}
|
|
4673
4842
|
const record = {
|
|
4674
4843
|
pid: entry.pid,
|
|
4675
4844
|
command,
|
|
4676
4845
|
attempt,
|
|
4677
4846
|
phase: 'starting',
|
|
4678
4847
|
recipe: { kind: 'node', code, opts: { ...opts, skipSpawn: undefined, callerPid: undefined } },
|
|
4679
|
-
|
|
4848
|
+
owner,
|
|
4849
|
+
...(held !== null ? { port: held.port, injectedPort: held.port } : {}),
|
|
4850
|
+
restart: residentRestartPolicy(opts.env),
|
|
4680
4851
|
};
|
|
4681
4852
|
try {
|
|
4682
|
-
|
|
4853
|
+
if (!ephemeral)
|
|
4854
|
+
await this.launchJournal.journal(record);
|
|
4683
4855
|
}
|
|
4684
4856
|
catch (e) {
|
|
4685
4857
|
// A resident that cannot be journalled does not start; the failure is
|
|
4686
4858
|
// reported the way a boot failure is.
|
|
4687
4859
|
pacer.settle();
|
|
4688
|
-
this.
|
|
4689
|
-
const reason = 'long-running node launch failed: ' + errorMessage(e);
|
|
4690
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
4691
|
-
try {
|
|
4692
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
4693
|
-
}
|
|
4694
|
-
catch { }
|
|
4860
|
+
this._failLaunch(entry.pid, 'long-running node launch failed: ' + errorMessage(e));
|
|
4695
4861
|
throw e;
|
|
4696
4862
|
}
|
|
4697
4863
|
try {
|
|
4698
|
-
await this._residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName);
|
|
4864
|
+
await this._residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv);
|
|
4699
4865
|
}
|
|
4700
4866
|
catch (e) {
|
|
4701
4867
|
await this.launchJournal.release(entry.pid);
|
|
@@ -4707,12 +4873,14 @@ export class FacetManager {
|
|
|
4707
4873
|
// Booted and running: the launch proved itself, so the resident starts
|
|
4708
4874
|
// its running life with a fresh re-drive budget. If the process already
|
|
4709
4875
|
// ended inside the launch body's own settlement, the terminal hook has
|
|
4710
|
-
// 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.
|
|
4711
4879
|
if (this.launchJournal.has(entry.pid)) {
|
|
4712
|
-
await this.
|
|
4880
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, attempt: 0, phase: 'running' }));
|
|
4713
4881
|
}
|
|
4714
4882
|
}
|
|
4715
|
-
async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName) {
|
|
4883
|
+
async _residentLaunchBody(entry, code, command, cwd, opts, pacer, durableFacetName, launchEnv) {
|
|
4716
4884
|
if (this.vfs && !this.esbuild) {
|
|
4717
4885
|
this.esbuild = new EsbuildService(this.vfs.as(CRED_KERNEL));
|
|
4718
4886
|
}
|
|
@@ -4725,9 +4893,14 @@ export class FacetManager {
|
|
|
4725
4893
|
? await buildPrefetchBundle(processVfs, opts.filename, cwd, code, this.esbuild || undefined, opts.bundleProfile, undefined, pacer, this.hooks.transformLargeEsm)
|
|
4726
4894
|
: { bundle: {}, manifest: {}, metadata: {}, reachableCount: 0, truncated: false };
|
|
4727
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 };
|
|
4728
4901
|
const processEnv = opts.attachedTty
|
|
4729
4902
|
? {
|
|
4730
|
-
...(
|
|
4903
|
+
...(spawnEnv || {}),
|
|
4731
4904
|
NIMBUS_ATTACHED_TTY: '1',
|
|
4732
4905
|
NIMBUS_CP_CHILD_PID: String(entry.pid),
|
|
4733
4906
|
TERM: opts.env?.TERM || 'xterm-256color',
|
|
@@ -4736,7 +4909,7 @@ export class FacetManager {
|
|
|
4736
4909
|
LINES: opts.env?.LINES || '24',
|
|
4737
4910
|
FORCE_COLOR: opts.env?.FORCE_COLOR || '1',
|
|
4738
4911
|
}
|
|
4739
|
-
:
|
|
4912
|
+
: spawnEnv;
|
|
4740
4913
|
const usesSqlite = bundleUsesNodeSqlite(code, vfsState.bundle);
|
|
4741
4914
|
const [sqliteModules, shims] = await Promise.all([
|
|
4742
4915
|
this.sqliteModuleEntry(usesSqlite),
|
|
@@ -4867,13 +5040,7 @@ export class FacetManager {
|
|
|
4867
5040
|
this.releaseProcessRpcResources(entry.pid);
|
|
4868
5041
|
else
|
|
4869
5042
|
handle?.kill();
|
|
4870
|
-
this.
|
|
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 { }
|
|
5043
|
+
this._failLaunch(entry.pid, 'long-running node boot failed: ' + errorMessage(e));
|
|
4877
5044
|
throw e;
|
|
4878
5045
|
}
|
|
4879
5046
|
}
|
|
@@ -4895,6 +5062,9 @@ export class FacetManager {
|
|
|
4895
5062
|
}
|
|
4896
5063
|
/** `attempt` is the journal's re-drive budget, as `_spawnResident` carries it. */
|
|
4897
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
|
+
}
|
|
4898
5068
|
this.processes.reap();
|
|
4899
5069
|
const entry = this.processes.spawn(command, [], cwd);
|
|
4900
5070
|
// Stamp the process-table entry so /api/processes exposes this as a
|
|
@@ -4912,13 +5082,14 @@ export class FacetManager {
|
|
|
4912
5082
|
let resourcesTracked = false;
|
|
4913
5083
|
let record;
|
|
4914
5084
|
let durableFacetName;
|
|
5085
|
+
let launchEnv;
|
|
4915
5086
|
try {
|
|
4916
5087
|
if (opts.durable) {
|
|
4917
5088
|
// A durable spawn on a declared port starts only when a reservation the
|
|
4918
5089
|
// owner already holds is persisted. Validated before the journal row is
|
|
4919
5090
|
// written and before the process boots, so a foreign or absent
|
|
4920
5091
|
// reservation refuses the launch rather than stealing the exposure.
|
|
4921
|
-
if (opts.port && opts.port > 0 && opts.port < 65536) {
|
|
5092
|
+
if (!opts.resident && opts.port && opts.port > 0 && opts.port < 65536) {
|
|
4922
5093
|
const preFlight = await readPortReservation(this.ctx, opts.port);
|
|
4923
5094
|
if (preFlight === null || preFlight.owner !== opts.durable.owner) {
|
|
4924
5095
|
throw new Error('port reservation conflict: durable worker does not own port ' + opts.port);
|
|
@@ -4944,7 +5115,15 @@ export class FacetManager {
|
|
|
4944
5115
|
modules: opts.modules ?? {},
|
|
4945
5116
|
...(opts.env !== undefined ? { env: opts.env } : {}),
|
|
4946
5117
|
vfsWasmModules: opts.vfsWasmModules,
|
|
5118
|
+
startArgs: opts.startArgs,
|
|
4947
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
|
+
});
|
|
4948
5127
|
// Journalled before the launch's first byte of work, so a row a
|
|
4949
5128
|
// later instance reads proves this process never ended.
|
|
4950
5129
|
record = {
|
|
@@ -4960,27 +5139,44 @@ export class FacetManager {
|
|
|
4960
5139
|
cwd,
|
|
4961
5140
|
compatibilityDate,
|
|
4962
5141
|
compatibilityFlags,
|
|
4963
|
-
startArgs: opts.startArgs,
|
|
5142
|
+
...(opts.durable.image && !opts.resident ? { startArgs: opts.startArgs } : {}),
|
|
5143
|
+
...(opts.resident ? { resident: opts.resident } : {}),
|
|
4964
5144
|
},
|
|
4965
5145
|
// The row-level fields `ensureDurableAppOnPort` reads — same values
|
|
4966
5146
|
// the recipe carries, stamped so port/owner lookup never depends on
|
|
4967
5147
|
// the recipe's kind.
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
5148
|
+
owner: opts.durable.owner,
|
|
5149
|
+
restart: opts.restart ?? 'never',
|
|
5150
|
+
...(opts.port !== undefined && opts.port > 0 ? { port: opts.port } : {}),
|
|
4971
5151
|
};
|
|
4972
5152
|
// The durable facet name is claimed once, ever, from DO storage — a
|
|
4973
5153
|
// re-drive after a reset, an eviction's re-attach and a relaunch all
|
|
4974
5154
|
// land on the same `app-slot-<n>`, which is the only thing that keeps
|
|
4975
5155
|
// the retained SQLite bound to this application.
|
|
4976
|
-
|
|
4977
|
-
|
|
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
|
+
}
|
|
4978
5171
|
}
|
|
4979
5172
|
handle = await this._startResidentProcess(entry.pid, {
|
|
4980
5173
|
// These runners answer startProcess with a boot payload (listening
|
|
4981
5174
|
// port, or a completed non-server run) and stay resident after it.
|
|
4982
5175
|
startContract: 'boot',
|
|
4983
|
-
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,
|
|
4984
5180
|
...(durableFacetName !== undefined
|
|
4985
5181
|
? { facet: { name: durableFacetName, durable: true } }
|
|
4986
5182
|
: {}),
|
|
@@ -4992,7 +5188,7 @@ export class FacetManager {
|
|
|
4992
5188
|
mainModule: 'worker.js',
|
|
4993
5189
|
modules: { 'worker.js': workerCode, ...(opts.modules || {}) },
|
|
4994
5190
|
vfsWasmModules: opts.vfsWasmModules,
|
|
4995
|
-
...(opts.env !== undefined ? { env: opts.env } : {}),
|
|
5191
|
+
...(opts.env !== undefined || launchEnv !== undefined ? { env: { ...opts.env, ...launchEnv } } : {}),
|
|
4996
5192
|
...(opts.globalOutbound !== undefined ? { globalOutbound: opts.globalOutbound } : {}),
|
|
4997
5193
|
},
|
|
4998
5194
|
},
|
|
@@ -5001,13 +5197,13 @@ export class FacetManager {
|
|
|
5001
5197
|
resourcesTracked = true;
|
|
5002
5198
|
this.portRegistry.bindFacetStub(entry.pid, handle.routeTarget);
|
|
5003
5199
|
const boot = await handle.booted();
|
|
5004
|
-
if (
|
|
5200
|
+
if (record && this.launchJournal.has(entry.pid)) {
|
|
5005
5201
|
// Booted and running: the launch proved itself, so the resident
|
|
5006
5202
|
// starts its running life with a fresh re-drive budget.
|
|
5007
|
-
await this.
|
|
5203
|
+
await this._amendRow(entry.pid, (row) => ({ ...row, attempt: 0, phase: 'running' }));
|
|
5008
5204
|
}
|
|
5009
5205
|
if (opts.port && opts.port > 0 && opts.port < 65536) {
|
|
5010
|
-
if (opts.durable) {
|
|
5206
|
+
if (opts.durable && !opts.resident) {
|
|
5011
5207
|
// Re-read after the boot: a release or reassignment during it wins.
|
|
5012
5208
|
// The durable launch registers only under a reservation it still
|
|
5013
5209
|
// owns; it never clears or takes a foreign exposure.
|
|
@@ -5019,15 +5215,8 @@ export class FacetManager {
|
|
|
5019
5215
|
// re-drove this launch, and preview URLs minted against it stay
|
|
5020
5216
|
// valid: the durable capability is re-adopted through the
|
|
5021
5217
|
// 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
5218
|
}
|
|
5219
|
+
await this._registerResidentPort(entry.pid, opts.port);
|
|
5031
5220
|
}
|
|
5032
5221
|
return { pid: entry.pid, boot };
|
|
5033
5222
|
}
|
|
@@ -5037,51 +5226,180 @@ export class FacetManager {
|
|
|
5037
5226
|
this.releaseProcessRpcResources(entry.pid);
|
|
5038
5227
|
else
|
|
5039
5228
|
handle?.kill();
|
|
5040
|
-
this.
|
|
5041
|
-
const reason = 'long-running worker boot failed: ' + errorMessage(e);
|
|
5042
|
-
this._w5RecordTermination(entry.pid, 1, 'facet', reason);
|
|
5043
|
-
try {
|
|
5044
|
-
this.hooks.onExternalExit?.(entry.pid, 1, reason);
|
|
5045
|
-
}
|
|
5046
|
-
catch { }
|
|
5229
|
+
this._failLaunch(entry.pid, 'long-running worker boot failed: ' + errorMessage(e));
|
|
5047
5230
|
throw e;
|
|
5048
5231
|
}
|
|
5049
5232
|
}
|
|
5050
5233
|
/**
|
|
5051
|
-
* A resident process announcing it bound `port`.
|
|
5052
|
-
*
|
|
5053
|
-
*
|
|
5054
|
-
*
|
|
5055
|
-
*
|
|
5056
|
-
*
|
|
5057
|
-
*
|
|
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.
|
|
5058
5251
|
*
|
|
5059
|
-
*
|
|
5060
|
-
*
|
|
5061
|
-
*
|
|
5062
|
-
* owner. An accidental port reuse inside one session therefore cannot
|
|
5063
|
-
* inherit the public capability; same-session processes are one trust
|
|
5064
|
-
* domain, so this is hygiene, not a security boundary.
|
|
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.
|
|
5065
5255
|
*/
|
|
5066
5256
|
async _registerResidentPort(pid, port) {
|
|
5067
5257
|
const reservation = await readPortReservation(this.ctx, port);
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
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);
|
|
5074
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)) {
|
|
5075
5325
|
this.portRegistry.register(port, pid);
|
|
5076
5326
|
await restoreReservedPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port, reservation.owner);
|
|
5077
5327
|
return;
|
|
5078
5328
|
}
|
|
5079
|
-
//
|
|
5080
|
-
//
|
|
5081
|
-
//
|
|
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
|
+
}
|
|
5082
5338
|
await clearPortCapability({ ctx: this.ctx, portRegistry: this.portRegistry }, port);
|
|
5083
5339
|
this.portRegistry.register(port, pid);
|
|
5084
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
|
+
}
|
|
5085
5403
|
async registerPort(pid, port) {
|
|
5086
5404
|
if (port > 0 && port < 65536) {
|
|
5087
5405
|
await this._registerResidentPort(pid, port);
|
|
@@ -5149,9 +5467,6 @@ export class FacetManager {
|
|
|
5149
5467
|
if (residentOwner(record) !== owner)
|
|
5150
5468
|
continue;
|
|
5151
5469
|
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
5470
|
}
|
|
5156
5471
|
for (const pid of ownedPids)
|
|
5157
5472
|
this.kill(pid);
|
|
@@ -5169,18 +5484,29 @@ export class FacetManager {
|
|
|
5169
5484
|
}
|
|
5170
5485
|
}
|
|
5171
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;
|
|
5172
5491
|
await releasePortReservation(this.ctx, { owner, port });
|
|
5173
|
-
this.portRegistry.
|
|
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);
|
|
5174
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}`);
|
|
5175
5507
|
const purged = await this.launchJournal.purgeWhere((record) => residentOwner(record) === owner);
|
|
5176
|
-
const name = await freeDurableFacetSlot(this.ctx, owner);
|
|
5177
|
-
|
|
5178
|
-
try {
|
|
5179
|
-
deleteFacetStorage(this.ctx, name);
|
|
5180
|
-
}
|
|
5181
|
-
catch { /* already gone */ }
|
|
5182
|
-
}
|
|
5183
|
-
return name !== null || purged > 0 || ownedPorts.size > 0;
|
|
5508
|
+
const name = await freeDurableFacetSlot(this.ctx, owner, (slot) => deleteFacetStorage(this.ctx, slot));
|
|
5509
|
+
return name !== null || purged > 0 || ownedPorts.size > 0 || imagesPurged > 0;
|
|
5184
5510
|
}
|
|
5185
5511
|
/** The session-shaped view the public-directory helpers read env from. */
|
|
5186
5512
|
_publicDirectoryHost() {
|
|
@@ -5219,19 +5545,20 @@ export class FacetManager {
|
|
|
5219
5545
|
async _ensureDurableAppOnPort(port) {
|
|
5220
5546
|
if (this.portRegistry.has(port))
|
|
5221
5547
|
return 'started';
|
|
5222
|
-
// The
|
|
5223
|
-
//
|
|
5224
|
-
//
|
|
5225
|
-
//
|
|
5226
|
-
//
|
|
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.
|
|
5227
5555
|
const reservation = await readPortReservation(this.ctx, port);
|
|
5228
|
-
if (reservation === null || reservation.owner === null)
|
|
5229
|
-
return 'absent';
|
|
5230
5556
|
const rows = await this.launchJournal.rows();
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
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];
|
|
5235
5562
|
if (entry === undefined)
|
|
5236
5563
|
return 'absent';
|
|
5237
5564
|
const [rowKey, record] = entry;
|