@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.
@@ -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
  });
@@ -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.
@@ -54,6 +54,27 @@ function listening(port: number): Promise<boolean> {
54
54
  });
55
55
  }
56
56
 
57
+ /** Whether the Redis answering on the port is this child (architecture D9): its own INFO server names its process_id, as
58
+ * Redis's does. A port that answers as another process, or does not answer yet, is not this child's. */
59
+ function redisServes(port: number, pid: number): Promise<boolean> {
60
+ return new Promise((resolve) => {
61
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
62
+ let reply = '';
63
+ socket.once('connect', () => socket.write('*2\r\n$4\r\nINFO\r\n$6\r\nserver\r\n'));
64
+ socket.on('data', (chunk: Buffer) => {
65
+ reply += chunk.toString('latin1');
66
+ // a bulk string: $<length>\r\n<body>\r\n
67
+ const head = /^\$(\d+)\r\n/.exec(reply);
68
+ if (!head) { if (!reply.startsWith('$') || reply.includes('\r\n')) { socket.destroy(); resolve(false); } return; }
69
+ if (Buffer.byteLength(reply, 'latin1') < head[0].length + Number(head[1]) + 2) return;
70
+ socket.destroy();
71
+ resolve(Number(/(?:^|\r\n)process_id:(\d+)\r\n/.exec(reply.slice(head[0].length))?.[1]) === pid);
72
+ });
73
+ socket.on('error', () => resolve(false));
74
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
75
+ });
76
+ }
77
+
57
78
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
58
79
 
59
80
  export async function redisTwinUp(services: InfraService[], dataDir: string, cli: string, launch: HostLaunch = {}): Promise<void> {
@@ -93,11 +114,11 @@ export async function redisTwinUp(services: InfraService[], dataDir: string, cli
93
114
  publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(identity) + '\n');
94
115
  publishProcessReceipt(pidFile, `${held.child.pid}\n`);
95
116
  const deadline = Date.now() + READY_TIMEOUT_MS;
96
- while (!(await listening(service.hostPort)) || !alive(held.child.pid)) {
117
+ while (!alive(held.child.pid) || !(await redisServes(service.hostPort, held.child.pid))) {
97
118
  if (!alive(held.child.pid)) throw new Error(`redis exited before serving — see ${log}`);
98
119
  launch.signal?.throwIfAborted();
99
120
  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);
121
+ await sleep(25);
101
122
  }
102
123
  launch.signal?.throwIfAborted();
103
124
  held.child.unref();
@@ -116,7 +137,8 @@ export async function redisTwinStatus(services: InfraService[], dataDir: string)
116
137
  for (const service of services) {
117
138
  const pidFile = pidPath(dataDir, service);
118
139
  if (!existsSync(pidFile)) continue;
119
- if (alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort)) ready += 1;
140
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
141
+ if (alive(pid) && await redisServes(service.hostPort, pid)) ready += 1;
120
142
  }
121
143
  return ready;
122
144
  }
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;
@@ -2903,30 +2865,36 @@ function canonicalDottedIpv4(host: string): boolean {
2903
2865
  function parseCustomShareOrigin(handshake: string): string {
2904
2866
  const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(handshake);
2905
2867
  if (!raw) {
2906
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must be a strict http(s) origin without path, query, fragment, or backslashes: ${handshake}`);
2868
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a strict http(s) origin without path, query, fragment, or backslashes: ${handshake}`);
2907
2869
  }
2908
2870
  const rawHost = raw[1]!;
2909
2871
  const rawPort = raw[2];
2910
2872
  let parsed: URL;
2911
2873
  try { parsed = new URL(handshake); }
2912
- catch { throw new Error(`Custom tunnel emitted an invalid VOLTER_SHARE_URL handshake: ${handshake}`); }
2874
+ catch { throw new Error(`A custom tunnel wrote an invalid URL to VOLTER_SHARE_URL_FILE: ${handshake}`); }
2913
2875
  const normalizedHostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
2914
2876
  if (!rawHost.startsWith('[') && isIP(normalizedHostname) === 4
2915
2877
  && (!canonicalDottedIpv4(rawHost) || rawHost !== normalizedHostname)) {
2916
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
2878
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
2917
2879
  }
2918
2880
  if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
2919
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must use canonical decimal port syntax: ${handshake}`);
2881
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical decimal port syntax: ${handshake}`);
2920
2882
  }
2921
2883
  if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password
2922
2884
  || parsed.pathname !== '/' || parsed.search || parsed.hash) {
2923
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must be a credential-free http(s) origin: ${handshake}`);
2885
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a credential-free http(s) origin: ${handshake}`);
2924
2886
  }
