@volter/world-runtime 3.0.37 → 3.0.39
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/known-external-services.json +3 -10
- package/dist/src/actions-runner-cli.js +3 -3
- package/dist/src/actions-runner.js +2 -1
- package/dist/src/app-url.js +2 -1
- package/dist/src/backings.d.ts +10 -1
- package/dist/src/backings.js +66 -7
- package/dist/src/branch.js +85 -52
- package/dist/src/browser-routes.cjs +59 -0
- package/dist/src/ca-trust.js +4 -3
- package/dist/src/changeset.js +4 -3
- package/dist/src/cli-commands.js +25 -9
- package/dist/src/cli.js +2 -1
- package/dist/src/command-lifetime.d.mts +12 -0
- package/dist/src/command-lifetime.d.ts +1 -15
- package/dist/src/command-lifetime.js +1 -98
- package/dist/src/command-lifetime.mjs +90 -0
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.js +3 -2
- package/dist/src/covers.d.ts +3 -2
- package/dist/src/covers.js +95 -18
- package/dist/src/error-message.d.mts +1 -0
- package/dist/src/error-message.d.ts +1 -0
- package/dist/src/error-message.js +2 -0
- package/dist/src/error-message.mjs +10 -0
- package/dist/src/guest-command.d.mts +4 -0
- package/dist/src/guest-command.mjs +26 -0
- package/dist/src/guest-control.d.mts +1 -0
- package/dist/src/guest-control.mjs +17 -0
- package/dist/src/guest-entry.mjs +146 -0
- package/dist/src/guest-processes.d.mts +9 -0
- package/dist/src/guest-processes.mjs +47 -0
- package/dist/src/guest-runner.d.ts +6 -0
- package/dist/src/guest-runner.js +567 -0
- package/dist/src/host.js +5 -4
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/infra-cli.js +4 -0
- package/dist/src/infra.js +133 -23
- package/dist/src/init.js +60 -23
- package/dist/src/lifecycle-record.d.ts +3 -1
- package/dist/src/lifecycle-record.js +29 -6
- package/dist/src/livekit-backing.mjs +37 -0
- package/dist/src/local-branches.js +11 -3
- package/dist/src/mongodb-backing.d.ts +7 -0
- package/dist/src/mongodb-backing.js +58 -0
- package/dist/src/native-postgres-backing.d.ts +4 -0
- package/dist/src/native-postgres-backing.js +404 -0
- package/dist/src/origin.js +2 -1
- package/dist/src/owned-path.d.ts +4 -0
- package/dist/src/owned-path.js +37 -0
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pglite-backing.d.ts +4 -13
- package/dist/src/pglite-backing.js +198 -106
- package/dist/src/pglite-host.mjs +47 -17
- package/dist/src/placeholder.d.ts +3 -2
- package/dist/src/placeholder.js +9 -4
- package/dist/src/process-groups.d.mts +2 -0
- package/dist/src/process-groups.d.ts +1 -4
- package/dist/src/process-groups.js +1 -49
- package/dist/src/process-groups.mjs +59 -0
- package/dist/src/process-receipt.d.mts +6 -0
- package/dist/src/process-receipt.d.ts +11 -0
- package/dist/src/process-receipt.js +3 -0
- package/dist/src/process-receipt.mjs +45 -0
- package/dist/src/process-signal.d.mts +9 -0
- package/dist/src/process-signal.mjs +92 -0
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +4 -2
- package/dist/src/proxy-daemon.js +2 -1
- package/dist/src/redirect-proxy.js +186 -19
- package/dist/src/redis-backing.js +99 -35
- package/dist/src/reflect.js +2 -1
- package/dist/src/root.js +7 -6
- package/dist/src/run-task-worker.js +2 -1
- package/dist/src/runtime.d.ts +24 -7
- package/dist/src/runtime.js +703 -286
- package/dist/src/schema.d.ts +12 -1
- package/dist/src/schema.js +20 -5
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +26 -14
- package/dist/src/service-recorder.js +8 -3
- package/dist/src/signals.d.mts +1 -0
- package/dist/src/signals.d.ts +1 -1
- package/dist/src/signals.js +1 -11
- package/dist/src/signals.mjs +10 -0
- package/dist/src/tail.d.ts +2 -0
- package/dist/src/tail.js +9 -2
- package/dist/src/tcp-port.js +27 -13
- package/dist/src/temporal-backing.mjs +28 -0
- package/dist/src/up-cli.js +8 -3
- package/dist/src/up-task-worker.js +2 -1
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/websocket-relay.js +23 -10
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +3 -10
- package/package.json +12 -7
- package/src/actions-runner-cli.ts +3 -3
- package/src/actions-runner.ts +2 -1
- package/src/app-url.ts +2 -1
- package/src/backings.ts +65 -7
- package/src/branch.ts +73 -34
- package/src/browser-routes.cjs +59 -0
- package/src/ca-trust.ts +4 -3
- package/src/changeset.ts +4 -3
- package/src/cli-commands.ts +21 -9
- package/src/cli.ts +2 -1
- package/src/command-lifetime.d.mts +12 -0
- package/src/command-lifetime.mjs +90 -0
- package/src/command-lifetime.ts +1 -79
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +2 -1
- package/src/covers.ts +97 -19
- package/src/error-message.d.mts +1 -0
- package/src/error-message.mjs +10 -0
- package/src/error-message.ts +2 -0
- package/src/guest-command.d.mts +4 -0
- package/src/guest-command.mjs +26 -0
- package/src/guest-control.d.mts +1 -0
- package/src/guest-control.mjs +17 -0
- package/src/guest-entry.mjs +146 -0
- package/src/guest-processes.d.mts +9 -0
- package/src/guest-processes.mjs +47 -0
- package/src/guest-runner.ts +414 -0
- package/src/host.ts +5 -4
- package/src/index.ts +1 -0
- package/src/infra-cli.ts +4 -0
- package/src/infra.ts +103 -22
- package/src/init.ts +55 -22
- package/src/lifecycle-record.ts +21 -6
- package/src/livekit-backing.mjs +37 -0
- package/src/local-branches.ts +6 -3
- package/src/mongodb-backing.ts +55 -0
- package/src/native-postgres-backing.ts +329 -0
- package/src/origin.ts +2 -1
- package/src/owned-path.ts +32 -0
- package/src/pack-facts.ts +1 -0
- package/src/pglite-backing.ts +155 -79
- package/src/pglite-host.mjs +47 -17
- package/src/placeholder.ts +10 -5
- package/src/process-groups.d.mts +2 -0
- package/src/process-groups.mjs +59 -0
- package/src/process-groups.ts +1 -33
- package/src/process-receipt.d.mts +6 -0
- package/src/process-receipt.mjs +45 -0
- package/src/process-receipt.ts +5 -0
- package/src/process-signal.d.mts +9 -0
- package/src/process-signal.mjs +92 -0
- package/src/project-inspect.ts +4 -2
- package/src/proxy-daemon.ts +2 -1
- package/src/redirect-proxy.ts +153 -18
- package/src/redis-backing.ts +74 -25
- package/src/reflect.ts +2 -1
- package/src/root.ts +7 -6
- package/src/run-task-worker.ts +2 -1
- package/src/runtime.ts +596 -265
- package/src/schema.ts +30 -6
- package/src/served-world.ts +21 -15
- package/src/service-recorder.ts +4 -2
- package/src/signals.d.mts +1 -0
- package/src/signals.mjs +10 -0
- package/src/signals.ts +1 -10
- package/src/tail.ts +10 -3
- package/src/tcp-port.ts +24 -13
- package/src/temporal-backing.mjs +28 -0
- package/src/up-cli.ts +6 -3
- package/src/up-task-worker.ts +2 -1
- package/src/websocket-relay.ts +18 -11
- package/src/world-view.ts +2 -2
package/src/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from './error-message.ts';
|
|
1
2
|
import { materializeBrowserFiles, clearBrowserFiles } from './browsers.ts';
|
|
2
3
|
import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
|
|
3
4
|
import { offSignal } from './signals.ts';
|
|
@@ -11,21 +12,27 @@ import { get as httpGet, type ClientRequest } from 'node:http';
|
|
|
11
12
|
import { siblingScript } from './sibling.ts';
|
|
12
13
|
import { commandLifetime, commandProcessGroup, CommandRetirementError } from './command-lifetime.ts';
|
|
13
14
|
import { superviseWorldRun } from './run-task.ts';
|
|
14
|
-
import { survivingOwnedGroups } from './process-groups.ts';
|
|
15
|
+
import { processStarts, survivingOwnedGroups } from './process-groups.ts';
|
|
16
|
+
import { spawnIdentity, signalOwnedProcess, signalChild, signalHeldChild, processBirth, childIdentity } from './process-signal.mjs';
|
|
17
|
+
import type { SpawnIdentity } from './process-receipt.ts';
|
|
18
|
+
import { readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
|
|
19
|
+
|
|
15
20
|
import { randomUUID, createHash } from 'node:crypto';
|
|
16
21
|
import { createRequire } from 'node:module';
|
|
17
22
|
import { isIP } from 'node:net';
|
|
18
23
|
import { allocatePort } from './tcp-port.ts';
|
|
19
24
|
import { constants as osConstants, hostname as osHostname } from 'node:os';
|
|
20
|
-
import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync, existsSync } from 'node:fs';
|
|
25
|
+
import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync, existsSync, fchmodSync, ftruncateSync } from 'node:fs';
|
|
21
26
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
22
27
|
import { packFacts } from './pack-facts.ts';
|
|
28
|
+
import { assertOwnedRealPath, ownedPath } from './owned-path.ts';
|
|
29
|
+
import { isEnvironmentName } from './schema.ts';
|
|
23
30
|
import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
|
|
24
31
|
import { describeServiceEnd, readServiceEnd, serviceOutput } from './service-exit.ts';
|
|
25
32
|
import { Socket } from 'node:net';
|
|
26
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';
|
|
27
34
|
import { loadWorldConfig } from './configs.ts';
|
|
28
|
-
import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
35
|
+
import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, type WorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
29
36
|
import { overlayPackFactsFile, resolveServicePackage } from './catalog.ts';
|
|
30
37
|
import { fakeEnvValue } from './fixture-env.ts';
|
|
31
38
|
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
|
|
@@ -35,7 +42,23 @@ import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnv
|
|
|
35
42
|
import { CaTrustError, sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
|
|
36
43
|
import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldRunOutcome, WorldRunRecord, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
|
|
37
44
|
import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from './consumers.ts';
|
|
38
|
-
import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle } from './lifecycle-record.ts';
|
|
45
|
+
import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle, exitedWorldLifecyclePids } from './lifecycle-record.ts';
|
|
46
|
+
|
|
47
|
+
// Births captured at actual spawn; retries never remeasure a retained PID.
|
|
48
|
+
const spawnedIdentities = new Map<number, SpawnIdentity>();
|
|
49
|
+
const spawnedChildren = new Map<number, ReturnType<typeof spawn>>();
|
|
50
|
+
function retainSpawn(child: ReturnType<typeof spawn>): Promise<SpawnIdentity | undefined> {
|
|
51
|
+
if (!child.pid) return Promise.resolve(undefined);
|
|
52
|
+
const pid = child.pid;
|
|
53
|
+
spawnedChildren.set(pid, child);
|
|
54
|
+
return childIdentity(child).then(identity => {
|
|
55
|
+
if (identity && spawnedChildren.get(pid) === child) spawnedIdentities.set(pid, identity);
|
|
56
|
+
return identity;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function heldStarts(): Record<string, string | undefined> {
|
|
60
|
+
return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
|
|
61
|
+
}
|
|
39
62
|
|
|
40
63
|
/** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
|
|
41
64
|
* shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
|
|
@@ -62,8 +85,8 @@ export function worldEventsPath(logs: string): string {
|
|
|
62
85
|
* its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
|
|
63
86
|
* file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
|
|
64
87
|
* `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
|
|
65
|
-
function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string): { child: ReturnType<typeof spawn>; ended: Promise<void> } {
|
|
66
|
-
const logDir = `${log.replace(/\.log$/, '')}.d
|
|
88
|
+
function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string, boot: BootContext): { child: ReturnType<typeof spawn>; ended: Promise<void>; identity: Promise<SpawnIdentity | undefined> } {
|
|
89
|
+
const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
|
|
67
90
|
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
68
91
|
const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
|
|
69
92
|
const recorderEnv: NodeJS.ProcessEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
|
|
@@ -75,9 +98,13 @@ function spawnRecorded(serviceId: string, command: string, args: string[], cwd:
|
|
|
75
98
|
detached: true,
|
|
76
99
|
stdio: ['ignore', out, out],
|
|
77
100
|
});
|
|
78
|
-
closeSync(out);
|
|
79
101
|
const ended = new Promise<void>((resolveEnded) => { child.once('exit', () => resolveEnded()); child.once('error', () => resolveEnded()); });
|
|
80
|
-
|
|
102
|
+
const identity = retainSpawn(child);
|
|
103
|
+
if (child.pid) { boot.pids.add(child.pid); boot.handles.set(child.pid, child); }
|
|
104
|
+
child.once('error', error => boot.fail(error));
|
|
105
|
+
boot.recordPids();
|
|
106
|
+
closeSync(out);
|
|
107
|
+
return { child, ended, identity };
|
|
81
108
|
}
|
|
82
109
|
|
|
83
110
|
/** A startup wait that fails as soon as the service ends, with the service's own account of its end (exit code or
|
|
@@ -218,6 +245,8 @@ export type UpWorldOptions = {
|
|
|
218
245
|
* episodes survive the boot; logs and the pid file start clean. Never the default: `up` starts
|
|
219
246
|
* clean, and the world dir is the reproducible story. */
|
|
220
247
|
keepState?: boolean;
|
|
248
|
+
/** A branch's base permission: intersects both the config and the launching process. */
|
|
249
|
+
networkCeiling?: WorldNetwork;
|
|
221
250
|
};
|
|
222
251
|
|
|
223
252
|
export type ShareWorldOptions = {
|
|
@@ -249,6 +278,8 @@ export type WorldDoctorReport = {
|
|
|
249
278
|
};
|
|
250
279
|
|
|
251
280
|
export type RunWorldOptions = UpWorldOptions & {
|
|
281
|
+
/** the command's working directory: the caller's, as `attach` runs its command (default: the World's root) */
|
|
282
|
+
cwd?: string;
|
|
252
283
|
keep?: boolean;
|
|
253
284
|
verbose?: boolean;
|
|
254
285
|
};
|
|
@@ -290,7 +321,8 @@ export function clockFile(root: string, name: string): string {
|
|
|
290
321
|
}
|
|
291
322
|
|
|
292
323
|
export function instanceDir(root: string, name: string): string {
|
|
293
|
-
|
|
324
|
+
assertSafeWorldName(name);
|
|
325
|
+
return ownedPath(worldBaseDir(root), name);
|
|
294
326
|
}
|
|
295
327
|
|
|
296
328
|
function instanceFile(root: string, name: string): string {
|
|
@@ -306,11 +338,13 @@ function foregroundRunFile(root: string, name: string): string {
|
|
|
306
338
|
* a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
|
|
307
339
|
* collide with (or be wiped as) a world's instance dir. */
|
|
308
340
|
function instanceLockFile(root: string, name: string): string {
|
|
309
|
-
|
|
341
|
+
assertSafeWorldName(name);
|
|
342
|
+
return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.lock`);
|
|
310
343
|
}
|
|
311
344
|
|
|
312
345
|
function stoppingFile(root: string, name: string): string {
|
|
313
|
-
|
|
346
|
+
assertSafeWorldName(name);
|
|
347
|
+
return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.stop.json`);
|
|
314
348
|
}
|
|
315
349
|
|
|
316
350
|
const SHARE_CLAIM_STALE_MS = 60_000;
|
|
@@ -332,7 +366,8 @@ export type SharingClaim = {
|
|
|
332
366
|
// liveness; this is host physics, not world time). Nothing here reads a process table.
|
|
333
367
|
|
|
334
368
|
function sharingClaimFile(root: string, name: string): string {
|
|
335
|
-
|
|
369
|
+
assertSafeWorldName(name);
|
|
370
|
+
return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.share.json`);
|
|
336
371
|
}
|
|
337
372
|
|
|
338
373
|
function readSharingClaim(path: string): SharingClaim | undefined {
|
|
@@ -443,13 +478,13 @@ function prepareBootForDown(root: string, name: string): void {
|
|
|
443
478
|
claim = JSON.parse(readFileSync(path, 'utf8')) as BootingClaim;
|
|
444
479
|
if (!claim || !Number.isInteger(claim.pid) || claim.pid <= 0 || typeof claim.at !== 'string'
|
|
445
480
|
|| !Number.isFinite(Date.parse(claim.at))) throw new Error('invalid boot claim');
|
|
446
|
-
} catch {
|
|
447
|
-
throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained
|
|
481
|
+
} catch (error) {
|
|
482
|
+
throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained`, { cause: error });
|
|
448
483
|
}
|
|
449
484
|
try { process.kill(claim.pid, 0); }
|
|
450
485
|
catch (error) {
|
|
451
486
|
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
|
|
452
|
-
throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained
|
|
487
|
+
throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained`, { cause: error });
|
|
453
488
|
}
|
|
454
489
|
rmSync(path); // the recorded booter cannot publish anything after this point
|
|
455
490
|
return;
|
|
@@ -488,7 +523,7 @@ function proxyEnvFile(root: string, name: string): string {
|
|
|
488
523
|
return join(instanceDir(root, name), 'proxy-env.json');
|
|
489
524
|
}
|
|
490
525
|
|
|
491
|
-
type ProxyState = { pid: number; url: string; caCertPath: string; guestUrl?: string };
|
|
526
|
+
type ProxyState = { pid: number; birth?: string; url: string; caCertPath: string; guestUrl?: string };
|
|
492
527
|
|
|
493
528
|
function readProxyState(root: string, name: string): ProxyState | null {
|
|
494
529
|
const file = proxyStateFile(root, name);
|
|
@@ -583,16 +618,127 @@ export function withoutVaultSignIn<T extends Record<string, string | undefined>>
|
|
|
583
618
|
* the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
|
|
584
619
|
* so a vendor the inner World does not twin reaches the parent's twin of it.
|
|
585
620
|
*/
|
|
586
|
-
function networkOf(name: string, own:
|
|
621
|
+
function networkOf(name: string, own: WorldNetwork | undefined, recorded: WorldNetwork | undefined, parentEnv: NodeJS.ProcessEnv = process.env): Record<string, string> | undefined {
|
|
587
622
|
const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
|
|
588
|
-
let ceiling:
|
|
623
|
+
let ceiling: WorldNetwork | undefined;
|
|
589
624
|
if (serialized !== undefined) {
|
|
590
|
-
//
|
|
591
|
-
try { ceiling = (JSON.parse(serialized)
|
|
625
|
+
// An unreadable parent policy closes the child, including opaque transport permission.
|
|
626
|
+
try { ceiling = networkPermission(parseWorldNetworkPolicy(JSON.parse(serialized))); } catch { ceiling = { egress: [] }; }
|
|
627
|
+
}
|
|
628
|
+
let network = own;
|
|
629
|
+
for (const limit of [recorded, ceiling]) if (limit !== undefined) network = network === undefined ? limit : intersectWorldNetwork(network, limit);
|
|
630
|
+
if (network === undefined) return undefined;
|
|
631
|
+
return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Strip the publication envelope before combining permissions (the config shape admits only these keys). */
|
|
635
|
+
function networkPermission(policy: WorldNetwork): WorldNetwork {
|
|
636
|
+
return { egress: policy.egress, phase: policy.phase, passthrough: policy.passthrough };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The boot permission, never a fallback to a mutable config. */
|
|
640
|
+
export function recordedWorldNetwork(instance: WorldInstance): WorldNetwork | undefined {
|
|
641
|
+
try {
|
|
642
|
+
if (!Object.hasOwn(instance, 'networkCeiling')) throw new Error();
|
|
643
|
+
const published = instance.env?.[WORLD_NETWORK_POLICY_ENV];
|
|
644
|
+
const bound = published === undefined ? undefined : parseWorldNetworkPolicy(JSON.parse(published));
|
|
645
|
+
if (bound && bound.world !== instance.name) throw new Error();
|
|
646
|
+
const saved = instance.networkCeiling === 'unrestricted' ? undefined : normalizeWorldNetwork(instance.networkCeiling);
|
|
647
|
+
const permission = bound === undefined ? undefined : networkPermission(bound);
|
|
648
|
+
return saved === undefined ? permission : permission === undefined ? saved : intersectWorldNetwork(saved, permission);
|
|
649
|
+
} catch { throw new Error(`World boot record is incomplete or invalid: ${instanceFile(instance.root, instance.name)}`); }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** An absent stored ceiling is derived from this World's own config and retained bound environment. */
|
|
653
|
+
function ceilingFromConfig(record: WorldInstance, config: WorldConfig): WorldNetwork | 'unrestricted' {
|
|
654
|
+
let ceiling = record.mode === 'sealed' ? { egress: [] } : config.network;
|
|
655
|
+
const published = record.env?.[WORLD_NETWORK_POLICY_ENV];
|
|
656
|
+
if (published !== undefined) {
|
|
657
|
+
const bound = parseWorldNetworkPolicy(JSON.parse(published));
|
|
658
|
+
if (bound.world !== record.name) throw new Error();
|
|
659
|
+
const permission = networkPermission(bound);
|
|
660
|
+
ceiling = ceiling === undefined ? permission : intersectWorldNetwork(ceiling, permission);
|
|
592
661
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
662
|
+
return ceiling === undefined ? 'unrestricted' : normalizeWorldNetwork(ceiling);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** A stopped receipt checks byte consistency before fields are read; it is not authentication. */
|
|
666
|
+
export function readWorldBootRecord(name: string, root: string): WorldInstance {
|
|
667
|
+
return withFileLock(instanceLockFile(root, name), () => readWorldBootRecordLocked(name, root));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Ordinary reading completes derivable metadata and its receipt together under the instance lock. */
|
|
671
|
+
function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
|
|
672
|
+
const path = instanceFile(root, name);
|
|
673
|
+
const raw = getActiveWorldStore().read(path);
|
|
674
|
+
if (raw === null) throw new Error(`World instance not found: ${name}`);
|
|
675
|
+
try {
|
|
676
|
+
const receiptPath = join(instanceDir(root, name), 'stopped.json');
|
|
677
|
+
const stopped = existsSync(receiptPath);
|
|
678
|
+
const receipt = stopped ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
679
|
+
if (stopped && (receipt.instanceHash !== createHash('sha256').update(raw).digest('hex')
|
|
680
|
+
|| typeof receipt.stoppedAt !== 'string' || !Number.isFinite(Date.parse(receipt.stoppedAt)))) throw new Error();
|
|
681
|
+
const record = JSON.parse(raw) as WorldInstance;
|
|
682
|
+
if (record.name !== name || typeof record.root !== 'string' || resolve(record.root) !== resolve(root)
|
|
683
|
+
|| typeof record.createdAt !== 'string' || !Number.isFinite(Date.parse(record.createdAt))
|
|
684
|
+
|| typeof record.config !== 'string' || !record.config || typeof record.configPath !== 'string' || !record.configPath
|
|
685
|
+
|| !record.env || record.env.VOLTER_WORLD !== name || record.env.VOLTER_WORLD_MODE !== record.mode
|
|
686
|
+
|| !record.dirs || record.dirs.instance !== instanceDir(root, name)
|
|
687
|
+
|| record.dirs.data !== join(instanceDir(root, name), 'data') || record.dirs.logs !== join(instanceDir(root, name), 'logs')
|
|
688
|
+
|| typeof record.envFile !== 'string' || !record.envFile || record.pidsFile !== join(instanceDir(root, name), 'pids')
|
|
689
|
+
|| !record.services || typeof record.services !== 'object' || Array.isArray(record.services)) throw new Error();
|
|
690
|
+
assertMode(record.mode);
|
|
691
|
+
if (!stopped && !isWorldLifecycleRunning(root, name, record.createdAt)) throw new Error();
|
|
692
|
+
let completed = false;
|
|
693
|
+
if (!Object.hasOwn(record, 'instance')) { record.instance = randomUUID(); completed = true; }
|
|
694
|
+
if (typeof record.instance !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(record.instance)) throw new Error();
|
|
695
|
+
const needsConfig = !Object.hasOwn(record, 'networkCeiling')
|
|
696
|
+
|| Object.values(record.services).some(service => service && !Object.hasOwn(service, 'branchState'));
|
|
697
|
+
const config = needsConfig ? loadWorldConfig(record.configPath, root).config : undefined;
|
|
698
|
+
if (config && config.id !== record.config) throw new Error();
|
|
699
|
+
if (!Object.hasOwn(record, 'networkCeiling')) {
|
|
700
|
+
record.networkCeiling = ceilingFromConfig(record, config!);
|
|
701
|
+
completed = true;
|
|
702
|
+
}
|
|
703
|
+
if (!Object.hasOwn(record, 'bootOwner')) {
|
|
704
|
+
if (!record.lifecycle && !record.resources) throw new Error();
|
|
705
|
+
record.bootOwner = record.lifecycle?.owner ?? record.resources?.owner ?? null;
|
|
706
|
+
completed = true;
|
|
707
|
+
}
|
|
708
|
+
if (!(record.bootOwner === null || typeof record.bootOwner === 'string')) throw new Error();
|
|
709
|
+
checkedOwner(record.bootOwner ?? undefined);
|
|
710
|
+
if (!record.lifecycle) {
|
|
711
|
+
record.lifecycle = { log: lifecycleLogPath(root, name), ...(record.bootOwner === null ? {} : { owner: record.bootOwner }) };
|
|
712
|
+
completed = true;
|
|
713
|
+
}
|
|
714
|
+
if (!Object.hasOwn(record.lifecycle, 'log')) { record.lifecycle.log = lifecycleLogPath(root, name); completed = true; }
|
|
715
|
+
if (!Object.hasOwn(record.lifecycle, 'owner') && record.bootOwner !== null) { record.lifecycle.owner = record.bootOwner; completed = true; }
|
|
716
|
+
if (record.lifecycle.log !== lifecycleLogPath(root, name) || (record.lifecycle.owner ?? null) !== record.bootOwner) throw new Error();
|
|
717
|
+
if (!Object.hasOwn(record, 'bootServices')) {
|
|
718
|
+
record.bootServices = Object.keys(record.services);
|
|
719
|
+
completed = true;
|
|
720
|
+
}
|
|
721
|
+
if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
|
|
722
|
+
|| new Set(record.bootServices).size !== record.bootServices.length
|
|
723
|
+
|| record.bootServices.length !== Object.keys(record.services).length
|
|
724
|
+
|| record.bootServices.some(id => !Object.hasOwn(record.services, id))) throw new Error();
|
|
725
|
+
for (const [id, service] of Object.entries(record.services)) {
|
|
726
|
+
if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string') throw new Error();
|
|
727
|
+
if (!Object.hasOwn(service, 'branchState')) {
|
|
728
|
+
const declared = config!.services.find(item => item.id === id);
|
|
729
|
+
if (!declared) throw new Error();
|
|
730
|
+
service.branchState = declared.branchState ?? 'supported'; completed = true;
|
|
731
|
+
}
|
|
732
|
+
if (!['supported', 'unsupported'].includes(service.branchState ?? '')) throw new Error();
|
|
733
|
+
}
|
|
734
|
+
recordedWorldNetwork(record);
|
|
735
|
+
if (completed) {
|
|
736
|
+
saveWorldInstance(record);
|
|
737
|
+
if (stopped) writeTextAtomic(receiptPath, JSON.stringify({ ...receipt,
|
|
738
|
+
instanceHash: createHash('sha256').update(readFileSync(path, 'utf8')).digest('hex') }) + '\n');
|
|
739
|
+
}
|
|
740
|
+
return record;
|
|
741
|
+
} catch { throw new Error(`World boot record is incomplete or invalid: ${path}`); }
|
|
596
742
|
}
|
|
597
743
|
|
|
598
744
|
export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
@@ -690,22 +836,16 @@ async function waitForTcp(port: number, timeoutMs = 15_000, abort?: AbortSignal)
|
|
|
690
836
|
|
|
691
837
|
function readPids(path: string): number[] {
|
|
692
838
|
const held = getActiveWorldStore().read(path);
|
|
693
|
-
|
|
694
|
-
return held
|
|
839
|
+
const guests = readGuestProcesses(dirname(path)).map(receipt => receipt.record.pid);
|
|
840
|
+
return [...new Set([...(held ?? '')
|
|
695
841
|
.split(/\r?\n/)
|
|
696
842
|
.map((line) => Number(line.trim()))
|
|
697
|
-
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
843
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0), ...guests])];
|
|
698
844
|
}
|
|
699
845
|
|
|
700
846
|
function livePids(pids: number[]): number[] {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
process.kill(pid, 0);
|
|
704
|
-
return true;
|
|
705
|
-
} catch (error) {
|
|
706
|
-
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
707
|
-
}
|
|
708
|
-
});
|
|
847
|
+
// A recorded leader may have exited while its detached descendants still run.
|
|
848
|
+
return survivingOwnedGroups(pids);
|
|
709
849
|
}
|
|
710
850
|
|
|
711
851
|
function tunnelPids(instance: WorldInstance): number[] {
|
|
@@ -730,13 +870,13 @@ function openPrivateLog(path: string, flags: 'a' | 'w'): number {
|
|
|
730
870
|
/** Record `pids` as the World's. `fresh` are the processes this very call has spawned: only their start times are
|
|
731
871
|
* measured anew; every other pid keeps the start time recorded when it was spawned (re-measuring it would bless
|
|
732
872
|
* whatever process holds that number now). */
|
|
733
|
-
function writeOwnedPids(root: string, name: string, pids: number[], fresh: number[] = []): void {
|
|
873
|
+
function writeOwnedPids(root: string, name: string, pids: number[], fresh: number[] = [], births = heldStarts()): void {
|
|
734
874
|
const path = join(instanceDir(root, name), 'pids');
|
|
735
875
|
withFileLock(`${instanceLockFile(root, name)}.pids`, () => {
|
|
736
876
|
const retained = undeadPids(readPids(path));
|
|
737
877
|
const merged = [...new Set([...retained, ...pids].filter(pid => Number.isInteger(pid) && pid > 0))];
|
|
738
878
|
writeTextAtomic(path, `${merged.join('\n')}\n`);
|
|
739
|
-
recordIdentities(root, name, merged, fresh);
|
|
879
|
+
recordIdentities(root, name, merged, fresh, births);
|
|
740
880
|
});
|
|
741
881
|
}
|
|
742
882
|
|
|
@@ -747,40 +887,31 @@ function writeOwnedPids(root: string, name: string, pids: number[], fresh: numbe
|
|
|
747
887
|
function identityPath(root: string, name: string): string {
|
|
748
888
|
return join(instanceDir(root, name), 'pids.identity');
|
|
749
889
|
}
|
|
750
|
-
/** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
|
|
751
|
-
* process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
|
|
752
|
-
* given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
|
|
753
|
-
* processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
|
|
754
|
-
* paid. */
|
|
755
|
-
function processStarts(pids: number[]): Map<number, string> {
|
|
756
|
-
const starts = new Map<number, string>();
|
|
757
|
-
if (!pids.length || process.platform === 'win32') return starts;
|
|
758
|
-
if (process.platform === 'linux' && existsSync('/proc/self/stat')) {
|
|
759
|
-
// Linux: the start tick since boot (field 22), which a wall-clock step does not move (lstart would).
|
|
760
|
-
for (const pid of pids) {
|
|
761
|
-
try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); starts.set(pid, `tick ${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`); } catch { /* no process */ }
|
|
762
|
-
}
|
|
763
|
-
return starts;
|
|
764
|
-
}
|
|
765
|
-
const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
|
|
766
|
-
for (const pid of pids) {
|
|
767
|
-
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
|
|
768
|
-
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
|
|
769
|
-
if (match && Number(match[1]) === pid) starts.set(pid, match[2]!);
|
|
770
|
-
}
|
|
771
|
-
return starts;
|
|
772
|
-
}
|
|
773
890
|
function readIdentities(root: string, name: string): Record<string, string> {
|
|
774
|
-
|
|
891
|
+
let known: Record<string, string> = {};
|
|
892
|
+
try {
|
|
893
|
+
const value = JSON.parse(getActiveWorldStore().read(identityPath(root, name)) ?? '{}');
|
|
894
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) known = Object.fromEntries(Object.entries(value).filter(([pid, start]) => Number.isInteger(Number(pid)) && Number(pid) > 0 && typeof start === 'string' && start.length > 0)) as Record<string, string>;
|
|
895
|
+
} catch { /* Guest receipts below remain independently authoritative. */ }
|
|
896
|
+
const births = new Map<number, string | undefined>();
|
|
897
|
+
for (const { record } of readGuestProcesses(instanceDir(root, name))) {
|
|
898
|
+
if (births.has(record.pid) && births.get(record.pid) !== record.start) throw new Error('Conflicting guest spawn identities; ownership retained');
|
|
899
|
+
births.set(record.pid, record.start);
|
|
900
|
+
}
|
|
901
|
+
for (const [pid, start] of births) { if (start) known[pid] = start; else delete known[pid]; }
|
|
902
|
+
return known;
|
|
775
903
|
}
|
|
776
|
-
function recordIdentities(root: string, name: string, pids: number[], fresh: number[] = []): void {
|
|
904
|
+
function recordIdentities(root: string, name: string, pids: number[], fresh: number[] = [], births = heldStarts()): void {
|
|
777
905
|
try {
|
|
778
906
|
// A pid the World has just spawned is measured anew, whatever an older record says of that number.
|
|
779
|
-
const known = readIdentities(root, name)
|
|
780
|
-
for (const pid of fresh)
|
|
781
|
-
|
|
907
|
+
const known = readIdentities(root, name);
|
|
908
|
+
for (const pid of fresh) {
|
|
909
|
+
if (births[pid] !== undefined) known[pid] = births[pid]!;
|
|
910
|
+
else delete known[pid];
|
|
911
|
+
}
|
|
912
|
+
const kept = Object.fromEntries(pids.flatMap(pid => known[pid] === undefined ? [] : [[String(pid), known[pid]]]));
|
|
782
913
|
writeTextAtomic(identityPath(root, name), JSON.stringify(kept));
|
|
783
|
-
} catch { /* without a record
|
|
914
|
+
} catch { /* without a record signalling refuses and lifecycle evidence is retained */ }
|
|
784
915
|
}
|
|
785
916
|
/** Recorded pids whose running process started at another time than recorded: the number now names another
|
|
786
917
|
* program. An unrecorded pid, and a pid with no process, are never in this list. */
|
|
@@ -906,13 +1037,15 @@ function getByJsonPath(value: unknown, jsonPath: string): unknown {
|
|
|
906
1037
|
return current;
|
|
907
1038
|
}
|
|
908
1039
|
|
|
1040
|
+
class ExternalCommandUnavailableError extends Error {}
|
|
1041
|
+
|
|
909
1042
|
async function runExternalCommand(
|
|
910
1043
|
command: string[], cwd: string, serviceId: string, phase: string,
|
|
911
1044
|
env: NodeJS.ProcessEnv = process.env, abort?: AbortSignal, timeoutMs = 60_000,
|
|
912
1045
|
): Promise<{ stdout: string; stderr: string }> {
|
|
913
1046
|
const [bin, ...args] = ownCommand(command);
|
|
914
1047
|
if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
|
|
915
|
-
if (!commandExists(bin)) throw new
|
|
1048
|
+
if (!commandExists(bin)) throw new ExternalCommandUnavailableError(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
|
|
916
1049
|
abort?.throwIfAborted();
|
|
917
1050
|
const child = spawn(bin, args, { windowsHide: true, cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
918
1051
|
const lifetime = commandLifetime(child, abort, true);
|
|
@@ -927,8 +1060,15 @@ async function runExternalCommand(
|
|
|
927
1060
|
const timer = setTimeout(() => { failure = new Error(`command timed out after ${timeoutMs}ms`); lifetime.cancel('SIGTERM'); }, timeoutMs);
|
|
928
1061
|
try {
|
|
929
1062
|
const result = await lifetime.finish();
|
|
930
|
-
if (failure)
|
|
931
|
-
|
|
1063
|
+
if (failure) {
|
|
1064
|
+
const ErrorKind = ['ENOENT', 'ENOEXEC', 'EACCES'].includes((failure as NodeJS.ErrnoException).code ?? '') ? ExternalCommandUnavailableError : Error;
|
|
1065
|
+
throw new ErrorKind(`External service "${serviceId}": ${phase} command failed to run (${errorMessage(failure)})`);
|
|
1066
|
+
}
|
|
1067
|
+
if (result.code !== 0 || lifetime.forwarded) {
|
|
1068
|
+
const parserRefusal = /unknown (?:shorthand flag|flag|option|command)|unrecognized (?:option|argument)|command not found|no such file or directory/iu.test(`${stdout}${stderr}`);
|
|
1069
|
+
const ErrorKind = !lifetime.forwarded && ([126, 127].includes(result.code ?? -1) || (result.code === 125 && parserRefusal)) ? ExternalCommandUnavailableError : Error;
|
|
1070
|
+
throw new ErrorKind(`External service "${serviceId}": ${phase} command exited ${result.code ?? 'null'}\n${stdout}${stderr}`);
|
|
1071
|
+
}
|
|
932
1072
|
return { stdout, stderr };
|
|
933
1073
|
} finally { clearTimeout(timer); }
|
|
934
1074
|
}
|
|
@@ -981,8 +1121,8 @@ async function runExternalUp(
|
|
|
981
1121
|
}
|
|
982
1122
|
|
|
983
1123
|
/** Whether an external `up` is this runtime's own managed-infrastructure tool (`volter-world-infra up`, which `init`
|
|
984
|
-
* names)
|
|
985
|
-
* child. The services it starts (the PGlite host, the redis and
|
|
1124
|
+
* names): the phase runs in this process (infra.ts) instead of in a
|
|
1125
|
+
* child. The services it starts (the PGlite host, the redis twin and mongod) are then children of the process that
|
|
986
1126
|
* owns the World's lifetime, as every service recorder is, not of a tool that exits once they serve. Where a child
|
|
987
1127
|
* does not outlive its parent (a browser tab's process layer, measured 2026-09-28: a detached, unref'd listener
|
|
988
1128
|
* ended with the process that spawned it), the PGlite host ended the moment `volter-world-infra up` returned and
|
|
@@ -1047,14 +1187,14 @@ async function awaitReadiness(
|
|
|
1047
1187
|
throw new Error(`Service "${serviceId}": readiness command \`${ready.command}\` not found on PATH`);
|
|
1048
1188
|
}
|
|
1049
1189
|
try { await runExternalCommand([ready.command, ...(ready.args ?? [])], cwd, serviceId, 'readiness', env, abort, Math.max(1, timeoutMs - (Date.now() - started))); return; }
|
|
1050
|
-
catch (error) { if (error instanceof CommandRetirementError) throw error; lastError =
|
|
1190
|
+
catch (error) { if (error instanceof CommandRetirementError) throw error; lastError = errorMessage(error); }
|
|
1051
1191
|
} else if (ready.httpUrl !== undefined) {
|
|
1052
1192
|
try {
|
|
1053
1193
|
const response = await fetch(ready.httpUrl, { signal: AbortSignal.any([AbortSignal.timeout(Math.max(1, timeoutMs - (Date.now() - started))), ...(abort ? [abort] : [])]) });
|
|
1054
1194
|
if (response.status >= 200 && response.status < 400) return;
|
|
1055
1195
|
lastError = `HTTP ${response.status}`;
|
|
1056
1196
|
} catch (error) {
|
|
1057
|
-
lastError =
|
|
1197
|
+
lastError = errorMessage(error);
|
|
1058
1198
|
}
|
|
1059
1199
|
} else if (ready.stdoutMatch !== undefined) {
|
|
1060
1200
|
const text = serviceOutput(readServiceText(logPath));
|
|
@@ -1066,17 +1206,14 @@ async function awaitReadiness(
|
|
|
1066
1206
|
throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
|
|
1067
1207
|
}
|
|
1068
1208
|
|
|
1069
|
-
|
|
1209
|
+
/** The environment an external service and its replacement infrastructure hosts run in. Shared with branching,
|
|
1210
|
+
* so both use the boot's admission, service overrides, lifecycle context and control-plane egress rules. */
|
|
1211
|
+
export function externalServiceEnv(
|
|
1070
1212
|
service: WorldServiceConfig,
|
|
1071
|
-
paths: {
|
|
1213
|
+
paths: { logs: string },
|
|
1072
1214
|
worldEnv: Record<string, string>,
|
|
1073
1215
|
stripEnv?: string[],
|
|
1074
|
-
|
|
1075
|
-
): Promise<WorldServiceInstance> {
|
|
1076
|
-
const external = service.external;
|
|
1077
|
-
if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
|
|
1078
|
-
const log = join(paths.logs, `${service.id}.log`);
|
|
1079
|
-
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1216
|
+
): NodeJS.ProcessEnv {
|
|
1080
1217
|
// External commands see the accumulated world env (process.env + earlier services' discovered/
|
|
1081
1218
|
// injected vars), so a later external can consume an earlier service's connection info.
|
|
1082
1219
|
const env: NodeJS.ProcessEnv = {
|
|
@@ -1084,10 +1221,26 @@ async function startExternalService(
|
|
|
1084
1221
|
...worldEnv,
|
|
1085
1222
|
...(service.env ?? {}),
|
|
1086
1223
|
VOLTER_WORLD_SERVICE_ID: service.id,
|
|
1087
|
-
VOLTER_WORLD_SERVICE_LOG: log,
|
|
1224
|
+
VOLTER_WORLD_SERVICE_LOG: ownedPath(paths.logs, `${service.id}.log`),
|
|
1088
1225
|
};
|
|
1089
1226
|
if (service.controlPlane) for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
1090
1227
|
|
|
1228
|
+
return env;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function startExternalService(
|
|
1232
|
+
service: WorldServiceConfig,
|
|
1233
|
+
paths: { root: string; logs: string },
|
|
1234
|
+
worldEnv: Record<string, string>,
|
|
1235
|
+
stripEnv?: string[],
|
|
1236
|
+
boot?: BootContext,
|
|
1237
|
+
): Promise<WorldServiceInstance> {
|
|
1238
|
+
const external = service.external;
|
|
1239
|
+
if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
|
|
1240
|
+
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1241
|
+
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1242
|
+
const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
|
|
1243
|
+
|
|
1091
1244
|
// 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
|
|
1092
1245
|
if (!external.up[0] || !commandExists(external.up[0])) {
|
|
1093
1246
|
throw new Error(`External service "${service.id}": \`${external.up[0] ?? ''}\` not found on PATH (the up command's tool is not installed)`);
|
|
@@ -1124,7 +1277,7 @@ async function startExternalService(
|
|
|
1124
1277
|
try {
|
|
1125
1278
|
jsonCache[source] = JSON.parse(raw) as unknown;
|
|
1126
1279
|
} catch (error) {
|
|
1127
|
-
throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${
|
|
1280
|
+
throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${errorMessage(error)})\nOutput: ${raw.slice(0, 500)}`);
|
|
1128
1281
|
}
|
|
1129
1282
|
}
|
|
1130
1283
|
const found = getByJsonPath(jsonCache[source], mapping.jsonPath);
|
|
@@ -1157,12 +1310,18 @@ type BootContext = {
|
|
|
1157
1310
|
signal: AbortSignal;
|
|
1158
1311
|
fail: (error: Error) => void;
|
|
1159
1312
|
pids: Set<number>;
|
|
1313
|
+
identities: Record<string, string | undefined>;
|
|
1314
|
+
handles: Map<number, ReturnType<typeof spawn>>;
|
|
1160
1315
|
external: (service: WorldServiceInstance) => void;
|
|
1161
1316
|
recordPids: () => void;
|
|
1162
1317
|
};
|
|
1163
|
-
function trackBootingChild(child:
|
|
1318
|
+
async function trackBootingChild(child: ReturnType<typeof spawn>, boot: BootContext, birth: Promise<SpawnIdentity | undefined>): Promise<void> {
|
|
1164
1319
|
if (!child.pid) return;
|
|
1165
|
-
boot.pids.add(child.pid);
|
|
1320
|
+
boot.pids.add(child.pid); boot.handles.set(child.pid, child);
|
|
1321
|
+
boot.recordPids(); // durable numeric uncertainty precedes the async birth capture
|
|
1322
|
+
const identity = await birth;
|
|
1323
|
+
if (!identity?.start) throw new Error('Service birth capture failed; retiring held child');
|
|
1324
|
+
boot.identities[child.pid] = identity.start;
|
|
1166
1325
|
boot.recordPids();
|
|
1167
1326
|
}
|
|
1168
1327
|
|
|
@@ -1177,7 +1336,7 @@ async function startService(
|
|
|
1177
1336
|
if (service.type === 'external') return startExternalService(service, paths, worldEnv, config.stripEnv, boot);
|
|
1178
1337
|
if (!service.command) throw new Error(`Service "${service.id}" must define command`);
|
|
1179
1338
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1180
|
-
const serviceDataDir =
|
|
1339
|
+
const serviceDataDir = ownedPath(paths.data, service.id);
|
|
1181
1340
|
mkdirSync(serviceDataDir, { recursive: true });
|
|
1182
1341
|
// R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
|
|
1183
1342
|
// is checked against its pin and its protocol standing before it is spawned.
|
|
@@ -1194,7 +1353,7 @@ async function startService(
|
|
|
1194
1353
|
if (service.portArg !== false) args.push(service.portArg ?? '--port', String(port));
|
|
1195
1354
|
if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
1196
1355
|
|
|
1197
|
-
const log =
|
|
1356
|
+
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1198
1357
|
const env = serviceProcessEnv(config, service, worldEnv, port);
|
|
1199
1358
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1200
1359
|
|
|
@@ -1209,10 +1368,10 @@ async function startService(
|
|
|
1209
1368
|
// The service runs under the service recorder, which outlives it to write its end into its own log: `up`
|
|
1210
1369
|
// returns once the World is ready, so nothing else is left to see a service stop.
|
|
1211
1370
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1212
|
-
const { child, ended } = spawnRecorded(service.id, service.command, args, cwd, env, log);
|
|
1371
|
+
const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot);
|
|
1213
1372
|
child.once('error', error => boot.fail(error));
|
|
1214
1373
|
child.unref();
|
|
1215
|
-
trackBootingChild(child, boot);
|
|
1374
|
+
await trackBootingChild(child, boot, identity);
|
|
1216
1375
|
|
|
1217
1376
|
// Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
|
|
1218
1377
|
// child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
|
|
@@ -1229,19 +1388,19 @@ async function startService(
|
|
|
1229
1388
|
// swapping, a docker build beside the boot) a heavy service takes longer than the 15s default
|
|
1230
1389
|
// to answer on its port, and failing it there wastes the whole boot.
|
|
1231
1390
|
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1232
|
-
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch((error) => {
|
|
1391
|
+
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1233
1392
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1234
1393
|
try {
|
|
1235
|
-
|
|
1394
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1236
1395
|
} catch {
|
|
1237
1396
|
// best effort cleanup
|
|
1238
1397
|
}
|
|
1239
|
-
throw new Error(`Service "${service.id}" failed to start: ${
|
|
1398
|
+
throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
|
|
1240
1399
|
});
|
|
1241
1400
|
|
|
1242
|
-
await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch((error) => {
|
|
1401
|
+
await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
|
|
1243
1402
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1244
|
-
try {
|
|
1403
|
+
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1245
1404
|
throw error;
|
|
1246
1405
|
});
|
|
1247
1406
|
if (exitState.value !== null) {
|
|
@@ -1257,10 +1416,10 @@ async function startService(
|
|
|
1257
1416
|
// Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
|
|
1258
1417
|
// before it is actually serving, so this makes `up` wait until the service truly responds.
|
|
1259
1418
|
if (service.ready && service.ready !== 'tcp') {
|
|
1260
|
-
await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch((error) => {
|
|
1419
|
+
await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1261
1420
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1262
|
-
try {
|
|
1263
|
-
throw new Error(`Service "${service.id}" failed its readiness probe: ${
|
|
1421
|
+
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1422
|
+
throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
|
|
1264
1423
|
});
|
|
1265
1424
|
}
|
|
1266
1425
|
|
|
@@ -1406,7 +1565,8 @@ const ISSUE_TIMEOUT_MS = 15_000;
|
|
|
1406
1565
|
* asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
|
|
1407
1566
|
* door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
|
|
1408
1567
|
async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>): Promise<void> {
|
|
1409
|
-
const
|
|
1568
|
+
const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
|
|
1569
|
+
const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
|
|
1410
1570
|
if (wanted.length === 0) return;
|
|
1411
1571
|
for (const name of wanted) pending.delete(name);
|
|
1412
1572
|
// the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
|
|
@@ -1420,26 +1580,92 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1420
1580
|
const answer = (await res.json()) as Record<string, unknown>;
|
|
1421
1581
|
for (const name of wanted) {
|
|
1422
1582
|
const spec = door.fill[name] ?? '';
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1583
|
+
if (spec.startsWith('directory:') || spec.startsWith('file:')) {
|
|
1584
|
+
if (direct.has(name)) throw new Error(`cannot give ${name}'s filesystem credential a direct URL`);
|
|
1585
|
+
if (!isEnvironmentName(name) || !/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error('Invalid credential destination name');
|
|
1586
|
+
if (!env.VOLTER_WORLD_INSTANCE) throw new Error('World credential custody is unknown');
|
|
1587
|
+
const instance = realpathSync(dirname(env.VOLTER_WORLD_INSTANCE));
|
|
1588
|
+
const custody = ownedPath(instance, 'credentials');
|
|
1589
|
+
const artifact = `${service.id}-${name.toLowerCase()}`;
|
|
1590
|
+
const isDirectory = spec.startsWith('directory:');
|
|
1591
|
+
const field = spec.slice(spec.indexOf(':') + 1), value = answer[field];
|
|
1428
1592
|
if (value === undefined || value === null) throw new Error(`answered no ${field}`);
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
const
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1593
|
+
if (isDirectory && (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0)) throw new Error('Invalid credential directory');
|
|
1594
|
+
const entries = isDirectory ? Object.entries(value as Record<string, unknown>) : [[`${artifact}.json`, value] as const];
|
|
1595
|
+
const files = entries.map(([file, contents]) => {
|
|
1596
|
+
if (isDirectory && (!file || file.includes('\\') || file.split('/').some(part => !/^[A-Za-z0-9._-]+$/.test(part) || part === '.' || part === '..'))) throw new Error('Invalid World-owned path');
|
|
1597
|
+
return { path: ownedPath(custody, isDirectory ? `${artifact}/${file}` : file), contents };
|
|
1598
|
+
});
|
|
1599
|
+
const directories = new Set([custody]);
|
|
1600
|
+
for (const { path } of files) {
|
|
1601
|
+
let parent = dirname(path);
|
|
1602
|
+
while (parent !== custody) { directories.add(parent); parent = dirname(parent); }
|
|
1603
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
1604
|
+
if (stat && (!stat.isFile() || stat.nlink !== 1)) throw new Error('Invalid World-owned path');
|
|
1605
|
+
}
|
|
1606
|
+
// Validate every destination and parent before creating or changing any custody artifact.
|
|
1607
|
+
for (const dir of directories) {
|
|
1608
|
+
ownedPath(instance, relative(instance, dir));
|
|
1609
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
1610
|
+
if (stat && !stat.isDirectory()) throw new Error('Invalid World-owned path');
|
|
1611
|
+
}
|
|
1612
|
+
for (const dir of [...directories].sort((a, b) => a.length - b.length)) {
|
|
1613
|
+
if (!existsSync(dir)) mkdirSync(dir, { mode: 0o700 });
|
|
1614
|
+
assertOwnedRealPath(custody, dir);
|
|
1615
|
+
chmodSync(dir, 0o700);
|
|
1616
|
+
}
|
|
1617
|
+
for (const { path, contents } of files) {
|
|
1618
|
+
ownedPath(custody, relative(custody, path));
|
|
1619
|
+
assertOwnedRealPath(custody, dirname(path));
|
|
1620
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW, 0o600);
|
|
1621
|
+
let opened: ReturnType<typeof fstatSync> | undefined;
|
|
1622
|
+
try {
|
|
1623
|
+
const stat = fstatSync(fd);
|
|
1624
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error('Invalid World-owned path');
|
|
1625
|
+
opened = stat;
|
|
1626
|
+
fchmodSync(fd, 0o600);
|
|
1627
|
+
assertOwnedRealPath(custody, dirname(path));
|
|
1628
|
+
ftruncateSync(fd, 0);
|
|
1629
|
+
writeFileSync(fd, typeof contents === 'string' ? contents : `${JSON.stringify(contents, null, 2)}\n`);
|
|
1630
|
+
} finally {
|
|
1631
|
+
closeSync(fd);
|
|
1632
|
+
let realFile: string | undefined;
|
|
1633
|
+
try {
|
|
1634
|
+
realFile = realpathSync(path);
|
|
1635
|
+
const current = statSync(realFile);
|
|
1636
|
+
if (!opened || current.dev !== opened.dev || current.ino !== opened.ino) throw new Error('Invalid World-owned path');
|
|
1637
|
+
assertOwnedRealPath(custody, realFile);
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
// Remove only the file whose descriptor we opened; a raced replacement is not ours to delete.
|
|
1640
|
+
const current = realFile ? lstatSync(realFile, { throwIfNoEntry: false }) : undefined;
|
|
1641
|
+
if (opened && current?.dev === opened.dev && current.ino === opened.ino) rmSync(realFile!, { force: true });
|
|
1642
|
+
throw error;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
env[name] = isDirectory ? ownedPath(custody, artifact) : files[0]!.path;
|
|
1436
1647
|
continue;
|
|
1437
1648
|
}
|
|
1649
|
+
const field = spec;
|
|
1650
|
+
const value = answer[field];
|
|
1438
1651
|
if (typeof value !== 'string') throw new Error(`answered no ${field}`);
|
|
1439
|
-
|
|
1652
|
+
if (direct.has(name)) {
|
|
1653
|
+
const issued = new URL(value);
|
|
1654
|
+
if (!['http:', 'https:'].includes(issued.protocol)) throw new Error(`cannot route ${name}'s non-HTTP URL directly`);
|
|
1655
|
+
const endpoint = new URL(started.url);
|
|
1656
|
+
issued.protocol = endpoint.protocol;
|
|
1657
|
+
issued.host = endpoint.host;
|
|
1658
|
+
env[name] = issued.toString();
|
|
1659
|
+
} else env[name] = value;
|
|
1660
|
+
}
|
|
1661
|
+
if (started.url) {
|
|
1662
|
+
const endpoint = new URL(started.url);
|
|
1663
|
+
Object.assign(env, resolveServiceEnvTemplates(service.injectEnvTemplates, {
|
|
1664
|
+
host: endpoint.hostname, port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)), url: started.url,
|
|
1665
|
+
}));
|
|
1440
1666
|
}
|
|
1441
1667
|
} catch (error) {
|
|
1442
|
-
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${
|
|
1668
|
+
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
|
|
1443
1669
|
for (const name of wanted) { delete env[name]; unissued.add(name); }
|
|
1444
1670
|
}
|
|
1445
1671
|
}
|
|
@@ -1502,7 +1728,7 @@ async function startColocatedServices(
|
|
|
1502
1728
|
const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage; sides: Awaited<ReturnType<typeof sidePortsFor>> }> = [];
|
|
1503
1729
|
for (const service of colocated) {
|
|
1504
1730
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1505
|
-
const serviceDataDir =
|
|
1731
|
+
const serviceDataDir = ownedPath(paths.data, service.id);
|
|
1506
1732
|
mkdirSync(serviceDataDir, { recursive: true });
|
|
1507
1733
|
// Relative module paths resolve from the world root (import() inside the host would
|
|
1508
1734
|
// otherwise resolve them against the runtime's own source directory).
|
|
@@ -1529,10 +1755,10 @@ async function startColocatedServices(
|
|
|
1529
1755
|
dropUnissued(env);
|
|
1530
1756
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1531
1757
|
// Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
|
|
1532
|
-
const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
|
|
1758
|
+
const { child, ended, identity } = spawnRecorded('host', process.execPath, args, paths.root, env, log, boot);
|
|
1533
1759
|
child.once('error', error => boot.fail(error));
|
|
1534
1760
|
child.unref();
|
|
1535
|
-
trackBootingChild(child, boot);
|
|
1761
|
+
await trackBootingChild(child, boot, identity);
|
|
1536
1762
|
|
|
1537
1763
|
const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
|
|
1538
1764
|
child.on('exit', (code, signal) => {
|
|
@@ -1565,17 +1791,17 @@ async function startColocatedServices(
|
|
|
1565
1791
|
});
|
|
1566
1792
|
const ready = (async (): Promise<void> => {
|
|
1567
1793
|
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1568
|
-
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1794
|
+
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch(async (error) => {
|
|
1569
1795
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1570
|
-
try {
|
|
1571
|
-
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${
|
|
1796
|
+
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1797
|
+
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
|
|
1572
1798
|
});
|
|
1573
1799
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1574
1800
|
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1575
|
-
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1801
|
+
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch(async (error) => {
|
|
1576
1802
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1577
|
-
try {
|
|
1578
|
-
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${
|
|
1803
|
+
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1804
|
+
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
|
|
1579
1805
|
});
|
|
1580
1806
|
});
|
|
1581
1807
|
// The host's receipt (this boot, every requested port) is published only
|
|
@@ -1585,9 +1811,9 @@ async function startColocatedServices(
|
|
|
1585
1811
|
// answers with another boot's identity is still refused.
|
|
1586
1812
|
const hostSettle = Promise.resolve('alive' as const);
|
|
1587
1813
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1588
|
-
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
|
|
1814
|
+
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch(async (error) => {
|
|
1589
1815
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1590
|
-
try {
|
|
1816
|
+
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1591
1817
|
throw error;
|
|
1592
1818
|
});
|
|
1593
1819
|
});
|
|
@@ -1659,6 +1885,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1659
1885
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
1660
1886
|
const instanceFile = join(instance, 'instance.json');
|
|
1661
1887
|
let carriedOrigin: WorldInstance['origin'] | undefined;
|
|
1888
|
+
let carriedNetwork = options.networkCeiling;
|
|
1662
1889
|
// the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
|
|
1663
1890
|
// writes it), so a file any of them last wrote is the World's own, never a custody file
|
|
1664
1891
|
const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
@@ -1677,6 +1904,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1677
1904
|
let resourcesClaimed = false;
|
|
1678
1905
|
let cleanupUncertain = false;
|
|
1679
1906
|
const createdAt = new Date().toISOString();
|
|
1907
|
+
const instanceToken = randomUUID();
|
|
1680
1908
|
|
|
1681
1909
|
// Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
|
|
1682
1910
|
// two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
|
|
@@ -1709,23 +1937,36 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1709
1937
|
);
|
|
1710
1938
|
}
|
|
1711
1939
|
if (!options.keepState) assertWorldStateRemovable(root, instance);
|
|
1712
|
-
//
|
|
1713
|
-
// remain. Require a receipt for the exact prior instance, not merely absent claims/PIDs.
|
|
1940
|
+
// Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
|
|
1714
1941
|
if (existsSync(instanceFile)) {
|
|
1942
|
+
let prior: WorldInstance | undefined;
|
|
1943
|
+
if (options.keepState) prior = readWorldBootRecordLocked(name, root);
|
|
1715
1944
|
const priorRaw = readFileSync(instanceFile, 'utf8');
|
|
1716
|
-
const prior = JSON.parse(priorRaw) as WorldInstance;
|
|
1717
1945
|
const receiptPath = join(instance, 'stopped.json');
|
|
1718
1946
|
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
1719
|
-
if (
|
|
1720
|
-
|| receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
|
|
1947
|
+
if (!receipt || receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
|
|
1721
1948
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
1722
1949
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
1723
1950
|
}
|
|
1951
|
+
if (!prior) { try { prior = JSON.parse(priorRaw) as WorldInstance; } catch { /* fresh boot needs no retained shape */ } }
|
|
1952
|
+
if (prior) {
|
|
1953
|
+
// A readable ceiling remains useful even when unrelated metadata is incomplete.
|
|
1954
|
+
try {
|
|
1955
|
+
const permissionRecord = { ...prior, name, root };
|
|
1956
|
+
if (!Object.hasOwn(permissionRecord, 'networkCeiling')) {
|
|
1957
|
+
let storedConfig = loaded.config;
|
|
1958
|
+
try { storedConfig = loadWorldConfig(prior.configPath, root).config; } catch { /* fresh boot can discard missing config metadata */ }
|
|
1959
|
+
permissionRecord.networkCeiling = ceilingFromConfig(permissionRecord, storedConfig);
|
|
1960
|
+
}
|
|
1961
|
+
const ceiling = recordedWorldNetwork(permissionRecord);
|
|
1962
|
+
if (ceiling !== undefined) carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
|
|
1963
|
+
} catch { if (options.keepState) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
|
|
1964
|
+
}
|
|
1724
1965
|
// the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
|
|
1725
1966
|
// same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
|
|
1726
1967
|
// ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
|
|
1727
1968
|
// only when its state (the mirror they count) is kept, as reset drops them
|
|
1728
|
-
if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
|
|
1969
|
+
if (prior?.origin && typeof prior.origin.url === 'string' && typeof prior.origin.namespace === 'string' && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
|
|
1729
1970
|
carriedOrigin = options.keepState ? prior.origin
|
|
1730
1971
|
: { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
|
|
1731
1972
|
}
|
|
@@ -1736,7 +1977,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1736
1977
|
retainWorldLifecycleForCleanup(root, name);
|
|
1737
1978
|
// The last run's logs survive this boot as logs/previous (one generation): the record of a crash must outlive the
|
|
1738
1979
|
// restart that usually follows it. Moved aside before the wipe and back in after it.
|
|
1739
|
-
const previousLogs =
|
|
1980
|
+
const previousLogs = ownedPath(dirname(instance), `.${name}.previous-logs`); // dot-prefixed: bookkeeping, never listed as a World
|
|
1740
1981
|
if (existsSync(previousLogs) && !existsSync(logs)) { mkdirSync(instance, { recursive: true }); renameSync(previousLogs, logs); } // an earlier failed claim's, back first
|
|
1741
1982
|
rmSync(previousLogs, { recursive: true, force: true });
|
|
1742
1983
|
if (existsSync(logs)) { rmSync(join(logs, 'previous'), { recursive: true, force: true }); renameSync(logs, previousLogs); }
|
|
@@ -1762,7 +2003,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1762
2003
|
// the failure that stopped the boot leads: a release that also fails is added to it, never put in its place
|
|
1763
2004
|
if (resourcesClaimed) {
|
|
1764
2005
|
try { releaseWorldLifecycle(root, name); }
|
|
1765
|
-
catch (release) {
|
|
2006
|
+
catch (release) { throw new AggregateError([error, release], 'World claim and release failed'); }
|
|
1766
2007
|
}
|
|
1767
2008
|
throw error;
|
|
1768
2009
|
}
|
|
@@ -1772,8 +2013,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1772
2013
|
// "already running" guard; on failure the world never came up, so the next upWorld must be
|
|
1773
2014
|
// allowed to claim. Removing the marker is safe — only THIS call's marker can exist at this
|
|
1774
2015
|
// point (a foreign live claim made the claim section above throw before this try).
|
|
2016
|
+
let bootFailed = false;
|
|
2017
|
+
let bootFailure: unknown;
|
|
1775
2018
|
try {
|
|
1776
2019
|
const services: Record<string, WorldServiceInstance> = {};
|
|
2020
|
+
const recordService = (started: WorldServiceInstance) => {
|
|
2021
|
+
const branchState = loaded.config.services.find(service => service.id === started.id)?.branchState;
|
|
2022
|
+
services[started.id] = { ...started, branchState: branchState ?? 'supported' };
|
|
2023
|
+
};
|
|
1777
2024
|
const inheritedEnv = admittedEnv(loaded.config.stripEnv);
|
|
1778
2025
|
const env: Record<string, string> = {
|
|
1779
2026
|
...(loaded.config.env ?? {}),
|
|
@@ -1784,10 +2031,11 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1784
2031
|
// the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
|
|
1785
2032
|
// on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
|
|
1786
2033
|
VOLTER_WORLD: name,
|
|
1787
|
-
...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
|
|
2034
|
+
...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network, carriedNetwork) ?? {}),
|
|
1788
2035
|
VOLTER_WORLD_MODE: mode,
|
|
1789
2036
|
VOLTER_WORLD_CONFIG: loaded.path,
|
|
1790
2037
|
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
2038
|
+
VOLTER_WORLD_GUEST_RUNNER: new URL(import.meta.url.endsWith('.ts') ? './guest-runner.ts' : './guest-runner.js', import.meta.url).href,
|
|
1791
2039
|
// every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
|
|
1792
2040
|
[WORLD_DATA_ENV]: data,
|
|
1793
2041
|
// the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
|
|
@@ -1810,8 +2058,10 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1810
2058
|
writeProxyEnv(root, name, env);
|
|
1811
2059
|
|
|
1812
2060
|
const worldInstance: WorldInstance = {
|
|
1813
|
-
name, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2061
|
+
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
1814
2062
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2063
|
+
networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]!))),
|
|
2064
|
+
bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
|
|
1815
2065
|
...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
|
|
1816
2066
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
1817
2067
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
@@ -1832,9 +2082,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1832
2082
|
abort?.removeEventListener('abort', onOwnerEnd);
|
|
1833
2083
|
};
|
|
1834
2084
|
const boot: BootContext = {
|
|
1835
|
-
bootId:
|
|
1836
|
-
external: service => {
|
|
1837
|
-
recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids]),
|
|
2085
|
+
bootId: instanceToken, signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(), identities: {}, handles: new Map(),
|
|
2086
|
+
external: service => { recordService(service); saveWorldInstance(worldInstance); },
|
|
2087
|
+
recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids], boot.identities),
|
|
1838
2088
|
};
|
|
1839
2089
|
const recordPidsSoFar = boot.recordPids;
|
|
1840
2090
|
try {
|
|
@@ -1854,7 +2104,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1854
2104
|
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
1855
2105
|
const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1856
2106
|
for (const started of host.services) {
|
|
1857
|
-
|
|
2107
|
+
recordService(started);
|
|
1858
2108
|
Object.assign(env, started.env);
|
|
1859
2109
|
}
|
|
1860
2110
|
colocatedReady = async () => {
|
|
@@ -1902,7 +2152,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1902
2152
|
attemptProxy();
|
|
1903
2153
|
boot.signal.throwIfAborted();
|
|
1904
2154
|
const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1905
|
-
|
|
2155
|
+
recordService(started);
|
|
1906
2156
|
recordPidsSoFar();
|
|
1907
2157
|
Object.assign(env, started.env);
|
|
1908
2158
|
await issueCredentials(service, started, env, issuing, unissued);
|
|
@@ -1940,7 +2190,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1940
2190
|
try {
|
|
1941
2191
|
return await shareWorldServices(name, { root, ...(options.share ?? {}) });
|
|
1942
2192
|
} catch (error) {
|
|
1943
|
-
await downWorld(name, root);
|
|
2193
|
+
await downWorld(name, root, { expectedInstance: instanceToken });
|
|
1944
2194
|
throw error;
|
|
1945
2195
|
}
|
|
1946
2196
|
}
|
|
@@ -1950,12 +2200,12 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1950
2200
|
detachBootSignals();
|
|
1951
2201
|
// Rollback never lets its own failure replace the boot's: each step's error is added to the original.
|
|
1952
2202
|
const rollbackErrors: string[] = [];
|
|
1953
|
-
try { stopWorldProxy(root, name); } catch (proxyError) { rollbackErrors.push(`proxy stop failed: ${
|
|
2203
|
+
try { await stopWorldProxy(root, name, boot.handles); } catch (proxyError) { rollbackErrors.push(`proxy stop failed: ${errorMessage(proxyError)}`); }
|
|
1954
2204
|
const pids = [...new Set([...Object.values(services).map((service) => service.pid).filter(Boolean), ...boot.pids])];
|
|
1955
2205
|
const rollbackAlive = undeadPids(Object.values(services).map((service) => service.pid).filter(Boolean));
|
|
1956
2206
|
const rollbackLogs = Object.values(services).filter((service) => service.type !== 'external' && service.log && rollbackAlive.includes(service.pid)).map((service) => service.log);
|
|
1957
2207
|
recordWorldStop(rollbackLogs, 'world.service.stop', 'boot rollback', 'SIGTERM');
|
|
1958
|
-
signalPids(pids, 'SIGTERM');
|
|
2208
|
+
await signalPids(pids, 'SIGTERM', boot.identities, boot.handles);
|
|
1959
2209
|
// Stop any self-managed externals that DID come up, so a failed `up` never leaves a
|
|
1960
2210
|
// half-running external stack behind. Best-effort: the original error is what we throw —
|
|
1961
2211
|
// but a `down` that ITSELF fails must not be silent (the operator would have no hint that
|
|
@@ -1969,28 +2219,28 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1969
2219
|
try {
|
|
1970
2220
|
await runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
|
|
1971
2221
|
} catch (downError) {
|
|
1972
|
-
rollbackErrors.push(
|
|
2222
|
+
rollbackErrors.push(errorMessage(downError));
|
|
1973
2223
|
}
|
|
1974
2224
|
}
|
|
1975
2225
|
}
|
|
1976
2226
|
// Same SIGTERM→SIGKILL contract as downWorld: a service that ignores SIGTERM must not
|
|
1977
2227
|
// outlive a failed boot either. The poll exits as soon as everything is dead, so the
|
|
1978
2228
|
// common case (services honor SIGTERM) adds no delay to the failure path.
|
|
1979
|
-
const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT);
|
|
2229
|
+
const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT, boot.identities, boot.handles);
|
|
1980
2230
|
recordWorldStop(Object.values(services).filter((service) => rollbackKilled.includes(service.pid) && service.log).map((service) => service.log), 'world.service.killed', 'boot rollback', 'SIGKILL');
|
|
1981
2231
|
const surviving = undeadPids(pids);
|
|
1982
2232
|
if (surviving.length) rollbackErrors.push('owned processes remain alive or uncertain');
|
|
1983
|
-
if (error instanceof CommandRetirementError) rollbackErrors.push((error as CommandRetirementError & { retirement?: string }).retirement ?? error
|
|
2233
|
+
if (error instanceof CommandRetirementError) rollbackErrors.push((error as CommandRetirementError & { retirement?: string }).retirement ?? errorMessage(error));
|
|
1984
2234
|
|
|
1985
2235
|
if (rollbackErrors.length > 0) {
|
|
1986
2236
|
cleanupUncertain = true;
|
|
1987
2237
|
try { retainWorldLifecycleForCleanup(root, name); saveWorldInstance(worldInstance); }
|
|
1988
|
-
catch (bookkeepingError) { rollbackErrors.push(`rollback bookkeeping failed: ${
|
|
2238
|
+
catch (bookkeepingError) { rollbackErrors.push(`rollback bookkeeping failed: ${errorMessage(bookkeepingError)}`); }
|
|
1989
2239
|
const suffix = `\n(rollback incomplete — processes or an external stack may still be running: ${rollbackErrors.join('; ')})`;
|
|
1990
2240
|
// Mutate rather than re-wrap: callers match on the ORIGINAL message (which must keep
|
|
1991
2241
|
// leading) and the original stack/type stay intact.
|
|
1992
2242
|
if (error instanceof Error) error.message += suffix;
|
|
1993
|
-
else throw new Error(`${
|
|
2243
|
+
else throw new Error(`${errorMessage(error)}${suffix}`);
|
|
1994
2244
|
}
|
|
1995
2245
|
if (rollbackErrors.length === 0) {
|
|
1996
2246
|
// Publication failure must retain ownership. Prune requires both a receipt and release.
|
|
@@ -2002,28 +2252,38 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
2002
2252
|
releaseWorldLifecycle(root, name);
|
|
2003
2253
|
cleanupUncertain = false;
|
|
2004
2254
|
} catch (receiptError) {
|
|
2005
|
-
const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${
|
|
2006
|
-
if (error instanceof Error) error.message += note; else throw new Error(`${
|
|
2255
|
+
const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${errorMessage(receiptError)}; lifecycle retained)`;
|
|
2256
|
+
if (error instanceof Error) error.message += note; else throw new Error(`${errorMessage(error)}${note}`);
|
|
2007
2257
|
}
|
|
2008
2258
|
}
|
|
2009
2259
|
throw error;
|
|
2010
2260
|
}
|
|
2011
2261
|
|
|
2012
2262
|
} catch (error) {
|
|
2263
|
+
bootFailed = true;
|
|
2264
|
+
bootFailure = error;
|
|
2013
2265
|
if (resourcesClaimed) {
|
|
2014
|
-
try { recordWorldLifecycleEvent(root, name, `boot failed: ${
|
|
2015
|
-
catch (diagnosticError) { process.stderr.write(`World "${name}": could not save boot failure: ${
|
|
2016
|
-
if (!cleanupUncertain)
|
|
2266
|
+
try { recordWorldLifecycleEvent(root, name, `boot failed: ${errorMessage(error)}`); }
|
|
2267
|
+
catch (diagnosticError) { process.stderr.write(`World "${name}": could not save boot failure: ${errorMessage(diagnosticError)}; original failure: ${errorMessage(error)}\n`); }
|
|
2268
|
+
if (!cleanupUncertain) {
|
|
2269
|
+
try { releaseWorldLifecycle(root, name); }
|
|
2270
|
+
catch (release) { bootFailure = new AggregateError([error, release], 'World startup and lifecycle release failed'); }
|
|
2271
|
+
}
|
|
2017
2272
|
}
|
|
2018
|
-
throw
|
|
2273
|
+
throw bootFailure;
|
|
2019
2274
|
} finally {
|
|
2020
|
-
rmSync(bootingFile(root, name), { force: true });
|
|
2275
|
+
try { rmSync(bootingFile(root, name), { force: true }); }
|
|
2276
|
+
catch (cleanup) {
|
|
2277
|
+
if (bootFailed) throw new AggregateError([bootFailure, cleanup], 'World startup and boot marker cleanup failed');
|
|
2278
|
+
throw cleanup;
|
|
2279
|
+
}
|
|
2021
2280
|
}
|
|
2022
2281
|
}
|
|
2023
2282
|
|
|
2024
|
-
async function teardownExternalServices(instance: WorldInstance): Promise<{ stopped: string[]; errors: string[] }> {
|
|
2283
|
+
async function teardownExternalServices(instance: WorldInstance): Promise<{ stopped: string[]; errors: string[]; unavailable: string[] }> {
|
|
2025
2284
|
const stopped: string[] = [];
|
|
2026
2285
|
const errors: string[] = [];
|
|
2286
|
+
const unavailable: string[] = [];
|
|
2027
2287
|
for (const service of Object.values(instance.services)) {
|
|
2028
2288
|
if (service.type !== 'external') continue;
|
|
2029
2289
|
if (!service.external || !Array.isArray(service.external.down) || !service.external.down.length
|
|
@@ -2036,10 +2296,12 @@ async function teardownExternalServices(instance: WorldInstance): Promise<{ stop
|
|
|
2036
2296
|
await runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
|
|
2037
2297
|
stopped.push(service.id);
|
|
2038
2298
|
} catch (error) {
|
|
2039
|
-
|
|
2299
|
+
const message = errorMessage(error);
|
|
2300
|
+
errors.push(message);
|
|
2301
|
+
if (error instanceof ExternalCommandUnavailableError) unavailable.push(message);
|
|
2040
2302
|
}
|
|
2041
2303
|
}
|
|
2042
|
-
return { stopped, errors };
|
|
2304
|
+
return { stopped, errors, unavailable };
|
|
2043
2305
|
}
|
|
2044
2306
|
|
|
2045
2307
|
/** SIGTERM contract (documented in docs/concepts/worlds.md § Teardown): services get SIGTERM to their process
|
|
@@ -2050,18 +2312,16 @@ const DOWN_GRACE_MS_DEFAULT = 5_000;
|
|
|
2050
2312
|
/** Signal each pid's process GROUP (services are spawned `detached`, so each pid is a group
|
|
2051
2313
|
* leader), falling back to the single pid when the group signal fails. Returns the pids that
|
|
2052
2314
|
* accepted the signal. */
|
|
2053
|
-
function signalPids(pids: number[], signal: NodeJS.Signals): number[] {
|
|
2315
|
+
async function signalPids(pids: number[], signal: NodeJS.Signals, identities: Record<string, string | undefined>, handles?: Map<number, ReturnType<typeof spawn>>): Promise<number[]> {
|
|
2054
2316
|
const signalled: number[] = [];
|
|
2055
2317
|
for (const pid of pids) {
|
|
2318
|
+
if (!undeadPids([pid]).length) continue;
|
|
2319
|
+
const identity = Object.hasOwn(identities, pid) ? { pid, start: identities[pid] } : undefined;
|
|
2056
2320
|
try {
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
process.kill(pid, signal);
|
|
2062
|
-
signalled.push(pid);
|
|
2063
|
-
} catch {}
|
|
2064
|
-
}
|
|
2321
|
+
const child = handles?.get(pid);
|
|
2322
|
+
if (child ? await signalHeldChild(child, signal, true, Promise.resolve(identity))
|
|
2323
|
+
: await signalOwnedProcess(identity, signal, true) || await signalOwnedProcess(identity, signal)) signalled.push(pid);
|
|
2324
|
+
} catch (error) { process.stderr.write(`${errorMessage(error)}\n`); }
|
|
2065
2325
|
}
|
|
2066
2326
|
return signalled;
|
|
2067
2327
|
}
|
|
@@ -2082,13 +2342,13 @@ const KILL_CONFIRM_MS = 2_000;
|
|
|
2082
2342
|
* exactly like the SIGTERM was delivered, then a short bounded confirm-wait so survivors are
|
|
2083
2343
|
* actually gone (not just signalled) by the time this returns. Returns the pids that needed
|
|
2084
2344
|
* escalation. */
|
|
2085
|
-
async function killSurvivorsAfterGrace(pids: number[], graceMs: number): Promise<number[]> {
|
|
2345
|
+
async function killSurvivorsAfterGrace(pids: number[], graceMs: number, identities: Record<string, string | undefined>, handles?: Map<number, ReturnType<typeof spawn>>): Promise<number[]> {
|
|
2086
2346
|
const deadline = Date.now() + graceMs;
|
|
2087
2347
|
while (undeadPids(pids).length > 0 && Date.now() < deadline) {
|
|
2088
2348
|
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
|
2089
2349
|
}
|
|
2090
2350
|
const survivors = undeadPids(pids);
|
|
2091
|
-
signalPids(survivors, 'SIGKILL');
|
|
2351
|
+
await signalPids(survivors, 'SIGKILL', identities, handles);
|
|
2092
2352
|
const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
|
|
2093
2353
|
while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
|
|
2094
2354
|
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
|
|
@@ -2096,31 +2356,35 @@ async function killSurvivorsAfterGrace(pids: number[], graceMs: number): Promise
|
|
|
2096
2356
|
return survivors;
|
|
2097
2357
|
}
|
|
2098
2358
|
|
|
2099
|
-
/**
|
|
2100
|
-
*
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
|
|
2359
|
+
/** The same bounded, identity-checked retirement used by World down, for provider-owned groups.
|
|
2360
|
+
* No caller may release a durable receipt when inspection or signal delivery is uncertain. */
|
|
2361
|
+
export async function retireOwnedProcessGroups(records: SpawnIdentity[], graceMs = DOWN_GRACE_MS_DEFAULT): Promise<void> {
|
|
2362
|
+
const pids = [...new Set(records.map(record => record.pid))];
|
|
2363
|
+
const identities = Object.fromEntries(records.map(record => [record.pid, record.start]));
|
|
2364
|
+
await signalPids(pids, 'SIGCONT', identities);
|
|
2365
|
+
await signalPids(pids, 'SIGTERM', identities);
|
|
2366
|
+
await killSurvivorsAfterGrace(pids, graceMs, identities);
|
|
2367
|
+
const remaining = undeadPids(pids);
|
|
2368
|
+
if (remaining.length) throw new CommandRetirementError(`Guest process groups still alive or uninspectable: ${remaining.join(', ')}; ownership retained`);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/** The synchronous proxy starter can retire only its still-held child/group. Numeric
|
|
2372
|
+
* recovery belongs to async downWorld, never to a blocking identity lookup. */
|
|
2373
|
+
function killHeldSurvivorsSync(child: ReturnType<typeof spawn>, graceMs: number): void {
|
|
2374
|
+
if (!child.pid) return;
|
|
2375
|
+
const pids = [child.pid], deadline = Date.now() + graceMs;
|
|
2376
|
+
while (undeadPids(pids).length && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
2377
|
+
if (undeadPids(pids).length) signalChild(child, 'SIGKILL', true);
|
|
2111
2378
|
const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
|
|
2112
|
-
while (undeadPids(
|
|
2113
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
2114
|
-
}
|
|
2115
|
-
return survivors;
|
|
2379
|
+
while (undeadPids(pids).length && Date.now() < confirmDeadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
2116
2380
|
}
|
|
2117
2381
|
|
|
2118
|
-
/**
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
*
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2382
|
+
/** Retirement awaits birth verification for recorded numbers and the SIGTERM→SIGKILL
|
|
2383
|
+
* grace/confirmation polls. Callers await downWorld before releasing ownership or purging
|
|
2384
|
+
* instance state; held children can receive their initial signal without a birth lookup.
|
|
2385
|
+
* External teardown and proxy retirement are joined in that same lifecycle.
|
|
2386
|
+
* A blocking sleep-loop would stall the caller's event loop through the grace, and a detached
|
|
2387
|
+
* watchdog would leave an unjoined process; lifecycle owners await retirement instead. */
|
|
2124
2388
|
/**
|
|
2125
2389
|
* Delete a world's instance dir entirely — the `--purge` half of `down` (TWIN-45 dev/02b): no
|
|
2126
2390
|
* twin data (per-service `data/<id>`, logs, instance.json) survives past teardown. Plain `rm`,
|
|
@@ -2156,20 +2420,27 @@ class WorldConsumersActiveError extends Error {}
|
|
|
2156
2420
|
export async function downWorld(
|
|
2157
2421
|
name: string,
|
|
2158
2422
|
root = process.cwd(),
|
|
2159
|
-
options: { graceMs?: number; purge?: boolean;
|
|
2160
|
-
): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; notSignalled?: string[]; purged?: string }> {
|
|
2423
|
+
options: { graceMs?: number; purge?: boolean; expectedInstance?: string } = {},
|
|
2424
|
+
): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; externalAbandoned?: string[]; notSignalled?: string[]; purged?: string }> {
|
|
2425
|
+
if (Object.hasOwn(options, 'expectedCreatedAt')) throw new Error('World teardown requires expectedInstance from the original boot');
|
|
2161
2426
|
assertSafeWorldName(name);
|
|
2162
2427
|
const resolvedRoot = resolve(root);
|
|
2163
2428
|
const token = randomUUID();
|
|
2164
2429
|
// Like boot's claim: a short synchronous lock protects an async lifecycle transition.
|
|
2165
2430
|
// Do not hold a filesystem lock while external shutdown or the grace period waits.
|
|
2166
2431
|
withFileLock(instanceLockFile(resolvedRoot, name), () => withFileLock(`${sharingClaimFile(resolvedRoot, name)}.lock`, () => {
|
|
2432
|
+
if (options.expectedInstance !== undefined) {
|
|
2433
|
+
try {
|
|
2434
|
+
const raw = getActiveWorldStore().read(instanceFile(resolvedRoot, name));
|
|
2435
|
+
const recorded = raw === null ? undefined : JSON.parse(raw);
|
|
2436
|
+
if (recorded?.name !== name || typeof recorded.root !== 'string' || resolve(recorded.root) !== resolvedRoot
|
|
2437
|
+
|| typeof options.expectedInstance !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(options.expectedInstance)
|
|
2438
|
+
|| recorded.instance !== options.expectedInstance) throw new Error();
|
|
2439
|
+
} catch { throw new Error(`World ownership mismatch: ${name}`); }
|
|
2440
|
+
}
|
|
2167
2441
|
assertProxyOwnershipResolved(resolvedRoot, name);
|
|
2168
2442
|
const prior = existsSync(instanceFile(resolvedRoot, name)) ? readWorldInstance(name, resolvedRoot, true) : undefined;
|
|
2169
2443
|
if (prior?.resources && !prior.lifecycle) throw new Error(`World "${name}" belongs to a legacy runtime; use its pinned runtime for teardown before migration`);
|
|
2170
|
-
if (options.expectedCreatedAt !== undefined && readWorldInstance(name, resolvedRoot, true).createdAt !== options.expectedCreatedAt) {
|
|
2171
|
-
throw new Error(`World "${name}" generation changed; teardown refused`);
|
|
2172
|
-
}
|
|
2173
2444
|
if (options.purge) assertWorldStateRemovable(resolvedRoot, instanceDir(resolvedRoot, name));
|
|
2174
2445
|
prepareBootForDown(resolvedRoot, name);
|
|
2175
2446
|
if (liveBootingClaim(stoppingFile(resolvedRoot, name))) throw new Error(`World "${name}" is already being stopped`);
|
|
@@ -2200,7 +2471,7 @@ async function downWorldClaimed(
|
|
|
2200
2471
|
name: string,
|
|
2201
2472
|
root: string,
|
|
2202
2473
|
options: { graceMs?: number; purge?: boolean },
|
|
2203
|
-
): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; notSignalled?: string[]; purged?: string }> {
|
|
2474
|
+
): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; externalAbandoned?: string[]; notSignalled?: string[]; purged?: string }> {
|
|
2204
2475
|
assertSafeWorldName(name);
|
|
2205
2476
|
const resolvedRoot = resolve(root);
|
|
2206
2477
|
const dir = instanceDir(resolvedRoot, name);
|
|
@@ -2217,6 +2488,7 @@ async function downWorldClaimed(
|
|
|
2217
2488
|
} catch {
|
|
2218
2489
|
instance = undefined;
|
|
2219
2490
|
}
|
|
2491
|
+
readGuestProcesses(dir, instance?.instance); // missing World metadata never erases guest ownership
|
|
2220
2492
|
// Nothing to tear down when the metadata is gone and every process the lifecycle recorded has exited.
|
|
2221
2493
|
if (!instance && before === undefined && releaseAbandonedWorldLifecycle(resolvedRoot, name)) {
|
|
2222
2494
|
return { name, stopped: [], escalated: [], externalStopped: [], externalErrors: [] };
|
|
@@ -2225,28 +2497,36 @@ async function downWorldClaimed(
|
|
|
2225
2497
|
return { name, stopped: [], escalated: [], externalStopped: [],
|
|
2226
2498
|
externalErrors: ['World instance or external teardown metadata missing or unreadable; cleanup refused; lifecycle evidence retained'] };
|
|
2227
2499
|
}
|
|
2228
|
-
const
|
|
2229
|
-
|
|
2500
|
+
const proxyState = readProxyState(resolvedRoot, name);
|
|
2501
|
+
const proxyPid = proxyState?.pid;
|
|
2502
|
+
await stopWorldProxy(resolvedRoot, name, spawnedChildren);
|
|
2230
2503
|
const foregroundPid = instance?.lastRun?.state !== 'completed' ? instance?.lastRun?.consumerPid : undefined;
|
|
2231
2504
|
const holderPid = instance?.lifecycle?.holderPid ?? instance?.resources?.holderPid;
|
|
2232
2505
|
const recordedPids = [...new Set([...readPids(pidsFile), ...(foregroundPid ? [foregroundPid] : []), ...(proxyPid ? [proxyPid] : [])])].filter(pid => pid !== holderPid);
|
|
2233
|
-
const
|
|
2506
|
+
const identities = readIdentities(resolvedRoot, name);
|
|
2507
|
+
if (proxyState?.birth) identities[proxyState.pid] = proxyState.birth;
|
|
2508
|
+
const foreign = (await Promise.all(recordedPids.map(async pid => {
|
|
2509
|
+
const now = identities[pid] ? await processBirth(pid) : undefined;
|
|
2510
|
+
return now && now !== identities[pid] ? { pid, recorded: identities[pid]!, now } : undefined;
|
|
2511
|
+
}))).filter((entry): entry is { pid: number; recorded: string; now: string } => !!entry);
|
|
2234
2512
|
const pids = recordedPids.filter((pid) => !foreign.some((entry) => entry.pid === pid));
|
|
2235
2513
|
const processServices = Object.values(instance?.services ?? {}).filter((service) => service.type !== 'external' && service.log);
|
|
2236
2514
|
// Only a service still alive is stopped by this `down`; a dead one's end was not the World's.
|
|
2237
2515
|
const stillAlive = undeadPids(processServices.map((service) => service.pid).filter((pid) => pids.includes(pid)));
|
|
2238
2516
|
recordWorldStop(processServices.filter((service) => stillAlive.includes(service.pid)).map((service) => service.log), 'world.service.stop', 'down', 'SIGTERM');
|
|
2239
|
-
const stopped = signalPids(pids, 'SIGTERM');
|
|
2517
|
+
const stopped = await signalPids(pids, 'SIGTERM', identities, spawnedChildren);
|
|
2240
2518
|
|
|
2241
2519
|
// Stop self-managed externals via their declared `down` command. A missing instance is
|
|
2242
2520
|
// safe only without retained lifecycle ownership, checked before any signals above.
|
|
2243
2521
|
let externalStopped: string[] = [];
|
|
2244
2522
|
let externalErrors: string[] = [];
|
|
2523
|
+
let unavailable: string[] = [];
|
|
2245
2524
|
try {
|
|
2246
2525
|
if (!instance) throw new Error('World instance unavailable');
|
|
2247
2526
|
const result = await teardownExternalServices(instance);
|
|
2248
2527
|
externalStopped = result.stopped;
|
|
2249
2528
|
externalErrors = result.errors;
|
|
2529
|
+
unavailable = result.unavailable;
|
|
2250
2530
|
} catch {
|
|
2251
2531
|
// no instance.json (never fully came up) — only owned processes needed stopping.
|
|
2252
2532
|
if (before !== undefined) externalErrors.push('World instance or external teardown metadata unreadable; cleanup refused');
|
|
@@ -2255,12 +2535,18 @@ async function downWorldClaimed(
|
|
|
2255
2535
|
// Escalate AFTER the external teardown so externals never wait on the grace, and
|
|
2256
2536
|
// poll the full pids list rather than `stopped`: a pid whose SIGTERM errored is almost always
|
|
2257
2537
|
// already dead, and livePids() re-checks anyway.
|
|
2258
|
-
const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
|
|
2538
|
+
const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren);
|
|
2259
2539
|
recordWorldStop(processServices.filter((service) => escalated.includes(service.pid)).map((service) => service.log), 'world.service.killed', 'down', 'SIGKILL');
|
|
2540
|
+
// A launch which had already entered its spawn seam may publish while TERM is delivered.
|
|
2541
|
+
// Once services/agents retire, join every late receipt before certifying this generation stopped.
|
|
2542
|
+
const lateGuests = readGuestProcesses(dir, instance?.instance).filter(receipt => !pids.includes(receipt.record.pid));
|
|
2543
|
+
try { await retireOwnedProcessGroups(lateGuests.map(receipt => receipt.record), options.graceMs ?? DOWN_GRACE_MS_DEFAULT); }
|
|
2544
|
+
catch (error) { externalErrors.push(errorMessage(error)); }
|
|
2545
|
+
pids.push(...lateGuests.map(receipt => receipt.record.pid));
|
|
2260
2546
|
if (holderPid && !externalErrors.length && !undeadPids(pids).length) {
|
|
2261
2547
|
pids.push(holderPid);
|
|
2262
|
-
stopped.push(...signalPids([holderPid], 'SIGTERM'));
|
|
2263
|
-
escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT));
|
|
2548
|
+
stopped.push(...await signalPids([holderPid], 'SIGTERM', identities, spawnedChildren));
|
|
2549
|
+
escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren));
|
|
2264
2550
|
}
|
|
2265
2551
|
|
|
2266
2552
|
// Finalization belongs to this exact generation, not just the reusable name. A new up may
|
|
@@ -2278,20 +2564,36 @@ async function downWorldClaimed(
|
|
|
2278
2564
|
externalErrors.push('Proxy or sharing lifecycle changed during teardown; cleanup refused');
|
|
2279
2565
|
return result;
|
|
2280
2566
|
}
|
|
2281
|
-
const
|
|
2567
|
+
const guestReceipts = readGuestProcesses(dir, instance?.instance);
|
|
2568
|
+
const surviving = undeadPids([...pids, ...guestReceipts.map(receipt => receipt.record.pid)]);
|
|
2282
2569
|
if (surviving.length) externalErrors.push(`World processes still alive or uninspectable after teardown: ${surviving.join(', ')}; cleanup refused`);
|
|
2570
|
+
if (guestReceipts.some(receipt => foreign.some(entry => entry.pid === receipt.record.pid))) externalErrors.push('Guest spawn identity changed; receipt retained and cleanup refused');
|
|
2571
|
+
let externalAbandoned: string[] | undefined;
|
|
2572
|
+
if (options.purge && instance && externalErrors.length && externalErrors.length === unavailable.length && !foreign.length) {
|
|
2573
|
+
const inventory = exitedWorldLifecyclePids(resolvedRoot, name, instance.createdAt);
|
|
2574
|
+
if (inventory && !undeadPids([...pids, ...inventory, ...(holderPid ? [holderPid] : [])]).length) {
|
|
2575
|
+
const consumers = worldConsumers(resolvedRoot, name);
|
|
2576
|
+
if (!consumers.warnings.length && consumers.consumers.every(c => c.state === 'exited')) {
|
|
2577
|
+
externalAbandoned = [...externalErrors];
|
|
2578
|
+
recordWorldLifecycleEvent(resolvedRoot, name, `explicit purge surrendered state; unavailable backend not certified stopped:\n${externalAbandoned.join('\n')}`);
|
|
2579
|
+
externalErrors.splice(0);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2283
2583
|
if (externalErrors.length) return result;
|
|
2584
|
+
releaseGuestProcesses(guestReceipts);
|
|
2284
2585
|
rmSync(pidsFile, { force: true });
|
|
2285
2586
|
if (before) {
|
|
2286
2587
|
writeTextAtomic(join(dir, 'stopped.json'), `${JSON.stringify({
|
|
2287
2588
|
instanceHash: createHash('sha256').update(before).digest('hex'), stoppedAt: new Date().toISOString(),
|
|
2589
|
+
...(externalAbandoned ? { externalAbandoned } : {}),
|
|
2288
2590
|
})}\n`);
|
|
2289
2591
|
}
|
|
2290
|
-
releaseWorldLifecycle(resolvedRoot, name);
|
|
2592
|
+
releaseWorldLifecycle(resolvedRoot, name, externalAbandoned ? 'recorded processes exited; explicit purge surrendered state; ownership released' : undefined);
|
|
2291
2593
|
if (!options.purge) return result;
|
|
2292
2594
|
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
2293
2595
|
if (purged && instance) clearBrowserFiles(instance.dirs.data, resolvedRoot);
|
|
2294
|
-
return { ...result, purged };
|
|
2596
|
+
return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
|
|
2295
2597
|
}));
|
|
2296
2598
|
}
|
|
2297
2599
|
|
|
@@ -2397,7 +2699,7 @@ function worldCleanupEntry(name: string, root: string): WorldCleanupEntry {
|
|
|
2397
2699
|
return { ...entry, reason: 'invalid or outdated teardown receipt' };
|
|
2398
2700
|
}
|
|
2399
2701
|
try { assertWorldStateRemovable(root, path); }
|
|
2400
|
-
catch (error) { return { ...entry, reason: error
|
|
2702
|
+
catch (error) { return { ...entry, reason: errorMessage(error) }; }
|
|
2401
2703
|
return { ...entry, eligible: true, reason: 'successfully stopped; disposable instance data only', stoppedAt: receipt.stoppedAt, logicalBytes: logicalTreeBytes(path) };
|
|
2402
2704
|
} catch (error) {
|
|
2403
2705
|
// Do not print malformed metadata: it can contain env secrets.
|
|
@@ -2434,7 +2736,7 @@ export function pruneWorlds(options: { root?: string; name?: string; apply?: boo
|
|
|
2434
2736
|
function worldServiceControlRoot(name: string, service: string, root = process.cwd()): string {
|
|
2435
2737
|
const instance = readWorldInstance(name, resolve(root));
|
|
2436
2738
|
if (!instance.services[service]) throw new Error(`World ${name} has no service "${service}"`);
|
|
2437
|
-
return
|
|
2739
|
+
return ownedPath(instance.dirs.data, service);
|
|
2438
2740
|
}
|
|
2439
2741
|
|
|
2440
2742
|
function worldServiceStateService(controlRoot: string, requestedService: string): string {
|
|
@@ -2526,7 +2828,7 @@ async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string
|
|
|
2526
2828
|
});
|
|
2527
2829
|
child.once('error', (error) => {
|
|
2528
2830
|
exited = true;
|
|
2529
|
-
exitSummary = error
|
|
2831
|
+
exitSummary = errorMessage(error);
|
|
2530
2832
|
});
|
|
2531
2833
|
|
|
2532
2834
|
const started = Date.now();
|
|
@@ -2558,17 +2860,17 @@ function commandExists(command: string): boolean {
|
|
|
2558
2860
|
return OWN_COMMANDS[command] !== undefined || onPath(command);
|
|
2559
2861
|
}
|
|
2560
2862
|
|
|
2561
|
-
/**
|
|
2562
|
-
*
|
|
2563
|
-
* answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
|
|
2564
|
-
* PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
|
|
2565
|
-
* command on PATH still wins, as before. */
|
|
2863
|
+
/** Built-in lifecycle names belong to the executing runtime. PATH may contain another
|
|
2864
|
+
* checkout's package-manager bin links; an operator override names an explicit executable path. */
|
|
2566
2865
|
const OWN_COMMANDS: Record<string, string> = { 'volter-world-infra': 'infra-cli' };
|
|
2567
2866
|
export function ownCommand(command: string[]): string[] {
|
|
2568
2867
|
const [bin, ...args] = command;
|
|
2569
2868
|
const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
|
|
2570
|
-
if (stem === undefined
|
|
2571
|
-
|
|
2869
|
+
if (stem === undefined) return command;
|
|
2870
|
+
const script = siblingScript(import.meta.url, stem);
|
|
2871
|
+
const loader = typeof (globalThis as { Bun?: unknown }).Bun === 'undefined' && script.endsWith('.ts')
|
|
2872
|
+
? ['--experimental-transform-types', '--no-warnings'] : [];
|
|
2873
|
+
return [process.execPath, ...loader, script, ...args];
|
|
2572
2874
|
}
|
|
2573
2875
|
|
|
2574
2876
|
/** A command answers when it names an executable: a path is checked as a file (what `which` does with a path),
|
|
@@ -2716,7 +3018,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
2716
3018
|
}
|
|
2717
3019
|
|
|
2718
3020
|
const tunnel = shareCommand(options, service.url);
|
|
2719
|
-
const log =
|
|
3021
|
+
const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
2720
3022
|
// Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
|
|
2721
3023
|
// share from accepting the previous process's URL handshake as the new process's endpoint.
|
|
2722
3024
|
const out = openPrivateLog(log, 'w');
|
|
@@ -2726,12 +3028,18 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
2726
3028
|
detached: true,
|
|
2727
3029
|
stdio: ['ignore', out, out],
|
|
2728
3030
|
});
|
|
2729
|
-
|
|
3031
|
+
const birth = retainSpawn(child);
|
|
3032
|
+
const identities: Record<string, string | undefined> = {};
|
|
3033
|
+
const handles = new Map(child.pid ? [[child.pid, child]] : []);
|
|
2730
3034
|
|
|
2731
3035
|
let publicUrl: string;
|
|
2732
3036
|
let publicVerification: WorldServiceInstance['publicVerification'];
|
|
2733
3037
|
let pidsWritten = false;
|
|
2734
3038
|
try {
|
|
3039
|
+
closeSync(out);
|
|
3040
|
+
const identity = await birth;
|
|
3041
|
+
if (!identity?.start) throw new Error('Tunnel birth capture failed; retiring held child');
|
|
3042
|
+
identities[identity.pid] = identity.start;
|
|
2735
3043
|
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
|
|
2736
3044
|
if (options.verifyPath !== false) {
|
|
2737
3045
|
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
@@ -2764,8 +3072,8 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
2764
3072
|
// gives owned services (a cloudflared that ignores SIGTERM must not keep serving after a
|
|
2765
3073
|
// failed/rejected `share`).
|
|
2766
3074
|
if (child.pid) {
|
|
2767
|
-
signalPids([child.pid], 'SIGTERM');
|
|
2768
|
-
await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
|
|
3075
|
+
await signalPids([child.pid], 'SIGTERM', identities, handles);
|
|
3076
|
+
await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT, identities, handles);
|
|
2769
3077
|
}
|
|
2770
3078
|
const cleanupProblems: string[] = [];
|
|
2771
3079
|
if (child.pid && undeadPids([child.pid]).length > 0) cleanupProblems.push(`tunnel pid ${child.pid} survived cleanup`);
|
|
@@ -2774,10 +3082,10 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
2774
3082
|
// file intact; this compensates the one successful write in a two-file persistence attempt.
|
|
2775
3083
|
if (pidsWritten) {
|
|
2776
3084
|
try { writePidsFromInstance(instance); }
|
|
2777
|
-
catch (restoreError) { cleanupProblems.push(`could not restore pids file: ${
|
|
3085
|
+
catch (restoreError) { cleanupProblems.push(`could not restore pids file: ${errorMessage(restoreError)}`); }
|
|
2778
3086
|
}
|
|
2779
3087
|
if (cleanupProblems.length > 0) {
|
|
2780
|
-
throw new Error(`${
|
|
3088
|
+
throw new Error(`${errorMessage(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
|
|
2781
3089
|
}
|
|
2782
3090
|
throw error;
|
|
2783
3091
|
}
|
|
@@ -2832,11 +3140,9 @@ export async function shareWorldServices(name: string, options: ShareWorldServic
|
|
|
2832
3140
|
* services — SIGTERM, confirm dead (escalating to SIGKILL past the grace), and only THEN erase the
|
|
2833
3141
|
* tunnel facts/pids. A best-effort SIGTERM with no confirmation can leave a `cloudflared` that
|
|
2834
3142
|
* ignores SIGTERM serving the public URL forever while instance.json (and the pids file) say
|
|
2835
|
-
* nothing is shared — the exact "half-shared" state docs/concepts/worlds.md promises cannot exist.
|
|
2836
|
-
* synchronous (via `killSurvivorsAfterGraceSync`) to match the existing signature/call sites
|
|
2837
|
-
* (the CLI does not await it). Returns the instance plus the tunnel pids that needed escalating,
|
|
3143
|
+
* nothing is shared — the exact "half-shared" state docs/concepts/worlds.md promises cannot exist. Retirement awaits asynchronous birth verification. Returns the instance plus the tunnel pids that needed escalating,
|
|
2838
3144
|
* for callers that want to report it. */
|
|
2839
|
-
export function unshareWorld(name: string, options: { root?: string; service?: string; graceMs?: number } = {}): WorldInstance & { escalated: number[] } {
|
|
3145
|
+
export async function unshareWorld(name: string, options: { root?: string; service?: string; graceMs?: number } = {}): Promise<WorldInstance & { escalated: number[] }> {
|
|
2840
3146
|
const root = resolve(options.root ?? process.cwd());
|
|
2841
3147
|
const instance = readWorldInstance(name, root);
|
|
2842
3148
|
const serviceIds = options.service ? [options.service] : Object.keys(instance.services);
|
|
@@ -2847,12 +3153,13 @@ export function unshareWorld(name: string, options: { root?: string; service?: s
|
|
|
2847
3153
|
if (!service) throw new Error(`World "${name}" has no service "${serviceId}"`);
|
|
2848
3154
|
if (service.tunnel?.pid) {
|
|
2849
3155
|
const pid = service.tunnel.pid;
|
|
2850
|
-
|
|
2851
|
-
|
|
3156
|
+
const identities = readIdentities(root, name);
|
|
3157
|
+
await signalPids([pid], 'SIGTERM', identities);
|
|
3158
|
+
const survivors = await killSurvivorsAfterGrace([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities);
|
|
2852
3159
|
if (survivors.length > 0) escalated.push(...survivors);
|
|
2853
3160
|
// `undeadPids` (not the raw `livePids`) is the right "confirmed dead" test here: a zombie
|
|
2854
|
-
// (exited but unreaped
|
|
2855
|
-
//
|
|
3161
|
+
// (exited but unreaped, possibly awaiting its parent's reap) still
|
|
3162
|
+
// occupies a process-table entry and
|
|
2856
3163
|
// answers `kill(pid, 0)` yet is dead for every practical purpose — see `survivingOwnedGroups`' own
|
|
2857
3164
|
// comment. Using plain `livePids` here would treat every zombie as "still shared".
|
|
2858
3165
|
if (undeadPids([pid]).length > 0) {
|
|
@@ -2908,7 +3215,7 @@ async function probeExternalOnce(
|
|
|
2908
3215
|
if (response.status >= 200 && response.status < 400) return { ok: true, message: `probe ok (HTTP ${response.status})` };
|
|
2909
3216
|
return { ok: false, message: `probe returned HTTP ${response.status}` };
|
|
2910
3217
|
} catch (error) {
|
|
2911
|
-
return { ok: false, message: `probe failed: ${
|
|
3218
|
+
return { ok: false, message: `probe failed: ${errorMessage(error)}` };
|
|
2912
3219
|
}
|
|
2913
3220
|
}
|
|
2914
3221
|
if (ready.stdoutMatch !== undefined) {
|
|
@@ -2935,7 +3242,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
2935
3242
|
instance = readWorldInstance(name, root);
|
|
2936
3243
|
checks.push({ id: 'instance', ok: true, message: `instance=${instanceFile(root, name)}` });
|
|
2937
3244
|
} catch (error) {
|
|
2938
|
-
const message =
|
|
3245
|
+
const message = errorMessage(error);
|
|
2939
3246
|
return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
|
|
2940
3247
|
}
|
|
2941
3248
|
|
|
@@ -2993,7 +3300,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
2993
3300
|
await runExternalCommand(configuredExternal.status, probeCwd, service.id, 'doctor-probe', probeEnv);
|
|
2994
3301
|
checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; probe ok (status)` });
|
|
2995
3302
|
} catch (error) {
|
|
2996
|
-
checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${
|
|
3303
|
+
checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${errorMessage(error)}` });
|
|
2997
3304
|
}
|
|
2998
3305
|
} else {
|
|
2999
3306
|
checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; no probe declared — health unknown` });
|
|
@@ -3035,7 +3342,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
3035
3342
|
checks.push({
|
|
3036
3343
|
id: `service:${service.id}:tcp`,
|
|
3037
3344
|
ok: false,
|
|
3038
|
-
message:
|
|
3345
|
+
message: errorMessage(error),
|
|
3039
3346
|
});
|
|
3040
3347
|
}
|
|
3041
3348
|
if (service.tunnel || service.publicUrl) {
|
|
@@ -3064,7 +3371,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
3064
3371
|
checks.push({
|
|
3065
3372
|
id: `service:${service.id}:public-health`,
|
|
3066
3373
|
ok: false,
|
|
3067
|
-
message:
|
|
3374
|
+
message: errorMessage(error),
|
|
3068
3375
|
});
|
|
3069
3376
|
}
|
|
3070
3377
|
}
|
|
@@ -3155,7 +3462,7 @@ export async function runWorldOwned(configId: string, command: string[], options
|
|
|
3155
3462
|
let failed = false;
|
|
3156
3463
|
let retired = true;
|
|
3157
3464
|
try {
|
|
3158
|
-
outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { abort, verbose: options.verbose });
|
|
3465
|
+
outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { cwd: options.cwd, abort, verbose: options.verbose });
|
|
3159
3466
|
instance.lastRun = outcome;
|
|
3160
3467
|
saveWorldInstance(instance);
|
|
3161
3468
|
} catch (error) {
|
|
@@ -3169,7 +3476,7 @@ export async function runWorldOwned(configId: string, command: string[], options
|
|
|
3169
3476
|
if (!options.keep && retired) {
|
|
3170
3477
|
try { await finishTaskWorld(instance); }
|
|
3171
3478
|
catch (error) {
|
|
3172
|
-
if (failed) throw new AggregateError([failure, error], `World execution and cleanup failed: ${
|
|
3479
|
+
if (failed) throw new AggregateError([failure, error], `World execution and cleanup failed: ${errorMessage(failure)}; ${errorMessage(error)}`);
|
|
3173
3480
|
throw error;
|
|
3174
3481
|
}
|
|
3175
3482
|
}
|
|
@@ -3182,7 +3489,7 @@ async function finishTaskWorld(instance: WorldInstance): Promise<void> {
|
|
|
3182
3489
|
let uncertainSince: number | undefined;
|
|
3183
3490
|
for (;;) {
|
|
3184
3491
|
try {
|
|
3185
|
-
const result = await downWorld(instance.name, instance.root, {
|
|
3492
|
+
const result = await downWorld(instance.name, instance.root, { expectedInstance: instance.instance });
|
|
3186
3493
|
if (result.externalErrors.length) {
|
|
3187
3494
|
const message = `World "${instance.name}" cleanup incomplete: ${result.externalErrors.join('; ')}. Reservation and diagnostics retained.`;
|
|
3188
3495
|
// The caller that would have read this on stderr may be gone; the lifecycle log keeps it.
|
|
@@ -3245,7 +3552,7 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false): {
|
|
|
3245
3552
|
} else if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
|
|
3246
3553
|
} catch (error) {
|
|
3247
3554
|
if (error instanceof CaTrustError) throw error;
|
|
3248
|
-
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${
|
|
3555
|
+
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
|
|
3249
3556
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
3250
3557
|
}
|
|
3251
3558
|
return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
@@ -3279,7 +3586,10 @@ export function runWithWorldEnv(name: string, command: string[], root = process.
|
|
|
3279
3586
|
env: { ...attached.env, ...(options.env ?? {}) },
|
|
3280
3587
|
stdio: 'inherit',
|
|
3281
3588
|
});
|
|
3282
|
-
const forward = (signal: NodeJS.Signals) => {
|
|
3589
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
3590
|
+
try { if (child.pid && child.exitCode === null && child.signalCode === null) signalChild(child, signal); }
|
|
3591
|
+
catch (error) { process.stderr.write(`${errorMessage(error)}\n`); }
|
|
3592
|
+
};
|
|
3283
3593
|
process.on('SIGINT', forward);
|
|
3284
3594
|
process.on('SIGTERM', forward);
|
|
3285
3595
|
child.once('error', () => { offSignal('SIGINT', forward); offSignal('SIGTERM', forward); resolveExit(1); });
|
|
@@ -3421,7 +3731,7 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
|
|
|
3421
3731
|
try {
|
|
3422
3732
|
attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
3423
3733
|
} catch (cause) {
|
|
3424
|
-
return finish(1, undefined,
|
|
3734
|
+
return finish(1, undefined, errorMessage(cause));
|
|
3425
3735
|
}
|
|
3426
3736
|
|
|
3427
3737
|
return await new Promise<WorldRunOutcome>((resolveOutcome, rejectOutcome) => {
|
|
@@ -3435,21 +3745,21 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
|
|
|
3435
3745
|
detached: commandProcessGroup,
|
|
3436
3746
|
});
|
|
3437
3747
|
} catch (cause) {
|
|
3438
|
-
resolveOutcome(finish(1, undefined,
|
|
3748
|
+
resolveOutcome(finish(1, undefined, errorMessage(cause)));
|
|
3439
3749
|
return;
|
|
3440
3750
|
}
|
|
3441
3751
|
|
|
3442
3752
|
const lifetime = commandLifetime(child, options.abort);
|
|
3443
3753
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
3444
3754
|
try { process.stdout.write(chunk); writeSync(logFd, chunk); }
|
|
3445
|
-
catch (error) { spawnError =
|
|
3755
|
+
catch (error) { spawnError = errorMessage(error); lifetime.cancel('SIGTERM'); }
|
|
3446
3756
|
});
|
|
3447
3757
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
3448
3758
|
try { process.stderr.write(chunk); writeSync(logFd, chunk); }
|
|
3449
|
-
catch (error) { spawnError =
|
|
3759
|
+
catch (error) { spawnError = errorMessage(error); lifetime.cancel('SIGTERM'); }
|
|
3450
3760
|
});
|
|
3451
3761
|
child.once('error', (cause) => {
|
|
3452
|
-
spawnError = cause
|
|
3762
|
+
spawnError = errorMessage(cause);
|
|
3453
3763
|
});
|
|
3454
3764
|
const activeRecord: WorldRunRecord = {
|
|
3455
3765
|
state: 'running',
|
|
@@ -3459,7 +3769,7 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
|
|
|
3459
3769
|
log,
|
|
3460
3770
|
};
|
|
3461
3771
|
try { writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(activeRecord, null, 2)}\n`); }
|
|
3462
|
-
catch (error) { spawnError =
|
|
3772
|
+
catch (error) { spawnError = errorMessage(error); lifetime.cancel('SIGTERM'); }
|
|
3463
3773
|
void lifetime.finish().then(({ code, signal }) => {
|
|
3464
3774
|
const effectiveSignal = signal ?? lifetime.forwarded;
|
|
3465
3775
|
const exitCode = effectiveSignal ? signalExitCode(effectiveSignal) : (code ?? 1);
|
|
@@ -3504,8 +3814,8 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
|
|
|
3504
3814
|
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (ambient proxy ${proxy.url}). type 'exit' to leave.\n`);
|
|
3505
3815
|
} catch (error) {
|
|
3506
3816
|
if (error instanceof CaTrustError) throw error;
|
|
3507
|
-
if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${
|
|
3508
|
-
process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${
|
|
3817
|
+
if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${errorMessage(error)})`);
|
|
3818
|
+
process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${errorMessage(error)}); falling back to env-only redirect.\n`);
|
|
3509
3819
|
}
|
|
3510
3820
|
} else {
|
|
3511
3821
|
if (sealed) throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
|
|
@@ -3554,10 +3864,7 @@ function currentProxyState(root: string, name: string): ProxyState | null {
|
|
|
3554
3864
|
const existing = readProxyState(root, name);
|
|
3555
3865
|
if (!existing || !isAlive(existing.pid)) return null;
|
|
3556
3866
|
if (existing.guestUrl) return existing;
|
|
3557
|
-
|
|
3558
|
-
for (let i = 0; i < 50 && isAlive(existing.pid); i += 1) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
3559
|
-
try { rmSync(proxyStateFile(root, name), { force: true }); } catch { /* ignore */ }
|
|
3560
|
-
return null;
|
|
3867
|
+
throw new Error('World proxy lacks its guest listener; stop this World before restarting its proxy');
|
|
3561
3868
|
}
|
|
3562
3869
|
|
|
3563
3870
|
function ensureWorldProxyUnlocked(name: string, root: string, caller: CaTrustInputs): { url: string; caCertPath: string; env: Record<string, string> } | null {
|
|
@@ -3608,9 +3915,20 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
|
|
|
3608
3915
|
child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', root, ...(envFile ? [`--env-file=${envFile}`] : [])], { windowsHide: true,
|
|
3609
3916
|
cwd: root, env: process.env, detached: true, stdio: ['ignore', proxyOut, proxyOut],
|
|
3610
3917
|
});
|
|
3918
|
+
if (child.pid) {
|
|
3919
|
+
spawnedChildren.set(child.pid, child);
|
|
3920
|
+
if (boot) { boot.pids.add(child.pid); boot.handles.set(child.pid, child); }
|
|
3921
|
+
}
|
|
3611
3922
|
} finally { closeSync(proxyOut); }
|
|
3612
3923
|
} catch (error) {
|
|
3613
|
-
|
|
3924
|
+
if (child!?.pid) {
|
|
3925
|
+
try {
|
|
3926
|
+
signalChild(child!, 'SIGTERM', true);
|
|
3927
|
+
killHeldSurvivorsSync(child!, DOWN_GRACE_MS_DEFAULT);
|
|
3928
|
+
if (undeadPids([child!.pid!]).length) throw new CommandRetirementError('Proxy retirement unconfirmed; startup intent retained');
|
|
3929
|
+
} catch (cleanup) { throw new AggregateError([error, cleanup], 'Proxy spawn/descriptor close and retirement failed'); }
|
|
3930
|
+
}
|
|
3931
|
+
try { rmSync(marker, { force: true }); } catch (cleanup) { throw new AggregateError([error, cleanup], 'Proxy spawn and intent cleanup failed'); }
|
|
3614
3932
|
throw error;
|
|
3615
3933
|
}
|
|
3616
3934
|
child.on('error', () => {}); // a failed spawn can emit after this synchronous operation throws
|
|
@@ -3619,16 +3937,16 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
|
|
|
3619
3937
|
rmSync(marker, { force: true });
|
|
3620
3938
|
throw new Error(`World "${name}": ambient proxy process did not start`);
|
|
3621
3939
|
}
|
|
3622
|
-
boot
|
|
3940
|
+
if (boot) { boot.pids.add(pid); boot.handles.set(pid, child); } // before any fallible filesystem operation
|
|
3623
3941
|
child.unref();
|
|
3624
3942
|
let retirementAttempted = false;
|
|
3625
3943
|
const retire = () => {
|
|
3626
3944
|
retirementAttempted = true;
|
|
3627
|
-
|
|
3628
|
-
|
|
3945
|
+
signalChild(child, 'SIGTERM', true);
|
|
3946
|
+
killHeldSurvivorsSync(child, DOWN_GRACE_MS_DEFAULT);
|
|
3629
3947
|
if (undeadPids([pid]).length) {
|
|
3630
3948
|
const error = new CommandRetirementError(`World "${name}": proxy process ${pid} retirement is unconfirmed; ownership retained`);
|
|
3631
|
-
try { retainWorldLifecycleForCleanup(root, name); } catch (cause) { error.message += `; claim update failed: ${
|
|
3949
|
+
try { retainWorldLifecycleForCleanup(root, name); } catch (cause) { error.message += `; claim update failed: ${errorMessage(cause)}`; }
|
|
3632
3950
|
throw error;
|
|
3633
3951
|
}
|
|
3634
3952
|
// Publication can happen during the grace period. Erase only this retired child's state.
|
|
@@ -3638,12 +3956,16 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
|
|
|
3638
3956
|
try {
|
|
3639
3957
|
if (boot) boot.recordPids();
|
|
3640
3958
|
else writeOwnedPids(root, name, [pid], [pid]);
|
|
3641
|
-
rmSync(marker, { force: true }); // canonical ownership is now durable
|
|
3642
3959
|
const deadlineMs = proxyDaemonDeadlineMs();
|
|
3643
3960
|
const deadline = Date.now() + deadlineMs;
|
|
3644
3961
|
while (Date.now() < deadline) {
|
|
3645
3962
|
const state = readProxyState(root, name);
|
|
3646
3963
|
if (state?.pid === pid) {
|
|
3964
|
+
if (!state.birth) throw new Error('Proxy birth token missing; retiring held child');
|
|
3965
|
+
spawnedIdentities.set(pid, { pid, start: state.birth });
|
|
3966
|
+
if (boot) { boot.identities[pid] = state.birth; boot.recordPids(); }
|
|
3967
|
+
else writeOwnedPids(root, name, [pid], [pid]);
|
|
3968
|
+
rmSync(marker, { force: true }); // both numeric ownership and birth are durable
|
|
3647
3969
|
if (!boot) handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
|
|
3648
3970
|
return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath, caller, state.guestUrl) };
|
|
3649
3971
|
}
|
|
@@ -3661,13 +3983,13 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
|
|
|
3661
3983
|
// a retirement error is still what is thrown (its type keeps the survivor's ownership), with the cause it hid leading its
|
|
3662
3984
|
// message; any other cleanup failure is added to the original, which is thrown
|
|
3663
3985
|
try { retire(); } catch (retirement) {
|
|
3664
|
-
const cause =
|
|
3986
|
+
const cause = errorMessage(error);
|
|
3665
3987
|
if (retirement instanceof CommandRetirementError) {
|
|
3666
|
-
Object.assign(retirement, { retirement: retirement
|
|
3667
|
-
retirement.message = `${cause}; ${retirement
|
|
3988
|
+
Object.assign(retirement, { retirement: errorMessage(retirement), cause: error });
|
|
3989
|
+
retirement.message = `${cause}; ${errorMessage(retirement)}`;
|
|
3668
3990
|
throw retirement;
|
|
3669
3991
|
}
|
|
3670
|
-
|
|
3992
|
+
throw new AggregateError([error, retirement], 'Proxy startup and retirement failed');
|
|
3671
3993
|
}
|
|
3672
3994
|
}
|
|
3673
3995
|
throw error;
|
|
@@ -3703,8 +4025,10 @@ export async function runProxyDaemon(name: string, root = process.cwd(), envFile
|
|
|
3703
4025
|
env = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
|
|
3704
4026
|
}
|
|
3705
4027
|
const proxy = await startRedirectProxy({ env, envLoader, tlsDir: tlsDir(resolvedRoot, name), note: (line) => process.stderr.write(`${line}\n`) });
|
|
3706
|
-
const
|
|
3707
|
-
|
|
4028
|
+
const identity = await spawnIdentity(process.pid);
|
|
4029
|
+
if (!identity.start) { await proxy.close(); throw new Error('Proxy birth capture failed'); }
|
|
4030
|
+
const state: ProxyState = { pid: process.pid, birth: identity.start, url: proxy.url, caCertPath: proxy.caCertPath, guestUrl: proxy.guestUrl };
|
|
4031
|
+
writeTextAtomic(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
|
|
3708
4032
|
// Not a recorded service, so it records its own start and end in the World's events (a crash included; a SIGKILL
|
|
3709
4033
|
// leaves none, which doctor's proxy check then names).
|
|
3710
4034
|
const events = worldEventsPath(join(instanceDir(resolvedRoot, name), 'logs'));
|
|
@@ -3724,10 +4048,17 @@ export async function runProxyDaemon(name: string, root = process.cwd(), envFile
|
|
|
3724
4048
|
|
|
3725
4049
|
/** Stop a world's ambient-redirect proxy daemon (if any) and drop its session CA. Best-effort —
|
|
3726
4050
|
* called from `downWorld` so the trusted CA never outlives the world. */
|
|
3727
|
-
function stopWorldProxy(root: string, name: string): void {
|
|
4051
|
+
async function stopWorldProxy(root: string, name: string, handles?: Map<number, ReturnType<typeof spawn>>): Promise<void> {
|
|
3728
4052
|
const state = readProxyState(root, name);
|
|
3729
4053
|
if (state && isAlive(state.pid)) {
|
|
3730
|
-
|
|
4054
|
+
const identities = readIdentities(root, name);
|
|
4055
|
+
const identity = { pid: state.pid, start: state.birth ?? identities[state.pid] };
|
|
4056
|
+
const child = handles?.get(state.pid);
|
|
4057
|
+
if (child) await signalHeldChild(child, 'SIGTERM', false, Promise.resolve(identity));
|
|
4058
|
+
else {
|
|
4059
|
+
if (!state.birth && !Object.hasOwn(identities, state.pid)) throw new Error('Proxy spawn identity missing; retirement refused; ownership retained');
|
|
4060
|
+
await signalOwnedProcess(identity, 'SIGTERM');
|
|
4061
|
+
}
|
|
3731
4062
|
}
|
|
3732
4063
|
try { rmSync(proxyStateFile(root, name), { force: true }); } catch { /* ignore */ }
|
|
3733
4064
|
try { rmSync(proxyEnvFile(root, name), { force: true }); } catch { /* ignore */ }
|
|
@@ -3766,7 +4097,7 @@ export function activateScript(name: string, root = process.cwd()): string {
|
|
|
3766
4097
|
} else if (sealed && hostHasRawEgress()) throw new Error('attachment proxy is unavailable');
|
|
3767
4098
|
} catch (error) {
|
|
3768
4099
|
if (error instanceof CaTrustError) throw error;
|
|
3769
|
-
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${
|
|
4100
|
+
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${errorMessage(error)})`);
|
|
3770
4101
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
3771
4102
|
}
|
|
3772
4103
|
env = withAppUrl(name, root, env);
|