@volter/world-runtime 3.0.99 → 3.0.101

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
  });
@@ -105,7 +105,7 @@ export async function redisTwinUp(services, dataDir, cli, launch = {}) {
105
105
  launch.signal?.throwIfAborted();
106
106
  if (Date.now() > deadline)
107
107
  throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
108
- await sleep(200);
108
+ await sleep(25);
109
109
  }
110
110
  launch.signal?.throwIfAborted();
111
111
  held.child.unref();
@@ -26,7 +26,7 @@ import { packFacts } from "./pack-facts.js";
26
26
  import { assertOwnedRealPath, ownedPath } from "./owned-path.js";
27
27
  import { isEnvironmentName } from "./schema.js";
28
28
  import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
29
- import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
29
+ import { describeServiceEnd, readServiceEnd } from "./service-exit.js";
30
30
  import { Socket } from 'node:net';
31
31
  import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
32
32
  import { loadWorldConfig } from "./configs.js";
@@ -57,25 +57,6 @@ function retainSpawn(child) {
57
57
  function heldStarts() {
58
58
  return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
59
59
  }
60
- /** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
61
- * shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
62
- function readServiceText(log, limit = 4 * 1024 * 1024) {
63
- const tailOfFile = (path, bytes) => {
64
- if (bytes <= 0 || !existsSync(path))
65
- return '';
66
- const fd = openSync(path, 'r');
67
- try {
68
- const size = fstatSync(fd).size, start = Math.max(0, size - bytes), buffer = Buffer.alloc(size - start);
69
- readSync(fd, buffer, 0, buffer.length, start);
70
- return buffer.toString('utf8');
71
- }
72
- finally {
73
- closeSync(fd);
74
- }
75
- };
76
- const current = tailOfFile(log, limit);
77
- return `${tailOfFile(`${log}.1`, limit - Buffer.byteLength(current))}${current}`;
78
- }
79
60
  /** The World's events file: every service's start and end, and the World's own stops, one JSON line each. */
80
61
  export function worldEventsPath(logs) {
81
62
  return join(logs, 'events.jsonl');
@@ -136,46 +117,25 @@ async function untilReadyOrEnded(wait, ended, boot, what, log) {
136
117
  });
137
118
  });
138
119
  }
139
- /** That the process answering a service's port is the service. allocatePort closes its probe before the service
140
- * binds, so another process can take the port and answer the first connect; the service then fails to bind and ends
141
- * at once, which a short settle catches for any program. A World twin also answers WORLD_BOOT_PATH with the boot it
142
- * belongs to, so a leftover World's twin on the port is named exactly; a server that does not answer the path (a
143
- * twin from before it, a program that is not a twin) is accepted on the settle alone. When the settle guards this
144
- * port alone (`early`), a twin's answer with this boot's id ends it early: a server of this boot holds the port, and
145
- * every port this boot assigns is kept apart from its siblings', so short of the kernel handing the just-probed port
146
- * to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
147
- * ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
148
- * Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
149
- async function confirmOwnPort(port, boot, ended, twin, what, log, early = false, sharedSettle) {
150
- // A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
120
+ /** That the process answering a service's port is the service, decided by an answer and never by a timed settle
121
+ * (architecture D9). A World twin answers WORLD_BOOT_PATH with the boot it belongs to: this boot's id is the proof; another
122
+ * boot's is a leftover World on the port, refused by name. A program that answers no boot identity (one that is not a
123
+ * twin) is the service when it is still running once its port answers: another process holding the port makes the
124
+ * service's own bind fail, which ends it, and its end is named. A co-located host's twins are vouched for by its
125
+ * all-started receipt first (ADR 0012, ADR 0014). */
126
+ async function confirmOwnPort(port, boot, ended, twin, what, log) {
151
127
  boot.signal.throwIfAborted();
152
- const answer = twin ? worldBootOf(port, boot.signal) : Promise.resolve(null);
153
- const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' : new Promise(() => undefined))) : new Promise(() => undefined);
154
- // `ended` absent: the process was already settled (a co-located host settles once for all its twins).
155
- if (ended) {
156
- let timer;
157
- let settled;
158
- try {
159
- const settle = sharedSettle ?? new Promise((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
160
- settled = await Promise.race([own, ended.then(() => 'ended'), settle]);
161
- }
162
- finally {
163
- if (timer !== undefined)
164
- clearTimeout(timer);
165
- }
166
- boot.signal.throwIfAborted();
167
- if (settled === 'own')
168
- return;
169
- if (settled === 'ended')
170
- throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
171
- }
172
- if (!twin)
173
- return;
174
- const answered = await answer;
128
+ const answered = twin ? await worldBootOf(port, boot.signal) : null;
175
129
  boot.signal.throwIfAborted();
176
- if (answered && typeof answered.boot === 'string' && answered.boot !== boot.bootId) {
130
+ if (answered && typeof answered.boot === 'string') {
131
+ if (answered.boot === boot.bootId)
132
+ return;
177
133
  throw new Error(`${what}: its port is answered by a twin of another World boot (${answered.boot}), not this one; a leftover World holds the port. Stop it (\`volter-world list\`, then \`down\`) and retry.\nLog: ${log}`);
178
134
  }
135
+ // Whether the service has already ended, read now: one turn of the event loop, not a wait for it to fail.
136
+ const endedNow = ended ? await Promise.race([ended.then(() => true), new Promise((resolveNow) => setImmediate(() => resolveNow(false)))]) : false;
137
+ if (endedNow)
138
+ throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
179
139
  }
180
140
  /** What the server on a loopback port says its World boot is (WORLD_BOOT_PATH), or null. A direct loopback request:
181
141
  * never through a proxy an activated shell's environment names. */
@@ -1237,7 +1197,7 @@ async function runOwnInfrastructureUp(command, serviceId, env, logPath, abort, p
1237
1197
  }
1238
1198
  /** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
1239
1199
  * service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
1240
- * literal in the config). Other probe kinds (command/stdoutMatch) are returned unchanged. */
1200
+ * literal in the config). A command probe is returned unchanged. */
1241
1201
  function resolveReadyProbe(ready, svc) {
1242
1202
  if (ready.httpUrl === undefined)
1243
1203
  return ready;
@@ -1247,7 +1207,7 @@ function resolveReadyProbe(ready, svc) {
1247
1207
  .split('${port}').join(String(svc.port));
1248
1208
  return { ...ready, httpUrl };
1249
1209
  }
1250
- async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env, abort) {
1210
+ async function awaitReadiness(ready, cwd, serviceId, env = process.env, abort) {
1251
1211
  if (!ready)
1252
1212
  return 'no readiness declared';
1253
1213
  const timeoutMs = ready.timeoutMs ?? 60_000;
@@ -1283,12 +1243,6 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1283
1243
  lastError = errorMessage(error);
1284
1244
  }
1285
1245
  }
1286
- else if (ready.stdoutMatch !== undefined) {
1287
- const text = serviceOutput(readServiceText(logPath));
1288
- if (new RegExp(ready.stdoutMatch).test(text))
1289
- return passed(`the log matched /${ready.stdoutMatch}/`);
1290
- lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
1291
- }
1292
1246
  // A service that is nearly ready is seen at once; one that is slow is asked at most every intervalMs.
1293
1247
  await sleep(Math.min(intervalMs, 25 * 2 ** attempt));
1294
1248
  attempt += 1;
@@ -1329,7 +1283,7 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1329
1283
  const upOutput = await runExternalUp(external.up, cwd, service.id, env, log, boot?.signal, boot?.progress);
1330
1284
  boot?.progress(`${service.id}: up returned`);
1331
1285
  // 2. readiness — Docker-backed externals take seconds to boot.
1332
- const readiness = await awaitReadiness(external.readyWhen, log, cwd, service.id, env, boot?.signal);
1286
+ const readiness = await awaitReadiness(external.readyWhen, cwd, service.id, env, boot?.signal);
1333
1287
  boot?.progress(`${service.id}: ready (${readiness})`);
1334
1288
  // 3. status — discover connection info (optional; may reuse up output instead).
1335
1289
  let statusOutput = '';
@@ -1465,7 +1419,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1465
1419
  throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
1466
1420
  });
