@volter/world-runtime 3.0.101 → 3.0.103
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/process-signal.mjs +13 -4
- package/dist/src/redis-backing.js +30 -2
- package/dist/src/runtime.js +45 -26
- package/dist/src/up-task.js +33 -12
- package/package.json +4 -4
- package/src/process-signal.mjs +13 -4
- package/src/redis-backing.ts +24 -2
- package/src/runtime.ts +44 -25
- package/src/up-task.ts +24 -9
|
@@ -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.
|
|
@@ -55,6 +55,33 @@ function listening(port) {
|
|
|
55
55
|
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
/** Whether the Redis answering on the port is this child (architecture D9): its own INFO server names its process_id, as
|
|
59
|
+
* Redis's does. A port that answers as another process, or does not answer yet, is not this child's. */
|
|
60
|
+
function redisServes(port, pid) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
|
|
63
|
+
let reply = '';
|
|
64
|
+
socket.once('connect', () => socket.write('*2\r\n$4\r\nINFO\r\n$6\r\nserver\r\n'));
|
|
65
|
+
socket.on('data', (chunk) => {
|
|
66
|
+
reply += chunk.toString('latin1');
|
|
67
|
+
// a bulk string: $<length>\r\n<body>\r\n
|
|
68
|
+
const head = /^\$(\d+)\r\n/.exec(reply);
|
|
69
|
+
if (!head) {
|
|
70
|
+
if (!reply.startsWith('$') || reply.includes('\r\n')) {
|
|
71
|
+
socket.destroy();
|
|
72
|
+
resolve(false);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (Buffer.byteLength(reply, 'latin1') < head[0].length + Number(head[1]) + 2)
|
|
77
|
+
return;
|
|
78
|
+
socket.destroy();
|
|
79
|
+
resolve(Number(/(?:^|\r\n)process_id:(\d+)\r\n/.exec(reply.slice(head[0].length))?.[1]) === pid);
|
|
80
|
+
});
|
|
81
|
+
socket.on('error', () => resolve(false));
|
|
82
|
+
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
58
85
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
86
|
export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
60
87
|
for (const service of services) {
|
|
@@ -99,7 +126,7 @@ export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
|
99
126
|
publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(identity) + '\n');
|
|
100
127
|
publishProcessReceipt(pidFile, `${held.child.pid}\n`);
|
|
101
128
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
102
|
-
while (!(await
|
|
129
|
+
while (!alive(held.child.pid) || !(await redisServes(service.hostPort, held.child.pid))) {
|
|
103
130
|
if (!alive(held.child.pid))
|
|
104
131
|
throw new Error(`redis exited before serving — see ${log}`);
|
|
105
132
|
launch.signal?.throwIfAborted();
|
|
@@ -129,7 +156,8 @@ export async function redisTwinStatus(services, dataDir) {
|
|
|
129
156
|
const pidFile = pidPath(dataDir, service);
|
|
130
157
|
if (!existsSync(pidFile))
|
|
131
158
|
continue;
|
|
132
|
-
|
|
159
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
160
|
+
if (alive(pid) && await redisServes(service.hostPort, pid))
|
|
133
161
|
ready += 1;
|
|
134
162
|
}
|
|
135
163
|
return ready;
|
package/dist/src/runtime.js
CHANGED
|
@@ -3056,7 +3056,7 @@ function canonicalDottedIpv4(host) {
|
|
|
3056
3056
|
function parseCustomShareOrigin(handshake) {
|
|
3057
3057
|
const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(handshake);
|
|
3058
3058
|
if (!raw) {
|
|
3059
|
-
throw new Error(`
|
|
3059
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a strict http(s) origin without path, query, fragment, or backslashes: ${handshake}`);
|
|
3060
3060
|
}
|
|
3061
3061
|
const rawHost = raw[1];
|
|
3062
3062
|
const rawPort = raw[2];
|
|
@@ -3065,24 +3065,27 @@ function parseCustomShareOrigin(handshake) {
|
|
|
3065
3065
|
parsed = new URL(handshake);
|
|
3066
3066
|
}
|
|
3067
3067
|
catch {
|
|
3068
|
-
throw new Error(`
|
|
3068
|
+
throw new Error(`A custom tunnel wrote an invalid URL to VOLTER_SHARE_URL_FILE: ${handshake}`);
|
|
3069
3069
|
}
|
|
3070
3070
|
const normalizedHostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
3071
3071
|
if (!rawHost.startsWith('[') && isIP(normalizedHostname) === 4
|
|
3072
3072
|
&& (!canonicalDottedIpv4(rawHost) || rawHost !== normalizedHostname)) {
|
|
3073
|
-
throw new Error(`
|
|
3073
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
|
|
3074
3074
|
}
|
|
3075
3075
|
if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
|
|
3076
|
-
throw new Error(`
|
|
3076
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical decimal port syntax: ${handshake}`);
|
|
3077
3077
|
}
|
|
3078
3078
|
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password
|
|
3079
3079
|
|| parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
3080
|
-
throw new Error(`
|
|
3080
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a credential-free http(s) origin: ${handshake}`);
|
|
3081
3081
|
}
|
|
3082
3082
|
return parsed.origin;
|
|
3083
3083
|
}
|
|
3084
|
-
|
|
3085
|
-
|
|
3084
|
+
/** The tunnel's public URL, from the tunnel's own answer and never its output (architecture D9). cloudflared's metrics
|
|
3085
|
+
* server answers GET /quicktunnel with {"hostname"} once the quick tunnel is registered (empty before); a custom command
|
|
3086
|
+
* writes its public URL to VOLTER_SHARE_URL_FILE, ending the line when it is complete, so a half-written URL is never
|
|
3087
|
+
* read. The process's log is for people. */
|
|
3088
|
+
async function waitForPublicUrl(child, source, logPath, timeoutMs) {
|
|
3086
3089
|
let exited = false;
|
|
3087
3090
|
let exitSummary = '';
|
|
3088
3091
|
child.once('exit', (code, signal) => {
|
|
@@ -3095,26 +3098,35 @@ async function waitForPublicUrl(child, logPath, timeoutMs, requireTryCloudflare)
|
|
|
3095
3098
|
});
|
|
3096
3099
|
const started = Date.now();
|
|
3097
3100
|
while (Date.now() - started < timeoutMs) {
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
const
|
|
3102
|
-
if (
|
|
3101
|
+
if ('metricsPort' in source) {
|
|
3102
|
+
try {
|
|
3103
|
+
const answer = await fetch(`http://127.0.0.1:${source.metricsPort}/quicktunnel`, { signal: AbortSignal.timeout(1000) });
|
|
3104
|
+
const hostname = answer.ok ? (await answer.json()).hostname : undefined;
|
|
3105
|
+
if (typeof hostname === 'string' && hostname) {
|
|
3106
|
+
const parsed = parseCloudflareQuickPublicUrl(`https://${hostname}`);
|
|
3107
|
+
if (!parsed)
|
|
3108
|
+
throw new Error(`cloudflared answered /quicktunnel with ${JSON.stringify(hostname)}, not a trycloudflare.com hostname. Log: ${logPath}`);
|
|
3103
3109
|
return parsed;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
catch (error) {
|
|
3113
|
+
if (error instanceof Error && error.message.startsWith('cloudflared answered'))
|
|
3114
|
+
throw error;
|
|
3115
|
+
// its metrics server not listening yet: asked again
|
|
3104
3116
|
}
|
|
3105
3117
|
}
|
|
3106
3118
|
else {
|
|
3107
|
-
const
|
|
3108
|
-
if (
|
|
3109
|
-
return parseCustomShareOrigin(
|
|
3119
|
+
const text = existsSync(source.urlFile) ? readFileSync(source.urlFile, 'utf8') : '';
|
|
3120
|
+
if (text.endsWith('\n'))
|
|
3121
|
+
return parseCustomShareOrigin(text.trim());
|
|
3110
3122
|
}
|
|
3111
3123
|
if (exited)
|
|
3112
|
-
throw new Error(`Tunnel process exited before
|
|
3113
|
-
await new Promise((resolveWait) => setTimeout(resolveWait,
|
|
3124
|
+
throw new Error(`Tunnel process exited before answering its public URL (${exitSummary}). Log: ${logPath}`);
|
|
3125
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
3114
3126
|
}
|
|
3115
|
-
throw new Error(
|
|
3116
|
-
? `
|
|
3117
|
-
: `Custom tunnel did not
|
|
3127
|
+
throw new Error('metricsPort' in source
|
|
3128
|
+
? `cloudflared's metrics server did not answer a trycloudflare.com hostname at /quicktunnel within ${timeoutMs}ms. Log: ${logPath}`
|
|
3129
|
+
: `Custom tunnel did not write its public URL, newline-terminated, to VOLTER_SHARE_URL_FILE within ${timeoutMs}ms. Log: ${logPath}`);
|
|
3118
3130
|
}
|
|
3119
3131
|
function sleep(ms) {
|
|
3120
3132
|
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
|
|
@@ -3284,12 +3296,19 @@ async function shareWorldReserved(name, options) {
|
|
|
3284
3296
|
}
|
|
3285
3297
|
const tunnel = shareCommand(options, service.url);
|
|
3286
3298
|
const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
3287
|
-
//
|
|
3288
|
-
//
|
|
3299
|
+
// The public URL is the tunnel's own answer, never its output (architecture D9): cloudflared's metrics server
|
|
3300
|
+
// answers /quicktunnel with the hostname it registered; a custom command writes its URL to VOLTER_SHARE_URL_FILE.
|
|
3301
|
+
// Each spawned tunnel owns one generation of both, so a repeated share never takes the previous process's URL.
|
|
3302
|
+
const source = tunnel.provider === 'cloudflare-quick'
|
|
3303
|
+
? { metricsPort: await allocatePort(new Set()) }
|
|
3304
|
+
: { urlFile: ownedPath(instance.dirs.logs, `${serviceId}.tunnel.url`) };
|
|
3305
|
+
if ('urlFile' in source)
|
|
3306
|
+
rmSync(source.urlFile, { force: true });
|
|
3307
|
+
const args = 'metricsPort' in source ? ['tunnel', '--metrics', `127.0.0.1:${source.metricsPort}`, ...tunnel.args.slice(1)] : tunnel.args;
|
|
3289
3308
|
const out = openPrivateLog(log, 'w');
|
|
3290
|
-
const child = spawn(tunnel.command,
|
|
3309
|
+
const child = spawn(tunnel.command, args, { windowsHide: true,
|
|
3291
3310
|
cwd: root,
|
|
3292
|
-
env: withoutVaultSignIn(process.env),
|
|
3311
|
+
env: { ...withoutVaultSignIn(process.env), ...('urlFile' in source ? { VOLTER_SHARE_URL_FILE: source.urlFile } : {}) },
|
|
3293
3312
|
detached: true,
|
|
3294
3313
|
stdio: ['ignore', out, out],
|
|
3295
3314
|
});
|
|
@@ -3305,7 +3324,7 @@ async function shareWorldReserved(name, options) {
|
|
|
3305
3324
|
if (!identity?.start)
|
|
3306
3325
|
throw new Error('Tunnel birth capture failed; retiring held child');
|
|
3307
3326
|
identities[identity.pid] = identity.start;
|
|
3308
|
-
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000
|
|
3327
|
+
publicUrl = await waitForPublicUrl(child, source, log, options.timeoutMs ?? 20_000);
|
|
3309
3328
|
if (options.verifyPath !== false) {
|
|
3310
3329
|
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
3311
3330
|
}
|
|
@@ -3324,7 +3343,7 @@ async function shareWorldReserved(name, options) {
|
|
|
3324
3343
|
provider: tunnel.provider,
|
|
3325
3344
|
pid: child.pid ?? 0,
|
|
3326
3345
|
log,
|
|
3327
|
-
command: [tunnel.command, ...
|
|
3346
|
+
command: [tunnel.command, ...args],
|
|
3328
3347
|
startedAt: new Date().toISOString(),
|
|
3329
3348
|
};
|
|
3330
3349
|
writePidsFromInstance(nextInstance, child.pid ? [child.pid] : []);
|
package/dist/src/up-task.js
CHANGED
|
@@ -14,6 +14,7 @@ export function superviseWorldUp(configId, options) {
|
|
|
14
14
|
});
|
|
15
15
|
let reply;
|
|
16
16
|
let failure;
|
|
17
|
+
let settled = false;
|
|
17
18
|
const cancel = (signal) => {
|
|
18
19
|
if (child.connected)
|
|
19
20
|
child.send({ type: 'cancel', signal }, error => { if (error)
|
|
@@ -23,8 +24,31 @@ export function superviseWorldUp(configId, options) {
|
|
|
23
24
|
const onTerm = () => cancel('SIGTERM');
|
|
24
25
|
process.on('SIGINT', onInt);
|
|
25
26
|
process.on('SIGTERM', onTerm);
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// `up` is done the moment the owner answers: the caller settles on the reply and lets go of the owner (its channel
|
|
28
|
+
// and its handle), never waiting on its 'close'. 'close' waits for every inherited descriptor to end, and in a
|
|
29
|
+
// browser tab's kernel a service the owner started still held one: run 29 printed "returning: the World is up" at
|
|
30
|
+
// 7.54 s and this process stayed until the 10 s stop. With no reply, the owner's channel closing and its exit settle it.
|
|
31
|
+
const settle = () => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
offSignal('SIGINT', onInt);
|
|
36
|
+
offSignal('SIGTERM', onTerm);
|
|
37
|
+
if (child.connected)
|
|
38
|
+
child.disconnect();
|
|
39
|
+
child.unref();
|
|
40
|
+
if (failure)
|
|
41
|
+
reject(failure);
|
|
42
|
+
else if (reply && 'error' in reply)
|
|
43
|
+
reject(new Error(reply.error));
|
|
44
|
+
else if (reply)
|
|
45
|
+
done(reply.instance);
|
|
46
|
+
else
|
|
47
|
+
reject(new Error(`World boot owner exited without a result (${child.signalCode ?? child.exitCode}); inspect \`volter-world status\` and the lifecycle log`));
|
|
48
|
+
};
|
|
49
|
+
child.on('error', error => { failure = error; if (!child.pid)
|
|
50
|
+
settle(); });
|
|
51
|
+
child.on('message', message => { reply = message; settle(); });
|
|
28
52
|
child.once('spawn', () => {
|
|
29
53
|
const request = { type: 'start', configId, options: { ...options, root: resolve(options.root ?? process.cwd()) } };
|
|
30
54
|
child.send(request, error => { if (error) {
|
|
@@ -33,17 +57,14 @@ export function superviseWorldUp(configId, options) {
|
|
|
33
57
|
child.disconnect();
|
|
34
58
|
} });
|
|
35
59
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
reject(new Error(reply.error));
|
|
43
|
-
else if (reply && code === 0)
|
|
44
|
-
done(reply.instance);
|
|
60
|
+
// No reply: every message the channel carried has been delivered once it disconnects; the owner's exit says why.
|
|
61
|
+
child.once('disconnect', () => {
|
|
62
|
+
if (reply)
|
|
63
|
+
return;
|
|
64
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
65
|
+
settle();
|
|
45
66
|
else
|
|
46
|
-
|
|
67
|
+
child.once('exit', settle);
|
|
47
68
|
});
|
|
48
69
|
});
|
|
49
70
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.103",
|
|
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.103",
|
|
73
|
+
"@volter/world-console": "3.0.98"
|
|
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.103",
|
|
80
80
|
"mongodb": "~6.20.0",
|
|
81
81
|
"pg-gateway": "0.3.0-beta.4",
|
|
82
82
|
"smol-toml": "^1.8.0",
|
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,7 +114,7 @@ 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}`);
|
|
@@ -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
|
@@ -2865,30 +2865,36 @@ function canonicalDottedIpv4(host: string): boolean {
|
|
|
2865
2865
|
function parseCustomShareOrigin(handshake: string): string {
|
|
2866
2866
|
const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(handshake);
|
|
2867
2867
|
if (!raw) {
|
|
2868
|
-
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}`);
|
|
2869
2869
|
}
|
|
2870
2870
|
const rawHost = raw[1]!;
|
|
2871
2871
|
const rawPort = raw[2];
|
|
2872
2872
|
let parsed: URL;
|
|
2873
2873
|
try { parsed = new URL(handshake); }
|
|
2874
|
-
catch { throw new Error(`
|
|
2874
|
+
catch { throw new Error(`A custom tunnel wrote an invalid URL to VOLTER_SHARE_URL_FILE: ${handshake}`); }
|
|
2875
2875
|
const normalizedHostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
2876
2876
|
if (!rawHost.startsWith('[') && isIP(normalizedHostname) === 4
|
|
2877
2877
|
&& (!canonicalDottedIpv4(rawHost) || rawHost !== normalizedHostname)) {
|
|
2878
|
-
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}`);
|
|
2879
2879
|
}
|
|
2880
2880
|
if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
|
|
2881
|
-
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}`);
|
|
2882
2882
|
}
|
|
2883
2883
|
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password
|
|
2884
2884
|
|| parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
2885
|
-
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}`);
|
|
2886
2886
|
}
|
|
2887
2887
|
return parsed.origin;
|
|
2888
2888
|
}
|
|
2889
2889
|
|
|
2890
|
-
|
|
2891
|
-
|
|
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> {
|
|
2892
2898
|
let exited = false;
|
|
2893
2899
|
let exitSummary = '';
|
|
2894
2900
|
child.once('exit', (code, signal) => {
|
|
@@ -2902,22 +2908,29 @@ async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string
|
|
|
2902
2908
|
|
|
2903
2909
|
const started = Date.now();
|
|
2904
2910
|
while (Date.now() - started < timeoutMs) {
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
const
|
|
2909
|
-
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
|
|
2910
2923
|
}
|
|
2911
2924
|
} else {
|
|
2912
|
-
const
|
|
2913
|
-
if (
|
|
2925
|
+
const text = existsSync(source.urlFile) ? readFileSync(source.urlFile, 'utf8') : '';
|
|
2926
|
+
if (text.endsWith('\n')) return parseCustomShareOrigin(text.trim());
|
|
2914
2927
|
}
|
|
2915
|
-
if (exited) throw new Error(`Tunnel process exited before
|
|
2916
|
-
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));
|
|
2917
2930
|
}
|
|
2918
|
-
throw new Error(
|
|
2919
|
-
? `
|
|
2920
|
-
: `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}`);
|
|
2921
2934
|
}
|
|
2922
2935
|
|
|
2923
2936
|
function sleep(ms: number): Promise<void> {
|
|
@@ -3090,12 +3103,18 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
3090
3103
|
|
|
3091
3104
|
const tunnel = shareCommand(options, service.url);
|
|
3092
3105
|
const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
3093
|
-
//
|
|
3094
|
-
//
|
|
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;
|
|
3095
3114
|
const out = openPrivateLog(log, 'w');
|
|
3096
|
-
const child = spawn(tunnel.command,
|
|
3115
|
+
const child = spawn(tunnel.command, args, { windowsHide: true,
|
|
3097
3116
|
cwd: root,
|
|
3098
|
-
env: withoutVaultSignIn(process.env),
|
|
3117
|
+
env: { ...withoutVaultSignIn(process.env), ...('urlFile' in source ? { VOLTER_SHARE_URL_FILE: source.urlFile } : {}) },
|
|
3099
3118
|
detached: true,
|
|
3100
3119
|
stdio: ['ignore', out, out],
|
|
3101
3120
|
});
|
|
@@ -3111,7 +3130,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
3111
3130
|
const identity = await birth;
|
|
3112
3131
|
if (!identity?.start) throw new Error('Tunnel birth capture failed; retiring held child');
|
|
3113
3132
|
identities[identity.pid] = identity.start;
|
|
3114
|
-
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000
|
|
3133
|
+
publicUrl = await waitForPublicUrl(child, source, log, options.timeoutMs ?? 20_000);
|
|
3115
3134
|
if (options.verifyPath !== false) {
|
|
3116
3135
|
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
3117
3136
|
}
|
|
@@ -3129,7 +3148,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
3129
3148
|
provider: tunnel.provider,
|
|
3130
3149
|
pid: child.pid ?? 0,
|
|
3131
3150
|
log,
|
|
3132
|
-
command: [tunnel.command, ...
|
|
3151
|
+
command: [tunnel.command, ...args],
|
|
3133
3152
|
startedAt: new Date().toISOString(),
|
|
3134
3153
|
};
|
|
3135
3154
|
writePidsFromInstance(nextInstance, child.pid ? [child.pid] : []);
|
package/src/up-task.ts
CHANGED
|
@@ -20,6 +20,7 @@ export function superviseWorldUp(configId: string, options: UpWorldOptions): Pro
|
|
|
20
20
|
});
|
|
21
21
|
let reply: UpTaskReply | undefined;
|
|
22
22
|
let failure: Error | undefined;
|
|
23
|
+
let settled = false;
|
|
23
24
|
const cancel = (signal: 'SIGINT' | 'SIGTERM') => {
|
|
24
25
|
if (child.connected) child.send({ type: 'cancel', signal }, error => { if (error) failure ??= error; });
|
|
25
26
|
};
|
|
@@ -27,19 +28,33 @@ export function superviseWorldUp(configId: string, options: UpWorldOptions): Pro
|
|
|
27
28
|
const onTerm = () => cancel('SIGTERM');
|
|
28
29
|
process.on('SIGINT', onInt);
|
|
29
30
|
process.on('SIGTERM', onTerm);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
// `up` is done the moment the owner answers: the caller settles on the reply and lets go of the owner (its channel
|
|
32
|
+
// and its handle), never waiting on its 'close'. 'close' waits for every inherited descriptor to end, and in a
|
|
33
|
+
// browser tab's kernel a service the owner started still held one: run 29 printed "returning: the World is up" at
|
|
34
|
+
// 7.54 s and this process stayed until the 10 s stop. With no reply, the owner's channel closing and its exit settle it.
|
|
35
|
+
const settle = () => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
37
38
|
offSignal('SIGINT', onInt);
|
|
38
39
|
offSignal('SIGTERM', onTerm);
|
|
40
|
+
if (child.connected) child.disconnect();
|
|
41
|
+
child.unref();
|
|
39
42
|
if (failure) reject(failure);
|
|
40
43
|
else if (reply && 'error' in reply) reject(new Error(reply.error));
|
|
41
|
-
else if (reply
|
|
42
|
-
else reject(new Error(`World boot owner exited without a result (${
|
|
44
|
+
else if (reply) done(reply.instance);
|
|
45
|
+
else reject(new Error(`World boot owner exited without a result (${child.signalCode ?? child.exitCode}); inspect \`volter-world status\` and the lifecycle log`));
|
|
46
|
+
};
|
|
47
|
+
child.on('error', error => { failure = error; if (!child.pid) settle(); });
|
|
48
|
+
child.on('message', message => { reply = message as UpTaskReply; settle(); });
|
|
49
|
+
child.once('spawn', () => {
|
|
50
|
+
const request: UpTaskRequest = { type: 'start', configId, options: { ...options, root: resolve(options.root ?? process.cwd()) } };
|
|
51
|
+
child.send(request, error => { if (error) { failure = error; if (child.connected) child.disconnect(); } });
|
|
52
|
+
});
|
|
53
|
+
// No reply: every message the channel carried has been delivered once it disconnects; the owner's exit says why.
|
|
54
|
+
child.once('disconnect', () => {
|
|
55
|
+
if (reply) return;
|
|
56
|
+
if (child.exitCode !== null || child.signalCode !== null) settle();
|
|
57
|
+
else child.once('exit', settle);
|
|
43
58
|
});
|
|
44
59
|
});
|
|
45
60
|
}
|