@volter/world-runtime 2.0.15 → 2.0.17

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/src/runtime.ts CHANGED
@@ -12,7 +12,7 @@ import { createRequire } from 'node:module';
12
12
  import { isIP } from 'node:net';
13
13
  import { allocatePort } from './tcp-port.ts';
14
14
  import { constants as osConstants, hostname as osHostname } from 'node:os';
15
- import { appendFileSync, chmodSync, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, existsSync } from 'node:fs';
15
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync, existsSync } from 'node:fs';
16
16
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
17
17
  import { packFacts } from './pack-facts.ts';
18
18
  import { describeServiceEnd, readServiceEnd, serviceOutput } from './service-exit.ts';
@@ -832,6 +832,7 @@ async function runExternalUp(
832
832
  logPath: string,
833
833
  abort?: AbortSignal,
834
834
  ): Promise<string> {
835
+ if (ownInfrastructureUp(command)) return runOwnInfrastructureUp(command, serviceId, env, logPath, abort);
835
836
  const [bin, ...args] = ownCommand(command);
836
837
  if (!bin) throw new Error(`External service "${serviceId}": up command is empty`);
837
838
  if (!commandExists(bin)) {
@@ -867,6 +868,41 @@ async function runExternalUp(
867
868
  return chunks.join('');
868
869
  }
869
870
 
871
+ /** Whether an external `up` is this runtime's own managed-infrastructure tool (`volter-world-infra up`, which `init`
872
+ * names), with nothing on PATH answering the name: the phase then runs in this process (infra.ts) instead of in a
873
+ * child. The services it starts (the PGlite host, the redis and MongoDB twins) are then children of the process that
874
+ * owns the World's lifetime, as every service recorder is, not of a tool that exits once they serve. Where a child
875
+ * does not outlive its parent (a browser tab's process layer, measured 2026-09-28: a detached, unref'd listener
876
+ * ended with the process that spawned it), the PGlite host ended the moment `volter-world-infra up` returned and
877
+ * the readiness probe timed out at "0/1 declared services are ready". status and down still run as the tool.
878
+ * The phase gets what the tool's process got: the infrastructure service's environment (`env`, which the hosts it
879
+ * starts are spawned with, never this process's own) and the boot's abort; its commands (docker info, compose)
880
+ * run without blocking this process's event loop. */
881
+ function ownInfrastructureUp(command: string[]): boolean {
882
+ return command[0] === 'volter-world-infra' && command[1] === 'up' && ownCommand(command)[0] === process.execPath;
883
+ }
884
+
885
+ async function runOwnInfrastructureUp(command: string[], serviceId: string, env: NodeJS.ProcessEnv, logPath: string, abort?: AbortSignal): Promise<string> {
886
+ writeFileSync(logPath, `$ ${command.join(' ')}\n`, { mode: 0o600 });
887
+ chmodSync(logPath, 0o600);
888
+ const out = openPrivateLog(logPath, 'a');
889
+ const chunks: string[] = [];
890
+ try {
891
+ abort?.throwIfAborted();
892
+ const { runInfrastructure } = await import('./infra.ts');
893
+ const write = (text: string) => { chunks.push(text); writeSync(out, text); };
894
+ const code = await runInfrastructure(command[1]!, env, { out: write, err: write }, abort);
895
+ abort?.throwIfAborted();
896
+ if (code !== 0) {
897
+ const detail = chunks.join('').trim().split('\n').slice(-40).join('\n');
898
+ throw new Error(`External service "${serviceId}": up command exited ${code}\n${detail}`);
899
+ }
900
+ } finally {
901
+ closeSync(out);
902
+ }
903
+ return chunks.join('');
904
+ }
905
+
870
906
  /** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
871
907
  * service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
872
908
  * literal in the config). Other probe kinds (command/stdoutMatch) are returned unchanged. */
@@ -2207,7 +2243,15 @@ export function ownCommand(command: string[]): string[] {
2207
2243
  return [process.execPath, siblingScript(import.meta.url, stem), ...args];
2208
2244
  }
2209
2245
 
2246
+ /** A command answers when it names an executable: a path is checked as a file (what `which` does with a path),
2247
+ * never by spawning `which`. `ownCommand` hands this the running executable's own path (process.execPath), and a
2248
+ * process layer without synchronous spawn (a browser tab's, where spawnSync is ENOSYS) answered "not found on PATH"
2249
+ * for the very node running the World: the managed infrastructure never started there. A bare name is still looked
2250
+ * up on PATH. */
2210
2251
  function onPath(command: string): boolean {
2252
+ if (command.includes(sep) || command.includes('/')) {
2253
+ try { accessSync(command, fsConstants.X_OK); return statSync(command).isFile(); } catch { return false; }
2254
+ }
2211
2255
  return spawnSync('which', [command], { windowsHide: true, stdio: 'ignore', env: process.env }).status === 0;
2212
2256
  }
2213
2257
 
@@ -161,7 +161,8 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
161
161
  const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
162
162
  const keep = (): void => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
163
163
  let timers: ReturnType<typeof setInterval>[] = [];
164
- return {
164
+ let stopping: Promise<void> | null = null;
165
+ const mounted: MountedWorld = {
165
166
  name, served, root: worldRoot,
166
167
  get token() { return world.token; }, get readToken() { return world.readToken; },
167
168
  twins: () => world.twins(),
@@ -188,13 +189,29 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
188
189
  throw error;
189
190
  }
190
191
  },
191
- async stop() {
192
- for (const t of timers) clearInterval(t);
193
- try { rmSync(record, { force: true }); } catch { /* already gone */ }
194
- const result = await downWorld(name, worldRoot);
195
- if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
192
+ stop() {
193
+ // one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
194
+ stopping ??= stopOnce().finally(() => { stopping = null; });
195
+ return stopping;
196
196
  },
197
197
  };
198
+ async function stopOnce(): Promise<void> {
199
+ // refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
200
+ // down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
201
+ // refused before anything stopped, the World is served as it was; failed part way, its record names what remains
202
+ for (const t of timers) clearInterval(t);
203
+ timers = [];
204
+ let result: Awaited<ReturnType<typeof downWorld>>;
205
+ try {
206
+ result = await downWorld(name, worldRoot);
207
+ if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
208
+ } catch (error) {
209
+ if (instance) { try { timers = await scheduleRefreshes(name, worldRoot, instance); } catch { /* the stop's error is the one reported */ } }
210
+ throw error;
211
+ }
212
+ try { rmSync(record, { force: true }); } catch { /* already gone */ }
213
+ }
214
+ return mounted;
198
215
  }
199
216
 
200
217
  /** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
@@ -1328,7 +1345,9 @@ export class WorldDoors {
1328
1345
  for (const vendor of Object.keys(this.up().services)) {
1329
1346
  const state = this.stateOf(vendor); const controlRoot = this.controlRoot(vendor);
1330
1347
  const counts = new Map<string, number>();
1331
- for (const r of readTree(state, controlRoot)) if (r.deleted !== true) counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
1348
+ // what the World holds as the vendor's own resources: the twin's bookkeeping (a `_` type, as a screens sign-in's token)
1349
+ // is never a resource a person reads, as it is in no log or diff
1350
+ for (const r of readTree(state, controlRoot)) if (r.deleted !== true && !r.type.startsWith('_')) counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
1332
1351
  twins.push({
1333
1352
  twin: vendor, position: wholeLog(state, controlRoot).length,
1334
1353
  mirror: Boolean(await this.host.hasMirror?.(vendor)),
package/src/world-view.ts CHANGED
@@ -55,11 +55,8 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
55
55
  switching = true;
56
56
  const from = world.name;
57
57
  try {
58
- // a stop that fails part way brings this branch back as it was before the error is reported
59
- try { await world.stop(); } catch (error) {
60
- try { await world.boot(url); } catch { /* the stop's own error is the one reported */ }
61
- throw error;
62
- }
58
+ // a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
59
+ await world.stop();
63
60
  let next: MountedWorld;
64
61
  try {
65
62
  next = await mountWorld(branch, mountOpts);