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