@volter/world-runtime 2.0.25 → 2.0.26
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/host-cli.js +14 -3
- package/dist/src/host-ready.d.ts +4 -0
- package/dist/src/host-ready.js +48 -0
- package/dist/src/runtime.js +46 -16
- package/package.json +1 -1
- package/src/host-cli.ts +8 -3
- package/src/host-ready.ts +41 -0
- package/src/runtime.ts +30 -12
package/dist/src/host-cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
import { startColocatedHost } from "./host.js";
|
|
16
|
+
import { publishHostReady } from "./host-ready.js";
|
|
16
17
|
const argv = process.argv.slice(2);
|
|
17
18
|
const opt = (name, fb = '') => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fb; };
|
|
18
19
|
const isolation = opt('--isolation', 'shared');
|
|
@@ -85,10 +86,20 @@ const host = await startColocatedHost(specs, {
|
|
|
85
86
|
recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
|
|
86
87
|
},
|
|
87
88
|
});
|
|
88
|
-
for (const [id, port] of Object.entries(host.ports))
|
|
89
|
-
process.stdout.write(`ready id=${id} port=${port}\n`);
|
|
90
|
-
process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
|
|
91
89
|
const shutdown = async () => { await host.stop(); process.exit(0); };
|
|
92
90
|
process.on('SIGTERM', shutdown);
|
|
93
91
|
process.on('SIGINT', shutdown);
|
|
92
|
+
const readyFile = opt('--ready-file');
|
|
93
|
+
if (readyFile) {
|
|
94
|
+
try {
|
|
95
|
+
publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
await host.stop();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const [id, port] of Object.entries(host.ports))
|
|
103
|
+
process.stdout.write(`ready id=${id} port=${port}\n`);
|
|
104
|
+
process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
|
|
94
105
|
await keepProcessAlive();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
|
|
2
|
+
export declare function publishHostReady(path: string, boot: string, ports: Record<string, number>): void;
|
|
3
|
+
/** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
|
|
4
|
+
export declare function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
|
|
4
|
+
export function publishHostReady(path, boot, ports) {
|
|
5
|
+
if (!boot)
|
|
6
|
+
throw new Error('A co-located host readiness receipt requires its World boot identity.');
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
9
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports })}\n`, { mode: 0o600 });
|
|
10
|
+
renameSync(temporary, path);
|
|
11
|
+
}
|
|
12
|
+
/** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
|
|
13
|
+
export async function waitForHostReady(path, boot, ports, timeoutMs, abort) {
|
|
14
|
+
const started = Date.now();
|
|
15
|
+
for (;;) {
|
|
16
|
+
abort.throwIfAborted();
|
|
17
|
+
let bytes;
|
|
18
|
+
try {
|
|
19
|
+
bytes = readFileSync(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== 'ENOENT')
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (bytes) {
|
|
26
|
+
if (bytes.byteLength > 256 * 1024)
|
|
27
|
+
throw new Error('The co-located host readiness receipt exceeds its bounds.');
|
|
28
|
+
const value = JSON.parse(bytes.toString('utf8'));
|
|
29
|
+
if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
|
|
30
|
+
throw new Error('The co-located host readiness receipt does not belong to this boot.');
|
|
31
|
+
}
|
|
32
|
+
const answered = value.ports;
|
|
33
|
+
if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
|
|
34
|
+
throw new Error('The co-located host readiness receipt does not cover its declared ports.');
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (Date.now() - started >= timeoutMs)
|
|
39
|
+
throw new Error(`The co-located host did not complete every declared start within ${timeoutMs} ms.`);
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const cancel = () => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
|
|
42
|
+
const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
|
43
|
+
abort.addEventListener('abort', cancel, { once: true });
|
|
44
|
+
if (abort.aborted)
|
|
45
|
+
cancel();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { claimEnvOutput, publishEnvOutput } from "./env-output.js";
|
|
2
2
|
import { offSignal } from "./signals.js";
|
|
3
3
|
import { parallelStartup } from "./startup.js";
|
|
4
|
+
import { waitForHostReady } from "./host-ready.js";
|
|
4
5
|
import { assertWorldStateRemovable, removeWorldState } from "./ancestry.js";
|
|
5
6
|
import { packCli, packEntry } from "./catalog.js";
|
|
6
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -111,16 +112,25 @@ async function untilReadyOrEnded(wait, ended, boot, what, log) {
|
|
|
111
112
|
* port alone (`early`), a twin's answer with this boot's id ends it early: a server of this boot holds the port, and
|
|
112
113
|
* every port this boot assigns is kept apart from its siblings', so short of the kernel handing the just-probed port
|
|
113
114
|
* to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
|
|
114
|
-
* ports
|
|
115
|
-
*
|
|
116
|
-
async function confirmOwnPort(port, boot, ended, twin, what, log, early = false) {
|
|
115
|
+
* ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
|
|
116
|
+
* Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
|
|
117
|
+
async function confirmOwnPort(port, boot, ended, twin, what, log, early = false, sharedSettle) {
|
|
117
118
|
// A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
|
|
118
119
|
boot.signal.throwIfAborted();
|
|
119
120
|
const answer = twin ? worldBootOf(port, boot.signal) : Promise.resolve(null);
|
|
120
121
|
const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' : new Promise(() => undefined))) : new Promise(() => undefined);
|
|
121
122
|
// `ended` absent: the process was already settled (a co-located host settles once for all its twins).
|
|
122
123
|
if (ended) {
|
|
123
|
-
|
|
124
|
+
let timer;
|
|
125
|
+
let settled;
|
|
126
|
+
try {
|
|
127
|
+
const settle = sharedSettle ?? new Promise((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
|
|
128
|
+
settled = await Promise.race([own, ended.then(() => 'ended'), settle]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (timer !== undefined)
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
124
134
|
boot.signal.throwIfAborted();
|
|
125
135
|
if (settled === 'own')
|
|
126
136
|
return;
|
|
@@ -1308,7 +1318,8 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1308
1318
|
if (colocated.length === 0)
|
|
1309
1319
|
return [];
|
|
1310
1320
|
const hostCli = siblingScript(import.meta.url, 'host-cli');
|
|
1311
|
-
const
|
|
1321
|
+
const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
|
|
1322
|
+
const args = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
|
|
1312
1323
|
const allocated = [];
|
|
1313
1324
|
for (const service of colocated) {
|
|
1314
1325
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
@@ -1344,6 +1355,16 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1344
1355
|
exitState.value = { code, signal };
|
|
1345
1356
|
});
|
|
1346
1357
|
const command = [process.execPath, ...args];
|
|
1358
|
+
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1359
|
+
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1360
|
+
if (undeadPids([child.pid]).length)
|
|
1361
|
+
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1362
|
+
try {
|
|
1363
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1364
|
+
}
|
|
1365
|
+
catch { /* best effort cleanup */ }
|
|
1366
|
+
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1367
|
+
});
|
|
1347
1368
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1348
1369
|
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1349
1370
|
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
@@ -1356,18 +1377,27 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1356
1377
|
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1357
1378
|
});
|
|
1358
1379
|
});
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1380
|
+
let hostSettleTimer;
|
|
1381
|
+
const hostSettle = new Promise((resolveAlive) => { hostSettleTimer = setTimeout(() => resolveAlive('alive'), 300); });
|
|
1382
|
+
try {
|
|
1383
|
+
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1384
|
+
// The host has completed every start. Each matching boot answer can now
|
|
1385
|
+
// end its own settle; legacy twins share one full host guard.
|
|
1386
|
+
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
|
|
1387
|
+
if (undeadPids([child.pid]).length)
|
|
1388
|
+
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1389
|
+
try {
|
|
1390
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1391
|
+
}
|
|
1392
|
+
catch { /* best effort cleanup */ }
|
|
1393
|
+
throw error;
|
|
1394
|
+
});
|
|
1369
1395
|
});
|
|
1370
|
-
}
|
|
1396
|
+
}
|
|
1397
|
+
finally {
|
|
1398
|
+
if (hostSettleTimer !== undefined)
|
|
1399
|
+
clearTimeout(hostSettleTimer);
|
|
1400
|
+
}
|
|
1371
1401
|
if (exitState.value !== null) {
|
|
1372
1402
|
throw new Error(`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
|
|
1373
1403
|
+ `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.26",
|
|
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",
|
package/src/host-cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
import { startColocatedHost, type ColocatedTwinSpec, type HostIsolation } from './host.ts';
|
|
16
|
+
import { publishHostReady } from './host-ready.ts';
|
|
16
17
|
|
|
17
18
|
const argv = process.argv.slice(2);
|
|
18
19
|
const opt = (name: string, fb = ''): string => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1]! : fb; };
|
|
@@ -83,10 +84,14 @@ const host = await startColocatedHost(specs, {
|
|
|
83
84
|
if (e.kind === 'gaveup') recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
|
|
84
85
|
},
|
|
85
86
|
});
|
|
86
|
-
for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
|
|
87
|
-
process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
|
|
88
|
-
|
|
89
87
|
const shutdown = async () => { await host.stop(); process.exit(0); };
|
|
90
88
|
process.on('SIGTERM', shutdown);
|
|
91
89
|
process.on('SIGINT', shutdown);
|
|
90
|
+
const readyFile = opt('--ready-file');
|
|
91
|
+
if (readyFile) {
|
|
92
|
+
try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports); }
|
|
93
|
+
catch (error) { await host.stop(); throw error; }
|
|
94
|
+
}
|
|
95
|
+
for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
|
|
96
|
+
process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
|
|
92
97
|
await keepProcessAlive();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
|
|
5
|
+
export function publishHostReady(path: string, boot: string, ports: Record<string, number>): void {
|
|
6
|
+
if (!boot) throw new Error('A co-located host readiness receipt requires its World boot identity.');
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
9
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports })}\n`, { mode: 0o600 });
|
|
10
|
+
renameSync(temporary, path);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
|
|
14
|
+
export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal): Promise<void> {
|
|
15
|
+
const started = Date.now();
|
|
16
|
+
for (;;) {
|
|
17
|
+
abort.throwIfAborted();
|
|
18
|
+
let bytes: Buffer | undefined;
|
|
19
|
+
try { bytes = readFileSync(path); }
|
|
20
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
21
|
+
if (bytes) {
|
|
22
|
+
if (bytes.byteLength > 256 * 1024) throw new Error('The co-located host readiness receipt exceeds its bounds.');
|
|
23
|
+
const value = JSON.parse(bytes.toString('utf8')) as { version?: unknown; boot?: unknown; ports?: unknown } | null;
|
|
24
|
+
if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
|
|
25
|
+
throw new Error('The co-located host readiness receipt does not belong to this boot.');
|
|
26
|
+
}
|
|
27
|
+
const answered = value.ports as Record<string, unknown>;
|
|
28
|
+
if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
|
|
29
|
+
throw new Error('The co-located host readiness receipt does not cover its declared ports.');
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host did not complete every declared start within ${timeoutMs} ms.`);
|
|
34
|
+
await new Promise<void>((resolve, reject) => {
|
|
35
|
+
const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
|
|
36
|
+
const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
|
37
|
+
abort.addEventListener('abort', cancel, { once: true });
|
|
38
|
+
if (abort.aborted) cancel();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
|
|
2
2
|
import { offSignal } from './signals.ts';
|
|
3
3
|
import { parallelStartup } from './startup.ts';
|
|
4
|
+
import { waitForHostReady } from './host-ready.ts';
|
|
4
5
|
import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
|
|
5
6
|
import { packCli, packEntry } from './catalog.ts';
|
|
6
7
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -105,16 +106,21 @@ async function untilReadyOrEnded<T>(wait: (abort: AbortSignal) => Promise<T>, en
|
|
|
105
106
|
* port alone (`early`), a twin's answer with this boot's id ends it early: a server of this boot holds the port, and
|
|
106
107
|
* every port this boot assigns is kept apart from its siblings', so short of the kernel handing the just-probed port
|
|
107
108
|
* to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
|
|
108
|
-
* ports
|
|
109
|
-
*
|
|
110
|
-
async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<void> | undefined, twin: boolean, what: string, log: string, early = false): Promise<void> {
|
|
109
|
+
* ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
|
|
110
|
+
* Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
|
|
111
|
+
async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<void> | undefined, twin: boolean, what: string, log: string, early = false, sharedSettle?: Promise<'alive'>): Promise<void> {
|
|
111
112
|
// A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
|
|
112
113
|
boot.signal.throwIfAborted();
|
|
113
114
|
const answer = twin ? worldBootOf(port, boot.signal) : Promise.resolve(null);
|
|
114
115
|
const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' as const : new Promise<never>(() => undefined))) : new Promise<never>(() => undefined);
|
|
115
116
|
// `ended` absent: the process was already settled (a co-located host settles once for all its twins).
|
|
116
117
|
if (ended) {
|
|
117
|
-
|
|
118
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
119
|
+
let settled: 'own' | 'ended' | 'alive';
|
|
120
|
+
try {
|
|
121
|
+
const settle = sharedSettle ?? new Promise<'alive'>((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
|
|
122
|
+
settled = await Promise.race([own, ended.then(() => 'ended' as const), settle]);
|
|
123
|
+
} finally { if (timer !== undefined) clearTimeout(timer); }
|
|
118
124
|
boot.signal.throwIfAborted();
|
|
119
125
|
if (settled === 'own') return;
|
|
120
126
|
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}`);
|
|
@@ -1324,7 +1330,8 @@ async function startColocatedServices(
|
|
|
1324
1330
|
): Promise<WorldServiceInstance[]> {
|
|
1325
1331
|
if (colocated.length === 0) return [];
|
|
1326
1332
|
const hostCli = siblingScript(import.meta.url, 'host-cli');
|
|
1327
|
-
const
|
|
1333
|
+
const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
|
|
1334
|
+
const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
|
|
1328
1335
|
const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage }> = [];
|
|
1329
1336
|
for (const service of colocated) {
|
|
1330
1337
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
@@ -1362,6 +1369,12 @@ async function startColocatedServices(
|
|
|
1362
1369
|
});
|
|
1363
1370
|
|
|
1364
1371
|
const command = [process.execPath, ...args];
|
|
1372
|
+
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1373
|
+
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1374
|
+
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1375
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1376
|
+
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1377
|
+
});
|
|
1365
1378
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1366
1379
|
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1367
1380
|
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
@@ -1370,14 +1383,19 @@ async function startColocatedServices(
|
|
|
1370
1383
|
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1371
1384
|
});
|
|
1372
1385
|
});
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1386
|
+
let hostSettleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1387
|
+
const hostSettle = new Promise<'alive'>((resolveAlive) => { hostSettleTimer = setTimeout(() => resolveAlive('alive'), 300); });
|
|
1388
|
+
try {
|
|
1389
|
+
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1390
|
+
// The host has completed every start. Each matching boot answer can now
|
|
1391
|
+
// end its own settle; legacy twins share one full host guard.
|
|
1392
|
+
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
|
|
1393
|
+
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1394
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1395
|
+
throw error;
|
|
1396
|
+
});
|
|
1379
1397
|
});
|
|
1380
|
-
});
|
|
1398
|
+
} finally { if (hostSettleTimer !== undefined) clearTimeout(hostSettleTimer); }
|
|
1381
1399
|
if (exitState.value !== null) {
|
|
1382
1400
|
throw new Error(
|
|
1383
1401
|
`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
|