1467
1421
  boot.progress(`${service.id}: port ${port} answers`);
1468
- await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
1422
+ await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log).catch(async (error) => {
1469
1423
  if (undeadPids([child.pid]).length)
1470
1424
  recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1471
1425
  try {
@@ -1481,10 +1435,10 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1481
1435
  const host = '127.0.0.1';
1482
1436
  const url = `http://${host}:${port}`;
1483
1437
  boot.progress(`${service.id}: port ${port} is its own`);
1484
- // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
1438
+ // Optional readiness probe beyond TCP (httpUrl/command): a process can bind its port
1485
1439
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1486
1440
  if (service.ready && service.ready !== 'tcp') {
1487
- const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1441
+ const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1488
1442
  if (undeadPids([child.pid]).length)
1489
1443
  recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1490
1444
  try {
@@ -1960,11 +1914,10 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1960
1914
  // The host's receipt (this boot, every requested port) is published only
1961
1915
  // after every factory bound its own port; a port another process held fails
1962
1916
  // its bind and leaves no receipt. The receipt is the host's ownership proof
1963
- // for each port, so no twin waits out a settle (ADR 0014). A port that
1964
- // answers with another boot's identity is still refused.
1965
- const hostSettle = Promise.resolve('alive');
1917
+ // for each port (ADR 0014). A port that answers with another boot's identity
1918
+ // is still refused.
1966
1919
  await parallelStartup(allocated, async ({ service, port }) => {
1967
- await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch(async (error) => {
1920
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log).catch(async (error) => {
1968
1921
  if (undeadPids([child.pid]).length)
1969
1922
  recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1970
1923
  try {
@@ -3503,8 +3456,8 @@ const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
3503
3456
  /** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
3504
3457
  * polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
3505
3458
  * must report the CURRENT state, not wait for it to become healthy). Reuses the same probe shapes
3506
- * (`command`/`httpUrl`/`stdoutMatch`) declared in config for the boot-time `readyWhen`. */
3507
- async function probeExternalOnce(ready, cwd, env, logPath) {
3459
+ * (`command`/`httpUrl`) declared in config for the boot-time `readyWhen`. */
3460
+ async function probeExternalOnce(ready, cwd, env) {
3508
3461
  if (ready.command !== undefined) {
3509
3462
  if (!commandExists(ready.command)) {
3510
3463
  return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
@@ -3529,13 +3482,7 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
3529
3482
  return { ok: false, message: `probe failed: ${errorMessage(error)}` };
3530
3483
  }
3531
3484
  }
3532
- if (ready.stdoutMatch !== undefined) {
3533
- const text = serviceOutput(readServiceText(logPath));
3534
- if (new RegExp(ready.stdoutMatch).test(text))
3535
- return { ok: true, message: 'probe ok (stdoutMatch)' };
3536
- return { ok: false, message: `probe pattern /${ready.stdoutMatch}/ not found in log` };
3537
- }
3538
- return { ok: false, message: 'malformed readiness probe (no command/httpUrl/stdoutMatch)' };
3485
+ return { ok: false, message: 'malformed readiness probe (no command/httpUrl)' };
3539
3486
  }
3540
3487
  /** A stopped service's own account for doctor: how it ended, its last output, and where its whole log is. */
3541
3488
  function stoppedServiceReport(pid, log) {
@@ -3599,7 +3546,7 @@ export async function doctorWorld(name, options = {}) {
3599
3546
  const probeCwd = service.external?.cwd ?? root;
3600
3547
  const probeEnv = { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
3601
3548
  if (configuredExternal?.readyWhen) {
3602
- const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv, service.log);
3549
+ const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv);
3603
3550
  checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
3604
3551
  }
3605
3552
  else if (configuredExternal?.status) {
@@ -3637,12 +3584,11 @@ export async function doctorWorld(name, options = {}) {
3637
3584
  checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url });
3638
3585
  // A port that accepts is not a service that answers (a stuck event loop still completes the handshake): the
3639
3586
  // readiness probe the service declared is run again, as it was at boot.
3640
- // A log pattern matched at boot matches forever, so it cannot tell a hung service: only a probe that asks is re-run,
3641
- // in the service's own cwd and with its PORT, as at boot.
3587
+ // The probe is re-run in the service's own cwd and with its PORT, as at boot.
3642
3588
  const configured = externalProbeConfig?.services.find((candidate) => candidate.id === service.id);
3643
3589
  const declared = configured?.ready;
3644
- if (pidLive && declared && declared !== 'tcp' && declared.stdoutMatch === undefined) {
3645
- const probe = await probeExternalOnce(resolveReadyProbe(declared, { host: '127.0.0.1', port: service.port, url: service.url }), configured?.cwd ? resolve(root, configured.cwd) : root, { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) }, service.log);
3590
+ if (pidLive && declared && declared !== 'tcp') {
3591
+ const probe = await probeExternalOnce(resolveReadyProbe(declared, { host: '127.0.0.1', port: service.port, url: service.url }), configured?.cwd ? resolve(root, configured.cwd) : root, { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) });
3646
3592
  checks.push({ id: `service:${service.id}:ready`, ok: probe.ok,
3647
3593
  message: probe.ok ? probe.message : `its port accepts but its declared readiness probe fails (${probe.message}): the service is up but not answering\n log: ${service.log}` });
3648
3594
  }
@@ -118,16 +118,15 @@ export type WorldExternalDiscover = {
118
118
  pattern?: string;
119
119
  };
120
120
  /**
121
- * Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses. Exactly one of:
122
- * - `command`/`args`: a command that must exit 0,
123
- * - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response,
124
- * - `stdoutMatch`: a regex that must appear in the `up` command's captured stdout/stderr log.
121
+ * Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses: the service answering its own
122
+ * protocol (architecture D9). Exactly one of:
123
+ * - `command`/`args`: a command that asks the service and exits 0 when it answers,
124
+ * - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response.
125
125
  */
126
126
  export type WorldExternalReadyWhen = {
127
127
  command?: string;
128
128
  args?: string[];
129
129
  httpUrl?: string;
130
- stdoutMatch?: string;
131
130
  timeoutMs?: number;
132
131
  intervalMs?: number;
133
132
  };
@@ -252,7 +251,6 @@ export type WorldServiceConfig = {
252
251
  * service truly serves, so `up` returning means "ready" (no post-up re-polling needed):
253
252
  * - `{ httpUrl }` — poll until 2xx/3xx. `${url}`/`${host}`/`${port}` resolve to the allocated port.
254
253
  * - `{ command, args }` — poll until the command exits 0 (runs with the service env, incl. `PORT`).
255
- * - `{ stdoutMatch }` — poll until a regex matches the service's stdout/stderr log.
256
254
  * 'external' services use `external.readyWhen` instead.
257
255
  */
258
256
  ready?: 'tcp' | WorldExternalReadyWhen;
@@ -31,17 +31,16 @@ function assertValidRegex(pattern, what, path) {
31
31
  }
32
32
  }
33
33
  /** Shared validator for a readiness probe — used by both `external.readyWhen` and a process/twin
34
- * service's `ready` object. Exactly one of command/httpUrl/stdoutMatch, correct types, and any
35
- * stdoutMatch regex must compile (so a typo fails at config time, not mid-boot). */
34
+ * service's `ready` object. Exactly one of command/httpUrl, with correct types. */
36
35
  function assertReadyWhen(ready, what, path) {
37
36
  if (!ready || typeof ready !== 'object' || Array.isArray(ready)) {
38
37
  throw new Error(`${what} must be an object in ${path}`);
39
38
  }
40
- assertKeys(ready, ['command', 'args', 'httpUrl', 'stdoutMatch', 'timeoutMs', 'intervalMs'], what, path);
39
+ assertKeys(ready, ['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs'], what, path);
41
40
  const r = ready;
42
- const probes = [r.command !== undefined, r.httpUrl !== undefined, r.stdoutMatch !== undefined].filter(Boolean).length;
41
+ const probes = [r.command !== undefined, r.httpUrl !== undefined].filter(Boolean).length;
43
42
  if (probes !== 1) {
44
- throw new Error(`${what} must set exactly one of command/httpUrl/stdoutMatch in ${path}`);
43
+ throw new Error(`${what} must set exactly one of command/httpUrl in ${path}`);
45
44
  }
46
45
  if (r.command !== undefined && typeof r.command !== 'string')
47
46
  throw new Error(`${what}.command must be a string in ${path}`);
@@ -51,11 +50,6 @@ function assertReadyWhen(ready, what, path) {
51
50
  throw new Error(`${what}.args requires command in ${path}`);
52
51
  if (r.httpUrl !== undefined && typeof r.httpUrl !== 'string')
53
52
  throw new Error(`${what}.httpUrl must be a string in ${path}`);
54
- if (r.stdoutMatch !== undefined) {
55
- if (typeof r.stdoutMatch !== 'string')
56
- throw new Error(`${what}.stdoutMatch must be a string in ${path}`);
57
- assertValidRegex(r.stdoutMatch, `${what}.stdoutMatch`, path);
58
- }
59
53
  for (const key of ['timeoutMs', 'intervalMs']) {
60
54
  if (r[key] !== undefined && (!Number.isInteger(r[key]) || r[key] <= 0))
61
55
  throw new Error(`${what}.${key} must be a positive integer in ${path}`);
@@ -24,9 +24,6 @@ export type ServiceEnd = {
24
24
  /** The service's own last lines of output in its last run, oldest first. */
25
25
  lastOutput: string[];
26
26
  };
27
- /** A log's text as the service wrote it, without the recorder's records: what a `stdoutMatch` readiness pattern is
28
- * matched against. */
29
- export declare function serviceOutput(text: string): string;
30
27
  /** The last run's end in a service log: its start, its exit record (absent while it runs, or when its parent was killed
31
28
  * with it), and the output it wrote last. Undefined for a log the parent never wrote to (a co-located twin's host log),
32
29
  * which has no runs to tell apart. A run is bounded by its start record or, for a spawn that failed before starting
@@ -74,11 +74,6 @@ function split(line) {
74
74
  return { output: line };
75
75
  }
76
76
  }
77
- /** A log's text as the service wrote it, without the recorder's records: what a `stdoutMatch` readiness pattern is
78
- * matched against. */
79
- export function serviceOutput(text) {
80
- return text.includes(MARK) ? text.split('\n').map((line) => split(line).output).join('\n') : text;
81
- }
82
77
  /** The last run's end in a service log: its start, its exit record (absent while it runs, or when its parent was killed
83
78
  * with it), and the output it wrote last. Undefined for a log the parent never wrote to (a co-located twin's host log),
84
79
  * which has no runs to tell apart. A run is bounded by its start record or, for a spawn that failed before starting
@@ -23,7 +23,18 @@ export async function upCommand(subject, rest, root) {
23
23
  // --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
24
24
  // browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
25
25
  const keepState = rest.includes('--keep-state');
26
+ // --app-url: where the booter serves the app, recorded as `volter-world app-url --set` records it, once the World is
27
+ // up and in this process: a booter that knows the address before the app listens (a browser tab's preview origin)
28
+ // saves a whole command between up and the app's start.
29
+ const appUrl = optionValue(rest, '--app-url') || undefined;
30
+ if (appUrl !== undefined && !(URL.canParse(appUrl) && /^https?:$/.test(new URL(appUrl).protocol)))
31
+ throw new Error(`volter-world up: --app-url "${appUrl}" must be an http(s) URL`);
26
32
  const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
33
+ if (appUrl !== undefined) {
34
+ const { setAppUrl } = await import("./app-url.js");
35
+ setAppUrl(instance.name, appUrl, { root });
36
+ process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
37
+ }
27
38
  if (rest.includes('--json'))
28
39
  process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
29
40
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.99",
3
+ "version": "3.0.101",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -69,14 +69,14 @@
69
69
  "node": ">=22.3"
70
70
  },
71
71
  "peerDependencies": {
72
- "@volter/world-core": "3.0.99",
73
- "@volter/world-console": "3.0.94"
72
+ "@volter/world-core": "3.0.101",
73
+ "@volter/world-console": "3.0.96"
74
74
  },
75
75
  "dependencies": {
76
76
  "@electric-sql/pglite": "0.5.8",
77
77
  "@electric-sql/pglite-pgvector": "0.0.9",
78
78
  "@volter/world-access": "3.0.1",
79
- "@volter/world-core": "3.0.99",
79
+ "@volter/world-core": "3.0.101",
80
80
  "mongodb": "~6.20.0",
81
81
  "pg-gateway": "0.3.0-beta.4",
82
82
  "smol-toml": "^1.8.0",
@@ -61,7 +61,7 @@ function printHelp(): void {
61
61
  # emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
62
62
  # Deterministic: same repo + same pack catalog => identical bytes.
63
63
  # init DIAGNOSES; it never boots — it prints the exact next commands
64
- volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--json] [--root <repo>]
64
+ 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>]
65
65
  volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
66
66
  volter-world audit <world> [--json] — each mounted twin's grade, as the catalog records it (R17)
67
67
  volter-world down <name> [--grace-ms <ms>] [--purge] [--if-instance <token>] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
package/src/configs.ts CHANGED
@@ -38,7 +38,7 @@ const LEGACY_NESTED = {
38
38
  bare: new Set(['name']), resources: new Set(['memoryMiB', 'writableStorageMiB']), catalog: new Set(['sha', 'protocol']),
39
39
  root: new Set(['url', 'deploy', 'refresh', 'scope']), refresh: new Set(['every', 'webhook']),
40
40
  external: new Set(['up', 'status', 'down', 'discover', 'readyWhen']), discover: new Set(['as', 'source', 'jsonPath', 'pattern']),
41
- ready: new Set(['command', 'args', 'httpUrl', 'stdoutMatch', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
41
+ ready: new Set(['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
42
42
  share: new Set(['provider', 'command', 'args', 'ephemeral', 'services']), shareService: new Set(['id', 'verifyPath']),
43
43
  } as const;
44
44
 
@@ -8,14 +8,16 @@ export function mongodbArgv(service: InfraService, data: string): string[] {
8
8
  ...(service.replicaSet ? ['--replSet', service.replicaSet] : [])];
9
9
  }
10
10
 
11
- /** MongoDB's structured record comes from this child, not a listener already holding the port. */
12
- export function mongodbListening(log: string, port: number): boolean {
13
- return log.split('\n').some((line) => {
14
- try {
15
- const record = JSON.parse(line);
16
- return record.id === 23016 && record.msg === 'Waiting for connections' && record.attr?.port === port;
17
- } catch { return false; } // Non-JSON log lines are not the structured readiness record.
18
- });
11
+ /** Whether the server answering MongoDB's protocol on the port is this child: its own serverStatus names its pid. A
12
+ * listener that is not answering yet, or another process's server on the port, is not. */
13
+ export async function mongodbServes(service: InfraService, pid: number): Promise<boolean> {
14
+ const { MongoClient } = await import('mongodb');
15
+ const client = new MongoClient(`mongodb://127.0.0.1:${service.hostPort}`, { directConnection: true, timeoutMS: 1000, serverSelectionTimeoutMS: 1000 });
16
+ try {
17
+ await client.connect();
18
+ const status = await client.db('admin').command({ serverStatus: 1 });
19
+ return Number(status.pid) === pid;
20
+ } catch { return false; } finally { await client.close().catch(() => undefined); }
19
21
  }
20
22
 
21
23
  /** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
@@ -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, type ChildProcess } from 'node:child_process';
@@ -22,7 +23,7 @@ import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnRecei
22
23
  import { errorMessage } from './error-message.ts';
23
24
  import { fileURLToPath } from 'node:url';
24
25
  import { parse } from 'yaml';
25
- import { mongodbArgv, mongodbListening, mongodbReady } from './mongodb-backing.ts';
26
+ import { mongodbArgv, mongodbReady, mongodbServes } from './mongodb-backing.ts';
26
27
 
27
28
  export interface InfraService { kind: string; hostPort: number; replicaSet?: string; postgres?: { user: string; database: string; password?: string } }
28
29
 
@@ -154,14 +155,44 @@ export function unsupportedKinds(services: InfraService[]): string[] {
154
155
  return [...new Set(services.filter((s) => CONTAINERLESS[s.kind] === undefined).map((s) => s.kind))];
155
156
  }
156
157
 
157
- /** The line each host prints once ITS OWN listener is bound: readiness is taken from the child, never
158
- * from a connect to the port, which any other process holding the port would answer. */
159
- function readyLine(port: number): string {
160
- return `serving postgres wire protocol on 127.0.0.1:${port}`;
158
+ /**
159
+ * What `up` means for a managed database (architecture, managed infrastructure): it answers its own protocol on its
160
+ * port, as pg_isready and a compose healthcheck ask, and the answer is this host's. Readiness never reads a log line.
161
+ * The PGlite host writes its own listener receipt once its socket is bound ({version, pid, port, boot}, pglite-host.mjs
162
+ * --listening-file); MongoDB's server names its own pid in serverStatus. The log is for people.
163
+ */
164
+ function listeningReceiptPath(pidFile: string): string {
165
+ return pidFile.replace(/\.pid$/, '.listening.json');
166
+ }
167
+
168
+ function listeningReceipt(path: string): { pid: number; port: number; boot: string | null } | undefined {
169
+ let text: string;
170
+ try { text = readFileSync(path, 'utf8'); } catch { return undefined; }
171
+ try {
172
+ const value = JSON.parse(text) as { version?: unknown; pid?: unknown; port?: unknown; boot?: unknown };
173
+ if (value.version !== 1 || !Number.isSafeInteger(value.pid) || !Number.isSafeInteger(value.port)) return undefined;
174
+ return { pid: value.pid as number, port: value.port as number, boot: typeof value.boot === 'string' ? value.boot : null };
175
+ } catch { return undefined; }
176
+ }
177
+
178
+ /** Postgres's own first exchange: an SSLRequest answered with one byte, 'N' or 'S' (pg_isready's question). */
179
+ function postgresAnswers(port: number): Promise<boolean> {
180
+ return new Promise((resolve) => {
181
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
182
+ socket.once('connect', () => socket.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f])));
183
+ socket.once('data', (chunk: Buffer) => { socket.destroy(); resolve(chunk[0] === 0x4e || chunk[0] === 0x53); });
184
+ socket.on('error', () => resolve(false));
185
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
186
+ });
161
187
  }
162
188
 
163
- function logSince(log: string, offset: number): string {
164
- return readFileSync(log).subarray(offset).toString('utf8');
189
+ /** Whether this host serves: its own receipt names it on the port (for this boot, when one is given), and the port
190
+ * answers the service's protocol. */
191
+ async function hostServes(service: InfraService, pidFile: string, pid: number, boot?: string): Promise<boolean> {
192
+ if (service.kind === 'mongodb') return mongodbServes(service, pid);
193
+ const receipt = listeningReceipt(listeningReceiptPath(pidFile));
194
+ if (!receipt || receipt.pid !== pid || receipt.port !== service.hostPort || (boot !== undefined && receipt.boot !== boot)) return false;
195
+ return postgresAnswers(service.hostPort);
165
196
  }
166
197
 
167
198
  /**
@@ -272,8 +303,10 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
272
303
  mkdirSync(serviceData, { recursive: true });
273
304
  mkdirSync(dirname(log), { recursive: true });
274
305
  if (placed.note) { appendFileSync(log, `${placed.note}\n`); launch.note?.(placed.note); }
275
- const offset = existsSync(log) ? statSync(log).size : 0;
276
- const [command, ...args] = placed.placed ? [...argv, '--prepared'] : argv;
306
+ // This boot's token, which the host writes into its listener receipt; an earlier receipt is never this one's.
307
+ const boot = randomUUID();
308
+ rmSync(listeningReceiptPath(pidFile), { force: true });
309
+ const [command, ...args] = [...(placed.placed ? [...argv, '--prepared'] : argv), ...(service.kind === 'postgres' ? ['--listening-file', listeningReceiptPath(pidFile), '--boot', boot] : [])];
277
310
  // a real fd, not a pipe: the host must outlive this process untethered
278
311
  const logFd = openSync(log, 'a');
279
312
  const child = spawn(command!, args, {
@@ -300,16 +333,15 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
300
333
  publishProcessReceipt(pidFile, `${child.pid}\n`);
301
334
  launch.note?.(`${name} spawned`);
302
335
  const deadline = Date.now() + READY_TIMEOUT_MS;
303
- const line = readyLine(service.hostPort);
304
- // ready when OUR child says its listener is bound (a squatter that took the port after the check
305
- // above makes the child fail its bind and exit, never print this) and the port answers
306
- while (!(alive(child.pid!) && (service.kind === 'mongodb' ? mongodbListening(logSince(log, offset), service.hostPort) : logSince(log, offset).includes(line)) && await listening(service.hostPort))) {
336
+ // Ready when the port answers the service's protocol and the answer is this child's: its own receipt (PGlite)
337
+ // or its own serverStatus (MongoDB). A squatter that took the port after the check above makes the child fail
338
+ // its bind and exit, and never answers as this child.
339
+ while (!(alive(child.pid!) && await hostServes(service, pidFile, child.pid!, service.kind === 'postgres' ? boot : undefined))) {
307
340
  if (spawnError) throw spawnError;
308
341
  if (!alive(child.pid!)) throw new Error(`${name} exited before serving — see ${log}`);
309
342
  if (Date.now() > deadline) throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
310
343
  // an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
311
344
  launch.signal?.throwIfAborted();
312
- // the ready line is a small log read, and the port is asked only once it is there
313
345
  await sleep(25);
314
346
  }
315
347
  launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}`);
@@ -333,7 +365,7 @@ export async function pgliteStatus(services: InfraService[], dataDir: string): P
333
365
  const pidFile = pidPath(dataDir, service.kind);
334
366
  const ownership = hostOwnership(pidFile);
335
367
  if (ownership.errors.length) throw new AggregateError(ownership.errors, 'Managed infrastructure ownership uncertain; retry down');
336
- if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await listening(service.hostPort))) continue;
368
+ if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await hostServes(service, pidFile, ownership.pid))) continue;
337
369
  if (ownership.identity) await verifySpawnIdentity(ownership.identity);
338
370
  if (service.kind === 'mongodb') {
339
371
  await mongodbReady(service, {}, Date.now() + READY_TIMEOUT_MS, false);
@@ -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
  });
@@ -97,7 +97,7 @@ export async function redisTwinUp(services: InfraService[], dataDir: string, cli
97
97
  if (!alive(held.child.pid)) throw new Error(`redis exited before serving — see ${log}`);
98
98
  launch.signal?.throwIfAborted();
99
99
  if (Date.now() > deadline) throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
100
- await sleep(200);
100
+ await sleep(25);
101
101
  }
102
102
  launch.signal?.throwIfAborted();
103
103
  held.child.unref();
package/src/runtime.ts CHANGED
@@ -28,7 +28,7 @@ import { packFacts } from './pack-facts.ts';
28
28
  import { assertOwnedRealPath, ownedPath } from './owned-path.ts';
29
29
  import { isEnvironmentName } from './schema.ts';
30
30
  import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
31
- import { describeServiceEnd, readServiceEnd, serviceOutput } from './service-exit.ts';
31
+ import { describeServiceEnd, readServiceEnd } from './service-exit.ts';
32
32
  import { Socket } from 'node:net';
33
33
  import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
34
34
  import { loadWorldConfig } from './configs.ts';
@@ -60,22 +60,6 @@ function heldStarts(): Record<string, string | undefined> {
60
60
  return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
61
61
  }
62
62
 
63
- /** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
64
- * shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
65
- function readServiceText(log: string, limit = 4 * 1024 * 1024): string {
66
- const tailOfFile = (path: string, bytes: number): string => {
67
- if (bytes <= 0 || !existsSync(path)) return '';
68
- const fd = openSync(path, 'r');
69
- try {
70
- const size = fstatSync(fd).size, start = Math.max(0, size - bytes), buffer = Buffer.alloc(size - start);
71
- readSync(fd, buffer, 0, buffer.length, start);
72
- return buffer.toString('utf8');
73
- } finally { closeSync(fd); }
74
- };
75
- const current = tailOfFile(log, limit);
76
- return `${tailOfFile(`${log}.1`, limit - Buffer.byteLength(current))}${current}`;
77
- }
78
-
79
63
  /** The World's events file: every service's start and end, and the World's own stops, one JSON line each. */
80
64
  export function worldEventsPath(logs: string): string {
81
65
  return join(logs, 'events.jsonl');
@@ -127,39 +111,23 @@ async function untilReadyOrEnded<T>(wait: (abort: AbortSignal) => Promise<T>, en
127
111
  });
128
112
  }
129
113
 
130
- /** That the process answering a service's port is the service. allocatePort closes its probe before the service
131
- * binds, so another process can take the port and answer the first connect; the service then fails to bind and ends
132
- * at once, which a short settle catches for any program. A World twin also answers WORLD_BOOT_PATH with the boot it
133
- * belongs to, so a leftover World's twin on the port is named exactly; a server that does not answer the path (a
134
- * twin from before it, a program that is not a twin) is accepted on the settle alone. When the settle guards this
135
- * port alone (`early`), a twin's answer with this boot's id ends it early: a server of this boot holds the port, and
136
- * every port this boot assigns is kept apart from its siblings', so short of the kernel handing the just-probed port
137
- * to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
138
- * ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
139
- * Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
140
- async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<void> | undefined, twin: boolean, what: string, log: string, early = false, sharedSettle?: Promise<'alive'>): Promise<void> {
141
- // A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
114
+ /** That the process answering a service's port is the service, decided by an answer and never by a timed settle
115
+ * (architecture D9). A World twin answers WORLD_BOOT_PATH with the boot it belongs to: this boot's id is the proof; another
116
+ * boot's is a leftover World on the port, refused by name. A program that answers no boot identity (one that is not a
117
+ * twin) is the service when it is still running once its port answers: another process holding the port makes the
118
+ * service's own bind fail, which ends it, and its end is named. A co-located host's twins are vouched for by its
119
+ * all-started receipt first (ADR 0012, ADR 0014). */
120
+ async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<void> | undefined, twin: boolean, what: string, log: string): Promise<void> {
142
121
  boot.signal.throwIfAborted();
143
- const answer = twin ? worldBootOf(port, boot.signal) : Promise.resolve(null);
144
- const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' as const : new Promise<never>(() => undefined))) : new Promise<never>(() => undefined);
145
- // `ended` absent: the process was already settled (a co-located host settles once for all its twins).
146
- if (ended) {
147
- let timer: ReturnType<typeof setTimeout> | undefined;
148
- let settled: 'own' | 'ended' | 'alive';
149
- try {
150
- const settle = sharedSettle ?? new Promise<'alive'>((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
151
- settled = await Promise.race([own, ended.then(() => 'ended' as const), settle]);
152
- } finally { if (timer !== undefined) clearTimeout(timer); }
153
- boot.signal.throwIfAborted();
154
- if (settled === 'own') return;
155
- if (settled === 'ended') throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
156
- }
157
- if (!twin) return;
158
- const answered = await answer;
122
+ const answered = twin ? await worldBootOf(port, boot.signal) : null;
159
123
  boot.signal.throwIfAborted();
160
- if (answered && typeof answered.boot === 'string' && answered.boot !== boot.bootId) {
124
+ if (answered && typeof answered.boot === 'string') {
125
+ if (answered.boot === boot.bootId) return;
161
126
  throw new Error(`${what}: its port is answered by a twin of another World boot (${answered.boot}), not this one; a leftover World holds the port. Stop it (\`volter-world list\`, then \`down\`) and retry.\nLog: ${log}`);
162
127
  }
128
+ // Whether the service has already ended, read now: one turn of the event loop, not a wait for it to fail.
129
+ const endedNow = ended ? await Promise.race([ended.then(() => true), new Promise<boolean>((resolveNow) => setImmediate(() => resolveNow(false)))]) : false;
130
+ if (endedNow) throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
163
131
  }
164
132
 
165
133
  /** What the server on a loopback port says its World boot is (WORLD_BOOT_PATH), or null. A direct loopback request:
@@ -1203,7 +1171,7 @@ async function runOwnInfrastructureUp(command: string[], serviceId: string, env:
1203
1171
 
1204
1172
  /** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
1205
1173
  * service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
1206
- * literal in the config). Other probe kinds (command/stdoutMatch) are returned unchanged. */
1174
+ * literal in the config). A command probe is returned unchanged. */
1207
1175
  function resolveReadyProbe(ready: WorldExternalReadyWhen, svc: { host: string; port: number; url: string }): WorldExternalReadyWhen {
1208
1176
  if (ready.httpUrl === undefined) return ready;
1209
1177
  const httpUrl = ready.httpUrl
@@ -1215,7 +1183,6 @@ function resolveReadyProbe(ready: WorldExternalReadyWhen, svc: { host: string; p
1215
1183
 
1216
1184
  async function awaitReadiness(
1217
1185
  ready: WorldExternalReadyWhen | undefined,
1218
- logPath: string,
1219
1186
  cwd: string,
1220
1187
  serviceId: string,
1221
1188
  env: NodeJS.ProcessEnv = process.env,
@@ -1244,10 +1211,6 @@ async function awaitReadiness(
1244
1211
  } catch (error) {
1245
1212
  lastError = errorMessage(error);
1246
1213
  }
1247
- } else if (ready.stdoutMatch !== undefined) {
1248
- const text = serviceOutput(readServiceText(logPath));
1249
- if (new RegExp(ready.stdoutMatch).test(text)) return passed(`the log matched /${ready.stdoutMatch}/`);
1250
- lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
1251
1214
  }
1252
1215
  // A service that is nearly ready is seen at once; one that is slow is asked at most every intervalMs.
1253
1216
  await sleep(Math.min(intervalMs, 25 * 2 ** attempt));
@@ -1302,7 +1265,7 @@ async function startExternalService(
1302
1265
  boot?.progress(`${service.id}: up returned`);
1303
1266
 
1304
1267
  // 2. readiness — Docker-backed externals take seconds to boot.
1305
- const readiness = await awaitReadiness(external.readyWhen, log, cwd, service.id, env, boot?.signal);
1268
+ const readiness = await awaitReadiness(external.readyWhen, cwd, service.id, env, boot?.signal);
1306
1269
  boot?.progress(`${service.id}: ready (${readiness})`);
1307
1270
 
1308
1271
  // 3. status — discover connection info (optional; may reuse up output instead).
@@ -1455,7 +1418,7 @@ async function startService(
1455
1418
  });
1456
1419
  boot.progress(`${service.id}: port ${port} answers`);
1457
1420
 
1458
- await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
1421
+ await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log).catch(async (error) => {
1459
1422
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1460
1423
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1461
1424
  throw error;
@@ -1471,10 +1434,10 @@ async function startService(
1471
1434
  const url = `http://${host}:${port}`;
1472
1435
  boot.progress(`${service.id}: port ${port} is its own`);
1473
1436
 
1474
- // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
1437
+ // Optional readiness probe beyond TCP (httpUrl/command): a process can bind its port
1475
1438
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1476
1439
  if (service.ready && service.ready !== 'tcp') {
1477
- const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1440
+ const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1478
1441
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1479
1442
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1480
1443
  throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
@@ -1882,11 +1845,10 @@ async function startColocatedServices(
1882
1845
  // The host's receipt (this boot, every requested port) is published only
1883
1846
  // after every factory bound its own port; a port another process held fails
1884
1847
  // its bind and leaves no receipt. The receipt is the host's ownership proof
1885
- // for each port, so no twin waits out a settle (ADR 0014). A port that
1886
- // answers with another boot's identity is still refused.
1887
- const hostSettle = Promise.resolve('alive' as const);
1848
+ // for each port (ADR 0014). A port that answers with another boot's identity
1849
+ // is still refused.
1888
1850
  await parallelStartup(allocated, async ({ service, port }) => {
1889
- await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch(async (error) => {
1851
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log).catch(async (error) => {
1890
1852
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1891
1853
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1892
1854
  throw error;
@@ -3299,12 +3261,11 @@ const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
3299
3261
  /** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
3300
3262
  * polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
3301
3263
  * must report the CURRENT state, not wait for it to become healthy). Reuses the same probe shapes
3302
- * (`command`/`httpUrl`/`stdoutMatch`) declared in config for the boot-time `readyWhen`. */
3264
+ * (`command`/`httpUrl`) declared in config for the boot-time `readyWhen`. */
3303
3265
  async function probeExternalOnce(
3304
3266
  ready: WorldExternalReadyWhen,
3305
3267
  cwd: string,
3306
3268
  env: NodeJS.ProcessEnv,
3307
- logPath: string,
3308
3269
  ): Promise<{ ok: boolean; message: string }> {
3309
3270
  if (ready.command !== undefined) {
3310
3271
  if (!commandExists(ready.command)) {
@@ -3327,12 +3288,7 @@ async function probeExternalOnce(
3327
3288
  return { ok: false, message: `probe failed: ${errorMessage(error)}` };
3328
3289
  }
3329
3290
  }
3330
- if (ready.stdoutMatch !== undefined) {
3331
- const text = serviceOutput(readServiceText(logPath));
3332
- if (new RegExp(ready.stdoutMatch).test(text)) return { ok: true, message: 'probe ok (stdoutMatch)' };
3333
- return { ok: false, message: `probe pattern /${ready.stdoutMatch}/ not found in log` };
3334
- }
3335
- return { ok: false, message: 'malformed readiness probe (no command/httpUrl/stdoutMatch)' };
3291
+ return { ok: false, message: 'malformed readiness probe (no command/httpUrl)' };
3336
3292
  }
3337
3293
 
3338
3294
  /** A stopped service's own account for doctor: how it ended, its last output, and where its whole log is. */
@@ -3402,7 +3358,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3402
3358
  const probeCwd = service.external?.cwd ?? root;
3403
3359
  const probeEnv = { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
3404
3360
  if (configuredExternal?.readyWhen) {
3405
- const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv, service.log);
3361
+ const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv);
3406
3362
  checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
3407
3363
  } else if (configuredExternal?.status) {
3408
3364
  try {
@@ -3437,13 +3393,12 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3437
3393
  checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url! });
3438
3394
  // A port that accepts is not a service that answers (a stuck event loop still completes the handshake): the
3439
3395
  // readiness probe the service declared is run again, as it was at boot.
3440
- // A log pattern matched at boot matches forever, so it cannot tell a hung service: only a probe that asks is re-run,
3441
- // in the service's own cwd and with its PORT, as at boot.
3396
+ // The probe is re-run in the service's own cwd and with its PORT, as at boot.
3442
3397
  const configured = externalProbeConfig?.services.find((candidate) => candidate.id === service.id);
3443
3398
  const declared = configured?.ready;
3444
- if (pidLive && declared && declared !== 'tcp' && declared.stdoutMatch === undefined) {
3399
+ if (pidLive && declared && declared !== 'tcp') {
3445
3400
  const probe = await probeExternalOnce(resolveReadyProbe(declared, { host: '127.0.0.1', port: service.port!, url: service.url! }), configured?.cwd ? resolve(root, configured.cwd) : root,
3446
- { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) }, service.log);
3401
+ { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) });
3447
3402
  checks.push({ id: `service:${service.id}:ready`, ok: probe.ok,
3448
3403
  message: probe.ok ? probe.message : `its port accepts but its declared readiness probe fails (${probe.message}): the service is up but not answering\n log: ${service.log}` });
3449
3404
  }
package/src/schema.ts CHANGED
@@ -97,16 +97,15 @@ export type WorldExternalDiscover = {
97
97
  };
98
98
 
99
99
  /**
100
- * Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses. Exactly one of:
101
- * - `command`/`args`: a command that must exit 0,
102
- * - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response,
103
- * - `stdoutMatch`: a regex that must appear in the `up` command's captured stdout/stderr log.
100
+ * Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses: the service answering its own
101
+ * protocol (architecture D9). Exactly one of:
102
+ * - `command`/`args`: a command that asks the service and exits 0 when it answers,
103
+ * - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response.
104
104
  */
105
105
  export type WorldExternalReadyWhen = {
106
106
  command?: string;
107
107
  args?: string[];
108
108
  httpUrl?: string;
109
- stdoutMatch?: string;
110
109
  timeoutMs?: number;
111
110
  intervalMs?: number;
112
111
  };
@@ -224,7 +223,6 @@ export type WorldServiceConfig = {
224
223
  * service truly serves, so `up` returning means "ready" (no post-up re-polling needed):
225
224
  * - `{ httpUrl }` — poll until 2xx/3xx. `${url}`/`${host}`/`${port}` resolve to the allocated port.
226
225
  * - `{ command, args }` — poll until the command exits 0 (runs with the service env, incl. `PORT`).
227
- * - `{ stdoutMatch }` — poll until a regex matches the service's stdout/stderr log.
228
226
  * 'external' services use `external.readyWhen` instead.
229
227
  */
230
228
  ready?: 'tcp' | WorldExternalReadyWhen;
@@ -461,26 +459,21 @@ function assertValidRegex(pattern: string, what: string, path: string): void {
461
459
  }
462
460
 
463
461
  /** Shared validator for a readiness probe — used by both `external.readyWhen` and a process/twin
464
- * service's `ready` object. Exactly one of command/httpUrl/stdoutMatch, correct types, and any
465
- * stdoutMatch regex must compile (so a typo fails at config time, not mid-boot). */
462
+ * service's `ready` object. Exactly one of command/httpUrl, with correct types. */
466
463
  function assertReadyWhen(ready: unknown, what: string, path: string): void {
467
464
  if (!ready || typeof ready !== 'object' || Array.isArray(ready)) {
468
465
  throw new Error(`${what} must be an object in ${path}`);
469
466
  }
470
- assertKeys(ready as Record<string, unknown>, ['command', 'args', 'httpUrl', 'stdoutMatch', 'timeoutMs', 'intervalMs'], what, path);
467
+ assertKeys(ready as Record<string, unknown>, ['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs'], what, path);
471
468
  const r = ready as WorldExternalReadyWhen;
472
- const probes = [r.command !== undefined, r.httpUrl !== undefined, r.stdoutMatch !== undefined].filter(Boolean).length;
469
+ const probes = [r.command !== undefined, r.httpUrl !== undefined].filter(Boolean).length;
473
470
  if (probes !== 1) {
474
- throw new Error(`${what} must set exactly one of command/httpUrl/stdoutMatch in ${path}`);
471
+ throw new Error(`${what} must set exactly one of command/httpUrl in ${path}`);
475
472
  }
476
473
  if (r.command !== undefined && typeof r.command !== 'string') throw new Error(`${what}.command must be a string in ${path}`);
477
474
  if (r.args !== undefined && (!Array.isArray(r.args) || r.args.some((arg) => typeof arg !== 'string'))) throw new Error(`${what}.args must be a string array in ${path}`);
478
475
  if (r.args !== undefined && r.command === undefined) throw new Error(`${what}.args requires command in ${path}`);
479
476
  if (r.httpUrl !== undefined && typeof r.httpUrl !== 'string') throw new Error(`${what}.httpUrl must be a string in ${path}`);
480
- if (r.stdoutMatch !== undefined) {
481
- if (typeof r.stdoutMatch !== 'string') throw new Error(`${what}.stdoutMatch must be a string in ${path}`);
482
- assertValidRegex(r.stdoutMatch, `${what}.stdoutMatch`, path);
483
- }
484
477
  for (const key of ['timeoutMs', 'intervalMs'] as const) {
485
478
  if (r[key] !== undefined && (!Number.isInteger(r[key]) || r[key]! <= 0)) throw new Error(`${what}.${key} must be a positive integer in ${path}`);
486
479
  }
@@ -83,12 +83,6 @@ function split(line: string): { output: string; record?: { event?: unknown; at?:
83
83
  try { return { output: line.slice(0, at), record: JSON.parse(line.slice(at)) as { event?: unknown; at?: unknown } }; } catch { return { output: line }; }
84
84
  }
85
85
 
86
- /** A log's text as the service wrote it, without the recorder's records: what a `stdoutMatch` readiness pattern is
87
- * matched against. */
88
- export function serviceOutput(text: string): string {
89
- return text.includes(MARK) ? text.split('\n').map((line) => split(line).output).join('\n') : text;
90
- }
91
-
92
86
  /** The last run's end in a service log: its start, its exit record (absent while it runs, or when its parent was killed
93
87
  * with it), and the output it wrote last. Undefined for a log the parent never wrote to (a co-located twin's host log),
94
88
  * which has no runs to tell apart. A run is bounded by its start record or, for a spawn that failed before starting
package/src/up-cli.ts CHANGED
@@ -22,7 +22,17 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
22
22
  // --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
23
23
  // browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
24
24
  const keepState = rest.includes('--keep-state');
25
+ // --app-url: where the booter serves the app, recorded as `volter-world app-url --set` records it, once the World is
26
+ // up and in this process: a booter that knows the address before the app listens (a browser tab's preview origin)
27
+ // saves a whole command between up and the app's start.
28
+ const appUrl = optionValue(rest, '--app-url') || undefined;
29
+ if (appUrl !== undefined && !(URL.canParse(appUrl) && /^https?:$/.test(new URL(appUrl).protocol))) throw new Error(`volter-world up: --app-url "${appUrl}" must be an http(s) URL`);
25
30
  const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
31
+ if (appUrl !== undefined) {
32
+ const { setAppUrl } = await import('./app-url.ts');
33
+ setAppUrl(instance.name, appUrl, { root });
34
+ process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
35
+ }
26
36
  if (rest.includes('--json')) process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
27
37
  else printReady(instance);
28
38
  }