@volter/world-runtime 2.0.0 → 2.0.1

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.
@@ -0,0 +1,107 @@
1
+ // The containerless backing for a World's managed REDIS: the redis twin (@volter/twin-redis) serving Redis's
2
+ // own protocol on the loopback port the definition declares, its keys held in the World's tree under
3
+ // VOLTER_WORLD_DATA. Chosen by `volter-world-infra` for every redis service unless the operator forces the
4
+ // container backing (VOLTER_WORLD_INFRA_BACKING=docker); the definition, the injected REDIS_URL and the declared
5
+ // service contract are the same either way, as the boundary comment in infra-cli.ts promises.
6
+ //
7
+ // Why the twin and not a container: a World's state branches, checkpoints and rolls back as one tree, and a Redis
8
+ // in a container is state outside it (a branch of the World would share the container's queue and cache). The
9
+ // twin keeps each key a subject of the World (packages/twin/redis/src/redis-storage.ts). It needs nothing
10
+ // installed: no container runtime, no redis-server binary.
11
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { connect } from 'node:net';
13
+ import { dirname, join } from 'node:path';
14
+ import { spawn } from 'node:child_process';
15
+ import { findInstalledPackage, packCli } from './catalog.ts';
16
+ import type { InfraService } from './pglite-backing.ts';
17
+
18
+ const PACKAGE = '@volter/twin-redis';
19
+ const READY_TIMEOUT_MS = 60_000;
20
+
21
+ /** The redis twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
22
+ export function redisTwinCli(from: string[]): string | undefined {
23
+ for (const dir of from) {
24
+ const found = findInstalledPackage(dir, PACKAGE);
25
+ const cli = found === undefined ? undefined : packCli(found);
26
+ if (cli !== undefined && existsSync(cli)) return cli;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ const pidPath = (dataDir: string, service: InfraService): string => join(dataDir, `redis-twin-${service.hostPort}.pid`);
32
+ /** Where the service's keys live: the World's tree for the `redis` service, under the World's data. */
33
+ export const redisTwinRoot = (dataDir: string): string => join(dataDir, 'redis-twin');
34
+
35
+ function alive(pid: number): boolean {
36
+ try { process.kill(pid, 0); return true; } catch { return false; }
37
+ }
38
+
39
+ function listening(port: number): Promise<boolean> {
40
+ return new Promise((resolve) => {
41
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
42
+ // a port that answers PING with PONG is this service; anything else is not ready (or not ours)
43
+ socket.once('connect', () => socket.write('PING\r\n'));
44
+ socket.once('data', (chunk) => { socket.destroy(); resolve(chunk.toString('latin1').startsWith('+PONG')); });
45
+ socket.on('error', () => resolve(false));
46
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
47
+ });
48
+ }
49
+
50
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
51
+
52
+ export async function redisTwinUp(services: InfraService[], dataDir: string, cli: string): Promise<void> {
53
+ for (const service of services) {
54
+ const pidFile = pidPath(dataDir, service);
55
+ if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort)) continue;
56
+ // a port that already answers is another process's (another World's twin, a redis-server): serving there would
57
+ // hand this World's app someone else's keys, so it is refused, never taken as ready
58
+ if (await listening(service.hostPort)) throw new Error(`port ${service.hostPort} already serves Redis for another process`);
59
+ const root = redisTwinRoot(dataDir);
60
+ mkdirSync(root, { recursive: true });
61
+ const log = join(dataDir, `redis-twin-${service.hostPort}.log`);
62
+ mkdirSync(dirname(log), { recursive: true });
63
+ // a real fd, not a pipe: the server outlives this process untethered
64
+ const logFd = openSync(log, 'a');
65
+ // the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
66
+ // type transformation, not only stripping (the kernel declares constructor parameter properties)
67
+ const runtime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
68
+ const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
69
+ detached: true,
70
+ stdio: ['ignore', logFd, logFd],
71
+ });
72
+ child.unref();
73
+ closeSync(logFd);
74
+ writeFileSync(pidFile, `${child.pid}\n`);
75
+ const deadline = Date.now() + READY_TIMEOUT_MS;
76
+ while (!(await listening(service.hostPort)) || !alive(child.pid!)) {
77
+ if (!alive(child.pid!)) throw new Error(`redis exited before serving — see ${log}`);
78
+ if (Date.now() > deadline) throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
79
+ await sleep(200);
80
+ }
81
+ }
82
+ }
83
+
84
+ export async function redisTwinStatus(services: InfraService[], dataDir: string): Promise<number> {
85
+ let ready = 0;
86
+ for (const service of services) {
87
+ const pidFile = pidPath(dataDir, service);
88
+ if (!existsSync(pidFile)) continue;
89
+ if (alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort)) ready += 1;
90
+ }
91
+ return ready;
92
+ }
93
+
94
+ export async function redisTwinDown(services: InfraService[], dataDir: string): Promise<void> {
95
+ for (const service of services) {
96
+ const pidFile = pidPath(dataDir, service);
97
+ if (!existsSync(pidFile)) continue;
98
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
99
+ if (alive(pid)) {
100
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
101
+ const deadline = Date.now() + 5000;
102
+ while (alive(pid) && Date.now() < deadline) await sleep(100);
103
+ if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* raced */ } }
104
+ }
105
+ rmSync(pidFile, { force: true });
106
+ }
107
+ }
package/src/runtime.ts CHANGED
@@ -17,7 +17,7 @@ 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';
19
19
  import { Socket } from 'node:net';
