@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.
@@ -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 {
@@ -3103,7 +3056,7 @@ function canonicalDottedIpv4(host) {
3103
3056
  function parseCustomShareOrigin(handshake) {
3104
3057
  const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(handshake);
3105
3058
  if (!raw) {
3106
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must be a strict http(s) origin without path, query, fragment, or backslashes: ${handshake}`);
3059
+ 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}`);
3107
3060
  }
3108
3061
  const rawHost = raw[1];
3109
3062
  const rawPort = raw[2];
@@ -3112,24 +3065,27 @@ function parseCustomShareOrigin(handshake) {
3112
3065
  parsed = new URL(handshake);
3113
3066
  }
3114
3067
  catch {
3115
- throw new Error(`Custom tunnel emitted an invalid VOLTER_SHARE_URL handshake: ${handshake}`);
3068
+ throw new Error(`A custom tunnel wrote an invalid URL to VOLTER_SHARE_URL_FILE: ${handshake}`);
3116
3069
  }
3117
3070
  const normalizedHostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
3118
3071
  if (!rawHost.startsWith('[') && isIP(normalizedHostname) === 4
3119
3072
  && (!canonicalDottedIpv4(rawHost) || rawHost !== normalizedHostname)) {
3120
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
3073
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
3121
3074
  }
3122
3075
  if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
3123
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must use canonical decimal port syntax: ${handshake}`);
3076
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical decimal port syntax: ${handshake}`);
3124
3077
  }
3125
3078
  if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password
3126
3079
  || parsed.pathname !== '/' || parsed.search || parsed.hash) {
3127
- throw new Error(`Custom tunnel VOLTER_SHARE_URL must be a credential-free http(s) origin: ${handshake}`);
3080
+ throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a credential-free http(s) origin: ${handshake}`);
3128
3081
  }
3129
3082
  return parsed.origin;
3130
3083
  }
3131
- async function waitForPublicUrl(child, logPath, timeoutMs, requireTryCloudflare) {
3132
- const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
3084
+ /** The tunnel's public URL, from the tunnel's own answer and never its output (architecture D9). cloudflared's metrics
3085
+ * server answers GET /quicktunnel with {"hostname"} once the quick tunnel is registered (empty before); a custom command
3086
+ * writes its public URL to VOLTER_SHARE_URL_FILE, ending the line when it is complete, so a half-written URL is never
3087
+ * read. The process's log is for people. */
3088
+ async function waitForPublicUrl(child, source, logPath, timeoutMs) {
3133
3089
  let exited = false;
3134
3090
  let exitSummary = '';
3135
3091
  child.once('exit', (code, signal) => {
@@ -3142,26 +3098,35 @@ async function waitForPublicUrl(child, logPath, timeoutMs, requireTryCloudflare)
3142
3098
  });
3143
3099
  const started = Date.now();
3144
3100
  while (Date.now() - started < timeoutMs) {
3145
- const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
3146
- if (requireTryCloudflare) {
3147
- for (const candidate of text.match(urlPattern) ?? []) {
3148
- const parsed = parseCloudflareQuickPublicUrl(candidate);
3149
- if (parsed)
3101
+ if ('metricsPort' in source) {
3102
+ try {
3103
+ const answer = await fetch(`http://127.0.0.1:${source.metricsPort}/quicktunnel`, { signal: AbortSignal.timeout(1000) });
3104
+ const hostname = answer.ok ? (await answer.json()).hostname : undefined;
3105
+ if (typeof hostname === 'string' && hostname) {
3106
+ const parsed = parseCloudflareQuickPublicUrl(`https://${hostname}`);
3107
+ if (!parsed)
3108
+ throw new Error(`cloudflared answered /quicktunnel with ${JSON.stringify(hostname)}, not a trycloudflare.com hostname. Log: ${logPath}`);
3150
3109
  return parsed;
3110
+ }
3111
+ }
3112
+ catch (error) {
3113
+ if (error instanceof Error && error.message.startsWith('cloudflared answered'))
3114
+ throw error;
3115
+ // its metrics server not listening yet: asked again
3151
3116
  }
3152
3117
  }
3153
3118
  else {
3154
- const handshake = /^VOLTER_SHARE_URL=(\S+)\s*$/m.exec(text)?.[1];
3155
- if (handshake)
3156
- return parseCustomShareOrigin(handshake);
3119
+ const text = existsSync(source.urlFile) ? readFileSync(source.urlFile, 'utf8') : '';
3120
+ if (text.endsWith('\n'))
3121
+ return parseCustomShareOrigin(text.trim());
3157
3122
  }
3158
3123
  if (exited)
3159
- throw new Error(`Tunnel process exited before printing a public URL (${exitSummary}). Log: ${logPath}`);
3160
- await new Promise((resolveWait) => setTimeout(resolveWait, 250));
3124
+ throw new Error(`Tunnel process exited before answering its public URL (${exitSummary}). Log: ${logPath}`);
3125
+ await new Promise((resolveWait) => setTimeout(resolveWait, 100));
3161
3126
  }
3162
- throw new Error(requireTryCloudflare
3163
- ? `Cloudflare tunnel did not print a trycloudflare.com https URL within ${timeoutMs}ms. Log: ${logPath}`
3164
- : `Custom tunnel did not print VOLTER_SHARE_URL=<http(s)-url> within ${timeoutMs}ms. Log: ${logPath}`);
3127
+ throw new Error('metricsPort' in source
3128
+ ? `cloudflared's metrics server did not answer a trycloudflare.com hostname at /quicktunnel within ${timeoutMs}ms. Log: ${logPath}`
3129
+ : `Custom tunnel did not write its public URL, newline-terminated, to VOLTER_SHARE_URL_FILE within ${timeoutMs}ms. Log: ${logPath}`);
3165
3130
  }
