@volter/world-runtime 3.0.100 → 3.0.102

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.
@@ -57,7 +57,7 @@ function printHelp() {
57
57
  # emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
58
58
  # Deterministic: same repo + same pack catalog => identical bytes.
59
59
  # init DIAGNOSES; it never boots — it prints the exact next commands
60
- volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--json] [--root <repo>]
60
+ volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--app-url <url>] [--json] [--root <repo>]
61
61
  volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
62
62
  volter-world audit <world> [--json] — each mounted twin's grade, as the catalog records it (R17)
63
63
  volter-world down <name> [--grace-ms <ms>] [--purge] [--if-instance <token>] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
@@ -34,7 +34,7 @@ const LEGACY_NESTED = {
34
34
  bare: new Set(['name']), resources: new Set(['memoryMiB', 'writableStorageMiB']), catalog: new Set(['sha', 'protocol']),
35
35
  root: new Set(['url', 'deploy', 'refresh', 'scope']), refresh: new Set(['every', 'webhook']),
36
36
  external: new Set(['up', 'status', 'down', 'discover', 'readyWhen']), discover: new Set(['as', 'source', 'jsonPath', 'pattern']),
37
- ready: new Set(['command', 'args', 'httpUrl', 'stdoutMatch', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
37
+ ready: new Set(['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
38
38
  share: new Set(['provider', 'command', 'args', 'ephemeral', 'services']), shareService: new Set(['id', 'verifyPath']),
39
39
  };
40
40
  function unknownKeys(value, known, allowComments = false) {
@@ -1,7 +1,8 @@
1
1
  import type { HostLaunch, InfraService } from './pglite-backing.js';
2
2
  /** PATH is the binary contract, as for the native PostgreSQL example and LiveKit. No acquisition at boot. */
3
3
  export declare function mongodbArgv(service: InfraService, data: string): string[];
4
- /** MongoDB's structured record comes from this child, not a listener already holding the port. */
5
- export declare function mongodbListening(log: string, port: number): boolean;
4
+ /** Whether the server answering MongoDB's protocol on the port is this child: its own serverStatus names its pid. A
5
+ * listener that is not answering yet, or another process's server on the port, is not. */
6
+ export declare function mongodbServes(service: InfraService, pid: number): Promise<boolean>;
6
7
  /** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
7
8
  export declare function mongodbReady(service: InfraService, launch: HostLaunch, deadline: number, initialize?: boolean): Promise<void>;
@@ -3,17 +3,22 @@ export function mongodbArgv(service, data) {
3
3
  return ['mongod', '--bind_ip', '127.0.0.1', '--port', String(service.hostPort), '--dbpath', data,
4
4
  ...(service.replicaSet ? ['--replSet', service.replicaSet] : [])];
5
5
  }
6
- /** MongoDB's structured record comes from this child, not a listener already holding the port. */
7
- export function mongodbListening(log, port) {
8
- return log.split('\n').some((line) => {
9
- try {
10
- const record = JSON.parse(line);
11
- return record.id === 23016 && record.msg === 'Waiting for connections' && record.attr?.port === port;
12
- }
13
- catch {
14
- return false;
15
- } // Non-JSON log lines are not the structured readiness record.
16
- });
6
+ /** Whether the server answering MongoDB's protocol on the port is this child: its own serverStatus names its pid. A
7
+ * listener that is not answering yet, or another process's server on the port, is not. */
8
+ export async function mongodbServes(service, pid) {
9
+ const { MongoClient } = await import('mongodb');
10
+ const client = new MongoClient(`mongodb://127.0.0.1:${service.hostPort}`, { directConnection: true, timeoutMS: 1000, serverSelectionTimeoutMS: 1000 });
11
+ try {
12
+ await client.connect();
13
+ const status = await client.db('admin').command({ serverStatus: 1 });
14
+ return Number(status.pid) === pid;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ finally {
20
+ await client.close().catch(() => undefined);
21
+ }
17
22
  }
18
23
  /** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
19
24
  export async function mongodbReady(service, launch, deadline, initialize = true) {
@@ -12,7 +12,8 @@
12
12
  // services only. A definition declaring mysql without a container runtime is
13
13
  // refused with the kinds named, never half-booted. Redis never reaches it: every
14
14
  // redis service is the redis twin's (redis-backing.ts).
15
- import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { randomUUID } from 'node:crypto';
16
17
  import { connect } from 'node:net';
17
18
  import { dirname, join } from 'node:path';
18
19
  import { spawn } from 'node:child_process';
@@ -22,7 +23,7 @@ import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnRecei
22
23
  import { errorMessage } from "./error-message.js";
23
24
  import { fileURLToPath } from 'node:url';
24
25
  import { parse } from 'yaml';
25
- import { mongodbArgv, mongodbListening, mongodbReady } from "./mongodb-backing.js";
26
+ import { mongodbArgv, mongodbReady, mongodbServes } from "./mongodb-backing.js";
26
27
  /** Read the generated definition's loopback port mappings. MongoDB may explicitly request
27
28
  * one replica-set member through its Compose-compatible command; other mongod options are refused. */
28
29
  export function parseInfraDefinition(text) {
@@ -149,13 +150,52 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
149
150
  export function unsupportedKinds(services) {
150
151
  return [...new Set(services.filter((s) => CONTAINERLESS[s.kind] === undefined).map((s) => s.kind))];
151
152
  }
152
- /** The line each host prints once ITS OWN listener is bound: readiness is taken from the child, never
153
- * from a connect to the port, which any other process holding the port would answer. */
154
- function readyLine(port) {
155
- return `serving postgres wire protocol on 127.0.0.1:${port}`;
153
+ /**
154
+ * What `up` means for a managed database (architecture, managed infrastructure): it answers its own protocol on its
155
+ * port, as pg_isready and a compose healthcheck ask, and the answer is this host's. Readiness never reads a log line.
156
+ * The PGlite host writes its own listener receipt once its socket is bound ({version, pid, port, boot}, pglite-host.mjs
157
+ * --listening-file); MongoDB's server names its own pid in serverStatus. The log is for people.
158
+ */
159
+ function listeningReceiptPath(pidFile) {
160
+ return pidFile.replace(/\.pid$/, '.listening.json');
161
+ }
162
+ function listeningReceipt(path) {
163
+ let text;
164
+ try {
165
+ text = readFileSync(path, 'utf8');
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ try {
171
+ const value = JSON.parse(text);
172
+ if (value.version !== 1 || !Number.isSafeInteger(value.pid) || !Number.isSafeInteger(value.port))
173
+ return undefined;
174
+ return { pid: value.pid, port: value.port, boot: typeof value.boot === 'string' ? value.boot : null };
175
+ }
176
+ catch {
177
+ return undefined;
178
+ }
179
+ }
180
+ /** Postgres's own first exchange: an SSLRequest answered with one byte, 'N' or 'S' (pg_isready's question). */
181
+ function postgresAnswers(port) {
182
+ return new Promise((resolve) => {
183
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
184
+ socket.once('connect', () => socket.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f])));
185
+ socket.once('data', (chunk) => { socket.destroy(); resolve(chunk[0] === 0x4e || chunk[0] === 0x53); });
186
+ socket.on('error', () => resolve(false));
187
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
188
+ });
156
189
  }
157
- function logSince(log, offset) {
158
- return readFileSync(log).subarray(offset).toString('utf8');
190
+ /** Whether this host serves: its own receipt names it on the port (for this boot, when one is given), and the port
191
+ * answers the service's protocol. */
192
+ async function hostServes(service, pidFile, pid, boot) {
193
+ if (service.kind === 'mongodb')
194
+ return mongodbServes(service, pid);
195
+ const receipt = listeningReceipt(listeningReceiptPath(pidFile));
196
+ if (!receipt || receipt.pid !== pid || receipt.port !== service.hostPort || (boot !== undefined && receipt.boot !== boot))
197
+ return false;
198
+ return postgresAnswers(service.hostPort);
159
199
  }
160
200
  /**
161
201
  * A World's PostgreSQL cluster prepared before its first boot (architecture, managed infrastructure; ADR 0018).
@@ -274,8 +314,10 @@ export async function pgliteUp(services, dataDir, launch = {}) {
274
314
  appendFileSync(log, `${placed.note}\n`);
275
315
  launch.note?.(placed.note);
276
316
  }
277
- const offset = existsSync(log) ? statSync(log).size : 0;
278
- const [command, ...args] = placed.placed ? [...argv, '--prepared'] : argv;
317
+ // This boot's token, which the host writes into its listener receipt; an earlier receipt is never this one's.
318
+ const boot = randomUUID();
319
+ rmSync(listeningReceiptPath(pidFile), { force: true });
320
+ const [command, ...args] = [...(placed.placed ? [...argv, '--prepared'] : argv), ...(service.kind === 'postgres' ? ['--listening-file', listeningReceiptPath(pidFile), '--boot', boot] : [])];
279
321
  // a real fd, not a pipe: the host must outlive this process untethered
280
322
  const logFd = openSync(log, 'a');
281
323
  const child = spawn(command, args, {
@@ -305,10 +347,10 @@ export async function pgliteUp(services, dataDir, launch = {}) {
305
347
  publishProcessReceipt(pidFile, `${child.pid}\n`);
306
348
  launch.note?.(`${name} spawned`);
307
349
  const deadline = Date.now() + READY_TIMEOUT_MS;
308
- const line = readyLine(service.hostPort);
309
- // ready when OUR child says its listener is bound (a squatter that took the port after the check
310
- // above makes the child fail its bind and exit, never print this) and the port answers
311
- while (!(alive(child.pid) && (service.kind === 'mongodb' ? mongodbListening(logSince(log, offset), service.hostPort) : logSince(log, offset).includes(line)) && await listening(service.hostPort))) {
350
+ // Ready when the port answers the service's protocol and the answer is this child's: its own receipt (PGlite)
351
+ // or its own serverStatus (MongoDB). A squatter that took the port after the check above makes the child fail
352
+ // its bind and exit, and never answers as this child.
353
+ while (!(alive(child.pid) && await hostServes(service, pidFile, child.pid, service.kind === 'postgres' ? boot : undefined))) {
312
354
  if (spawnError)
313
355
  throw spawnError;
314
356
  if (!alive(child.pid))
@@ -317,7 +359,6 @@ export async function pgliteUp(services, dataDir, launch = {}) {
317
359
  throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
318
360
  // an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
319
361
  launch.signal?.throwIfAborted();
320
- // the ready line is a small log read, and the port is asked only once it is there
321
362
  await sleep(25);
322
363
  }
323
364
  launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}`);
@@ -349,7 +390,7 @@ export async function pgliteStatus(services, dataDir) {
349
390
  const ownership = hostOwnership(pidFile);
350
391
  if (ownership.errors.length)
351
392
  throw new AggregateError(ownership.errors, 'Managed infrastructure ownership uncertain; retry down');
352
- if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await listening(service.hostPort)))
393
+ if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await hostServes(service, pidFile, ownership.pid)))
353
394
  continue;
354
395
  if (ownership.identity)
355
396
  await verifySpawnIdentity(ownership.identity);
@@ -63,7 +63,7 @@
63
63
  // Randomness remains the host's own; no database schema objects are installed.
64
64
  import { errorMessage } from './error-message.mjs';
65
65
  import net from 'node:net';
66
- import { mkdirSync, readFileSync } from 'node:fs';
66
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
67
67
  import clockForm from '@volter/world-core/world-clock';
68
68
  import { fromNodeSocket } from 'pg-gateway/node';
69
69
  import { BackendError } from 'pg-gateway';
@@ -122,6 +122,12 @@ function arg(name) {
122
122
  // path a serving host takes, close it cleanly and exit without listening. The World's image build runs it on the
123
123
  // host so the tab opens an existing cluster (pglite-backing.ts, pglitePrepare).
124
124
  const preparing = process.argv.includes('--prepare');
125
+ // --listening-file <path> --boot <token>: once the listener is bound, the host writes its own receipt there
126
+ // ({version, pid, port, boot}); the backing reads it as this host's ownership of the port (pglite-backing.ts). Readiness
127
+ // is the port answering Postgres's own protocol; the stdout line is for people only.
128
+ const optional = (name) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; };
129
+ const listeningFile = optional('--listening-file');
130
+ const bootToken = optional('--boot');
125
131
  // --prepared: the data directory is a cluster `--prepare` made for this declared identity (pglite-backing.ts checks
126
132
  // its stamp before passing this), so the declared database and role exist and the maintenance open is skipped.
127
133
  const prepared = process.argv.includes('--prepared');
@@ -649,6 +655,11 @@ if (preparing) void dbReady.then(async (db) => {
649
655
  else void dbReady.then(() => {
650
656
  clearInterval(initKeepAlive);
651
657
  server.listen(port, '127.0.0.1', () => {
658
+ if (listeningFile) {
659
+ const temporary = `${listeningFile}.${process.pid}.tmp`;
660
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, pid: process.pid, port, boot: bootToken ?? null })}\n`, { mode: 0o600 });
661
+ renameSync(temporary, listeningFile);
662
+ }
652
663
  process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
653
664
  });
654
665
  });
@@ -20,10 +20,19 @@ export async function processBirth(pid) {
20
20
  } catch { return undefined; }
21
21
  }
22
22
  return new Promise(resolve => {
23
- const child = spawn('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], {
24
- windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
25
- env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
26
- });
23
+ // A probe that cannot run is a birth unknown, never a lifecycle failure: spawn throws synchronously where the
24
+ // process may not exec `ps` at all (setuid root on macOS, which a sandbox-exec profile refuses: EPERM), and reports
25
+ // other failures as 'error' (below). Either way the identity is the pid without a start time.
26
+ let child;
27
+ try {
28
+ child = spawn('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], {
29
+ windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
30
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
31
+ });
32
+ } catch {
33
+ resolve(undefined);
34
+ return;
35
+ }
27
36
  let output = '', settled = false;
28
37
  const finish = value => { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(value); } };
29
38
  // This is our still-held inspector, not a PID from a receipt.
@@ -55,6 +55,33 @@ function listening(port) {
55
55
  socket.on('timeout', () => { socket.destroy(); resolve(false); });
56
56
  });
57
57
  }
58
+ /** Whether the Redis answering on the port is this child (architecture D9): its own INFO server names its process_id, as
59
+ * Redis's does. A port that answers as another process, or does not answer yet, is not this child's. */
60
+ function redisServes(port, pid) {
61
+ return new Promise((resolve) => {
62
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
63
+ let reply = '';
64
+ socket.once('connect', () => socket.write('*2\r\n$4\r\nINFO\r\n$6\r\nserver\r\n'));
65
+ socket.on('data', (chunk) => {
66
+ reply += chunk.toString('latin1');
67
+ // a bulk string: $<length>\r\n<body>\r\n
68
+ const head = /^\$(\d+)\r\n/.exec(reply);
69
+ if (!head) {
70
+ if (!reply.startsWith('$') || reply.includes('\r\n')) {
71
+ socket.destroy();
72
+ resolve(false);
73
+ }
74
+ return;
75
+ }
76
+ if (Buffer.byteLength(reply, 'latin1') < head[0].length + Number(head[1]) + 2)
77
+ return;
78
+ socket.destroy();
79
+ resolve(Number(/(?:^|\r\n)process_id:(\d+)\r\n/.exec(reply.slice(head[0].length))?.[1]) === pid);
80
+ });
81
+ socket.on('error', () => resolve(false));
82
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
83
+ });
84
+ }
58
85
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
86
  export async function redisTwinUp(services, dataDir, cli, launch = {}) {
60
87
  for (const service of services) {
@@ -99,13 +126,13 @@ export async function redisTwinUp(services, dataDir, cli, launch = {}) {
99
126
  publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(identity) + '\n');
100
127
  publishProcessReceipt(pidFile, `${held.child.pid}\n`);
101
128
  const deadline = Date.now() + READY_TIMEOUT_MS;
102
- while (!(await listening(service.hostPort)) || !alive(held.child.pid)) {
129
+ while (!alive(held.child.pid) || !(await redisServes(service.hostPort, held.child.pid))) {
103
130
  if (!alive(held.child.pid))
104
131
  throw new Error(`redis exited before serving — see ${log}`);
105
132
  launch.signal?.throwIfAborted();
106
133
  if (Date.now() > deadline)
107
134
  throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
108
- await sleep(200);
135
+ await sleep(25);
109
136
  }
110
137
  launch.signal?.throwIfAborted();
111
138
  held.child.unref();
@@ -129,7 +156,8 @@ export async function redisTwinStatus(services, dataDir) {
129
156
  const pidFile = pidPath(dataDir, service);
130
157
  if (!existsSync(pidFile))
131
158
  continue;
132
- if (alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
159
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
160
+ if (alive(pid) && await redisServes(service.hostPort, pid))
133
161
  ready += 1;
134
162
  }
135
163
  return ready;