2925
2887
  return parsed.origin;
2926
2888
  }
2927
2889
 
2928
- async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string, timeoutMs: number, requireTryCloudflare: boolean): Promise<string> {
2929
- const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
2890
+ /** Where a tunnel answers its public URL: cloudflared's metrics server, or the file a custom command writes. */
2891
+ type PublicUrlSource = { metricsPort: number } | { urlFile: string };
2892
+
2893
+ /** The tunnel's public URL, from the tunnel's own answer and never its output (architecture D9). cloudflared's metrics
2894
+ * server answers GET /quicktunnel with {"hostname"} once the quick tunnel is registered (empty before); a custom command
2895
+ * writes its public URL to VOLTER_SHARE_URL_FILE, ending the line when it is complete, so a half-written URL is never
2896
+ * read. The process's log is for people. */
2897
+ async function waitForPublicUrl(child: ReturnType<typeof spawn>, source: PublicUrlSource, logPath: string, timeoutMs: number): Promise<string> {
2930
2898
  let exited = false;
2931
2899
  let exitSummary = '';
2932
2900
  child.once('exit', (code, signal) => {
@@ -2940,22 +2908,29 @@ async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string
2940
2908
 
2941
2909
  const started = Date.now();
2942
2910
  while (Date.now() - started < timeoutMs) {
2943
- const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
2944
- if (requireTryCloudflare) {
2945
- for (const candidate of text.match(urlPattern) ?? []) {
2946
- const parsed = parseCloudflareQuickPublicUrl(candidate);
2947
- if (parsed) return parsed;
2911
+ if ('metricsPort' in source) {
2912
+ try {
2913
+ const answer = await fetch(`http://127.0.0.1:${source.metricsPort}/quicktunnel`, { signal: AbortSignal.timeout(1000) });
2914
+ const hostname = answer.ok ? ((await answer.json()) as { hostname?: unknown }).hostname : undefined;
2915
+ if (typeof hostname === 'string' && hostname) {
2916
+ const parsed = parseCloudflareQuickPublicUrl(`https://${hostname}`);
2917
+ if (!parsed) throw new Error(`cloudflared answered /quicktunnel with ${JSON.stringify(hostname)}, not a trycloudflare.com hostname. Log: ${logPath}`);
2918
+ return parsed;
2919
+ }
2920
+ } catch (error) {
2921
+ if (error instanceof Error && error.message.startsWith('cloudflared answered')) throw error;
2922
+ // its metrics server not listening yet: asked again
2948
2923
  }
2949
2924
  } else {
2950
- const handshake = /^VOLTER_SHARE_URL=(\S+)\s*$/m.exec(text)?.[1];
2951
- if (handshake) return parseCustomShareOrigin(handshake);
2925
+ const text = existsSync(source.urlFile) ? readFileSync(source.urlFile, 'utf8') : '';
2926
+ if (text.endsWith('\n')) return parseCustomShareOrigin(text.trim());
2952
2927
  }
2953
- if (exited) throw new Error(`Tunnel process exited before printing a public URL (${exitSummary}). Log: ${logPath}`);
2954
- await new Promise((resolveWait) => setTimeout(resolveWait, 250));
2928
+ if (exited) throw new Error(`Tunnel process exited before answering its public URL (${exitSummary}). Log: ${logPath}`);
2929
+ await new Promise((resolveWait) => setTimeout(resolveWait, 100));
2955
2930
  }
2956
- throw new Error(requireTryCloudflare
2957
- ? `Cloudflare tunnel did not print a trycloudflare.com https URL within ${timeoutMs}ms. Log: ${logPath}`
2958
- : `Custom tunnel did not print VOLTER_SHARE_URL=<http(s)-url> within ${timeoutMs}ms. Log: ${logPath}`);
2931
+ throw new Error('metricsPort' in source
2932
+ ? `cloudflared's metrics server did not answer a trycloudflare.com hostname at /quicktunnel within ${timeoutMs}ms. Log: ${logPath}`
2933
+ : `Custom tunnel did not write its public URL, newline-terminated, to VOLTER_SHARE_URL_FILE within ${timeoutMs}ms. Log: ${logPath}`);
2959
2934
  }
2960
2935
 
2961
2936
  function sleep(ms: number): Promise<void> {
@@ -3128,12 +3103,18 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
3128
3103
 
3129
3104
  const tunnel = shareCommand(options, service.url);
3130
3105
  const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
3131
- // Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
3132
- // share from accepting the previous process's URL handshake as the new process's endpoint.
3106
+ // The public URL is the tunnel's own answer, never its output (architecture D9): cloudflared's metrics server
3107
+ // answers /quicktunnel with the hostname it registered; a custom command writes its URL to VOLTER_SHARE_URL_FILE.
3108
+ // Each spawned tunnel owns one generation of both, so a repeated share never takes the previous process's URL.
3109
+ const source: PublicUrlSource = tunnel.provider === 'cloudflare-quick'
3110
+ ? { metricsPort: await allocatePort(new Set()) }
3111
+ : { urlFile: ownedPath(instance.dirs.logs, `${serviceId}.tunnel.url`) };
3112
+ if ('urlFile' in source) rmSync(source.urlFile, { force: true });
3113
+ const args = 'metricsPort' in source ? ['tunnel', '--metrics', `127.0.0.1:${source.metricsPort}`, ...tunnel.args.slice(1)] : tunnel.args;
3133
3114
  const out = openPrivateLog(log, 'w');
3134
- const child = spawn(tunnel.command, tunnel.args, { windowsHide: true,
3115
+ const child = spawn(tunnel.command, args, { windowsHide: true,
3135
3116
  cwd: root,
3136
- env: withoutVaultSignIn(process.env),
3117
+ env: { ...withoutVaultSignIn(process.env), ...('urlFile' in source ? { VOLTER_SHARE_URL_FILE: source.urlFile } : {}) },
3137
3118
  detached: true,
3138
3119
  stdio: ['ignore', out, out],
3139
3120
  });
@@ -3149,7 +3130,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
3149
3130
  const identity = await birth;
3150
3131
  if (!identity?.start) throw new Error('Tunnel birth capture failed; retiring held child');
3151
3132
  identities[identity.pid] = identity.start;
3152
- publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
3133
+ publicUrl = await waitForPublicUrl(child, source, log, options.timeoutMs ?? 20_000);
3153
3134
  if (options.verifyPath !== false) {
3154
3135
  publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
3155
3136
  }
@@ -3167,7 +3148,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
3167
3148
  provider: tunnel.provider,
3168
3149
  pid: child.pid ?? 0,
3169
3150
  log,
3170
- command: [tunnel.command, ...tunnel.args],
3151
+ command: [tunnel.command, ...args],
3171
3152
  startedAt: new Date().toISOString(),
3172
3153
  };
3173
3154
  writePidsFromInstance(nextInstance, child.pid ? [child.pid] : []);
@@ -3299,12 +3280,11 @@ const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
3299
3280
  /** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
3300
3281
  * polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
3301
3282
  * 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`. */
3283
+ * (`command`/`httpUrl`) declared in config for the boot-time `readyWhen`. */
3303
3284
  async function probeExternalOnce(
3304
3285
  ready: WorldExternalReadyWhen,
3305
3286
  cwd: string,
3306
3287
  env: NodeJS.ProcessEnv,
3307
- logPath: string,
3308
3288
  ): Promise<{ ok: boolean; message: string }> {
3309
3289
  if (ready.command !== undefined) {
3310
3290
  if (!commandExists(ready.command)) {
@@ -3327,12 +3307,7 @@ async function probeExternalOnce(
3327
3307
  return { ok: false, message: `probe failed: ${errorMessage(error)}` };
3328
3308
  }
3329
3309
  }
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)' };
3310
+ return { ok: false, message: 'malformed readiness probe (no command/httpUrl)' };
3336
3311
  }
3337
3312
 
3338
3313
  /** A stopped service's own account for doctor: how it ended, its last output, and where its whole log is. */
@@ -3402,7 +3377,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3402
3377
  const probeCwd = service.external?.cwd ?? root;
3403
3378
  const probeEnv = { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
3404
3379
  if (configuredExternal?.readyWhen) {
3405
- const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv, service.log);
3380
+ const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv);
3406
3381
  checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
3407
3382
  } else if (configuredExternal?.status) {
3408
3383
  try {
@@ -3437,13 +3412,12 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3437
3412
  checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url! });
3438
3413
  // A port that accepts is not a service that answers (a stuck event loop still completes the handshake): the
3439
3414
  // 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.
3415
+ // The probe is re-run in the service's own cwd and with its PORT, as at boot.
3442
3416
  const configured = externalProbeConfig?.services.find((candidate) => candidate.id === service.id);
3443
3417
  const declared = configured?.ready;
3444
- if (pidLive && declared && declared !== 'tcp' && declared.stdoutMatch === undefined) {
3418
+ if (pidLive && declared && declared !== 'tcp') {
3445
3419
  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);
3420
+ { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) });
3447
3421
  checks.push({ id: `service:${service.id}:ready`, ok: probe.ok,
3448
3422
  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
3423
  }