3166
3131
  function sleep(ms) {
3167
3132
  return new Promise((resolveWait) => setTimeout(resolveWait, ms));
@@ -3331,12 +3296,19 @@ async function shareWorldReserved(name, options) {
3331
3296
  }
3332
3297
  const tunnel = shareCommand(options, service.url);
3333
3298
  const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
3334
- // Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
3335
- // share from accepting the previous process's URL handshake as the new process's endpoint.
3299
+ // The public URL is the tunnel's own answer, never its output (architecture D9): cloudflared's metrics server
3300
+ // answers /quicktunnel with the hostname it registered; a custom command writes its URL to VOLTER_SHARE_URL_FILE.
3301
+ // Each spawned tunnel owns one generation of both, so a repeated share never takes the previous process's URL.
3302
+ const source = tunnel.provider === 'cloudflare-quick'
3303
+ ? { metricsPort: await allocatePort(new Set()) }
3304
+ : { urlFile: ownedPath(instance.dirs.logs, `${serviceId}.tunnel.url`) };
3305
+ if ('urlFile' in source)
3306
+ rmSync(source.urlFile, { force: true });
3307
+ const args = 'metricsPort' in source ? ['tunnel', '--metrics', `127.0.0.1:${source.metricsPort}`, ...tunnel.args.slice(1)] : tunnel.args;
3336
3308
  const out = openPrivateLog(log, 'w');
3337
- const child = spawn(tunnel.command, tunnel.args, { windowsHide: true,
3309
+ const child = spawn(tunnel.command, args, { windowsHide: true,
3338
3310
  cwd: root,
3339
- env: withoutVaultSignIn(process.env),
3311
+ env: { ...withoutVaultSignIn(process.env), ...('urlFile' in source ? { VOLTER_SHARE_URL_FILE: source.urlFile } : {}) },
3340
3312
  detached: true,
3341
3313
  stdio: ['ignore', out, out],
3342
3314
  });
@@ -3352,7 +3324,7 @@ async function shareWorldReserved(name, options) {
3352
3324
  if (!identity?.start)
3353
3325
  throw new Error('Tunnel birth capture failed; retiring held child');
3354
3326
  identities[identity.pid] = identity.start;
3355
- publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
3327
+ publicUrl = await waitForPublicUrl(child, source, log, options.timeoutMs ?? 20_000);
3356
3328
  if (options.verifyPath !== false) {
3357
3329
  publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
3358
3330
  }
@@ -3371,7 +3343,7 @@ async function shareWorldReserved(name, options) {
3371
3343
  provider: tunnel.provider,
3372
3344
  pid: child.pid ?? 0,
3373
3345
  log,
3374
- command: [tunnel.command, ...tunnel.args],
3346
+ command: [tunnel.command, ...args],
3375
3347
  startedAt: new Date().toISOString(),
3376
3348
  };
3377
3349
  writePidsFromInstance(nextInstance, child.pid ? [child.pid] : []);
@@ -3503,8 +3475,8 @@ const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
3503
3475
  /** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
3504
3476
  * polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
3505
3477
  * 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) {
3478
+ * (`command`/`httpUrl`) declared in config for the boot-time `readyWhen`. */
3479
+ async function probeExternalOnce(ready, cwd, env) {
3508
3480
  if (ready.command !== undefined) {
3509
3481
  if (!commandExists(ready.command)) {
3510
3482
  return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
@@ -3529,13 +3501,7 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
3529
3501
  return { ok: false, message: `probe failed: ${errorMessage(error)}` };
3530
3502
  }
3531
3503
  }
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)' };
3504
+ return { ok: false, message: 'malformed readiness probe (no command/httpUrl)' };
3539
3505
  }
3540
3506
  /** A stopped service's own account for doctor: how it ended, its last output, and where its whole log is. */
3541
3507
  function stoppedServiceReport(pid, log) {
@@ -3599,7 +3565,7 @@ export async function doctorWorld(name, options = {}) {
3599
3565
  const probeCwd = service.external?.cwd ?? root;
3600
3566
  const probeEnv = { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
3601
3567
  if (configuredExternal?.readyWhen) {
3602
- const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv, service.log);
3568
+ const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv);
3603
3569
  checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
3604
3570
  }
3605
3571
  else if (configuredExternal?.status) {
@@ -3637,12 +3603,11 @@ export async function doctorWorld(name, options = {}) {
3637
3603
  checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url });
3638
3604
  // A port that accepts is not a service that answers (a stuck event loop still completes the handshake): the
3639
3605
  // 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.
3606
+ // The probe is re-run in the service's own cwd and with its PORT, as at boot.
3642
3607
  const configured = externalProbeConfig?.services.find((candidate) => candidate.id === service.id);
3643
3608
  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);
3609
+ if (pidLive && declared && declared !== 'tcp') {
3610
+ 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
3611
  checks.push({ id: `service:${service.id}:ready`, ok: probe.ok,
3647
3612
  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
3613
  }
@@ -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.100",
3
+ "version": "3.0.102",
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.100",
73
- "@volter/world-console": "3.0.95"
72
+ "@volter/world-core": "3.0.102",
73
+ "@volter/world-console": "3.0.97"
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.100",
79
+ "@volter/world-core": "3.0.102",
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. */