@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.
Files changed (61) hide show
  1. package/dist/_shared/preview-host.d.ts +26 -8
  2. package/dist/_shared/preview-host.d.ts.map +1 -1
  3. package/dist/_shared/preview-host.js +51 -11
  4. package/dist/facets/durable-images.d.ts +9 -0
  5. package/dist/facets/durable-images.d.ts.map +1 -1
  6. package/dist/facets/durable-images.js +20 -1
  7. package/dist/facets/durable-slots.d.ts +1 -1
  8. package/dist/facets/durable-slots.d.ts.map +1 -1
  9. package/dist/facets/durable-slots.js +2 -1
  10. package/dist/facets/manager.d.ts +98 -15
  11. package/dist/facets/manager.d.ts.map +1 -1
  12. package/dist/facets/manager.js +443 -116
  13. package/dist/facets/resident-identity.d.ts +23 -0
  14. package/dist/facets/resident-identity.d.ts.map +1 -0
  15. package/dist/facets/resident-identity.js +31 -0
  16. package/dist/git/commands.d.ts +0 -1
  17. package/dist/git/commands.d.ts.map +1 -1
  18. package/dist/git/commands.js +0 -3
  19. package/dist/router/index.d.ts.map +1 -1
  20. package/dist/router/index.js +47 -10
  21. package/dist/router/public-directory.d.ts +5 -1
  22. package/dist/router/public-directory.d.ts.map +1 -1
  23. package/dist/router/public-directory.js +9 -2
  24. package/dist/router/remote-api.d.ts +18 -0
  25. package/dist/router/remote-api.d.ts.map +1 -1
  26. package/dist/router/remote-api.js +33 -4
  27. package/dist/runtime/cpython-resident.d.ts.map +1 -1
  28. package/dist/runtime/cpython-resident.js +3 -1
  29. package/dist/runtime/package-manager.d.ts +44 -1
  30. package/dist/runtime/package-manager.d.ts.map +1 -1
  31. package/dist/runtime/package-manager.js +168 -3
  32. package/dist/runtime/ruby-resident.d.ts.map +1 -1
  33. package/dist/runtime/ruby-resident.js +2 -0
  34. package/dist/session/agent.d.ts.map +1 -1
  35. package/dist/session/agent.js +28 -1
  36. package/dist/session/init.d.ts.map +1 -1
  37. package/dist/session/init.js +9 -0
  38. package/dist/session/keys.d.ts +4 -0
  39. package/dist/session/keys.d.ts.map +1 -1
  40. package/dist/session/keys.js +4 -0
  41. package/dist/session/nimbus-session.d.ts +34 -1
  42. package/dist/session/nimbus-session.d.ts.map +1 -1
  43. package/dist/session/nimbus-session.js +71 -10
  44. package/dist/session/port-capability.d.ts +46 -0
  45. package/dist/session/port-capability.d.ts.map +1 -1
  46. package/dist/session/port-capability.js +109 -9
  47. package/dist/session/programmatic.d.ts +95 -1
  48. package/dist/session/programmatic.d.ts.map +1 -1
  49. package/dist/session/programmatic.js +350 -26
  50. package/dist/session/routes.d.ts +9 -0
  51. package/dist/session/routes.d.ts.map +1 -1
  52. package/dist/session/routes.js +27 -2
  53. package/dist/session/rpc.d.ts +24 -1
  54. package/dist/session/rpc.d.ts.map +1 -1
  55. package/dist/session/rpc.js +51 -94
  56. package/dist/session/supervisor-op.d.ts +41 -4
  57. package/dist/session/supervisor-op.d.ts.map +1 -1
  58. package/dist/session/supervisor-op.js +95 -73
  59. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  60. package/dist/session/supervisor-rpc.js +2 -3
  61. package/package.json +4 -4
@@ -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.launchJournal.release(pid));
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 = this.hooks.resolveWorkerLaunch ?? this.hooks.resolveWorkerLaunchFallback;
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
- // Durability is a property of the RESERVATION, not the recipe: a resident
4656
- // that declares a port at spawn claims the durable contract the port's
4657
- // reservation declares — the owner's durable slot for a store and the
4658
- // row's port/owner for `ensureDurableAppOnPort`. A resident whose port is
4659
- // only learned at listen() gets the same journal+capability durability at
4660
- // registration (see `registerPort`), but keeps its ephemeral facet name —
4661
- // the store cannot be re-rooted under a running facet.
4662
- let durableFacetName;
4663
- let durablePort;
4664
- let durableOwner;
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 reservation = await readPortReservation(this.ctx, opts.port);
4667
- if (reservation !== null && reservation.owner !== null) {
4668
- durableOwner = reservation.owner;
4669
- durablePort = opts.port;
4670
- durableFacetName = await acquireDurableFacetSlot(this.ctx, durableOwner);
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
- ...(durablePort !== undefined ? { port: durablePort, owner: durableOwner } : {}),
4848
+ owner,
4849
+ ...(held !== null ? { port: held.port, injectedPort: held.port } : {}),
4850
+ restart: residentRestartPolicy(opts.env),
4680
4851
  };
