@volter/world-runtime 3.0.98 → 3.0.99
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 +49 -5
- package/dist/src/pglite-backing.d.ts +18 -0
- package/dist/src/pglite-backing.js +93 -3
- package/dist/src/pglite-host.mjs +25 -5
- package/dist/src/runtime.js +112 -15
- package/package.json +4 -4
- package/src/infra.ts +40 -5
- package/src/pglite-backing.ts +87 -4
- package/src/pglite-host.mjs +25 -5
- package/src/runtime.ts +95 -15
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
|
|
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
|
-
|
|
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();
|
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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/dist/src/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.0.99",
|
|
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.
|
|
73
|
-
"@volter/world-console": "3.0.
|
|
72
|
+
"@volter/world-core": "3.0.99",
|
|
73
|
+
"@volter/world-console": "3.0.94"
|
|
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.
|
|
79
|
+
"@volter/world-core": "3.0.99",
|
|
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
|
|
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';
|
package/src/pglite-backing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/pglite-host.mjs
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}`); }
|