20
- import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
20
+ import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
21
21
  import type { PlanReviewRecord } from '@volter/world-core';
22
22
  import { loadWorldConfig } from './configs.ts';
23
23
  import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
@@ -798,7 +798,7 @@ async function runExternalCommand(
798
798
  command: string[], cwd: string, serviceId: string, phase: string,
799
799
  env: NodeJS.ProcessEnv = process.env, abort?: AbortSignal, timeoutMs = 60_000,
800
800
  ): Promise<{ stdout: string; stderr: string }> {
801
- const [bin, ...args] = command;
801
+ const [bin, ...args] = ownCommand(command);
802
802
  if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
803
803
  if (!commandExists(bin)) throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
804
804
  abort?.throwIfAborted();
@@ -832,7 +832,7 @@ async function runExternalUp(
832
832
  logPath: string,
833
833
  abort?: AbortSignal,
834
834
  ): Promise<string> {
835
- const [bin, ...args] = command;
835
+ const [bin, ...args] = ownCommand(command);
836
836
  if (!bin) throw new Error(`External service "${serviceId}": up command is empty`);
837
837
  if (!commandExists(bin)) {
838
838
  throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the up command's tool is not installed)`);
@@ -1456,7 +1456,8 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1456
1456
  VOLTER_WORLD_MODE: mode,
1457
1457
  VOLTER_WORLD_CONFIG: loaded.path,
1458
1458
  VOLTER_WORLD_INSTANCE: instanceFile,
1459
- VOLTER_WORLD_DATA: data,
1459
+ // every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
1460
+ [WORLD_DATA_ENV]: data,
1460
1461
  // the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
1461
1462
  VOLTER_WORLD_ENV_FILE: envFile,
1462
1463
  // THE WORLD CLOCK: every service stamps from this file via the kernel's worldNow();
@@ -2190,8 +2191,24 @@ function sleep(ms: number): Promise<void> {
2190
2191
 
2191
2192
  function commandExists(command: string): boolean {
2192
2193
  // Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
2193
- const result = spawnSync('which', [command], { stdio: 'ignore', env: process.env });
2194
- return result.status === 0;
2194
+ return OWN_COMMANDS[command] !== undefined || onPath(command);
2195
+ }
2196
+
2197
+ /** A lifecycle command naming this runtime's own bin (`volter-world-infra`, which `init` names in every managed
2198
+ * infrastructure's lifecycle) runs this runtime's own script with the executable running it, when nothing on PATH
2199
+ * answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
2200
+ * PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
2201
+ * command on PATH still wins, as before. */
2202
+ const OWN_COMMANDS: Record<string, string> = { 'volter-world-infra': 'infra-cli' };
2203
+ export function ownCommand(command: string[]): string[] {
2204
+ const [bin, ...args] = command;
2205
+ const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
2206
+ if (stem === undefined || onPath(bin!)) return command;
2207
+ return [process.execPath, siblingScript(import.meta.url, stem), ...args];
2208
+ }
2209
+
2210
+ function onPath(command: string): boolean {
2211
+ return spawnSync('which', [command], { stdio: 'ignore', env: process.env }).status === 0;
2195
2212
  }
2196
2213
 
2197
2214
  function resolveWithPublicDns(hostname: string): string | undefined {
@@ -2505,7 +2522,8 @@ async function probeExternalOnce(
2505
2522
  if (!commandExists(ready.command)) {
2506
2523
  return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
2507
2524
  }
2508
- const result = spawnSync(ready.command, ready.args ?? [], {
2525
+ const [probeBin, ...probeArgs] = ownCommand([ready.command, ...(ready.args ?? [])]);
2526
+ const result = spawnSync(probeBin!, probeArgs, {
2509
2527
  cwd, encoding: 'utf8', env, timeout: DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
2510
2528
  });
2511
2529
  if (result.status === 0) return { ok: true, message: 'probe ok (command)' };