4681
4852
  try {
4682
- await this.launchJournal.journal(record);
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.processes.exit(entry.pid, 1);
4689
- const reason = 'long-running node launch failed: ' + errorMessage(e);
4690
- this._w5RecordTermination(entry.pid, 1, 'facet', reason);
4691
- try {
4692
- this.hooks.onExternalExit?.(entry.pid, 1, reason);
4693
- }
4694
- catch { }
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.launchJournal.journal({ ...record, attempt: 0, phase: 'running' });
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
- ...(opts.env || {}),
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
- : opts.env;
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.processes.exit(entry.pid, 1);
4871
- const reason = 'long-running node boot failed: ' + errorMessage(e);
4872
- this._w5RecordTermination(entry.pid, 1, 'facet', reason);
4873
- try {
4874
- this.hooks.onExternalExit?.(entry.pid, 1, reason);
4875
- }
4876
- catch { }
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
- ...(opts.port !== undefined && opts.port > 0
4969
- ? { port: opts.port, owner: opts.durable.owner }
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
- durableFacetName = await acquireDurableFacetSlot(this.ctx, opts.durable.owner);
4977
- await this.launchJournal.journal(record);
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 (opts.durable && this.launchJournal.has(entry.pid) && record) {
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.launchJournal.journal({ ...record, attempt: 0, phase: 'running' });
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.processes.exit(entry.pid, 1);
5041
- const reason = 'long-running worker boot failed: ' + errorMessage(e);
5042
- this._w5RecordTermination(entry.pid, 1, 'facet', reason);
5043
- try {
5044
- this.hooks.onExternalExit?.(entry.pid, 1, reason);
5045
- }
5046
- catch { }
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`. When the port is reserved,
5052
- * the reservation's owner is stamped onto this pid's journal row — the
5053
- * reservation is what declares which application the port serves, and a
5054
- * resident that binds it inherits the whole durable contract: the row names
5055
- * the port `ensureDurableAppOnPort` looks up, the minted capability is
5056
- * re-adopted rather than retired, and `removeDurableApp` can find the launch
5057
- * by owner.
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
- * A pid with no journal row — a process outside the resident lifecycle —
5060
- * registering on a reserved port takes it as today: the stored capability
5061
- * is retired and a fresh one is minted, the reservation stays with the
5062
- * owner. An accidental port reuse inside one session therefore cannot
5063
- * inherit the public capability; same-session processes are one trust
5064
- * domain, so this is hygiene, not a security boundary.
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
- const rowKey = `${FENCED_WORK_KEY_PREFIX}${pid}`;
5069
- const rows = await this.launchJournal.rows();
5070
- const row = rows.get(rowKey);
5071
- if (reservation !== null && reservation.owner !== null && row !== undefined) {
5072
- if (row.port !== port || residentOwner(row) !== reservation.owner) {
5073
- await this.launchJournal.journal({ ...row, port, owner: reservation.owner });
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
- // A new process on an unreserved port — or one the journal does not know —
5080
- // retires the previous occupant's preview capability, so a URL handed out
5081
- // for that one cannot reach this one.
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.unregister(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);
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
- if (name !== null) {
5178
- try {
5179
- deleteFacetStorage(this.ctx, name);
5180
- }
5181
- catch { /* already gone */ }
5182
- }
5183
- return name !== null || purged > 0 || ownedPorts.size > 0;
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 reservation says the port means something to someone; the journal
5223
- // row is what makes it a launch this instance owes. A reservation with
5224
- // no row (the spawn died before journalling, or the app was removed and
5225
- // the release already ran) answers absent — nothing here can bring back
5226
- // an application with no recipe.
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
- // The reservation names the owner; the row's stamped port names which
5232
- // resident serves it — kind-agnostic, so a node resident that bound the
5233
- // port re-drives the same way a durable worker launch does.
5234
- const entry = [...rows].find(([, record]) => record.port === port && residentOwner(record) === reservation.owner);
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;