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