@volter/world-runtime 3.0.98 → 3.0.100

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.
package/dist/src/infra.js CHANGED
@@ -7,13 +7,29 @@
7
7
  // that owns the World's lifetime, as every other service's recorder is, and not of a tool that exits once they
8
8
  // serve. A process layer where a child does not outlive its parent (a browser tab's) otherwise ended the PGlite host
9
9
  // the moment `volter-world-infra up` returned, and the World's readiness probe never saw it.
10
- import { existsSync, readFileSync } from 'node:fs';
11
- import { dirname, join } from 'node:path';
10
+ import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
11
+ import { delimiter, dirname, join } from 'node:path';
12
12
  import { signalChild } from './process-signal.mjs';
13
13
  import { spawn } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
16
16
  import { errorMessage } from "./error-message.js";
17
+ /** Whether a program is on PATH, read from the file system without starting a process: where a missing program
18
+ * still costs a process round trip (a browser tab's kernel), the container probe is not spawned at all. */
19
+ function onPath(name, env) {
20
+ return (env.PATH ?? '').split(delimiter).some((dir) => {
21
+ if (!dir)
22
+ return false;
23
+ try {
24
+ const path = join(dir, name);
25
+ accessSync(path, constants.X_OK);
26
+ return statSync(path).isFile();
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ });
32
+ }
17
33
  /** A command run to its end without blocking this process: in the World's own `up` the phase shares the event loop
18
34
  * with the boot's signal handling, where the tool's spawnSync blocked only the tool. Ended by `timeout` (SIGTERM,
19
35
  * as spawnSync's timeout) or by the boot's abort. */
@@ -75,10 +91,35 @@ function runCommand(command, args, options) {
75
91
  /** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
76
92
  * services this starts run in; `signal` is the boot's abort (the World's own up passes it; the tool has none). */
77
93
  export async function runInfrastructure(phase, env, io, signal) {
78
- if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
79
- io.err('managed infrastructure: expected up, status, or down\n');
94
+ if (phase !== 'up' && phase !== 'status' && phase !== 'down' && phase !== 'prepare') {
95
+ io.err('managed infrastructure: expected up, status, down, or prepare\n');
80
96
  return 2;
81
97
  }
98
+ // prepare: the image build's step that makes the World's PGlite cluster once, on its host, so the World's first up
99
+ // opens it instead of running initdb (pglite-backing.ts, pglitePrepare; ADR 0018). It needs the World's
100
+ // definition, not an instance.
101
+ if (phase === 'prepare') {
102
+ const config = env.VOLTER_WORLD_CONFIG;
103
+ const declaredDefinition = config ? join(dirname(config), 'world.infrastructure.yml') : undefined;
104
+ if (!config || !declaredDefinition || !existsSync(declaredDefinition)) {
105
+ io.err('managed infrastructure prepare: VOLTER_WORLD_CONFIG must name a world.json beside its world.infrastructure.yml\n');
106
+ return 2;
107
+ }
108
+ const postgres = parseInfraDefinition(readFileSync(declaredDefinition, 'utf8')).filter((s) => s.kind === 'postgres');
109
+ if (postgres.length !== 1) {
110
+ io.out('managed infrastructure prepare: no single declared PostgreSQL; nothing to prepare\n');
111
+ return 0;
112
+ }
113
+ const { pglitePrepare } = await import("./pglite-backing.js");
114
+ try {
115
+ io.out(`managed infrastructure prepared ${await pglitePrepare(postgres[0], config, { env, ...(signal ? { signal } : {}) })}\n`);
116
+ return 0;
117
+ }
118
+ catch (error) {
119
+ io.err(`managed infrastructure prepare failed: ${errorMessage(error)}\n`);
120
+ return 1;
121
+ }
122
+ }
82
123
  const worldConfig = env.VOLTER_WORLD_CONFIG;
83
124
  const worldData = env.VOLTER_WORLD_DATA;
84
125
  if (!worldConfig || !worldData) {
@@ -96,7 +137,7 @@ export async function runInfrastructure(phase, env, io, signal) {
96
137
  signal?.throwIfAborted();
97
138
  return ran;
98
139
  };
99
- const launch = { env, ...(signal ? { signal } : {}) };
140
+ const launch = { env, ...(signal ? { signal } : {}), note: (what) => io.out(`managed infrastructure: ${what}\n`) };
100
141
  const classify = (raw) => /no space left on device|enospc/iu.test(raw)
101
142
  ? 'insufficient writable storage'
102
143
  : /out of memory|cannot allocate memory|killed/iu.test(raw)
@@ -139,6 +180,9 @@ export async function runInfrastructure(phase, env, io, signal) {
139
180
  io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker, pglite or native (got ${JSON.stringify(forced)})\n`);
140
181
  return 2;
141
182
  }
183
+ // No docker on PATH is the same absence the probe's ENOENT reports, read without a process.
184
+ if (process.platform !== 'win32' && !onPath('docker', env))
185
+ return 'pglite';
142
186
  const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
143
187
  signal?.throwIfAborted();
144
188
  if (probe.status === 0 && probe.stdout.trim())
@@ -23,9 +23,27 @@ export declare function containerlessDataDirectory(kind: string): string | undef
23
23
  export interface HostLaunch {
24
24
  env?: NodeJS.ProcessEnv;
25
25
  signal?: AbortSignal;
26
+ note?: (what: string) => void;
26
27
  }
27
28
  /** The kinds this backing cannot serve, or [] when it can serve the world. */
28
29
  export declare function unsupportedKinds(services: InfraService[]): string[];
30
+ /**
31
+ * A World's PostgreSQL cluster prepared before its first boot (architecture, managed infrastructure; ADR 0018).
32
+ * PGlite's first start in an empty data directory runs initdb in wasm, writes the whole cluster through the file
33
+ * system, then the host creates the declared database and role; in a browser tab that is the World's slowest boot
34
+ * step. `volter-world-infra prepare` runs the host's own open path (`pglite-host.mjs --prepare`) once, on the image
35
+ * build's host, into `<World dir>/.volter-prepared/pglite-postgres-data`, with a stamp naming the PGlite pin and the
36
+ * declared identity. At `up`, an empty service data directory takes that cluster by rename, and PGlite finds
37
+ * PG_VERSION and resumes it (its own existing-dataDir path). Only an exact stamp is taken; anything else boots as before.
38
+ */
39
+ export declare const PREPARED_DIRECTORY = ".volter-prepared";
40
+ /** Where a World's prepared PostgreSQL cluster lives, beside its world.json, and its stamp. */
41
+ export declare function preparedCluster(worldConfig: string): {
42
+ directory: string;
43
+ stamp: string;
44
+ };
45
+ /** Run the host's open path once into the World's prepared directory; replaces an earlier preparation. */
46
+ export declare function pglitePrepare(service: InfraService, worldConfig: string, launch?: HostLaunch): Promise<string>;
29
47
  export declare function pgliteUp(services: InfraService[], dataDir: string, launch?: HostLaunch): Promise<void>;
30
48
  export declare function pgliteStatus(services: InfraService[], dataDir: string): Promise<{
31
49
  ok: boolean;
@@ -12,7 +12,7 @@
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 { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
15
+ import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { connect } from 'node:net';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { spawn } from 'node:child_process';
@@ -157,6 +157,88 @@ function readyLine(port) {
157
157
  function logSince(log, offset) {
158
158
  return readFileSync(log).subarray(offset).toString('utf8');
159
159
  }
160
+ /**
161
+ * A World's PostgreSQL cluster prepared before its first boot (architecture, managed infrastructure; ADR 0018).
162
+ * PGlite's first start in an empty data directory runs initdb in wasm, writes the whole cluster through the file
163
+ * system, then the host creates the declared database and role; in a browser tab that is the World's slowest boot
164
+ * step. `volter-world-infra prepare` runs the host's own open path (`pglite-host.mjs --prepare`) once, on the image
165
+ * build's host, into `<World dir>/.volter-prepared/pglite-postgres-data`, with a stamp naming the PGlite pin and the
166
+ * declared identity. At `up`, an empty service data directory takes that cluster by rename, and PGlite finds
167
+ * PG_VERSION and resumes it (its own existing-dataDir path). Only an exact stamp is taken; anything else boots as before.
168
+ */
169
+ export const PREPARED_DIRECTORY = '.volter-prepared';
170
+ /** Where a World's prepared PostgreSQL cluster lives, beside its world.json, and its stamp. */
171
+ export function preparedCluster(worldConfig) {
172
+ const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, containerlessDataDirectory('postgres'));
173
+ return { directory, stamp: `${directory}.json` };
174
+ }
175
+ /**
176
+ * What a prepared cluster must have been made with: this runtime (whose pglite-host.mjs is the open path that made
177
+ * it), its exact PGlite pin (initdb's encoding and locale are PGlite's defaults: the host passes no initdb
178
+ * parameters), and the declared user and database. The password is not in the cluster: the role is created without
179
+ * one (LOGIN SUPERUSER) and the host's gateway checks the declared password on each connection, so a World with
180
+ * another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
181
+ * at each open, never created in the cluster by the open path.
182
+ */
183
+ function preparedStamp(service) {
184
+ const manifest = JSON.parse(readFileSync(join(here(), '..', 'package.json'), 'utf8'));
185
+ const pglite = manifest.dependencies?.['@electric-sql/pglite'];
186
+ if (!pglite || !/^\d+\.\d+\.\d+$/.test(pglite))
187
+ throw new Error('world-runtime pins no exact @electric-sql/pglite version');
188
+ return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres.user, database: service.postgres.database })}\n`;
189
+ }
190
+ /** Run the host's open path once into the World's prepared directory; replaces an earlier preparation. */
191
+ export async function pglitePrepare(service, worldConfig, launch = {}) {
192
+ if (service.kind !== 'postgres' || !service.postgres?.password)
193
+ throw new Error('only a declared PostgreSQL with a literal POSTGRES_PASSWORD is prepared');
194
+ const { directory, stamp } = preparedCluster(worldConfig);
195
+ rmSync(directory, { recursive: true, force: true });
196
+ rmSync(stamp, { force: true });
197
+ mkdirSync(directory, { recursive: true });
198
+ const child = spawn('node', [hostScript(), '--prepare', '--data', directory], {
199
+ windowsHide: true,
200
+ env: { ...(launch.env ?? process.env), VOLTER_WORLD_PGLITE_USER: service.postgres.user,
201
+ VOLTER_WORLD_PGLITE_DATABASE: service.postgres.database, VOLTER_WORLD_PGLITE_PASSWORD: service.postgres.password },
202
+ stdio: ['ignore', 'pipe', 'pipe'],
203
+ });
204
+ const lifetime = commandLifetime(child, launch.signal);
205
+ let output = '';
206
+ child.stdout.on('data', (chunk) => { output += chunk.toString('utf8'); });
207
+ child.stderr.on('data', (chunk) => { output += chunk.toString('utf8'); });
208
+ const { code } = await lifetime.finish();
209
+ if (code !== 0 || !existsSync(join(directory, 'PG_VERSION'))) {
210
+ rmSync(directory, { recursive: true, force: true });
211
+ throw new Error(`pglite prepare exited ${code ?? 'null'} without a cluster\n${output.trim().split('\n').slice(-40).join('\n')}`);
212
+ }
213
+ writeFileSync(stamp, preparedStamp(service));
214
+ return directory;
215
+ }
216
+ /** Give an empty service data directory the World's prepared cluster when its stamp matches; says what it did. */
217
+ function placePreparedCluster(service, serviceData, worldConfig) {
218
+ if (service.kind !== 'postgres' || !worldConfig || existsSync(join(serviceData, 'PG_VERSION')))
219
+ return { placed: false };
220
+ const { directory, stamp } = preparedCluster(worldConfig);
221
+ if (!existsSync(join(directory, 'PG_VERSION')) || !existsSync(stamp))
222
+ return { placed: false };
223
+ if (readFileSync(stamp, 'utf8') !== preparedStamp(service))
224
+ return { placed: false, note: `prepared cluster ${directory} not taken: its stamp names another PGlite pin or identity` };
225
+ if (existsSync(serviceData)) {
226
+ if (readdirSync(serviceData).length)
227
+ return { placed: false };
228
+ rmdirSync(serviceData);
229
+ }
230
+ mkdirSync(dirname(serviceData), { recursive: true });
231
+ // A rename moves the cluster without writing its files; across file systems it is copied.
232
+ try {
233
+ renameSync(directory, serviceData);
234
+ }
235
+ catch (error) {
236
+ if (error.code !== 'EXDEV')
237
+ throw error;
238
+ cpSync(directory, serviceData, { recursive: true });
239
+ }
240
+ return { placed: true, note: `prepared cluster ${directory} placed at ${serviceData}` };
241
+ }
160
242
  export async function pgliteUp(services, dataDir, launch = {}) {
161
243
  const env = launch.env ?? process.env;
162
244
  // Every check before any start: a refused up starts nothing, so it never leaves the World half-booted.
@@ -185,10 +267,15 @@ export async function pgliteUp(services, dataDir, launch = {}) {
185
267
  const started = [];
186
268
  try {
187
269
  for (const { service, name, serviceData, log, pidFile, argv } of plan) {
270
+ const placed = placePreparedCluster(service, serviceData, env.VOLTER_WORLD_CONFIG);
188
271
  mkdirSync(serviceData, { recursive: true });
189
272
  mkdirSync(dirname(log), { recursive: true });
273
+ if (placed.note) {
274
+ appendFileSync(log, `${placed.note}\n`);
275
+ launch.note?.(placed.note);
276
+ }
190
277
  const offset = existsSync(log) ? statSync(log).size : 0;
191
- const [command, ...args] = argv;
278
+ const [command, ...args] = placed.placed ? [...argv, '--prepared'] : argv;
192
279
  // a real fd, not a pipe: the host must outlive this process untethered
193
280
  const logFd = openSync(log, 'a');
194
281
  const child = spawn(command, args, {
@@ -216,6 +303,7 @@ export async function pgliteUp(services, dataDir, launch = {}) {
216
303
  throw new Error('Managed host birth capture failed; retiring held child');
217
304
  publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(host.identity) + '\n');
218
305
  publishProcessReceipt(pidFile, `${child.pid}\n`);
306
+ launch.note?.(`${name} spawned`);
219
307
  const deadline = Date.now() + READY_TIMEOUT_MS;
220
308
  const line = readyLine(service.hostPort);
221
309
  // ready when OUR child says its listener is bound (a squatter that took the port after the check
@@ -229,8 +317,10 @@ export async function pgliteUp(services, dataDir, launch = {}) {
229
317
  throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
230
318
  // an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
231
319
  launch.signal?.throwIfAborted();
232
- await sleep(200);
320
+ // the ready line is a small log read, and the port is asked only once it is there
321
+ await sleep(25);
233
322
  }
323
+ launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}`);
234
324
  if (service.kind === 'mongodb')
235
325
  await mongodbReady(service, launch, deadline);
236
326
  child.unref();
@@ -100,8 +100,10 @@ const CONTRIB = [
100
100
  const { PGlite } = await import('@electric-sql/pglite');
101
101
  const { vector } = await import('@electric-sql/pglite-pgvector');
102
102
  const extensions = { vector };
103
- for (const name of CONTRIB) {
104
- const extension = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
103
+ // Imported together: in a browser tab each import is a module load through the kernel, and one after another
104
+ // they were a fixed wait on every boot.
105
+ for (const [name, module] of await Promise.all(CONTRIB.map(async name => [name, await import(`@electric-sql/pglite/contrib/${name}`)]))) {
106
+ const extension = module[name];
105
107
  if (!extension) throw new Error(`pglite-host: @electric-sql/pglite/contrib/${name} exports no ${name}`);
106
108
  extensions[name] = extension;
107
109
  }
@@ -116,7 +118,14 @@ function arg(name) {
116
118
  return value;
117
119
  }
118
120
 
119
- const port = Number(arg('--port'));
121
+ // --prepare: create the declared cluster in --data (initdb, the declared database and role) through the same open
122
+ // path a serving host takes, close it cleanly and exit without listening. The World's image build runs it on the
123
+ // host so the tab opens an existing cluster (pglite-backing.ts, pglitePrepare).
124
+ const preparing = process.argv.includes('--prepare');
125
+ // --prepared: the data directory is a cluster `--prepare` made for this declared identity (pglite-backing.ts checks
126
+ // its stamp before passing this), so the declared database and role exist and the maintenance open is skipped.
127
+ const prepared = process.argv.includes('--prepared');
128
+ const port = preparing ? 0 : Number(arg('--port'));
120
129
  const dataDir = arg('--data');
121
130
  const databaseUser = process.env.VOLTER_WORLD_PGLITE_USER;
122
131
  const databaseName = process.env.VOLTER_WORLD_PGLITE_DATABASE;
@@ -125,7 +134,7 @@ if (![databaseUser, databaseName, databasePassword].every(value => typeof value
125
134
  process.stderr.write('pglite-host: declared database identity and throwaway password are required\n');
126
135
  process.exit(2);
127
136
  }
128
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
137
+ if (!preparing && (!Number.isInteger(port) || port < 1 || port > 65535)) {
129
138
  process.stderr.write('pglite-host: --port must be 1-65535\n');
130
139
  process.exit(2);
131
140
  }
@@ -139,6 +148,11 @@ process.stderr.on('error', () => {});
139
148
  const initKeepAlive = setInterval(() => {}, 500);
140
149
  const quoteIdentifier = value => `"${value.replaceAll('"', '""')}"`;
141
150
  async function openDeclaredDatabase() {
151
+ if (prepared) {
152
+ const db = await PGlite.create({ dataDir, extensions, database: databaseName });
153
+ await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
154
+ return db;
155
+ }
142
156
  // PostgreSQL cannot rename its current database. Use its maintenance database
143
157
  // before opening the declared one; old Worlds' rows in postgres stay intact.
144
158
  const maintenance = await PGlite.create({ dataDir, extensions, database: 'template1' });
@@ -626,7 +640,13 @@ const server = net.createServer((socket) => {
626
640
  });
627
641
  });
628
642
 
629
- void dbReady.then(() => {
643
+ if (preparing) void dbReady.then(async (db) => {
644
+ await db.close();
645
+ clearInterval(initKeepAlive);
646
+ process.stdout.write(`pglite-host prepared ${dataDir}\n`);
647
+ process.exit(0);
648
+ });
649
+ else void dbReady.then(() => {
630
650
  clearInterval(initKeepAlive);
631
651
  server.listen(port, '127.0.0.1', () => {
632
652
  process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
@@ -1068,6 +1068,8 @@ function getByJsonPath(value, jsonPath) {
1068
1068
  class ExternalCommandUnavailableError extends Error {
1069
1069
  }
1070
1070
  async function runExternalCommand(command, cwd, serviceId, phase, env = process.env, abort, timeoutMs = 60_000) {
1071
+ if (ownInfrastructureStatus(command))
1072
+ return runOwnInfrastructureStatus(serviceId, phase, env, abort, timeoutMs);
1071
1073
  const [bin, ...args] = ownCommand(command);
1072
1074
  if (!bin)
1073
1075
  throw new Error(`External service "${serviceId}": ${phase} command is empty`);
@@ -1113,9 +1115,9 @@ async function runExternalCommand(command, cwd, serviceId, phase, env = process.
1113
1115
  /** Run an external service's `up` command, STREAMING its stdout+stderr to the service log as it runs
1114
1116
  * (instead of buffering through spawnSync's 1MB cap, which a large DB restore would overflow). Returns
1115
1117
  * the combined output so `discover` can read it. Throws with a bounded tail on nonzero exit. */
1116
- async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
1118
+ async function runExternalUp(command, cwd, serviceId, env, logPath, abort, progress) {
1117
1119
  if (ownInfrastructureUp(command))
1118
- return runOwnInfrastructureUp(command, serviceId, env, logPath, abort);
1120
+ return runOwnInfrastructureUp(command, serviceId, env, logPath, abort, progress);
1119
1121
  const [bin, ...args] = ownCommand(command);
1120
1122
  if (!bin)
1121
1123
  throw new Error(`External service "${serviceId}": up command is empty`);
@@ -1172,7 +1174,40 @@ async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
1172
1174
  function ownInfrastructureUp(command) {
1173
1175
  return command[0] === 'volter-world-infra' && command[1] === 'up' && ownCommand(command)[0] === process.execPath;
1174
1176
  }
1175
- async function runOwnInfrastructureUp(command, serviceId, env, logPath, abort) {
1177
+ /** Whether a declaration is the runtime's own managed infrastructure: an external service whose `up` is
1178
+ * `volter-world-infra up` run in this process. It reads only the World's own configuration and calls no declared
1179
+ * service while it starts, so it starts beside the co-located host (ADR 0017). */
1180
+ function ownInfrastructureService(service) {
1181
+ return service.type === 'external' && service.external !== undefined && ownInfrastructureUp(service.external.up);
1182
+ }
1183
+ /** Whether a command is this runtime's own `volter-world-infra status`: the managed infrastructure's readiness probe
1184
+ * and its status read (init declares both). It runs in this process, as `up` does: a separate process would load the
1185
+ * runtime again to read a pid and connect to a port, which in a browser tab costs a whole Node start per call. */
1186
+ function ownInfrastructureStatus(command) {
1187
+ return command.length === 2 && command[0] === 'volter-world-infra' && command[1] === 'status' && ownCommand(command)[0] === process.execPath;
1188
+ }
1189
+ async function runOwnInfrastructureStatus(serviceId, phase, env, abort, timeoutMs) {
1190
+ abort?.throwIfAborted();
1191
+ const bounded = new AbortController();
1192
+ const onAbort = () => bounded.abort(abort.reason);
1193
+ abort?.addEventListener('abort', onAbort, { once: true });
1194
+ const timer = setTimeout(() => bounded.abort(new Error(`External service "${serviceId}": ${phase} command timed out after ${timeoutMs}ms`)), timeoutMs);
1195
+ let stdout = '';
1196
+ let stderr = '';
1197
+ try {
1198
+ const { runInfrastructure } = await import("./infra.js");
1199
+ const code = await runInfrastructure('status', env, { out: (text) => { stdout += text; }, err: (text) => { stderr += text; } }, bounded.signal);
1200
+ bounded.signal.throwIfAborted();
1201
+ if (code !== 0)
1202
+ throw new Error(`External service "${serviceId}": ${phase} command exited ${code}\n${stdout}${stderr}`);
1203
+ return { stdout, stderr };
1204
+ }
1205
+ finally {
1206
+ clearTimeout(timer);
1207
+ abort?.removeEventListener('abort', onAbort);
1208
+ }
1209
+ }
1210
+ async function runOwnInfrastructureUp(command, serviceId, env, logPath, abort, progress) {
1176
1211
  writeFileSync(logPath, `$ ${command.join(' ')}\n`, { mode: 0o600 });
1177
1212
  chmodSync(logPath, 0o600);
1178
1213
  const out = openPrivateLog(logPath, 'a');
@@ -1180,7 +1215,14 @@ async function runOwnInfrastructureUp(command, serviceId, env, logPath, abort) {
1180
1215
  try {
1181
1216
  abort?.throwIfAborted();
1182
1217
  const { runInfrastructure } = await import("./infra.js");
1183
- const write = (text) => { chunks.push(text); writeSync(out, text); };
1218
+ // Its phase lines (backing chosen, host spawned, serving) are the boot's progress too.
1219
+ const write = (text) => {
1220
+ chunks.push(text);
1221
+ writeSync(out, text);
1222
+ for (const line of text.split('\n'))
1223
+ if (line.startsWith('managed infrastructure'))
1224
+ progress?.(`${serviceId}: ${line}`);
1225
+ };
1184
1226
  const code = await runInfrastructure(command[1], env, { out: write, err: write }, abort);
1185
1227
  abort?.throwIfAborted();
1186
1228
  if (code !== 0) {
@@ -1207,11 +1249,13 @@ function resolveReadyProbe(ready, svc) {
1207
1249
  }
1208
1250
  async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env, abort) {
1209
1251
  if (!ready)
1210
- return;
1252
+ return 'no readiness declared';
1211
1253
  const timeoutMs = ready.timeoutMs ?? 60_000;
1212
1254
  const intervalMs = ready.intervalMs ?? 500;
1213
1255
  const started = Date.now();
1214
1256
  let lastError = '';
1257
+ let attempt = 0;
1258
+ const passed = (check) => `${check} after ${attempt + 1} ${attempt ? 'checks' : 'check'}`;
1215
1259
  while (Date.now() - started < timeoutMs) {
1216
1260
  abort?.throwIfAborted();
1217
1261
  if (ready.command !== undefined) {
@@ -1220,7 +1264,7 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1220
1264
  }
1221
1265
  try {
1222
1266
  await runExternalCommand([ready.command, ...(ready.args ?? [])], cwd, serviceId, 'readiness', env, abort, Math.max(1, timeoutMs - (Date.now() - started)));
1223
- return;
1267
+ return passed(`\`${[ready.command, ...(ready.args ?? [])].join(' ')}\` exited 0`);
1224
1268
  }
1225
1269
  catch (error) {
1226
1270
  if (error instanceof CommandRetirementError)
@@ -1232,7 +1276,7 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1232
1276
  try {
1233
1277
  const response = await fetch(ready.httpUrl, { signal: AbortSignal.any([AbortSignal.timeout(Math.max(1, timeoutMs - (Date.now() - started))), ...(abort ? [abort] : [])]) });
1234
1278
  if (response.status >= 200 && response.status < 400)
1235
- return;
1279
+ return passed(`GET ${ready.httpUrl} answered ${response.status}`);
1236
1280
  lastError = `HTTP ${response.status}`;
1237
1281
  }
1238
1282
  catch (error) {
@@ -1242,10 +1286,12 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1242
1286
  else if (ready.stdoutMatch !== undefined) {
1243
1287
  const text = serviceOutput(readServiceText(logPath));
1244
1288
  if (new RegExp(ready.stdoutMatch).test(text))
1245
- return;
1289
+ return passed(`the log matched /${ready.stdoutMatch}/`);
1246
1290
  lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
1247
1291
  }
1248
- await sleep(intervalMs);
1292
+ // A service that is nearly ready is seen at once; one that is slow is asked at most every intervalMs.
1293
+ await sleep(Math.min(intervalMs, 25 * 2 ** attempt));
1294
+ attempt += 1;
1249
1295
  }
1250
1296
  throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
1251
1297
  }
@@ -1280,15 +1326,18 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1280
1326
  // Publish the declared teardown before an external command can create state.
1281
1327
  boot?.external({ id: service.id, type: 'external', command: [...external.up], pid: 0, log, env: {},
1282
1328
  external: { down: [...external.down], cwd, discoveredEnv: { ...(service.env ?? {}) } } });
1283
- const upOutput = await runExternalUp(external.up, cwd, service.id, env, log, boot?.signal);
1329
+ const upOutput = await runExternalUp(external.up, cwd, service.id, env, log, boot?.signal, boot?.progress);
1330
+ boot?.progress(`${service.id}: up returned`);
1284
1331
  // 2. readiness — Docker-backed externals take seconds to boot.
1285
- await awaitReadiness(external.readyWhen, log, cwd, service.id, env, boot?.signal);
1332
+ const readiness = await awaitReadiness(external.readyWhen, log, cwd, service.id, env, boot?.signal);
1333
+ boot?.progress(`${service.id}: ready (${readiness})`);
1286
1334
  // 3. status — discover connection info (optional; may reuse up output instead).
1287
1335
  let statusOutput = '';
1288
1336
  if (external.status) {
1289
1337
  const statusResult = await runExternalCommand(external.status, cwd, service.id, 'status', env, boot?.signal);
1290
1338
  statusOutput = statusResult.stdout || statusResult.stderr;
1291
1339
  appendFileSync(log, `\n$ ${external.status.join(' ')}\n${statusOutput}`);
1340
+ boot?.progress(`${service.id}: status read`);
1292
1341
  }
1293
1342
  // 4. discover — apply declarative mappings → injected env vars.
1294
1343
  const injected = {};
@@ -1389,6 +1438,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1389
1438
  child.once('error', error => boot.fail(error));
1390
1439
  child.unref();
1391
1440
  await trackBootingChild(child, boot, identity);
1441
+ boot.progress(`${service.id}: spawned`);
1392
1442
  // Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
1393
1443
  // child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
1394
1444
  // and dies — but waitForTcp would then see the OTHER process answering and the world would
@@ -1414,6 +1464,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1414
1464
  }
1415
1465
  throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
1416
1466
  });
1467
+ boot.progress(`${service.id}: port ${port} answers`);
1417
1468
  await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
1418
1469
  if (undeadPids([child.pid]).length)
1419
1470
  recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
@@ -1429,10 +1480,11 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1429
1480
  }
1430
1481
  const host = '127.0.0.1';
1431
1482
  const url = `http://${host}:${port}`;
1483
+ boot.progress(`${service.id}: port ${port} is its own`);
1432
1484
  // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
1433
1485
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1434
1486
  if (service.ready && service.ready !== 'tcp') {
1435
- await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
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) => {
1436
1488
  if (undeadPids([child.pid]).length)
1437
1489
  recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1438
1490
  try {
@@ -1441,6 +1493,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1441
1493
  catch { /* best effort cleanup */ }
1442
1494
  throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
1443
1495
  });
1496
+ boot.progress(`${service.id}: ready (${readiness})`);
1444
1497
  }
1445
1498
  return {
1446
1499
  id: service.id,
@@ -1891,6 +1944,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1891
1944
  catch { /* best effort cleanup */ }
1892
1945
  throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
1893
1946
  });
1947
+ boot.progress('co-located host: its all-started receipt is read');
1894
1948
  await parallelStartup(allocated, async ({ service, port }) => {
1895
1949
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1896
1950
  await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch(async (error) => {
@@ -1932,6 +1986,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1932
1986
  }
1933
1987
  export async function upWorld(configId, options = {}, abort) {
1934
1988
  abort?.throwIfAborted();
1989
+ const upStarted = Date.now();
1935
1990
  const root = resolve(options.root ?? process.cwd());
1936
1991
  const found = loadWorldConfig(configId, root);
1937
1992
  // the facts of the World's installed packs, over the kernel's, for this process and (through the instance file) its own
@@ -2228,10 +2283,17 @@ export async function upWorld(configId, options = {}, abort) {
2228
2283
  };
2229
2284
  const boot = {
2230
2285
  bootId: instanceToken, signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(), identities: {}, handles: new Map(),
2286
+ // The product's own progress, one line per phase, so a caller that streams stderr sees where up's time goes.
2287
+ progress: what => { try {
2288
+ process.stderr.write(`volter-world up ${name}: +${((Date.now() - upStarted) / 1000).toFixed(2)} s ${what}\n`);
2289
+ }
2290
+ catch { /* a closed stderr never fails a boot */ } },
2231
2291
  external: service => { recordService(service); saveWorldInstance(worldInstance); },
2232
2292
  recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids], boot.identities),
2233
2293
  };
2234
2294
  const recordPidsSoFar = boot.recordPids;
2295
+ // The runtime's own infrastructure, started before its declared position (ADR 0017), by service id.
2296
+ const early = new Map();
2235
2297
  try {
2236
2298
  // Ownership survives caller/process loss without an inert capacity-holder process.
2237
2299
  retainWorldLifecycleForCleanup(root, name);
@@ -2242,28 +2304,46 @@ export async function upWorld(configId, options = {}, abort) {
2242
2304
  // The co-located host's twins are known by address once their ports are
2243
2305
  // allocated. A twin serves and calls no other service while it starts,
2244
2306
  // so a later twin declaration starts beside the host's startup; any other
2245
- // service waits until every earlier one is ready, as declared (ADR 0015).
2307
+ // service waits until every earlier one is ready, as declared (ADR 0015), except the runtime's own
2308
+ // infrastructure, which starts beside the host (ADR 0017).
2246
2309
  // A co-located twin's issued credentials are asked of it once the host is ready: it answers no request before.
2247
2310
  let colocatedReady;
2248
2311
  if (isolation !== 'process') {
2249
2312
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
2250
2313
  const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
2314
+ if (colocated.length)
2315
+ boot.progress(`co-located host spawned: ${colocated.length} twins`);
2251
2316
  for (const started of host.services) {
2252
2317
  recordService(started);
2253
2318
  Object.assign(env, started.env);
2254
2319
  }
2255
2320
  colocatedReady = async () => {
2256
2321
  await host.ready;
2322
+ if (colocated.length)
2323
+ boot.progress('co-located host ready: receipt read, every port admitted as its own');
2257
2324
  for (const started of host.services) {
2258
2325
  const declared = colocated.find((s) => s.id === started.id);
2259
2326
  if (declared)
2260
2327
  await issueCredentials(declared, started, env, issuing, unissued);
2261
2328
  }
2262
2329
  writeProxyEnv(root, name, env);
2330
+ if (colocated.length)
2331
+ boot.progress('co-located twins: credentials issued, proxy environment written');
2263
2332
  };
2264
2333
  recordPidsSoFar();
2265
2334
  writeProxyEnv(root, name, env);
2266
2335
  }
2336
+ // The runtime's own infrastructure starts now, beside the host, with the environment as it stands: it reads
2337
+ // only the World's configuration and calls no service while it starts. Its result is taken at its declared
2338
+ // position, so records, environment and `up`'s return keep declaration order; its failure fails the boot (ADR 0017).
2339
+ for (const service of loaded.config.services) {
2340
+ if (services[service.id] || !ownInfrastructureService(service))
2341
+ continue;
2342
+ boot.progress(`${service.id}: starting beside the host (the runtime's own infrastructure)`);
2343
+ const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot);
2344
+ starting.catch((error) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
2345
+ early.set(service.id, starting);
2346
+ }
2267
2347
  let proxyForServices = null;
2268
2348
  // TWIN-64: attempt the ambient proxy once activeVendorMap(env) has at least one twin — tried
2269
2349
  // again after each service in case ITS start is what makes the map non-empty. A sealed world
@@ -2294,20 +2374,29 @@ export async function upWorld(configId, options = {}, abort) {
2294
2374
  }
2295
2375
  };
2296
2376
  for (const service of loaded.config.services) {
2297
- if (services[service.id])
2377
+ const starting = early.get(service.id);
2378
+ if (!starting && services[service.id])
2298
2379
  continue; // already up co-located
2380
+ // At its position an early start still takes the host's readiness first, so credentials and environment merge in
2381
+ // declared order; its own `up` has been running beside the host all along.
2299
2382
  if (colocatedReady && (service.type ?? 'twin') !== 'twin') {
2300
2383
  await colocatedReady();
2301
2384
  colocatedReady = undefined;
2302
2385
  }
2303
2386
  attemptProxy();
2304
2387
  boot.signal.throwIfAborted();
2305
- const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
2388
+ if (!starting)
2389
+ boot.progress(`${service.id}: starting (${service.type ?? 'twin'})`);
2390
+ const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot));
2391
+ // An early start published its teardown when it began; its record moves to its declared position.
2392
+ if (starting)
2393
+ delete services[service.id];
2306
2394
  recordService(started);
2307
2395
  recordPidsSoFar();
2308
2396
  Object.assign(env, started.env);
2309
2397
  await issueCredentials(service, started, env, issuing, unissued);
2310
2398
  writeProxyEnv(root, name, env);
2399
+ boot.progress(`${service.id}: started, its environment merged`);
2311
2400
  }
2312
2401
  if (colocatedReady)
2313
2402
  await colocatedReady();
@@ -2337,11 +2426,13 @@ export async function upWorld(configId, options = {}, abort) {
2337
2426
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
2338
2427
  // the mode the caller chose (a shared env file may be read under another uid).
2339
2428
  publishEnvOutput(envOutputClaim, envFileContents(env));
2429
+ boot.progress(`environment written: ${envFile}`);
2340
2430
  materializeBrowserFiles(data, root, name, loaded.config.id);
2341
2431
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
2342
2432
  saveWorldInstance(worldInstance);
2343
2433
  const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
2344
2434
  handoffWorldLifecycle(root, name, servicePids);
2435
+ boot.progress('returning: the World is up');
2345
2436
  if (mode === 'share') {
2346
2437
  try {
2347
2438
  return await shareWorldServices(name, { root, ...(options.share ?? {}) });
@@ -2356,6 +2447,12 @@ export async function upWorld(configId, options = {}, abort) {
2356
2447
  catch (error) {
2357
2448
  cleanupUncertain = true;
2358
2449
  detachBootSignals();
2450
+ // An infrastructure `up` started early may still be running: end it and let it settle before its teardown runs.
2451
+ if (early.size) {
2452
+ cancelled.abort(error);
2453
+ await Promise.allSettled(early.values());
2454
+ saveWorldInstance(worldInstance);
2455
+ }
2359
2456
  // Rollback never lets its own failure replace the boot's: each step's error is added to the original.
2360
2457
  const rollbackErrors = [];
2361
2458
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.98",
3
+ "version": "3.0.100",
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.98",
73
- "@volter/world-console": "3.0.93"
72
+ "@volter/world-core": "3.0.100",
73
+ "@volter/world-console": "3.0.95"
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.98",
79
+ "@volter/world-core": "3.0.100",
80
80
  "mongodb": "~6.20.0",
81
81
  "pg-gateway": "0.3.0-beta.4",
82
82
  "smol-toml": "^1.8.0",
package/src/infra.ts CHANGED
@@ -7,14 +7,23 @@
7
7
  // that owns the World's lifetime, as every other service's recorder is, and not of a tool that exits once they
8
8
  // serve. A process layer where a child does not outlive its parent (a browser tab's) otherwise ended the PGlite host
9
9
  // the moment `volter-world-infra up` returned, and the World's readiness probe never saw it.
10
- import { existsSync, readFileSync } from 'node:fs';
11
- import { dirname, join } from 'node:path';
10
+ import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
11
+ import { delimiter, dirname, join } from 'node:path';
12
12
  import { signalChild } from './process-signal.mjs';
13
13
  import { spawn } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
16
16
  import { errorMessage } from './error-message.ts';
17
17
 
18
+ /** Whether a program is on PATH, read from the file system without starting a process: where a missing program
19
+ * still costs a process round trip (a browser tab's kernel), the container probe is not spawned at all. */
20
+ function onPath(name: string, env: NodeJS.ProcessEnv): boolean {
21
+ return (env.PATH ?? '').split(delimiter).some((dir) => {
22
+ if (!dir) return false;
23
+ try { const path = join(dir, name); accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; }
24
+ });
25
+ }
26
+
18
27
  export interface InfrastructureIo { out: (text: string) => void; err: (text: string) => void }
19
28
 
20
29
  interface Ran { status: number | null; stdout: string; stderr: string; error?: Error }
@@ -61,10 +70,34 @@ function runCommand(command: string, args: string[], options: { env: NodeJS.Proc
61
70
  /** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
62
71
  * services this starts run in; `signal` is the boot's abort (the World's own up passes it; the tool has none). */
63
72
  export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, io: InfrastructureIo, signal?: AbortSignal): Promise<number> {
64
- if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
65
- io.err('managed infrastructure: expected up, status, or down\n');
73
+ if (phase !== 'up' && phase !== 'status' && phase !== 'down' && phase !== 'prepare') {
74
+ io.err('managed infrastructure: expected up, status, down, or prepare\n');
66
75
  return 2;
67
76
  }
77
+ // prepare: the image build's step that makes the World's PGlite cluster once, on its host, so the World's first up
78
+ // opens it instead of running initdb (pglite-backing.ts, pglitePrepare; ADR 0018). It needs the World's
79
+ // definition, not an instance.
80
+ if (phase === 'prepare') {
81
+ const config = env.VOLTER_WORLD_CONFIG;
82
+ const declaredDefinition = config ? join(dirname(config), 'world.infrastructure.yml') : undefined;
83
+ if (!config || !declaredDefinition || !existsSync(declaredDefinition)) {
84
+ io.err('managed infrastructure prepare: VOLTER_WORLD_CONFIG must name a world.json beside its world.infrastructure.yml\n');
85
+ return 2;
86
+ }
87
+ const postgres = parseInfraDefinition(readFileSync(declaredDefinition, 'utf8')).filter((s) => s.kind === 'postgres');
88
+ if (postgres.length !== 1) {
89
+ io.out('managed infrastructure prepare: no single declared PostgreSQL; nothing to prepare\n');
90
+ return 0;
91
+ }
92
+ const { pglitePrepare } = await import('./pglite-backing.ts');
93
+ try {
94
+ io.out(`managed infrastructure prepared ${await pglitePrepare(postgres[0]!, config, { env, ...(signal ? { signal } : {}) })}\n`);
95
+ return 0;
96
+ } catch (error) {
97
+ io.err(`managed infrastructure prepare failed: ${errorMessage(error)}\n`);
98
+ return 1;
99
+ }
100
+ }
68
101
 
69
102
  const worldConfig = env.VOLTER_WORLD_CONFIG;
70
103
  const worldData = env.VOLTER_WORLD_DATA;
@@ -84,7 +117,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
84
117
  signal?.throwIfAborted();
85
118
  return ran;
86
119
  };
87
- const launch = { env, ...(signal ? { signal } : {}) };
120
+ const launch = { env, ...(signal ? { signal } : {}), note: (what: string) => io.out(`managed infrastructure: ${what}\n`) };
88
121
  const classify = (raw: string): string => /no space left on device|enospc/iu.test(raw)
89
122
  ? 'insufficient writable storage'
90
123
  : /out of memory|cannot allocate memory|killed/iu.test(raw)
@@ -125,6 +158,8 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
125
158
  io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker, pglite or native (got ${JSON.stringify(forced)})\n`);
126
159
  return 2;
127
160
  }
161
+ // No docker on PATH is the same absence the probe's ENOENT reports, read without a process.
162
+ if (process.platform !== 'win32' && !onPath('docker', env)) return 'pglite';
128
163
  const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
129
164
  signal?.throwIfAborted();
130
165
  if (probe.status === 0 && probe.stdout.trim()) return 'docker';
@@ -12,7 +12,7 @@
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 { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
15
+ import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { connect } from 'node:net';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { spawn, type ChildProcess } from 'node:child_process';
@@ -123,7 +123,7 @@ function hostOwnership(pidFile: string) {
123
123
  * the control plane's NODE_OPTIONS and proxies), and the boot's abort. The `volter-world-infra` process passes
124
124
  * nothing and its hosts inherit its own environment, which is that same service environment. The World's `up`,
125
125
  * running the up phase in its own process, passes it: its own process.env is the operator's, not the service's. */
126
- export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal }
126
+ export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal; note?: (what: string) => void }
127
127
 
128
128
  /** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
129
129
  function hostArgv(service: InfraService, serviceData: string): string[] {
@@ -164,6 +164,84 @@ function logSince(log: string, offset: number): string {
164
164
  return readFileSync(log).subarray(offset).toString('utf8');
165
165
  }
166
166
 
167
+ /**
168
+ * A World's PostgreSQL cluster prepared before its first boot (architecture, managed infrastructure; ADR 0018).
169
+ * PGlite's first start in an empty data directory runs initdb in wasm, writes the whole cluster through the file
170
+ * system, then the host creates the declared database and role; in a browser tab that is the World's slowest boot
171
+ * step. `volter-world-infra prepare` runs the host's own open path (`pglite-host.mjs --prepare`) once, on the image
172
+ * build's host, into `<World dir>/.volter-prepared/pglite-postgres-data`, with a stamp naming the PGlite pin and the
173
+ * declared identity. At `up`, an empty service data directory takes that cluster by rename, and PGlite finds
174
+ * PG_VERSION and resumes it (its own existing-dataDir path). Only an exact stamp is taken; anything else boots as before.
175
+ */
176
+ export const PREPARED_DIRECTORY = '.volter-prepared';
177
+
178
+ /** Where a World's prepared PostgreSQL cluster lives, beside its world.json, and its stamp. */
179
+ export function preparedCluster(worldConfig: string): { directory: string; stamp: string } {
180
+ const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, containerlessDataDirectory('postgres')!);
181
+ return { directory, stamp: `${directory}.json` };
182
+ }
183
+
184
+ /**
185
+ * What a prepared cluster must have been made with: this runtime (whose pglite-host.mjs is the open path that made
186
+ * it), its exact PGlite pin (initdb's encoding and locale are PGlite's defaults: the host passes no initdb
187
+ * parameters), and the declared user and database. The password is not in the cluster: the role is created without
188
+ * one (LOGIN SUPERUSER) and the host's gateway checks the declared password on each connection, so a World with
189
+ * another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
190
+ * at each open, never created in the cluster by the open path.
191
+ */
192
+ function preparedStamp(service: InfraService): string {
193
+ const manifest = JSON.parse(readFileSync(join(here(), '..', 'package.json'), 'utf8')) as { version?: string; dependencies?: Record<string, string> };
194
+ const pglite = manifest.dependencies?.['@electric-sql/pglite'];
195
+ if (!pglite || !/^\d+\.\d+\.\d+$/.test(pglite)) throw new Error('world-runtime pins no exact @electric-sql/pglite version');
196
+ return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres!.user, database: service.postgres!.database })}\n`;
197
+ }
198
+
199
+ /** Run the host's open path once into the World's prepared directory; replaces an earlier preparation. */
200
+ export async function pglitePrepare(service: InfraService, worldConfig: string, launch: HostLaunch = {}): Promise<string> {
201
+ if (service.kind !== 'postgres' || !service.postgres?.password) throw new Error('only a declared PostgreSQL with a literal POSTGRES_PASSWORD is prepared');
202
+ const { directory, stamp } = preparedCluster(worldConfig);
203
+ rmSync(directory, { recursive: true, force: true });
204
+ rmSync(stamp, { force: true });
205
+ mkdirSync(directory, { recursive: true });
206
+ const child = spawn('node', [hostScript(), '--prepare', '--data', directory], {
207
+ windowsHide: true,
208
+ env: { ...(launch.env ?? process.env), VOLTER_WORLD_PGLITE_USER: service.postgres.user,
209
+ VOLTER_WORLD_PGLITE_DATABASE: service.postgres.database, VOLTER_WORLD_PGLITE_PASSWORD: service.postgres.password },
210
+ stdio: ['ignore', 'pipe', 'pipe'],
211
+ });
212
+ const lifetime = commandLifetime(child, launch.signal);
213
+ let output = '';
214
+ child.stdout!.on('data', (chunk: Buffer) => { output += chunk.toString('utf8'); });
215
+ child.stderr!.on('data', (chunk: Buffer) => { output += chunk.toString('utf8'); });
216
+ const { code } = await lifetime.finish();
217
+ if (code !== 0 || !existsSync(join(directory, 'PG_VERSION'))) {
218
+ rmSync(directory, { recursive: true, force: true });
219
+ throw new Error(`pglite prepare exited ${code ?? 'null'} without a cluster\n${output.trim().split('\n').slice(-40).join('\n')}`);
220
+ }
221
+ writeFileSync(stamp, preparedStamp(service));
222
+ return directory;
223
+ }
224
+
225
+ /** Give an empty service data directory the World's prepared cluster when its stamp matches; says what it did. */
226
+ function placePreparedCluster(service: InfraService, serviceData: string, worldConfig: string | undefined): { placed: boolean; note?: string } {
227
+ if (service.kind !== 'postgres' || !worldConfig || existsSync(join(serviceData, 'PG_VERSION'))) return { placed: false };
228
+ const { directory, stamp } = preparedCluster(worldConfig);
229
+ if (!existsSync(join(directory, 'PG_VERSION')) || !existsSync(stamp)) return { placed: false };
230
+ if (readFileSync(stamp, 'utf8') !== preparedStamp(service)) return { placed: false, note: `prepared cluster ${directory} not taken: its stamp names another PGlite pin or identity` };
231
+ if (existsSync(serviceData)) {
232
+ if (readdirSync(serviceData).length) return { placed: false };
233
+ rmdirSync(serviceData);
234
+ }
235
+ mkdirSync(dirname(serviceData), { recursive: true });
236
+ // A rename moves the cluster without writing its files; across file systems it is copied.
237
+ try { renameSync(directory, serviceData); }
238
+ catch (error) {
239
+ if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
240
+ cpSync(directory, serviceData, { recursive: true });
241
+ }
242
+ return { placed: true, note: `prepared cluster ${directory} placed at ${serviceData}` };
243
+ }
244
+
167
245
  export async function pgliteUp(services: InfraService[], dataDir: string, launch: HostLaunch = {}): Promise<void> {
168
246
  const env = launch.env ?? process.env;
169
247
  // Every check before any start: a refused up starts nothing, so it never leaves the World half-booted.
@@ -190,10 +268,12 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
190
268
  const started: string[] = [];
191
269
  try {
192
270
  for (const { service, name, serviceData, log, pidFile, argv } of plan) {
271
+ const placed = placePreparedCluster(service, serviceData, env.VOLTER_WORLD_CONFIG);
193
272
  mkdirSync(serviceData, { recursive: true });
194
273
  mkdirSync(dirname(log), { recursive: true });
274
+ if (placed.note) { appendFileSync(log, `${placed.note}\n`); launch.note?.(placed.note); }
195
275
  const offset = existsSync(log) ? statSync(log).size : 0;
196
- const [command, ...args] = argv;
276
+ const [command, ...args] = placed.placed ? [...argv, '--prepared'] : argv;
197
277
  // a real fd, not a pipe: the host must outlive this process untethered
198
278
  const logFd = openSync(log, 'a');
199
279
  const child = spawn(command!, args, {
@@ -218,6 +298,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
218
298
  if (!host.identity?.start) throw new Error('Managed host birth capture failed; retiring held child');
219
299
  publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(host.identity) + '\n');
220
300
  publishProcessReceipt(pidFile, `${child.pid}\n`);
301
+ launch.note?.(`${name} spawned`);
221
302
  const deadline = Date.now() + READY_TIMEOUT_MS;
222
303
  const line = readyLine(service.hostPort);
223
304
  // ready when OUR child says its listener is bound (a squatter that took the port after the check
@@ -228,8 +309,10 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
228
309
  if (Date.now() > deadline) throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
229
310
  // an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
230
311
  launch.signal?.throwIfAborted();
231
- await sleep(200);
312
+ // the ready line is a small log read, and the port is asked only once it is there
313
+ await sleep(25);
232
314
  }
315
+ launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}`);
233
316
  if (service.kind === 'mongodb') await mongodbReady(service, launch, deadline);
234
317
  child.unref();
235
318
  }
@@ -100,8 +100,10 @@ const CONTRIB = [
100
100
  const { PGlite } = await import('@electric-sql/pglite');
101
101
  const { vector } = await import('@electric-sql/pglite-pgvector');
102
102
  const extensions = { vector };
103
- for (const name of CONTRIB) {
104
- const extension = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
103
+ // Imported together: in a browser tab each import is a module load through the kernel, and one after another
104
+ // they were a fixed wait on every boot.
105
+ for (const [name, module] of await Promise.all(CONTRIB.map(async name => [name, await import(`@electric-sql/pglite/contrib/${name}`)]))) {
106
+ const extension = module[name];
105
107
  if (!extension) throw new Error(`pglite-host: @electric-sql/pglite/contrib/${name} exports no ${name}`);
106
108
  extensions[name] = extension;
107
109
  }
@@ -116,7 +118,14 @@ function arg(name) {
116
118
  return value;
117
119
  }
118
120
 
119
- const port = Number(arg('--port'));
121
+ // --prepare: create the declared cluster in --data (initdb, the declared database and role) through the same open
122
+ // path a serving host takes, close it cleanly and exit without listening. The World's image build runs it on the
123
+ // host so the tab opens an existing cluster (pglite-backing.ts, pglitePrepare).
124
+ const preparing = process.argv.includes('--prepare');
125
+ // --prepared: the data directory is a cluster `--prepare` made for this declared identity (pglite-backing.ts checks
126
+ // its stamp before passing this), so the declared database and role exist and the maintenance open is skipped.
127
+ const prepared = process.argv.includes('--prepared');
128
+ const port = preparing ? 0 : Number(arg('--port'));
120
129
  const dataDir = arg('--data');
121
130
  const databaseUser = process.env.VOLTER_WORLD_PGLITE_USER;
122
131
  const databaseName = process.env.VOLTER_WORLD_PGLITE_DATABASE;
@@ -125,7 +134,7 @@ if (![databaseUser, databaseName, databasePassword].every(value => typeof value
125
134
  process.stderr.write('pglite-host: declared database identity and throwaway password are required\n');
126
135
  process.exit(2);
127
136
  }
128
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
137
+ if (!preparing && (!Number.isInteger(port) || port < 1 || port > 65535)) {
129
138
  process.stderr.write('pglite-host: --port must be 1-65535\n');
130
139
  process.exit(2);
131
140
  }
@@ -139,6 +148,11 @@ process.stderr.on('error', () => {});
139
148
  const initKeepAlive = setInterval(() => {}, 500);
140
149
  const quoteIdentifier = value => `"${value.replaceAll('"', '""')}"`;
141
150
  async function openDeclaredDatabase() {
151
+ if (prepared) {
152
+ const db = await PGlite.create({ dataDir, extensions, database: databaseName });
153
+ await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
154
+ return db;
155
+ }
142
156
  // PostgreSQL cannot rename its current database. Use its maintenance database
143
157
  // before opening the declared one; old Worlds' rows in postgres stay intact.
144
158
  const maintenance = await PGlite.create({ dataDir, extensions, database: 'template1' });
@@ -626,7 +640,13 @@ const server = net.createServer((socket) => {
626
640
  });
627
641
  });
628
642
 
629
- void dbReady.then(() => {
643
+ if (preparing) void dbReady.then(async (db) => {
644
+ await db.close();
645
+ clearInterval(initKeepAlive);
646
+ process.stdout.write(`pglite-host prepared ${dataDir}\n`);
647
+ process.exit(0);
648
+ });
649
+ else void dbReady.then(() => {
630
650
  clearInterval(initKeepAlive);
631
651
  server.listen(port, '127.0.0.1', () => {
632
652
  process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
package/src/runtime.ts CHANGED
@@ -1053,6 +1053,7 @@ async function runExternalCommand(
1053
1053
  command: string[], cwd: string, serviceId: string, phase: string,
1054
1054
  env: NodeJS.ProcessEnv = process.env, abort?: AbortSignal, timeoutMs = 60_000,
1055
1055
  ): Promise<{ stdout: string; stderr: string }> {
1056
+ if (ownInfrastructureStatus(command)) return runOwnInfrastructureStatus(serviceId, phase, env, abort, timeoutMs);
1056
1057
  const [bin, ...args] = ownCommand(command);
1057
1058
  if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
1058
1059
  if (!commandExists(bin)) throw new ExternalCommandUnavailableError(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
@@ -1093,8 +1094,9 @@ async function runExternalUp(
1093
1094
  env: NodeJS.ProcessEnv,
1094
1095
  logPath: string,
1095
1096
  abort?: AbortSignal,
1097
+ progress?: (what: string) => void,
1096
1098
  ): Promise<string> {
1097
- if (ownInfrastructureUp(command)) return runOwnInfrastructureUp(command, serviceId, env, logPath, abort);
1099
+ if (ownInfrastructureUp(command)) return runOwnInfrastructureUp(command, serviceId, env, logPath, abort, progress);
1098
1100
  const [bin, ...args] = ownCommand(command);
1099
1101
  if (!bin) throw new Error(`External service "${serviceId}": up command is empty`);
1100
1102
  if (!commandExists(bin)) {
@@ -1144,7 +1146,37 @@ function ownInfrastructureUp(command: string[]): boolean {
1144
1146
  return command[0] === 'volter-world-infra' && command[1] === 'up' && ownCommand(command)[0] === process.execPath;
1145
1147
  }
1146
1148
 
1147
- async function runOwnInfrastructureUp(command: string[], serviceId: string, env: NodeJS.ProcessEnv, logPath: string, abort?: AbortSignal): Promise<string> {
1149
+ /** Whether a declaration is the runtime's own managed infrastructure: an external service whose `up` is
1150
+ * `volter-world-infra up` run in this process. It reads only the World's own configuration and calls no declared
1151
+ * service while it starts, so it starts beside the co-located host (ADR 0017). */
1152
+ function ownInfrastructureService(service: WorldServiceConfig): boolean {
1153
+ return service.type === 'external' && service.external !== undefined && ownInfrastructureUp(service.external.up);
1154
+ }
1155
+
1156
+ /** Whether a command is this runtime's own `volter-world-infra status`: the managed infrastructure's readiness probe
1157
+ * and its status read (init declares both). It runs in this process, as `up` does: a separate process would load the
1158
+ * runtime again to read a pid and connect to a port, which in a browser tab costs a whole Node start per call. */
1159
+ function ownInfrastructureStatus(command: string[]): boolean {
1160
+ return command.length === 2 && command[0] === 'volter-world-infra' && command[1] === 'status' && ownCommand(command)[0] === process.execPath;
1161
+ }
1162
+
1163
+ async function runOwnInfrastructureStatus(serviceId: string, phase: string, env: NodeJS.ProcessEnv, abort: AbortSignal | undefined, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
1164
+ abort?.throwIfAborted();
1165
+ const bounded = new AbortController();
1166
+ const onAbort = () => bounded.abort(abort!.reason);
1167
+ abort?.addEventListener('abort', onAbort, { once: true });
1168
+ const timer = setTimeout(() => bounded.abort(new Error(`External service "${serviceId}": ${phase} command timed out after ${timeoutMs}ms`)), timeoutMs);
1169
+ let stdout = ''; let stderr = '';
1170
+ try {
1171
+ const { runInfrastructure } = await import('./infra.ts');
1172
+ const code = await runInfrastructure('status', env, { out: (text) => { stdout += text; }, err: (text) => { stderr += text; } }, bounded.signal);
1173
+ bounded.signal.throwIfAborted();
1174
+ if (code !== 0) throw new Error(`External service "${serviceId}": ${phase} command exited ${code}\n${stdout}${stderr}`);
1175
+ return { stdout, stderr };
1176
+ } finally { clearTimeout(timer); abort?.removeEventListener('abort', onAbort); }
1177
+ }
1178
+
1179
+ async function runOwnInfrastructureUp(command: string[], serviceId: string, env: NodeJS.ProcessEnv, logPath: string, abort?: AbortSignal, progress?: (what: string) => void): Promise<string> {
1148
1180
  writeFileSync(logPath, `$ ${command.join(' ')}\n`, { mode: 0o600 });
1149
1181
  chmodSync(logPath, 0o600);
1150
1182
  const out = openPrivateLog(logPath, 'a');
@@ -1152,7 +1184,11 @@ async function runOwnInfrastructureUp(command: string[], serviceId: string, env:
1152
1184
  try {
1153
1185
  abort?.throwIfAborted();
1154
1186
  const { runInfrastructure } = await import('./infra.ts');
1155
- const write = (text: string) => { chunks.push(text); writeSync(out, text); };
1187
+ // Its phase lines (backing chosen, host spawned, serving) are the boot's progress too.
1188
+ const write = (text: string) => {
1189
+ chunks.push(text); writeSync(out, text);
1190
+ for (const line of text.split('\n')) if (line.startsWith('managed infrastructure')) progress?.(`${serviceId}: ${line}`);
1191
+ };
1156
1192
  const code = await runInfrastructure(command[1]!, env, { out: write, err: write }, abort);
1157
1193
  abort?.throwIfAborted();
1158
1194
  if (code !== 0) {
@@ -1184,34 +1220,38 @@ async function awaitReadiness(
1184
1220
  serviceId: string,
1185
1221
  env: NodeJS.ProcessEnv = process.env,
1186
1222
  abort?: AbortSignal,
1187
- ): Promise<void> {
1188
- if (!ready) return;
1223
+ ): Promise<string> {
1224
+ if (!ready) return 'no readiness declared';
1189
1225
  const timeoutMs = ready.timeoutMs ?? 60_000;
1190
1226
  const intervalMs = ready.intervalMs ?? 500;
1191
1227
  const started = Date.now();
1192
1228
  let lastError = '';
1229
+ let attempt = 0;
1230
+ const passed = (check: string) => `${check} after ${attempt + 1} ${attempt ? 'checks' : 'check'}`;
1193
1231
  while (Date.now() - started < timeoutMs) {
1194
1232
  abort?.throwIfAborted();
1195
1233
  if (ready.command !== undefined) {
1196
1234
  if (!commandExists(ready.command)) {
1197
1235
  throw new Error(`Service "${serviceId}": readiness command \`${ready.command}\` not found on PATH`);
1198
1236
  }
1199
- try { await runExternalCommand([ready.command, ...(ready.args ?? [])], cwd, serviceId, 'readiness', env, abort, Math.max(1, timeoutMs - (Date.now() - started))); return; }
1237
+ try { await runExternalCommand([ready.command, ...(ready.args ?? [])], cwd, serviceId, 'readiness', env, abort, Math.max(1, timeoutMs - (Date.now() - started))); return passed(`\`${[ready.command, ...(ready.args ?? [])].join(' ')}\` exited 0`); }
1200
1238
  catch (error) { if (error instanceof CommandRetirementError) throw error; lastError = errorMessage(error); }
1201
1239
  } else if (ready.httpUrl !== undefined) {
1202
1240
  try {
1203
1241
  const response = await fetch(ready.httpUrl, { signal: AbortSignal.any([AbortSignal.timeout(Math.max(1, timeoutMs - (Date.now() - started))), ...(abort ? [abort] : [])]) });
1204
- if (response.status >= 200 && response.status < 400) return;
1242
+ if (response.status >= 200 && response.status < 400) return passed(`GET ${ready.httpUrl} answered ${response.status}`);
1205
1243
  lastError = `HTTP ${response.status}`;
1206
1244
  } catch (error) {
1207
1245
  lastError = errorMessage(error);
1208
1246
  }
1209
1247
  } else if (ready.stdoutMatch !== undefined) {
1210
1248
  const text = serviceOutput(readServiceText(logPath));
1211
- if (new RegExp(ready.stdoutMatch).test(text)) return;
1249
+ if (new RegExp(ready.stdoutMatch).test(text)) return passed(`the log matched /${ready.stdoutMatch}/`);
1212
1250
  lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
1213
1251
  }
1214
- await sleep(intervalMs);
1252
+ // A service that is nearly ready is seen at once; one that is slow is asked at most every intervalMs.
1253
+ await sleep(Math.min(intervalMs, 25 * 2 ** attempt));
1254
+ attempt += 1;
1215
1255
  }
1216
1256
  throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
1217
1257
  }
@@ -1258,10 +1298,12 @@ async function startExternalService(
1258
1298
  // Publish the declared teardown before an external command can create state.
1259
1299
  boot?.external({ id: service.id, type: 'external', command: [...external.up], pid: 0, log, env: {},
1260
1300
  external: { down: [...external.down], cwd, discoveredEnv: { ...(service.env ?? {}) } } });
1261
- const upOutput = await runExternalUp(external.up, cwd, service.id, env, log, boot?.signal);
1301
+ const upOutput = await runExternalUp(external.up, cwd, service.id, env, log, boot?.signal, boot?.progress);
1302
+ boot?.progress(`${service.id}: up returned`);
1262
1303
 
1263
1304
  // 2. readiness — Docker-backed externals take seconds to boot.
1264
- await awaitReadiness(external.readyWhen, log, cwd, service.id, env, boot?.signal);
1305
+ const readiness = await awaitReadiness(external.readyWhen, log, cwd, service.id, env, boot?.signal);
1306
+ boot?.progress(`${service.id}: ready (${readiness})`);
1265
1307
 
1266
1308
  // 3. status — discover connection info (optional; may reuse up output instead).
1267
1309
  let statusOutput = '';
@@ -1269,6 +1311,7 @@ async function startExternalService(
1269
1311
  const statusResult = await runExternalCommand(external.status, cwd, service.id, 'status', env, boot?.signal);
1270
1312
  statusOutput = statusResult.stdout || statusResult.stderr;
1271
1313
  appendFileSync(log, `\n$ ${external.status.join(' ')}\n${statusOutput}`);
1314
+ boot?.progress(`${service.id}: status read`);
1272
1315
  }
1273
1316
 
1274
1317
  // 4. discover — apply declarative mappings → injected env vars.
@@ -1324,6 +1367,8 @@ type BootContext = {
1324
1367
  handles: Map<number, ReturnType<typeof spawn>>;
1325
1368
  external: (service: WorldServiceInstance) => void;
1326
1369
  recordPids: () => void;
1370
+ /** One progress line of this `up` on stderr, stamped with the time since it began (architecture, co-located startup). */
1371
+ progress: (what: string) => void;
1327
1372
  };
1328
1373
  async function trackBootingChild(child: ReturnType<typeof spawn>, boot: BootContext, birth: Promise<SpawnIdentity | undefined>): Promise<void> {
1329
1374
  if (!child.pid) return;
@@ -1382,6 +1427,7 @@ async function startService(
1382
1427
  child.once('error', error => boot.fail(error));
1383
1428
  child.unref();
1384
1429
  await trackBootingChild(child, boot, identity);
1430
+ boot.progress(`${service.id}: spawned`);
1385
1431
 
1386
1432
  // Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
1387
1433
  // child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
@@ -1407,6 +1453,7 @@ async function startService(
1407
1453
  }
1408
1454
  throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
1409
1455
  });
1456
+ boot.progress(`${service.id}: port ${port} answers`);
1410
1457
 
1411
1458
  await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
1412
1459
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
@@ -1422,15 +1469,17 @@ async function startService(
1422
1469
 
1423
1470
  const host = '127.0.0.1';
1424
1471
  const url = `http://${host}:${port}`;
1472
+ boot.progress(`${service.id}: port ${port} is its own`);
1425
1473
 
1426
1474
  // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
1427
1475
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1428
1476
  if (service.ready && service.ready !== 'tcp') {
1429
- 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) => {
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) => {
1430
1478
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1431
1479
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1432
1480
  throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
1433
1481
  });
1482
+ boot.progress(`${service.id}: ready (${readiness})`);
1434
1483
  }
1435
1484
 
1436
1485
  return {
@@ -1821,6 +1870,7 @@ async function startColocatedServices(
1821
1870
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1822
1871
  throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
1823
1872
  });
1873
+ boot.progress('co-located host: its all-started receipt is read');
1824
1874
  await parallelStartup(allocated, async ({ service, port }) => {
1825
1875
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1826
1876
  await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch(async (error) => {
@@ -1857,6 +1907,7 @@ async function startColocatedServices(
1857
1907
 
1858
1908
  export async function upWorld(configId: string, options: UpWorldOptions = {}, abort?: AbortSignal): Promise<WorldInstance> {
1859
1909
  abort?.throwIfAborted();
1910
+ const upStarted = Date.now();
1860
1911
  const root = resolve(options.root ?? process.cwd());
1861
1912
  const found = loadWorldConfig(configId, root);
1862
1913
  // the facts of the World's installed packs, over the kernel's, for this process and (through the instance file) its own
@@ -2110,10 +2161,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2110
2161
  };
2111
2162
  const boot: BootContext = {
2112
2163
  bootId: instanceToken, signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(), identities: {}, handles: new Map(),
2164
+ // The product's own progress, one line per phase, so a caller that streams stderr sees where up's time goes.
2165
+ progress: what => { try { process.stderr.write(`volter-world up ${name}: +${((Date.now() - upStarted) / 1000).toFixed(2)} s ${what}\n`); } catch { /* a closed stderr never fails a boot */ } },
2113
2166
  external: service => { recordService(service); saveWorldInstance(worldInstance); },
2114
2167
  recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids], boot.identities),
2115
2168
  };
2116
2169
  const recordPidsSoFar = boot.recordPids;
2170
+ // The runtime's own infrastructure, started before its declared position (ADR 0017), by service id.
2171
+ const early = new Map<string, Promise<WorldServiceInstance>>();
2117
2172
  try {
2118
2173
  // Ownership survives caller/process loss without an inert capacity-holder process.
2119
2174
  retainWorldLifecycleForCleanup(root, name);
@@ -2124,27 +2179,41 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2124
2179
  // The co-located host's twins are known by address once their ports are
2125
2180
  // allocated. A twin serves and calls no other service while it starts,
2126
2181
  // so a later twin declaration starts beside the host's startup; any other
2127
- // service waits until every earlier one is ready, as declared (ADR 0015).
2182
+ // service waits until every earlier one is ready, as declared (ADR 0015), except the runtime's own
2183
+ // infrastructure, which starts beside the host (ADR 0017).
2128
2184
  // A co-located twin's issued credentials are asked of it once the host is ready: it answers no request before.
2129
2185
  let colocatedReady: (() => Promise<void>) | undefined;
2130
2186
  if (isolation !== 'process') {
2131
2187
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
2132
2188
  const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
2189
+ if (colocated.length) boot.progress(`co-located host spawned: ${colocated.length} twins`);
2133
2190
  for (const started of host.services) {
2134
2191
  recordService(started);
2135
2192
  Object.assign(env, started.env);
2136
2193
  }
2137
2194
  colocatedReady = async () => {
2138
2195
  await host.ready;
2196
+ if (colocated.length) boot.progress('co-located host ready: receipt read, every port admitted as its own');
2139
2197
  for (const started of host.services) {
2140
2198
  const declared = colocated.find((s) => s.id === started.id);
2141
2199
  if (declared) await issueCredentials(declared, started, env, issuing, unissued);
2142
2200
  }
2143
2201
  writeProxyEnv(root, name, env);
2202
+ if (colocated.length) boot.progress('co-located twins: credentials issued, proxy environment written');
2144
2203
  };
2145
2204
  recordPidsSoFar();
2146
2205
  writeProxyEnv(root, name, env);
2147
2206
  }
2207
+ // The runtime's own infrastructure starts now, beside the host, with the environment as it stands: it reads
2208
+ // only the World's configuration and calls no service while it starts. Its result is taken at its declared
2209
+ // position, so records, environment and `up`'s return keep declaration order; its failure fails the boot (ADR 0017).
2210
+ for (const service of loaded.config.services) {
2211
+ if (services[service.id] || !ownInfrastructureService(service)) continue;
2212
+ boot.progress(`${service.id}: starting beside the host (the runtime's own infrastructure)`);
2213
+ const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot);
2214
+ starting.catch((error: unknown) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
2215
+ early.set(service.id, starting);
2216
+ }
2148
2217
  let proxyForServices: ReturnType<typeof ensureWorldProxyFromEnv> | null = null;
2149
2218
  // TWIN-64: attempt the ambient proxy once activeVendorMap(env) has at least one twin — tried
2150
2219
  // again after each service in case ITS start is what makes the map non-empty. A sealed world
@@ -2174,16 +2243,23 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2174
2243
  }
2175
2244
  };
2176
2245
  for (const service of loaded.config.services) {
2177
- if (services[service.id]) continue; // already up co-located
2246
+ const starting = early.get(service.id);
2247
+ if (!starting && services[service.id]) continue; // already up co-located
2248
+ // At its position an early start still takes the host's readiness first, so credentials and environment merge in
2249
+ // declared order; its own `up` has been running beside the host all along.
2178
2250
  if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady(); colocatedReady = undefined; }
2179
2251
  attemptProxy();
2180
2252
  boot.signal.throwIfAborted();
2181
- const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
2253
+ if (!starting) boot.progress(`${service.id}: starting (${service.type ?? 'twin'})`);
2254
+ const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot));
2255
+ // An early start published its teardown when it began; its record moves to its declared position.
2256
+ if (starting) delete services[service.id];
2182
2257
  recordService(started);
2183
2258
  recordPidsSoFar();
2184
2259
  Object.assign(env, started.env);
2185
2260
  await issueCredentials(service, started, env, issuing, unissued);
2186
2261
  writeProxyEnv(root, name, env);
2262
+ boot.progress(`${service.id}: started, its environment merged`);
2187
2263
  }
2188
2264
  if (colocatedReady) await colocatedReady();
2189
2265
  detachBootSignals();
@@ -2208,11 +2284,13 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2208
2284
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
2209
2285
  // the mode the caller chose (a shared env file may be read under another uid).
2210
2286
  publishEnvOutput(envOutputClaim, envFileContents(env));
2287
+ boot.progress(`environment written: ${envFile}`);
2211
2288
  materializeBrowserFiles(data, root, name, loaded.config.id);
2212
2289
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
2213
2290
  saveWorldInstance(worldInstance);
2214
2291
  const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
2215
2292
  handoffWorldLifecycle(root, name, servicePids);
2293
+ boot.progress('returning: the World is up');
2216
2294
  if (mode === 'share') {
2217
2295
  try {
2218
2296
  return await shareWorldServices(name, { root, ...(options.share ?? {}) });
@@ -2225,6 +2303,8 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2225
2303
  } catch (error) {
2226
2304
  cleanupUncertain = true;
2227
2305
  detachBootSignals();
2306
+ // An infrastructure `up` started early may still be running: end it and let it settle before its teardown runs.
2307
+ if (early.size) { cancelled.abort(error); await Promise.allSettled(early.values()); saveWorldInstance(worldInstance); }
2228
2308
  // Rollback never lets its own failure replace the boot's: each step's error is added to the original.
2229
2309
  const rollbackErrors: string[] = [];
2230
2310
  try { await stopWorldProxy(root, name, boot.handles); } catch (proxyError) { rollbackErrors.push(`proxy stop failed: ${errorMessage(proxyError)}`); }