@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/dist/src/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from "./error-message.js";
|
|
1
2
|
import { materializeBrowserFiles, clearBrowserFiles } from "./browsers.js";
|
|
2
3
|
import { claimEnvOutput, publishEnvOutput } from "./env-output.js";
|
|
3
4
|
import { offSignal } from "./signals.js";
|
|
@@ -11,21 +12,25 @@ import { get as httpGet } from 'node:http';
|
|
|
11
12
|
import { siblingScript } from "./sibling.js";
|
|
12
13
|
import { commandLifetime, commandProcessGroup, CommandRetirementError } from "./command-lifetime.js";
|
|
13
14
|
import { superviseWorldRun } from "./run-task.js";
|
|
14
|
-
import { survivingOwnedGroups } from "./process-groups.js";
|
|
15
|
+
import { processStarts, survivingOwnedGroups } from "./process-groups.js";
|
|
16
|
+
import { spawnIdentity, signalOwnedProcess, signalChild, signalHeldChild, processBirth, childIdentity } from './process-signal.mjs';
|
|
17
|
+
import { readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
|
|
15
18
|
import { randomUUID, createHash } from 'node:crypto';
|
|
16
19
|
import { createRequire } from 'node:module';
|
|
17
20
|
import { isIP } from 'node:net';
|
|
18
21
|
import { allocatePort } from "./tcp-port.js";
|
|
19
22
|
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';
|
|
23
|
+
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
24
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
22
25
|
import { packFacts } from "./pack-facts.js";
|
|
26
|
+
import { assertOwnedRealPath, ownedPath } from "./owned-path.js";
|
|
27
|
+
import { isEnvironmentName } from "./schema.js";
|
|
23
28
|
import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
|
|
24
29
|
import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
|
|
25
30
|
import { Socket } from 'node:net';
|
|
26
31
|
import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
|
|
27
32
|
import { loadWorldConfig } from "./configs.js";
|
|
28
|
-
import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
33
|
+
import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
29
34
|
import { overlayPackFactsFile, resolveServicePackage } from "./catalog.js";
|
|
30
35
|
import { fakeEnvValue } from "./fixture-env.js";
|
|
31
36
|
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from "./schema.js";
|
|
@@ -34,7 +39,24 @@ import { inertInjectEnvWarnings } from "./inject-map.js";
|
|
|
34
39
|
import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from "./redirect-proxy.js";
|
|
35
40
|
import { CaTrustError, sessionTrustEnv } from "./ca-trust.js";
|
|
36
41
|
import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from "./consumers.js";
|
|
37
|
-
import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle } from "./lifecycle-record.js";
|
|
42
|
+
import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle, exitedWorldLifecyclePids } from "./lifecycle-record.js";
|
|
43
|
+
// Births captured at actual spawn; retries never remeasure a retained PID.
|
|
44
|
+
const spawnedIdentities = new Map();
|
|
45
|
+
const spawnedChildren = new Map();
|
|
46
|
+
function retainSpawn(child) {
|
|
47
|
+
if (!child.pid)
|
|
48
|
+
return Promise.resolve(undefined);
|
|
49
|
+
const pid = child.pid;
|
|
50
|
+
spawnedChildren.set(pid, child);
|
|
51
|
+
return childIdentity(child).then(identity => {
|
|
52
|
+
if (identity && spawnedChildren.get(pid) === child)
|
|
53
|
+
spawnedIdentities.set(pid, identity);
|
|
54
|
+
return identity;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function heldStarts() {
|
|
58
|
+
return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
|
|
59
|
+
}
|
|
38
60
|
/** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
|
|
39
61
|
* shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
|
|
40
62
|
function readServiceText(log, limit = 4 * 1024 * 1024) {
|
|
@@ -62,8 +84,8 @@ export function worldEventsPath(logs) {
|
|
|
62
84
|
* its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
|
|
63
85
|
* file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
|
|
64
86
|
* `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
|
|
65
|
-
function spawnRecorded(serviceId, command, args, cwd, env, log) {
|
|
66
|
-
const logDir = `${log.replace(/\.log$/, '')}.d
|
|
87
|
+
function spawnRecorded(serviceId, command, args, cwd, env, log, boot) {
|
|
88
|
+
const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
|
|
67
89
|
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
68
90
|
const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
|
|
69
91
|
const recorderEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
|
|
@@ -78,9 +100,16 @@ function spawnRecorded(serviceId, command, args, cwd, env, log) {
|
|
|
78
100
|
detached: true,
|
|
79
101
|
stdio: ['ignore', out, out],
|
|
80
102
|
});
|
|
81
|
-
closeSync(out);
|
|
82
103
|
const ended = new Promise((resolveEnded) => { child.once('exit', () => resolveEnded()); child.once('error', () => resolveEnded()); });
|
|
83
|
-
|
|
104
|
+
const identity = retainSpawn(child);
|
|
105
|
+
if (child.pid) {
|
|
106
|
+
boot.pids.add(child.pid);
|
|
107
|
+
boot.handles.set(child.pid, child);
|
|
108
|
+
}
|
|
109
|
+
child.once('error', error => boot.fail(error));
|
|
110
|
+
boot.recordPids();
|
|
111
|
+
closeSync(out);
|
|
112
|
+
return { child, ended, identity };
|
|
84
113
|
}
|
|
85
114
|
/** A startup wait that fails as soon as the service ends, with the service's own account of its end (exit code or
|
|
86
115
|
* signal, last output) instead of a timeout that names neither. */
|
|
@@ -266,7 +295,8 @@ export function clockFile(root, name) {
|
|
|
266
295
|
return join(instanceDir(root, name), 'clock');
|
|
267
296
|
}
|
|
268
297
|
export function instanceDir(root, name) {
|
|
269
|
-
|
|
298
|
+
assertSafeWorldName(name);
|
|
299
|
+
return ownedPath(worldBaseDir(root), name);
|
|
270
300
|
}
|
|
271
301
|
function instanceFile(root, name) {
|
|
272
302
|
return join(instanceDir(root, name), 'instance.json');
|
|
@@ -279,10 +309,12 @@ function foregroundRunFile(root, name) {
|
|
|
279
309
|
* a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
|
|
280
310
|
* collide with (or be wiped as) a world's instance dir. */
|
|
281
311
|
function instanceLockFile(root, name) {
|
|
282
|
-
|
|
312
|
+
assertSafeWorldName(name);
|
|
313
|
+
return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.lock`);
|
|
283
314
|
}
|
|
284
315
|
function stoppingFile(root, name) {
|
|
285
|
-
|
|
316
|
+
assertSafeWorldName(name);
|
|
317
|
+
return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.stop.json`);
|
|
286
318
|
}
|
|
287
319
|
const SHARE_CLAIM_STALE_MS = 60_000;
|
|
288
320
|
const SHARE_CLAIM_HEARTBEAT_MS = 5_000;
|
|
@@ -292,7 +324,8 @@ const SHARE_CLAIM_HEARTBEAT_MS = 5_000;
|
|
|
292
324
|
// heartbeat, a REAL instant refreshed by the holder (a world's frozen clock cannot measure
|
|
293
325
|
// liveness; this is host physics, not world time). Nothing here reads a process table.
|
|
294
326
|
function sharingClaimFile(root, name) {
|
|
295
|
-
|
|
327
|
+
assertSafeWorldName(name);
|
|
328
|
+
return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.share.json`);
|
|
296
329
|
}
|
|
297
330
|
function readSharingClaim(path) {
|
|
298
331
|
if (!existsSync(path))
|
|
@@ -409,15 +442,15 @@ function prepareBootForDown(root, name) {
|
|
|
409
442
|
|| !Number.isFinite(Date.parse(claim.at)))
|
|
410
443
|
throw new Error('invalid boot claim');
|
|
411
444
|
}
|
|
412
|
-
catch {
|
|
413
|
-
throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained
|
|
445
|
+
catch (error) {
|
|
446
|
+
throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained`, { cause: error });
|
|
414
447
|
}
|
|
415
448
|
try {
|
|
416
449
|
process.kill(claim.pid, 0);
|
|
417
450
|
}
|
|
418
451
|
catch (error) {
|
|
419
452
|
if (error.code !== 'ESRCH') {
|
|
420
|
-
throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained
|
|
453
|
+
throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained`, { cause: error });
|
|
421
454
|
}
|
|
422
455
|
rmSync(path); // the recorded booter cannot publish anything after this point
|
|
423
456
|
return;
|
|
@@ -538,22 +571,163 @@ export function withoutVaultSignIn(env) {
|
|
|
538
571
|
* the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
|
|
539
572
|
* so a vendor the inner World does not twin reaches the parent's twin of it.
|
|
540
573
|
*/
|
|
541
|
-
function networkOf(name, own, parentEnv = process.env) {
|
|
574
|
+
function networkOf(name, own, recorded, parentEnv = process.env) {
|
|
542
575
|
const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
|
|
543
576
|
let ceiling;
|
|
544
577
|
if (serialized !== undefined) {
|
|
545
|
-
//
|
|
578
|
+
// An unreadable parent policy closes the child, including opaque transport permission.
|
|
546
579
|
try {
|
|
547
|
-
ceiling = JSON.parse(serialized)
|
|
580
|
+
ceiling = networkPermission(parseWorldNetworkPolicy(JSON.parse(serialized)));
|
|
548
581
|
}
|
|
549
582
|
catch {
|
|
550
|
-
ceiling = [];
|
|
583
|
+
ceiling = { egress: [] };
|
|
551
584
|
}
|
|
552
585
|
}
|
|
553
|
-
|
|
586
|
+
let network = own;
|
|
587
|
+
for (const limit of [recorded, ceiling])
|
|
588
|
+
if (limit !== undefined)
|
|
589
|
+
network = network === undefined ? limit : intersectWorldNetwork(network, limit);
|
|
590
|
+
if (network === undefined)
|
|
554
591
|
return undefined;
|
|
555
|
-
|
|
556
|
-
|
|
592
|
+
return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
|
|
593
|
+
}
|
|
594
|
+
/** Strip the publication envelope before combining permissions (the config shape admits only these keys). */
|
|
595
|
+
function networkPermission(policy) {
|
|
596
|
+
return { egress: policy.egress, phase: policy.phase, passthrough: policy.passthrough };
|
|
597
|
+
}
|
|
598
|
+
/** The boot permission, never a fallback to a mutable config. */
|
|
599
|
+
export function recordedWorldNetwork(instance) {
|
|
600
|
+
try {
|
|
601
|
+
if (!Object.hasOwn(instance, 'networkCeiling'))
|
|
602
|
+
throw new Error();
|
|
603
|
+
const published = instance.env?.[WORLD_NETWORK_POLICY_ENV];
|
|
604
|
+
const bound = published === undefined ? undefined : parseWorldNetworkPolicy(JSON.parse(published));
|
|
605
|
+
if (bound && bound.world !== instance.name)
|
|
606
|
+
throw new Error();
|
|
607
|
+
const saved = instance.networkCeiling === 'unrestricted' ? undefined : normalizeWorldNetwork(instance.networkCeiling);
|
|
608
|
+
const permission = bound === undefined ? undefined : networkPermission(bound);
|
|
609
|
+
return saved === undefined ? permission : permission === undefined ? saved : intersectWorldNetwork(saved, permission);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
throw new Error(`World boot record is incomplete or invalid: ${instanceFile(instance.root, instance.name)}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** An absent stored ceiling is derived from this World's own config and retained bound environment. */
|
|
616
|
+
function ceilingFromConfig(record, config) {
|
|
617
|
+
let ceiling = record.mode === 'sealed' ? { egress: [] } : config.network;
|
|
618
|
+
const published = record.env?.[WORLD_NETWORK_POLICY_ENV];
|
|
619
|
+
if (published !== undefined) {
|
|
620
|
+
const bound = parseWorldNetworkPolicy(JSON.parse(published));
|
|
621
|
+
if (bound.world !== record.name)
|
|
622
|
+
throw new Error();
|
|
623
|
+
const permission = networkPermission(bound);
|
|
624
|
+
ceiling = ceiling === undefined ? permission : intersectWorldNetwork(ceiling, permission);
|
|
625
|
+
}
|
|
626
|
+
return ceiling === undefined ? 'unrestricted' : normalizeWorldNetwork(ceiling);
|
|
627
|
+
}
|
|
628
|
+
/** A stopped receipt checks byte consistency before fields are read; it is not authentication. */
|
|
629
|
+
export function readWorldBootRecord(name, root) {
|
|
630
|
+
return withFileLock(instanceLockFile(root, name), () => readWorldBootRecordLocked(name, root));
|
|
631
|
+
}
|
|
632
|
+
/** Ordinary reading completes derivable metadata and its receipt together under the instance lock. */
|
|
633
|
+
function readWorldBootRecordLocked(name, root) {
|
|
634
|
+
const path = instanceFile(root, name);
|
|
635
|
+
const raw = getActiveWorldStore().read(path);
|
|
636
|
+
if (raw === null)
|
|
637
|
+
throw new Error(`World instance not found: ${name}`);
|
|
638
|
+
try {
|
|
639
|
+
const receiptPath = join(instanceDir(root, name), 'stopped.json');
|
|
640
|
+
const stopped = existsSync(receiptPath);
|
|
641
|
+
const receipt = stopped ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
642
|
+
if (stopped && (receipt.instanceHash !== createHash('sha256').update(raw).digest('hex')
|
|
643
|
+
|| typeof receipt.stoppedAt !== 'string' || !Number.isFinite(Date.parse(receipt.stoppedAt))))
|
|
644
|
+
throw new Error();
|
|
645
|
+
const record = JSON.parse(raw);
|
|
646
|
+
if (record.name !== name || typeof record.root !== 'string' || resolve(record.root) !== resolve(root)
|
|
647
|
+
|| typeof record.createdAt !== 'string' || !Number.isFinite(Date.parse(record.createdAt))
|
|
648
|
+
|| typeof record.config !== 'string' || !record.config || typeof record.configPath !== 'string' || !record.configPath
|
|
649
|
+
|| !record.env || record.env.VOLTER_WORLD !== name || record.env.VOLTER_WORLD_MODE !== record.mode
|
|
650
|
+
|| !record.dirs || record.dirs.instance !== instanceDir(root, name)
|
|
651
|
+
|| record.dirs.data !== join(instanceDir(root, name), 'data') || record.dirs.logs !== join(instanceDir(root, name), 'logs')
|
|
652
|
+
|| typeof record.envFile !== 'string' || !record.envFile || record.pidsFile !== join(instanceDir(root, name), 'pids')
|
|
653
|
+
|| !record.services || typeof record.services !== 'object' || Array.isArray(record.services))
|
|
654
|
+
throw new Error();
|
|
655
|
+
assertMode(record.mode);
|
|
656
|
+
if (!stopped && !isWorldLifecycleRunning(root, name, record.createdAt))
|
|
657
|
+
throw new Error();
|
|
658
|
+
let completed = false;
|
|
659
|
+
if (!Object.hasOwn(record, 'instance')) {
|
|
660
|
+
record.instance = randomUUID();
|
|
661
|
+
completed = true;
|
|
662
|
+
}
|
|
663
|
+
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))
|
|
664
|
+
throw new Error();
|
|
665
|
+
const needsConfig = !Object.hasOwn(record, 'networkCeiling')
|
|
666
|
+
|| Object.values(record.services).some(service => service && !Object.hasOwn(service, 'branchState'));
|
|
667
|
+
const config = needsConfig ? loadWorldConfig(record.configPath, root).config : undefined;
|
|
668
|
+
if (config && config.id !== record.config)
|
|
669
|
+
throw new Error();
|
|
670
|
+
if (!Object.hasOwn(record, 'networkCeiling')) {
|
|
671
|
+
record.networkCeiling = ceilingFromConfig(record, config);
|
|
672
|
+
completed = true;
|
|
673
|
+
}
|
|
674
|
+
if (!Object.hasOwn(record, 'bootOwner')) {
|
|
675
|
+
if (!record.lifecycle && !record.resources)
|
|
676
|
+
throw new Error();
|
|
677
|
+
record.bootOwner = record.lifecycle?.owner ?? record.resources?.owner ?? null;
|
|
678
|
+
completed = true;
|
|
679
|
+
}
|
|
680
|
+
if (!(record.bootOwner === null || typeof record.bootOwner === 'string'))
|
|
681
|
+
throw new Error();
|
|
682
|
+
checkedOwner(record.bootOwner ?? undefined);
|
|
683
|
+
if (!record.lifecycle) {
|
|
684
|
+
record.lifecycle = { log: lifecycleLogPath(root, name), ...(record.bootOwner === null ? {} : { owner: record.bootOwner }) };
|
|
685
|
+
completed = true;
|
|
686
|
+
}
|
|
687
|
+
if (!Object.hasOwn(record.lifecycle, 'log')) {
|
|
688
|
+
record.lifecycle.log = lifecycleLogPath(root, name);
|
|
689
|
+
completed = true;
|
|
690
|
+
}
|
|
691
|
+
if (!Object.hasOwn(record.lifecycle, 'owner') && record.bootOwner !== null) {
|
|
692
|
+
record.lifecycle.owner = record.bootOwner;
|
|
693
|
+
completed = true;
|
|
694
|
+
}
|
|
695
|
+
if (record.lifecycle.log !== lifecycleLogPath(root, name) || (record.lifecycle.owner ?? null) !== record.bootOwner)
|
|
696
|
+
throw new Error();
|
|
697
|
+
if (!Object.hasOwn(record, 'bootServices')) {
|
|
698
|
+
record.bootServices = Object.keys(record.services);
|
|
699
|
+
completed = true;
|
|
700
|
+
}
|
|
701
|
+
if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
|
|
702
|
+
|| new Set(record.bootServices).size !== record.bootServices.length
|
|
703
|
+
|| record.bootServices.length !== Object.keys(record.services).length
|
|
704
|
+
|| record.bootServices.some(id => !Object.hasOwn(record.services, id)))
|
|
705
|
+
throw new Error();
|
|
706
|
+
for (const [id, service] of Object.entries(record.services)) {
|
|
707
|
+
if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string')
|
|
708
|
+
throw new Error();
|
|
709
|
+
if (!Object.hasOwn(service, 'branchState')) {
|
|
710
|
+
const declared = config.services.find(item => item.id === id);
|
|
711
|
+
if (!declared)
|
|
712
|
+
throw new Error();
|
|
713
|
+
service.branchState = declared.branchState ?? 'supported';
|
|
714
|
+
completed = true;
|
|
715
|
+
}
|
|
716
|
+
if (!['supported', 'unsupported'].includes(service.branchState ?? ''))
|
|
717
|
+
throw new Error();
|
|
718
|
+
}
|
|
719
|
+
recordedWorldNetwork(record);
|
|
720
|
+
if (completed) {
|
|
721
|
+
saveWorldInstance(record);
|
|
722
|
+
if (stopped)
|
|
723
|
+
writeTextAtomic(receiptPath, JSON.stringify({ ...receipt,
|
|
724
|
+
instanceHash: createHash('sha256').update(readFileSync(path, 'utf8')).digest('hex') }) + '\n');
|
|
725
|
+
}
|
|
726
|
+
return record;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
throw new Error(`World boot record is incomplete or invalid: ${path}`);
|
|
730
|
+
}
|
|
557
731
|
}
|
|
558
732
|
export function admittedEnv(strip, env = process.env) {
|
|
559
733
|
const out = {};
|
|
@@ -665,23 +839,15 @@ async function waitForTcp(port, timeoutMs = 15_000, abort) {
|
|
|
665
839
|
}
|
|
666
840
|
function readPids(path) {
|
|
667
841
|
const held = getActiveWorldStore().read(path);
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
842
|
+
const guests = readGuestProcesses(dirname(path)).map(receipt => receipt.record.pid);
|
|
843
|
+
return [...new Set([...(held ?? '')
|
|
844
|
+
.split(/\r?\n/)
|
|
845
|
+
.map((line) => Number(line.trim()))
|
|
846
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0), ...guests])];
|
|
674
847
|
}
|
|
675
848
|
function livePids(pids) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
process.kill(pid, 0);
|
|
679
|
-
return true;
|
|
680
|
-
}
|
|
681
|
-
catch (error) {
|
|
682
|
-
return error.code !== 'ESRCH';
|
|
683
|
-
}
|
|
684
|
-
});
|
|
849
|
+
// A recorded leader may have exited while its detached descendants still run.
|
|
850
|
+
return survivingOwnedGroups(pids);
|
|
685
851
|
}
|
|
686
852
|
function tunnelPids(instance) {
|
|
687
853
|
return Object.values(instance.services)
|
|
@@ -702,13 +868,13 @@ function openPrivateLog(path, flags) {
|
|
|
702
868
|
/** Record `pids` as the World's. `fresh` are the processes this very call has spawned: only their start times are
|
|
703
869
|
* measured anew; every other pid keeps the start time recorded when it was spawned (re-measuring it would bless
|
|
704
870
|
* whatever process holds that number now). */
|
|
705
|
-
function writeOwnedPids(root, name, pids, fresh = []) {
|
|
871
|
+
function writeOwnedPids(root, name, pids, fresh = [], births = heldStarts()) {
|
|
706
872
|
const path = join(instanceDir(root, name), 'pids');
|
|
707
873
|
withFileLock(`${instanceLockFile(root, name)}.pids`, () => {
|
|
708
874
|
const retained = undeadPids(readPids(path));
|
|
709
875
|
const merged = [...new Set([...retained, ...pids].filter(pid => Number.isInteger(pid) && pid > 0))];
|
|
710
876
|
writeTextAtomic(path, `${merged.join('\n')}\n`);
|
|
711
|
-
recordIdentities(root, name, merged, fresh);
|
|
877
|
+
recordIdentities(root, name, merged, fresh, births);
|
|
712
878
|
});
|
|
713
879
|
}
|
|
714
880
|
// ---- which process a recorded pid names -------------------------------------------------------------------------
|
|
@@ -718,54 +884,42 @@ function writeOwnedPids(root, name, pids, fresh = []) {
|
|
|
718
884
|
function identityPath(root, name) {
|
|
719
885
|
return join(instanceDir(root, name), 'pids.identity');
|
|
720
886
|
}
|
|
721
|
-
/** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
|
|
722
|
-
* process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
|
|
723
|
-
* given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
|
|
724
|
-
* processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
|
|
725
|
-
* paid. */
|
|
726
|
-
function processStarts(pids) {
|
|
727
|
-
const starts = new Map();
|
|
728
|
-
if (!pids.length || process.platform === 'win32')
|
|
729
|
-
return starts;
|
|
730
|
-
if (process.platform === 'linux' && existsSync('/proc/self/stat')) {
|
|
731
|
-
// Linux: the start tick since boot (field 22), which a wall-clock step does not move (lstart would).
|
|
732
|
-
for (const pid of pids) {
|
|
733
|
-
try {
|
|
734
|
-
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
735
|
-
starts.set(pid, `tick ${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`);
|
|
736
|
-
}
|
|
737
|
-
catch { /* no process */ }
|
|
738
|
-
}
|
|
739
|
-
return starts;
|
|
740
|
-
}
|
|
741
|
-
const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
|
|
742
|
-
for (const pid of pids) {
|
|
743
|
-
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
|
|
744
|
-
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
|
|
745
|
-
if (match && Number(match[1]) === pid)
|
|
746
|
-
starts.set(pid, match[2]);
|
|
747
|
-
}
|
|
748
|
-
return starts;
|
|
749
|
-
}
|
|
750
887
|
function readIdentities(root, name) {
|
|
888
|
+
let known = {};
|
|
751
889
|
try {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
890
|
+
const value = JSON.parse(getActiveWorldStore().read(identityPath(root, name)) ?? '{}');
|
|
891
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
892
|
+
known = Object.fromEntries(Object.entries(value).filter(([pid, start]) => Number.isInteger(Number(pid)) && Number(pid) > 0 && typeof start === 'string' && start.length > 0));
|
|
893
|
+
}
|
|
894
|
+
catch { /* Guest receipts below remain independently authoritative. */ }
|
|
895
|
+
const births = new Map();
|
|
896
|
+
for (const { record } of readGuestProcesses(instanceDir(root, name))) {
|
|
897
|
+
if (births.has(record.pid) && births.get(record.pid) !== record.start)
|
|
898
|
+
throw new Error('Conflicting guest spawn identities; ownership retained');
|
|
899
|
+
births.set(record.pid, record.start);
|
|
900
|
+
}
|
|
901
|
+
for (const [pid, start] of births) {
|
|
902
|
+
if (start)
|
|
903
|
+
known[pid] = start;
|
|
904
|
+
else
|
|
905
|
+
delete known[pid];
|
|
756
906
|
}
|
|
907
|
+
return known;
|
|
757
908
|
}
|
|
758
|
-
function recordIdentities(root, name, pids, fresh = []) {
|
|
909
|
+
function recordIdentities(root, name, pids, fresh = [], births = heldStarts()) {
|
|
759
910
|
try {
|
|
760
911
|
// A pid the World has just spawned is measured anew, whatever an older record says of that number.
|
|
761
|
-
const known = readIdentities(root, name)
|
|
762
|
-
for (const pid of fresh)
|
|
763
|
-
if (
|
|
912
|
+
const known = readIdentities(root, name);
|
|
913
|
+
for (const pid of fresh) {
|
|
914
|
+
if (births[pid] !== undefined)
|
|
915
|
+
known[pid] = births[pid];
|
|
916
|
+
else
|
|
764
917
|
delete known[pid];
|
|
765
|
-
|
|
918
|
+
}
|
|
919
|
+
const kept = Object.fromEntries(pids.flatMap(pid => known[pid] === undefined ? [] : [[String(pid), known[pid]]]));
|
|
766
920
|
writeTextAtomic(identityPath(root, name), JSON.stringify(kept));
|
|
767
921
|
}
|
|
768
|
-
catch { /* without a record
|
|
922
|
+
catch { /* without a record signalling refuses and lifecycle evidence is retained */ }
|
|
769
923
|
}
|
|
770
924
|
/** Recorded pids whose running process started at another time than recorded: the number now names another
|
|
771
925
|
* program. An unrecorded pid, and a pid with no process, are never in this list. */
|
|
@@ -902,12 +1056,14 @@ function getByJsonPath(value, jsonPath) {
|
|
|
902
1056
|
}
|
|
903
1057
|
return current;
|
|
904
1058
|
}
|
|
1059
|
+
class ExternalCommandUnavailableError extends Error {
|
|
1060
|
+
}
|
|
905
1061
|
async function runExternalCommand(command, cwd, serviceId, phase, env = process.env, abort, timeoutMs = 60_000) {
|
|
906
1062
|
const [bin, ...args] = ownCommand(command);
|
|
907
1063
|
if (!bin)
|
|
908
1064
|
throw new Error(`External service "${serviceId}": ${phase} command is empty`);
|
|
909
1065
|
if (!commandExists(bin))
|
|
910
|
-
throw new
|
|
1066
|
+
throw new ExternalCommandUnavailableError(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
|
|
911
1067
|
abort?.throwIfAborted();
|
|
912
1068
|
const child = spawn(bin, args, { windowsHide: true, cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
913
1069
|
const lifetime = commandLifetime(child, abort, true);
|
|
@@ -930,10 +1086,15 @@ async function runExternalCommand(command, cwd, serviceId, phase, env = process.
|
|
|
930
1086
|
const timer = setTimeout(() => { failure = new Error(`command timed out after ${timeoutMs}ms`); lifetime.cancel('SIGTERM'); }, timeoutMs);
|
|
931
1087
|
try {
|
|
932
1088
|
const result = await lifetime.finish();
|
|
933
|
-
if (failure)
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1089
|
+
if (failure) {
|
|
1090
|
+
const ErrorKind = ['ENOENT', 'ENOEXEC', 'EACCES'].includes(failure.code ?? '') ? ExternalCommandUnavailableError : Error;
|
|
1091
|
+
throw new ErrorKind(`External service "${serviceId}": ${phase} command failed to run (${errorMessage(failure)})`);
|
|
1092
|
+
}
|
|
1093
|
+
if (result.code !== 0 || lifetime.forwarded) {
|
|
1094
|
+
const parserRefusal = /unknown (?:shorthand flag|flag|option|command)|unrecognized (?:option|argument)|command not found|no such file or directory/iu.test(`${stdout}${stderr}`);
|
|
1095
|
+
const ErrorKind = !lifetime.forwarded && ([126, 127].includes(result.code ?? -1) || (result.code === 125 && parserRefusal)) ? ExternalCommandUnavailableError : Error;
|
|
1096
|
+
throw new ErrorKind(`External service "${serviceId}": ${phase} command exited ${result.code ?? 'null'}\n${stdout}${stderr}`);
|
|
1097
|
+
}
|
|
937
1098
|
return { stdout, stderr };
|
|
938
1099
|
}
|
|
939
1100
|
finally {
|
|
@@ -990,8 +1151,8 @@ async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
|
|
|
990
1151
|
return chunks.join('');
|
|
991
1152
|
}
|
|
992
1153
|
/** Whether an external `up` is this runtime's own managed-infrastructure tool (`volter-world-infra up`, which `init`
|
|
993
|
-
* names)
|
|
994
|
-
* child. The services it starts (the PGlite host, the redis and
|
|
1154
|
+
* names): the phase runs in this process (infra.ts) instead of in a
|
|
1155
|
+
* child. The services it starts (the PGlite host, the redis twin and mongod) are then children of the process that
|
|
995
1156
|
* owns the World's lifetime, as every service recorder is, not of a tool that exits once they serve. Where a child
|
|
996
1157
|
* does not outlive its parent (a browser tab's process layer, measured 2026-09-28: a detached, unref'd listener
|
|
997
1158
|
* ended with the process that spawned it), the PGlite host ended the moment `volter-world-infra up` returned and
|
|
@@ -1055,7 +1216,7 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
|
|
|
1055
1216
|
catch (error) {
|
|
1056
1217
|
if (error instanceof CommandRetirementError)
|
|
1057
1218
|
throw error;
|
|
1058
|
-
lastError =
|
|
1219
|
+
lastError = errorMessage(error);
|
|
1059
1220
|
}
|
|
1060
1221
|
}
|
|
1061
1222
|
else if (ready.httpUrl !== undefined) {
|
|
@@ -1066,7 +1227,7 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
|
|
|
1066
1227
|
lastError = `HTTP ${response.status}`;
|
|
1067
1228
|
}
|
|
1068
1229
|
catch (error) {
|
|
1069
|
-
lastError =
|
|
1230
|
+
lastError = errorMessage(error);
|
|
1070
1231
|
}
|
|
1071
1232
|
}
|
|
1072
1233
|
else if (ready.stdoutMatch !== undefined) {
|
|
@@ -1079,12 +1240,9 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
|
|
|
1079
1240
|
}
|
|
1080
1241
|
throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
|
|
1081
1242
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
throw new Error(`External service "${service.id}" is missing its external config`);
|
|
1086
|
-
const log = join(paths.logs, `${service.id}.log`);
|
|
1087
|
-
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1243
|
+
/** The environment an external service and its replacement infrastructure hosts run in. Shared with branching,
|
|
1244
|
+
* so both use the boot's admission, service overrides, lifecycle context and control-plane egress rules. */
|
|
1245
|
+
export function externalServiceEnv(service, paths, worldEnv, stripEnv) {
|
|
1088
1246
|
// External commands see the accumulated world env (process.env + earlier services' discovered/
|
|
1089
1247
|
// injected vars), so a later external can consume an earlier service's connection info.
|
|
1090
1248
|
const env = {
|
|
@@ -1092,11 +1250,20 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
|
1092
1250
|
...worldEnv,
|
|
1093
1251
|
...(service.env ?? {}),
|
|
1094
1252
|
VOLTER_WORLD_SERVICE_ID: service.id,
|
|
1095
|
-
VOLTER_WORLD_SERVICE_LOG: log,
|
|
1253
|
+
VOLTER_WORLD_SERVICE_LOG: ownedPath(paths.logs, `${service.id}.log`),
|
|
1096
1254
|
};
|
|
1097
1255
|
if (service.controlPlane)
|
|
1098
1256
|
for (const key of CONTROL_PLANE_EGRESS_ENV)
|
|
1099
1257
|
delete env[key];
|
|
1258
|
+
return env;
|
|
1259
|
+
}
|
|
1260
|
+
async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
1261
|
+
const external = service.external;
|
|
1262
|
+
if (!external)
|
|
1263
|
+
throw new Error(`External service "${service.id}" is missing its external config`);
|
|
1264
|
+
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1265
|
+
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1266
|
+
const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
|
|
1100
1267
|
// 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
|
|
1101
1268
|
if (!external.up[0] || !commandExists(external.up[0])) {
|
|
1102
1269
|
throw new Error(`External service "${service.id}": \`${external.up[0] ?? ''}\` not found on PATH (the up command's tool is not installed)`);
|
|
@@ -1131,7 +1298,7 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
|
1131
1298
|
jsonCache[source] = JSON.parse(raw);
|
|
1132
1299
|
}
|
|
1133
1300
|
catch (error) {
|
|
1134
|
-
throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${
|
|
1301
|
+
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)}`);
|
|
1135
1302
|
}
|
|
1136
1303
|
}
|
|
1137
1304
|
const found = getByJsonPath(jsonCache[source], mapping.jsonPath);
|
|
@@ -1157,10 +1324,16 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
|
1157
1324
|
external: { down: [...external.down], cwd, discoveredEnv: injected },
|
|
1158
1325
|
};
|
|
1159
1326
|
}
|
|
1160
|
-
function trackBootingChild(child, boot) {
|
|
1327
|
+
async function trackBootingChild(child, boot, birth) {
|
|
1161
1328
|
if (!child.pid)
|
|
1162
1329
|
return;
|
|
1163
1330
|
boot.pids.add(child.pid);
|
|
1331
|
+
boot.handles.set(child.pid, child);
|
|
1332
|
+
boot.recordPids(); // durable numeric uncertainty precedes the async birth capture
|
|
1333
|
+
const identity = await birth;
|
|
1334
|
+
if (!identity?.start)
|
|
1335
|
+
throw new Error('Service birth capture failed; retiring held child');
|
|
1336
|
+
boot.identities[child.pid] = identity.start;
|
|
1164
1337
|
boot.recordPids();
|
|
1165
1338
|
}
|
|
1166
1339
|
async function startService(config, service, worldEnv, paths, allocatedPorts, boot) {
|
|
@@ -1169,7 +1342,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1169
1342
|
if (!service.command)
|
|
1170
1343
|
throw new Error(`Service "${service.id}" must define command`);
|
|
1171
1344
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1172
|
-
const serviceDataDir =
|
|
1345
|
+
const serviceDataDir = ownedPath(paths.data, service.id);
|
|
1173
1346
|
mkdirSync(serviceDataDir, { recursive: true });
|
|
1174
1347
|
// R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
|
|
1175
1348
|
// is checked against its pin and its protocol standing before it is spawned.
|
|
@@ -1189,7 +1362,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1189
1362
|
args.push(service.portArg ?? '--port', String(port));
|
|
1190
1363
|
if (service.rootArg !== false)
|
|
1191
1364
|
args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
1192
|
-
const log =
|
|
1365
|
+
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1193
1366
|
const env = serviceProcessEnv(config, service, worldEnv, port);
|
|
1194
1367
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1195
1368
|
// Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
|
|
@@ -1203,10 +1376,10 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1203
1376
|
// The service runs under the service recorder, which outlives it to write its end into its own log: `up`
|
|
1204
1377
|
// returns once the World is ready, so nothing else is left to see a service stop.
|
|
1205
1378
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1206
|
-
const { child, ended } = spawnRecorded(service.id, service.command, args, cwd, env, log);
|
|
1379
|
+
const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot);
|
|
1207
1380
|
child.once('error', error => boot.fail(error));
|
|
1208
1381
|
child.unref();
|
|
1209
|
-
trackBootingChild(child, boot);
|
|
1382
|
+
await trackBootingChild(child, boot, identity);
|
|
1210
1383
|
// Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
|
|
1211
1384
|
// child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
|
|
1212
1385
|
// and dies — but waitForTcp would then see the OTHER process answering and the world would
|
|
@@ -1221,22 +1394,22 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1221
1394
|
// swapping, a docker build beside the boot) a heavy service takes longer than the 15s default
|
|
1222
1395
|
// to answer on its port, and failing it there wastes the whole boot.
|
|
1223
1396
|
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1224
|
-
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch((error) => {
|
|
1397
|
+
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1225
1398
|
if (undeadPids([child.pid]).length)
|
|
1226
1399
|
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1227
1400
|
try {
|
|
1228
|
-
|
|
1401
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1229
1402
|
}
|
|
1230
1403
|
catch {
|
|
1231
1404
|
// best effort cleanup
|
|
1232
1405
|
}
|
|
1233
|
-
throw new Error(`Service "${service.id}" failed to start: ${
|
|
1406
|
+
throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
|
|
1234
1407
|
});
|
|
1235
|
-
await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch((error) => {
|
|
1408
|
+
await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
|
|
1236
1409
|
if (undeadPids([child.pid]).length)
|
|
1237
1410
|
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1238
1411
|
try {
|
|
1239
|
-
|
|
1412
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1240
1413
|
}
|
|
1241
1414
|
catch { /* best effort cleanup */ }
|
|
1242
1415
|
throw error;
|
|
@@ -1250,14 +1423,14 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1250
1423
|
// Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
|
|
1251
1424
|
// before it is actually serving, so this makes `up` wait until the service truly responds.
|
|
1252
1425
|
if (service.ready && service.ready !== 'tcp') {
|
|
1253
|
-
await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch((error) => {
|
|
1426
|
+
await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1254
1427
|
if (undeadPids([child.pid]).length)
|
|
1255
1428
|
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1256
1429
|
try {
|
|
1257
|
-
|
|
1430
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1258
1431
|
}
|
|
1259
1432
|
catch { /* best effort cleanup */ }
|
|
1260
|
-
throw new Error(`Service "${service.id}" failed its readiness probe: ${
|
|
1433
|
+
throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
|
|
1261
1434
|
});
|
|
1262
1435
|
}
|
|
1263
1436
|
return {
|
|
@@ -1422,7 +1595,8 @@ const ISSUE_TIMEOUT_MS = 15_000;
|
|
|
1422
1595
|
* asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
|
|
1423
1596
|
* door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
|
|
1424
1597
|
async function issueCredentials(service, started, env, pending, unissued) {
|
|
1425
|
-
const
|
|
1598
|
+
const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
|
|
1599
|
+
const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
|
|
1426
1600
|
if (wanted.length === 0)
|
|
1427
1601
|
return;
|
|
1428
1602
|
for (const name of wanted)
|
|
@@ -1444,30 +1618,114 @@ async function issueCredentials(service, started, env, pending, unissued) {
|
|
|
1444
1618
|
const answer = (await res.json());
|
|
1445
1619
|
for (const name of wanted) {
|
|
1446
1620
|
const spec = door.fill[name] ?? '';
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1621
|
+
if (spec.startsWith('directory:') || spec.startsWith('file:')) {
|
|
1622
|
+
if (direct.has(name))
|
|
1623
|
+
throw new Error(`cannot give ${name}'s filesystem credential a direct URL`);
|
|
1624
|
+
if (!isEnvironmentName(name) || !/^[a-z0-9][a-z0-9._-]*$/.test(service.id))
|
|
1625
|
+
throw new Error('Invalid credential destination name');
|
|
1626
|
+
if (!env.VOLTER_WORLD_INSTANCE)
|
|
1627
|
+
throw new Error('World credential custody is unknown');
|
|
1628
|
+
const instance = realpathSync(dirname(env.VOLTER_WORLD_INSTANCE));
|
|
1629
|
+
const custody = ownedPath(instance, 'credentials');
|
|
1630
|
+
const artifact = `${service.id}-${name.toLowerCase()}`;
|
|
1631
|
+
const isDirectory = spec.startsWith('directory:');
|
|
1632
|
+
const field = spec.slice(spec.indexOf(':') + 1), value = answer[field];
|
|
1452
1633
|
if (value === undefined || value === null)
|
|
1453
1634
|
throw new Error(`answered no ${field}`);
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1635
|
+
if (isDirectory && (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0))
|
|
1636
|
+
throw new Error('Invalid credential directory');
|
|
1637
|
+
const entries = isDirectory ? Object.entries(value) : [[`${artifact}.json`, value]];
|
|
1638
|
+
const files = entries.map(([file, contents]) => {
|
|
1639
|
+
if (isDirectory && (!file || file.includes('\\') || file.split('/').some(part => !/^[A-Za-z0-9._-]+$/.test(part) || part === '.' || part === '..')))
|
|
1640
|
+
throw new Error('Invalid World-owned path');
|
|
1641
|
+
return { path: ownedPath(custody, isDirectory ? `${artifact}/${file}` : file), contents };
|
|
1642
|
+
});
|
|
1643
|
+
const directories = new Set([custody]);
|
|
1644
|
+
for (const { path } of files) {
|
|
1645
|
+
let parent = dirname(path);
|
|
1646
|
+
while (parent !== custody) {
|
|
1647
|
+
directories.add(parent);
|
|
1648
|
+
parent = dirname(parent);
|
|
1649
|
+
}
|
|
1650
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
1651
|
+
if (stat && (!stat.isFile() || stat.nlink !== 1))
|
|
1652
|
+
throw new Error('Invalid World-owned path');
|
|
1653
|
+
}
|
|
1654
|
+
// Validate every destination and parent before creating or changing any custody artifact.
|
|
1655
|
+
for (const dir of directories) {
|
|
1656
|
+
ownedPath(instance, relative(instance, dir));
|
|
1657
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
1658
|
+
if (stat && !stat.isDirectory())
|
|
1659
|
+
throw new Error('Invalid World-owned path');
|
|
1660
|
+
}
|
|
1661
|
+
for (const dir of [...directories].sort((a, b) => a.length - b.length)) {
|
|
1662
|
+
if (!existsSync(dir))
|
|
1663
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
1664
|
+
assertOwnedRealPath(custody, dir);
|
|
1665
|
+
chmodSync(dir, 0o700);
|
|
1666
|
+
}
|
|
1667
|
+
for (const { path, contents } of files) {
|
|
1668
|
+
ownedPath(custody, relative(custody, path));
|
|
1669
|
+
assertOwnedRealPath(custody, dirname(path));
|
|
1670
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW, 0o600);
|
|
1671
|
+
let opened;
|
|
1672
|
+
try {
|
|
1673
|
+
const stat = fstatSync(fd);
|
|
1674
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
1675
|
+
throw new Error('Invalid World-owned path');
|
|
1676
|
+
opened = stat;
|
|
1677
|
+
fchmodSync(fd, 0o600);
|
|
1678
|
+
assertOwnedRealPath(custody, dirname(path));
|
|
1679
|
+
ftruncateSync(fd, 0);
|
|
1680
|
+
writeFileSync(fd, typeof contents === 'string' ? contents : `${JSON.stringify(contents, null, 2)}\n`);
|
|
1681
|
+
}
|
|
1682
|
+
finally {
|
|
1683
|
+
closeSync(fd);
|
|
1684
|
+
let realFile;
|
|
1685
|
+
try {
|
|
1686
|
+
realFile = realpathSync(path);
|
|
1687
|
+
const current = statSync(realFile);
|
|
1688
|
+
if (!opened || current.dev !== opened.dev || current.ino !== opened.ino)
|
|
1689
|
+
throw new Error('Invalid World-owned path');
|
|
1690
|
+
assertOwnedRealPath(custody, realFile);
|
|
1691
|
+
}
|
|
1692
|
+
catch (error) {
|
|
1693
|
+
// Remove only the file whose descriptor we opened; a raced replacement is not ours to delete.
|
|
1694
|
+
const current = realFile ? lstatSync(realFile, { throwIfNoEntry: false }) : undefined;
|
|
1695
|
+
if (opened && current?.dev === opened.dev && current.ino === opened.ino)
|
|
1696
|
+
rmSync(realFile, { force: true });
|
|
1697
|
+
throw error;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
env[name] = isDirectory ? ownedPath(custody, artifact) : files[0].path;
|
|
1462
1702
|
continue;
|
|
1463
1703
|
}
|
|
1704
|
+
const field = spec;
|
|
1705
|
+
const value = answer[field];
|
|
1464
1706
|
if (typeof value !== 'string')
|
|
1465
1707
|
throw new Error(`answered no ${field}`);
|
|
1466
|
-
|
|
1708
|
+
if (direct.has(name)) {
|
|
1709
|
+
const issued = new URL(value);
|
|
1710
|
+
if (!['http:', 'https:'].includes(issued.protocol))
|
|
1711
|
+
throw new Error(`cannot route ${name}'s non-HTTP URL directly`);
|
|
1712
|
+
const endpoint = new URL(started.url);
|
|
1713
|
+
issued.protocol = endpoint.protocol;
|
|
1714
|
+
issued.host = endpoint.host;
|
|
1715
|
+
env[name] = issued.toString();
|
|
1716
|
+
}
|
|
1717
|
+
else
|
|
1718
|
+
env[name] = value;
|
|
1719
|
+
}
|
|
1720
|
+
if (started.url) {
|
|
1721
|
+
const endpoint = new URL(started.url);
|
|
1722
|
+
Object.assign(env, resolveServiceEnvTemplates(service.injectEnvTemplates, {
|
|
1723
|
+
host: endpoint.hostname, port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)), url: started.url,
|
|
1724
|
+
}));
|
|
1467
1725
|
}
|
|
1468
1726
|
}
|
|
1469
1727
|
catch (error) {
|
|
1470
|
-
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${
|
|
1728
|
+
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
|
|
1471
1729
|
for (const name of wanted) {
|
|
1472
1730
|
delete env[name];
|
|
1473
1731
|
unissued.add(name);
|
|
@@ -1528,7 +1786,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1528
1786
|
const allocated = [];
|
|
1529
1787
|
for (const service of colocated) {
|
|
1530
1788
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1531
|
-
const serviceDataDir =
|
|
1789
|
+
const serviceDataDir = ownedPath(paths.data, service.id);
|
|
1532
1790
|
mkdirSync(serviceDataDir, { recursive: true });
|
|
1533
1791
|
// Relative module paths resolve from the world root (import() inside the host would
|
|
1534
1792
|
// otherwise resolve them against the runtime's own source directory).
|
|
@@ -1557,10 +1815,10 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1557
1815
|
dropUnissued(env);
|
|
1558
1816
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1559
1817
|
// Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
|
|
1560
|
-
const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
|
|
1818
|
+
const { child, ended, identity } = spawnRecorded('host', process.execPath, args, paths.root, env, log, boot);
|
|
1561
1819
|
child.once('error', error => boot.fail(error));
|
|
1562
1820
|
child.unref();
|
|
1563
|
-
trackBootingChild(child, boot);
|
|
1821
|
+
await trackBootingChild(child, boot, identity);
|
|
1564
1822
|
const exitState = { value: null };
|
|
1565
1823
|
child.on('exit', (code, signal) => {
|
|
1566
1824
|
exitState.value = { code, signal };
|
|
@@ -1596,25 +1854,25 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1596
1854
|
}
|
|
1597
1855
|
catch {
|
|
1598
1856
|
return 0;
|
|
1599
|
-
} }), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1857
|
+
} }), ended, boot, 'the co-located host', log).catch(async (error) => {
|
|
1600
1858
|
if (undeadPids([child.pid]).length)
|
|
1601
1859
|
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1602
1860
|
try {
|
|
1603
|
-
|
|
1861
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1604
1862
|
}
|
|
1605
1863
|
catch { /* best effort cleanup */ }
|
|
1606
|
-
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${
|
|
1864
|
+
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
|
|
1607
1865
|
});
|
|
1608
1866
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1609
1867
|
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1610
|
-
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1868
|
+
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch(async (error) => {
|
|
1611
1869
|
if (undeadPids([child.pid]).length)
|
|
1612
1870
|
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1613
1871
|
try {
|
|
1614
|
-
|
|
1872
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1615
1873
|
}
|
|
1616
1874
|
catch { /* best effort cleanup */ }
|
|
1617
|
-
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${
|
|
1875
|
+
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
|
|
1618
1876
|
});
|
|
1619
1877
|
});
|
|
1620
1878
|
// The host's receipt (this boot, every requested port) is published only
|
|
@@ -1624,11 +1882,11 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1624
1882
|
// answers with another boot's identity is still refused.
|
|
1625
1883
|
const hostSettle = Promise.resolve('alive');
|
|
1626
1884
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1627
|
-
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
|
|
1885
|
+
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch(async (error) => {
|
|
1628
1886
|
if (undeadPids([child.pid]).length)
|
|
1629
1887
|
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1630
1888
|
try {
|
|
1631
|
-
|
|
1889
|
+
await signalHeldChild(child, 'SIGTERM', true, identity);
|
|
1632
1890
|
}
|
|
1633
1891
|
catch { /* best effort cleanup */ }
|
|
1634
1892
|
throw error;
|
|
@@ -1701,6 +1959,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1701
1959
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
1702
1960
|
const instanceFile = join(instance, 'instance.json');
|
|
1703
1961
|
let carriedOrigin;
|
|
1962
|
+
let carriedNetwork = options.networkCeiling;
|
|
1704
1963
|
// the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
|
|
1705
1964
|
// writes it), so a file any of them last wrote is the World's own, never a custody file
|
|
1706
1965
|
const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
@@ -1723,6 +1982,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1723
1982
|
let resourcesClaimed = false;
|
|
1724
1983
|
let cleanupUncertain = false;
|
|
1725
1984
|
const createdAt = new Date().toISOString();
|
|
1985
|
+
const instanceToken = randomUUID();
|
|
1726
1986
|
// Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
|
|
1727
1987
|
// two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
|
|
1728
1988
|
// dir, and one clobbers the other's freshly-spawned services (orphaned processes + a corrupt
|
|
@@ -1754,23 +2014,50 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1754
2014
|
}
|
|
1755
2015
|
if (!options.keepState)
|
|
1756
2016
|
assertWorldStateRemovable(root, instance);
|
|
1757
|
-
//
|
|
1758
|
-
// remain. Require a receipt for the exact prior instance, not merely absent claims/PIDs.
|
|
2017
|
+
// Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
|
|
1759
2018
|
if (existsSync(instanceFile)) {
|
|
2019
|
+
let prior;
|
|
2020
|
+
if (options.keepState)
|
|
2021
|
+
prior = readWorldBootRecordLocked(name, root);
|
|
1760
2022
|
const priorRaw = readFileSync(instanceFile, 'utf8');
|
|
1761
|
-
const prior = JSON.parse(priorRaw);
|
|
1762
2023
|
const receiptPath = join(instance, 'stopped.json');
|
|
1763
2024
|
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
1764
|
-
if (
|
|
1765
|
-
|| receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
|
|
2025
|
+
if (!receipt || receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
|
|
1766
2026
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
1767
2027
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
1768
2028
|
}
|
|
2029
|
+
if (!prior) {
|
|
2030
|
+
try {
|
|
2031
|
+
prior = JSON.parse(priorRaw);
|
|
2032
|
+
}
|
|
2033
|
+
catch { /* fresh boot needs no retained shape */ }
|
|
2034
|
+
}
|
|
2035
|
+
if (prior) {
|
|
2036
|
+
// A readable ceiling remains useful even when unrelated metadata is incomplete.
|
|
2037
|
+
try {
|
|
2038
|
+
const permissionRecord = { ...prior, name, root };
|
|
2039
|
+
if (!Object.hasOwn(permissionRecord, 'networkCeiling')) {
|
|
2040
|
+
let storedConfig = loaded.config;
|
|
2041
|
+
try {
|
|
2042
|
+
storedConfig = loadWorldConfig(prior.configPath, root).config;
|
|
2043
|
+
}
|
|
2044
|
+
catch { /* fresh boot can discard missing config metadata */ }
|
|
2045
|
+
permissionRecord.networkCeiling = ceilingFromConfig(permissionRecord, storedConfig);
|
|
2046
|
+
}
|
|
2047
|
+
const ceiling = recordedWorldNetwork(permissionRecord);
|
|
2048
|
+
if (ceiling !== undefined)
|
|
2049
|
+
carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
if (options.keepState)
|
|
2053
|
+
throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
1769
2056
|
// the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
|
|
1770
2057
|
// same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
|
|
1771
2058
|
// ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
|
|
1772
2059
|
// only when its state (the mirror they count) is kept, as reset drops them
|
|
1773
|
-
if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
|
|
2060
|
+
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))) {
|
|
1774
2061
|
carriedOrigin = options.keepState ? prior.origin
|
|
1775
2062
|
: { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
|
|
1776
2063
|
}
|
|
@@ -1781,7 +2068,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1781
2068
|
retainWorldLifecycleForCleanup(root, name);
|
|
1782
2069
|
// The last run's logs survive this boot as logs/previous (one generation): the record of a crash must outlive the
|
|
1783
2070
|
// restart that usually follows it. Moved aside before the wipe and back in after it.
|
|
1784
|
-
const previousLogs =
|
|
2071
|
+
const previousLogs = ownedPath(dirname(instance), `.${name}.previous-logs`); // dot-prefixed: bookkeeping, never listed as a World
|
|
1785
2072
|
if (existsSync(previousLogs) && !existsSync(logs)) {
|
|
1786
2073
|
mkdirSync(instance, { recursive: true });
|
|
1787
2074
|
renameSync(previousLogs, logs);
|
|
@@ -1829,8 +2116,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1829
2116
|
releaseWorldLifecycle(root, name);
|
|
1830
2117
|
}
|
|
1831
2118
|
catch (release) {
|
|
1832
|
-
|
|
1833
|
-
error.message += `; releasing the World's claim failed too: ${release instanceof Error ? release.message : String(release)}`;
|
|
2119
|
+
throw new AggregateError([error, release], 'World claim and release failed');
|
|
1834
2120
|
}
|
|
1835
2121
|
}
|
|
1836
2122
|
throw error;
|
|
@@ -1840,8 +2126,14 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1840
2126
|
// "already running" guard; on failure the world never came up, so the next upWorld must be
|
|
1841
2127
|
// allowed to claim. Removing the marker is safe — only THIS call's marker can exist at this
|
|
1842
2128
|
// point (a foreign live claim made the claim section above throw before this try).
|
|
2129
|
+
let bootFailed = false;
|
|
2130
|
+
let bootFailure;
|
|
1843
2131
|
try {
|
|
1844
2132
|
const services = {};
|
|
2133
|
+
const recordService = (started) => {
|
|
2134
|
+
const branchState = loaded.config.services.find(service => service.id === started.id)?.branchState;
|
|
2135
|
+
services[started.id] = { ...started, branchState: branchState ?? 'supported' };
|
|
2136
|
+
};
|
|
1845
2137
|
const inheritedEnv = admittedEnv(loaded.config.stripEnv);
|
|
1846
2138
|
const env = {
|
|
1847
2139
|
...(loaded.config.env ?? {}),
|
|
@@ -1852,10 +2144,11 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1852
2144
|
// the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
|
|
1853
2145
|
// on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
|
|
1854
2146
|
VOLTER_WORLD: name,
|
|
1855
|
-
...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
|
|
2147
|
+
...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network, carriedNetwork) ?? {}),
|
|
1856
2148
|
VOLTER_WORLD_MODE: mode,
|
|
1857
2149
|
VOLTER_WORLD_CONFIG: loaded.path,
|
|
1858
2150
|
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
2151
|
+
VOLTER_WORLD_GUEST_RUNNER: new URL(import.meta.url.endsWith('.ts') ? './guest-runner.ts' : './guest-runner.js', import.meta.url).href,
|
|
1859
2152
|
// every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
|
|
1860
2153
|
[WORLD_DATA_ENV]: data,
|
|
1861
2154
|
// the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
|
|
@@ -1877,8 +2170,10 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1877
2170
|
const unissued = new Set();
|
|
1878
2171
|
writeProxyEnv(root, name, env);
|
|
1879
2172
|
const worldInstance = {
|
|
1880
|
-
name, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2173
|
+
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
1881
2174
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2175
|
+
networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]))),
|
|
2176
|
+
bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
|
|
1882
2177
|
...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
|
|
1883
2178
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
1884
2179
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
@@ -1902,9 +2197,9 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1902
2197
|
abort?.removeEventListener('abort', onOwnerEnd);
|
|
1903
2198
|
};
|
|
1904
2199
|
const boot = {
|
|
1905
|
-
bootId:
|
|
1906
|
-
external: service => {
|
|
1907
|
-
recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids]),
|
|
2200
|
+
bootId: instanceToken, signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(), identities: {}, handles: new Map(),
|
|
2201
|
+
external: service => { recordService(service); saveWorldInstance(worldInstance); },
|
|
2202
|
+
recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids], boot.identities),
|
|
1908
2203
|
};
|
|
1909
2204
|
const recordPidsSoFar = boot.recordPids;
|
|
1910
2205
|
try {
|
|
@@ -1924,7 +2219,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1924
2219
|
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
1925
2220
|
const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1926
2221
|
for (const started of host.services) {
|
|
1927
|
-
|
|
2222
|
+
recordService(started);
|
|
1928
2223
|
Object.assign(env, started.env);
|
|
1929
2224
|
}
|
|
1930
2225
|
colocatedReady = async () => {
|
|
@@ -1978,7 +2273,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1978
2273
|
attemptProxy();
|
|
1979
2274
|
boot.signal.throwIfAborted();
|
|
1980
2275
|
const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1981
|
-
|
|
2276
|
+
recordService(started);
|
|
1982
2277
|
recordPidsSoFar();
|
|
1983
2278
|
Object.assign(env, started.env);
|
|
1984
2279
|
await issueCredentials(service, started, env, issuing, unissued);
|
|
@@ -2022,7 +2317,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2022
2317
|
return await shareWorldServices(name, { root, ...(options.share ?? {}) });
|
|
2023
2318
|
}
|
|
2024
2319
|
catch (error) {
|
|
2025
|
-
await downWorld(name, root);
|
|
2320
|
+
await downWorld(name, root, { expectedInstance: instanceToken });
|
|
2026
2321
|
throw error;
|
|
2027
2322
|
}
|
|
2028
2323
|
}
|
|
@@ -2034,16 +2329,16 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2034
2329
|
// Rollback never lets its own failure replace the boot's: each step's error is added to the original.
|
|
2035
2330
|
const rollbackErrors = [];
|
|
2036
2331
|
try {
|
|
2037
|
-
stopWorldProxy(root, name);
|
|
2332
|
+
await stopWorldProxy(root, name, boot.handles);
|
|
2038
2333
|
}
|
|
2039
2334
|
catch (proxyError) {
|
|
2040
|
-
rollbackErrors.push(`proxy stop failed: ${
|
|
2335
|
+
rollbackErrors.push(`proxy stop failed: ${errorMessage(proxyError)}`);
|
|
2041
2336
|
}
|
|
2042
2337
|
const pids = [...new Set([...Object.values(services).map((service) => service.pid).filter(Boolean), ...boot.pids])];
|
|
2043
2338
|
const rollbackAlive = undeadPids(Object.values(services).map((service) => service.pid).filter(Boolean));
|
|
2044
2339
|
const rollbackLogs = Object.values(services).filter((service) => service.type !== 'external' && service.log && rollbackAlive.includes(service.pid)).map((service) => service.log);
|
|
2045
2340
|
recordWorldStop(rollbackLogs, 'world.service.stop', 'boot rollback', 'SIGTERM');
|
|
2046
|
-
signalPids(pids, 'SIGTERM');
|
|
2341
|
+
await signalPids(pids, 'SIGTERM', boot.identities, boot.handles);
|
|
2047
2342
|
// Stop any self-managed externals that DID come up, so a failed `up` never leaves a
|
|
2048
2343
|
// half-running external stack behind. Best-effort: the original error is what we throw —
|
|
2049
2344
|
// but a `down` that ITSELF fails must not be silent (the operator would have no hint that
|
|
@@ -2058,20 +2353,20 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2058
2353
|
await runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
|
|
2059
2354
|
}
|
|
2060
2355
|
catch (downError) {
|
|
2061
|
-
rollbackErrors.push(
|
|
2356
|
+
rollbackErrors.push(errorMessage(downError));
|
|
2062
2357
|
}
|
|
2063
2358
|
}
|
|
2064
2359
|
}
|
|
2065
2360
|
// Same SIGTERM→SIGKILL contract as downWorld: a service that ignores SIGTERM must not
|
|
2066
2361
|
// outlive a failed boot either. The poll exits as soon as everything is dead, so the
|
|
2067
2362
|
// common case (services honor SIGTERM) adds no delay to the failure path.
|
|
2068
|
-
const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT);
|
|
2363
|
+
const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT, boot.identities, boot.handles);
|
|
2069
2364
|
recordWorldStop(Object.values(services).filter((service) => rollbackKilled.includes(service.pid) && service.log).map((service) => service.log), 'world.service.killed', 'boot rollback', 'SIGKILL');
|
|
2070
2365
|
const surviving = undeadPids(pids);
|
|
2071
2366
|
if (surviving.length)
|
|
2072
2367
|
rollbackErrors.push('owned processes remain alive or uncertain');
|
|
2073
2368
|
if (error instanceof CommandRetirementError)
|
|
2074
|
-
rollbackErrors.push(error.retirement ?? error
|
|
2369
|
+
rollbackErrors.push(error.retirement ?? errorMessage(error));
|
|
2075
2370
|
if (rollbackErrors.length > 0) {
|
|
2076
2371
|
cleanupUncertain = true;
|
|
2077
2372
|
try {
|
|
@@ -2079,7 +2374,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2079
2374
|
saveWorldInstance(worldInstance);
|
|
2080
2375
|
}
|
|
2081
2376
|
catch (bookkeepingError) {
|
|
2082
|
-
rollbackErrors.push(`rollback bookkeeping failed: ${
|
|
2377
|
+
rollbackErrors.push(`rollback bookkeeping failed: ${errorMessage(bookkeepingError)}`);
|
|
2083
2378
|
}
|
|
2084
2379
|
const suffix = `\n(rollback incomplete — processes or an external stack may still be running: ${rollbackErrors.join('; ')})`;
|
|
2085
2380
|
// Mutate rather than re-wrap: callers match on the ORIGINAL message (which must keep
|
|
@@ -2087,7 +2382,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2087
2382
|
if (error instanceof Error)
|
|
2088
2383
|
error.message += suffix;
|
|
2089
2384
|
else
|
|
2090
|
-
throw new Error(`${
|
|
2385
|
+
throw new Error(`${errorMessage(error)}${suffix}`);
|
|
2091
2386
|
}
|
|
2092
2387
|
if (rollbackErrors.length === 0) {
|
|
2093
2388
|
// Publication failure must retain ownership. Prune requires both a receipt and release.
|
|
@@ -2100,36 +2395,52 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2100
2395
|
cleanupUncertain = false;
|
|
2101
2396
|
}
|
|
2102
2397
|
catch (receiptError) {
|
|
2103
|
-
const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${
|
|
2398
|
+
const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${errorMessage(receiptError)}; lifecycle retained)`;
|
|
2104
2399
|
if (error instanceof Error)
|
|
2105
2400
|
error.message += note;
|
|
2106
2401
|
else
|
|
2107
|
-
throw new Error(`${
|
|
2402
|
+
throw new Error(`${errorMessage(error)}${note}`);
|
|
2108
2403
|
}
|
|
2109
2404
|
}
|
|
2110
2405
|
throw error;
|
|
2111
2406
|
}
|
|
2112
2407
|
}
|
|
2113
2408
|
catch (error) {
|
|
2409
|
+
bootFailed = true;
|
|
2410
|
+
bootFailure = error;
|
|
2114
2411
|
if (resourcesClaimed) {
|
|
2115
2412
|
try {
|
|
2116
|
-
recordWorldLifecycleEvent(root, name, `boot failed: ${
|
|
2413
|
+
recordWorldLifecycleEvent(root, name, `boot failed: ${errorMessage(error)}`);
|
|
2117
2414
|
}
|
|
2118
2415
|
catch (diagnosticError) {
|
|
2119
|
-
process.stderr.write(`World "${name}": could not save boot failure: ${
|
|
2416
|
+
process.stderr.write(`World "${name}": could not save boot failure: ${errorMessage(diagnosticError)}; original failure: ${errorMessage(error)}\n`);
|
|
2417
|
+
}
|
|
2418
|
+
if (!cleanupUncertain) {
|
|
2419
|
+
try {
|
|
2420
|
+
releaseWorldLifecycle(root, name);
|
|
2421
|
+
}
|
|
2422
|
+
catch (release) {
|
|
2423
|
+
bootFailure = new AggregateError([error, release], 'World startup and lifecycle release failed');
|
|
2424
|
+
}
|
|
2120
2425
|
}
|
|
2121
|
-
if (!cleanupUncertain)
|
|
2122
|
-
releaseWorldLifecycle(root, name);
|
|
2123
2426
|
}
|
|
2124
|
-
throw
|
|
2427
|
+
throw bootFailure;
|
|
2125
2428
|
}
|
|
2126
2429
|
finally {
|
|
2127
|
-
|
|
2430
|
+
try {
|
|
2431
|
+
rmSync(bootingFile(root, name), { force: true });
|
|
2432
|
+
}
|
|
2433
|
+
catch (cleanup) {
|
|
2434
|
+
if (bootFailed)
|
|
2435
|
+
throw new AggregateError([bootFailure, cleanup], 'World startup and boot marker cleanup failed');
|
|
2436
|
+
throw cleanup;
|
|
2437
|
+
}
|
|
2128
2438
|
}
|
|
2129
2439
|
}
|
|
2130
2440
|
async function teardownExternalServices(instance) {
|
|
2131
2441
|
const stopped = [];
|
|
2132
2442
|
const errors = [];
|
|
2443
|
+
const unavailable = [];
|
|
2133
2444
|
for (const service of Object.values(instance.services)) {
|
|
2134
2445
|
if (service.type !== 'external')
|
|
2135
2446
|
continue;
|
|
@@ -2144,10 +2455,13 @@ async function teardownExternalServices(instance) {
|
|
|
2144
2455
|
stopped.push(service.id);
|
|
2145
2456
|
}
|
|
2146
2457
|
catch (error) {
|
|
2147
|
-
|
|
2458
|
+
const message = errorMessage(error);
|
|
2459
|
+
errors.push(message);
|
|
2460
|
+
if (error instanceof ExternalCommandUnavailableError)
|
|
2461
|
+
unavailable.push(message);
|
|
2148
2462
|
}
|
|
2149
2463
|
}
|
|
2150
|
-
return { stopped, errors };
|
|
2464
|
+
return { stopped, errors, unavailable };
|
|
2151
2465
|
}
|
|
2152
2466
|
/** SIGTERM contract (documented in docs/concepts/worlds.md § Teardown): services get SIGTERM to their process
|
|
2153
2467
|
* GROUP, then `downWorld` waits up to this grace for them to exit before SIGKILLing survivors.
|
|
@@ -2156,19 +2470,20 @@ const DOWN_GRACE_MS_DEFAULT = 5_000;
|
|
|
2156
2470
|
/** Signal each pid's process GROUP (services are spawned `detached`, so each pid is a group
|
|
2157
2471
|
* leader), falling back to the single pid when the group signal fails. Returns the pids that
|
|
2158
2472
|
* accepted the signal. */
|
|
2159
|
-
function signalPids(pids, signal) {
|
|
2473
|
+
async function signalPids(pids, signal, identities, handles) {
|
|
2160
2474
|
const signalled = [];
|
|
2161
2475
|
for (const pid of pids) {
|
|
2476
|
+
if (!undeadPids([pid]).length)
|
|
2477
|
+
continue;
|
|
2478
|
+
const identity = Object.hasOwn(identities, pid) ? { pid, start: identities[pid] } : undefined;
|
|
2162
2479
|
try {
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
catch {
|
|
2167
|
-
try {
|
|
2168
|
-
process.kill(pid, signal);
|
|
2480
|
+
const child = handles?.get(pid);
|
|
2481
|
+
if (child ? await signalHeldChild(child, signal, true, Promise.resolve(identity))
|
|
2482
|
+
: await signalOwnedProcess(identity, signal, true) || await signalOwnedProcess(identity, signal))
|
|
2169
2483
|
signalled.push(pid);
|
|
2170
|
-
|
|
2171
|
-
|
|
2484
|
+
}
|
|
2485
|
+
catch (error) {
|
|
2486
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
2172
2487
|
}
|
|
2173
2488
|
}
|
|
2174
2489
|
return signalled;
|
|
@@ -2187,43 +2502,51 @@ const KILL_CONFIRM_MS = 2_000;
|
|
|
2187
2502
|
* exactly like the SIGTERM was delivered, then a short bounded confirm-wait so survivors are
|
|
2188
2503
|
* actually gone (not just signalled) by the time this returns. Returns the pids that needed
|
|
2189
2504
|
* escalation. */
|
|
2190
|
-
async function killSurvivorsAfterGrace(pids, graceMs) {
|
|
2505
|
+
async function killSurvivorsAfterGrace(pids, graceMs, identities, handles) {
|
|
2191
2506
|
const deadline = Date.now() + graceMs;
|
|
2192
2507
|
while (undeadPids(pids).length > 0 && Date.now() < deadline) {
|
|
2193
2508
|
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
|
2194
2509
|
}
|
|
2195
2510
|
const survivors = undeadPids(pids);
|
|
2196
|
-
signalPids(survivors, 'SIGKILL');
|
|
2511
|
+
await signalPids(survivors, 'SIGKILL', identities, handles);
|
|
2197
2512
|
const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
|
|
2198
2513
|
while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
|
|
2199
2514
|
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
|
|
2200
2515
|
}
|
|
2201
2516
|
return survivors;
|
|
2202
2517
|
}
|
|
2203
|
-
/**
|
|
2204
|
-
*
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2518
|
+
/** The same bounded, identity-checked retirement used by World down, for provider-owned groups.
|
|
2519
|
+
* No caller may release a durable receipt when inspection or signal delivery is uncertain. */
|
|
2520
|
+
export async function retireOwnedProcessGroups(records, graceMs = DOWN_GRACE_MS_DEFAULT) {
|
|
2521
|
+
const pids = [...new Set(records.map(record => record.pid))];
|
|
2522
|
+
const identities = Object.fromEntries(records.map(record => [record.pid, record.start]));
|
|
2523
|
+
await signalPids(pids, 'SIGCONT', identities);
|
|
2524
|
+
await signalPids(pids, 'SIGTERM', identities);
|
|
2525
|
+
await killSurvivorsAfterGrace(pids, graceMs, identities);
|
|
2526
|
+
const remaining = undeadPids(pids);
|
|
2527
|
+
if (remaining.length)
|
|
2528
|
+
throw new CommandRetirementError(`Guest process groups still alive or uninspectable: ${remaining.join(', ')}; ownership retained`);
|
|
2529
|
+
}
|
|
2530
|
+
/** The synchronous proxy starter can retire only its still-held child/group. Numeric
|
|
2531
|
+
* recovery belongs to async downWorld, never to a blocking identity lookup. */
|
|
2532
|
+
function killHeldSurvivorsSync(child, graceMs) {
|
|
2533
|
+
if (!child.pid)
|
|
2534
|
+
return;
|
|
2535
|
+
const pids = [child.pid], deadline = Date.now() + graceMs;
|
|
2536
|
+
while (undeadPids(pids).length && Date.now() < deadline)
|
|
2211
2537
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
signalPids(survivors, 'SIGKILL');
|
|
2538
|
+
if (undeadPids(pids).length)
|
|
2539
|
+
signalChild(child, 'SIGKILL', true);
|
|
2215
2540
|
const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
|
|
2216
|
-
while (undeadPids(
|
|
2541
|
+
while (undeadPids(pids).length && Date.now() < confirmDeadline)
|
|
2217
2542
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
2218
|
-
}
|
|
2219
|
-
return survivors;
|
|
2220
2543
|
}
|
|
2221
|
-
/**
|
|
2222
|
-
*
|
|
2223
|
-
*
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
2544
|
+
/** Retirement awaits birth verification for recorded numbers and the SIGTERM→SIGKILL
|
|
2545
|
+
* grace/confirmation polls. Callers await downWorld before releasing ownership or purging
|
|
2546
|
+
* instance state; held children can receive their initial signal without a birth lookup.
|
|
2547
|
+
* External teardown and proxy retirement are joined in that same lifecycle.
|
|
2548
|
+
* A blocking sleep-loop would stall the caller's event loop through the grace, and a detached
|
|
2549
|
+
* watchdog would leave an unjoined process; lifecycle owners await retirement instead. */
|
|
2227
2550
|
/**
|
|
2228
2551
|
* Delete a world's instance dir entirely — the `--purge` half of `down` (TWIN-45 dev/02b): no
|
|
2229
2552
|
* twin data (per-service `data/<id>`, logs, instance.json) survives past teardown. Plain `rm`,
|
|
@@ -2256,19 +2579,31 @@ function purgeInstanceDir(root, name) {
|
|
|
2256
2579
|
class WorldConsumersActiveError extends Error {
|
|
2257
2580
|
}
|
|
2258
2581
|
export async function downWorld(name, root = process.cwd(), options = {}) {
|
|
2582
|
+
if (Object.hasOwn(options, 'expectedCreatedAt'))
|
|
2583
|
+
throw new Error('World teardown requires expectedInstance from the original boot');
|
|
2259
2584
|
assertSafeWorldName(name);
|
|
2260
2585
|
const resolvedRoot = resolve(root);
|
|
2261
2586
|
const token = randomUUID();
|
|
2262
2587
|
// Like boot's claim: a short synchronous lock protects an async lifecycle transition.
|
|
2263
2588
|
// Do not hold a filesystem lock while external shutdown or the grace period waits.
|
|
2264
2589
|
withFileLock(instanceLockFile(resolvedRoot, name), () => withFileLock(`${sharingClaimFile(resolvedRoot, name)}.lock`, () => {
|
|
2590
|
+
if (options.expectedInstance !== undefined) {
|
|
2591
|
+
try {
|
|
2592
|
+
const raw = getActiveWorldStore().read(instanceFile(resolvedRoot, name));
|
|
2593
|
+
const recorded = raw === null ? undefined : JSON.parse(raw);
|
|
2594
|
+
if (recorded?.name !== name || typeof recorded.root !== 'string' || resolve(recorded.root) !== resolvedRoot
|
|
2595
|
+
|| 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)
|
|
2596
|
+
|| recorded.instance !== options.expectedInstance)
|
|
2597
|
+
throw new Error();
|
|
2598
|
+
}
|
|
2599
|
+
catch {
|
|
2600
|
+
throw new Error(`World ownership mismatch: ${name}`);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2265
2603
|
assertProxyOwnershipResolved(resolvedRoot, name);
|
|
2266
2604
|
const prior = existsSync(instanceFile(resolvedRoot, name)) ? readWorldInstance(name, resolvedRoot, true) : undefined;
|
|
2267
2605
|
if (prior?.resources && !prior.lifecycle)
|
|
2268
2606
|
throw new Error(`World "${name}" belongs to a legacy runtime; use its pinned runtime for teardown before migration`);
|
|
2269
|
-
if (options.expectedCreatedAt !== undefined && readWorldInstance(name, resolvedRoot, true).createdAt !== options.expectedCreatedAt) {
|
|
2270
|
-
throw new Error(`World "${name}" generation changed; teardown refused`);
|
|
2271
|
-
}
|
|
2272
2607
|
if (options.purge)
|
|
2273
2608
|
assertWorldStateRemovable(resolvedRoot, instanceDir(resolvedRoot, name));
|
|
2274
2609
|
prepareBootForDown(resolvedRoot, name);
|
|
@@ -2318,6 +2653,7 @@ async function downWorldClaimed(name, root, options) {
|
|
|
2318
2653
|
catch {
|
|
2319
2654
|
instance = undefined;
|
|
2320
2655
|
}
|
|
2656
|
+
readGuestProcesses(dir, instance?.instance); // missing World metadata never erases guest ownership
|
|
2321
2657
|
// Nothing to tear down when the metadata is gone and every process the lifecycle recorded has exited.
|
|
2322
2658
|
if (!instance && before === undefined && releaseAbandonedWorldLifecycle(resolvedRoot, name)) {
|
|
2323
2659
|
return { name, stopped: [], escalated: [], externalStopped: [], externalErrors: [] };
|
|
@@ -2326,28 +2662,37 @@ async function downWorldClaimed(name, root, options) {
|
|
|
2326
2662
|
return { name, stopped: [], escalated: [], externalStopped: [],
|
|
2327
2663
|
externalErrors: ['World instance or external teardown metadata missing or unreadable; cleanup refused; lifecycle evidence retained'] };
|
|
2328
2664
|
}
|
|
2329
|
-
const
|
|
2330
|
-
|
|
2665
|
+
const proxyState = readProxyState(resolvedRoot, name);
|
|
2666
|
+
const proxyPid = proxyState?.pid;
|
|
2667
|
+
await stopWorldProxy(resolvedRoot, name, spawnedChildren);
|
|
2331
2668
|
const foregroundPid = instance?.lastRun?.state !== 'completed' ? instance?.lastRun?.consumerPid : undefined;
|
|
2332
2669
|
const holderPid = instance?.lifecycle?.holderPid ?? instance?.resources?.holderPid;
|
|
2333
2670
|
const recordedPids = [...new Set([...readPids(pidsFile), ...(foregroundPid ? [foregroundPid] : []), ...(proxyPid ? [proxyPid] : [])])].filter(pid => pid !== holderPid);
|
|
2334
|
-
const
|
|
2671
|
+
const identities = readIdentities(resolvedRoot, name);
|
|
2672
|
+
if (proxyState?.birth)
|
|
2673
|
+
identities[proxyState.pid] = proxyState.birth;
|
|
2674
|
+
const foreign = (await Promise.all(recordedPids.map(async (pid) => {
|
|
2675
|
+
const now = identities[pid] ? await processBirth(pid) : undefined;
|
|
2676
|
+
return now && now !== identities[pid] ? { pid, recorded: identities[pid], now } : undefined;
|
|
2677
|
+
}))).filter((entry) => !!entry);
|
|
2335
2678
|
const pids = recordedPids.filter((pid) => !foreign.some((entry) => entry.pid === pid));
|
|
2336
2679
|
const processServices = Object.values(instance?.services ?? {}).filter((service) => service.type !== 'external' && service.log);
|
|
2337
2680
|
// Only a service still alive is stopped by this `down`; a dead one's end was not the World's.
|
|
2338
2681
|
const stillAlive = undeadPids(processServices.map((service) => service.pid).filter((pid) => pids.includes(pid)));
|
|
2339
2682
|
recordWorldStop(processServices.filter((service) => stillAlive.includes(service.pid)).map((service) => service.log), 'world.service.stop', 'down', 'SIGTERM');
|
|
2340
|
-
const stopped = signalPids(pids, 'SIGTERM');
|
|
2683
|
+
const stopped = await signalPids(pids, 'SIGTERM', identities, spawnedChildren);
|
|
2341
2684
|
// Stop self-managed externals via their declared `down` command. A missing instance is
|
|
2342
2685
|
// safe only without retained lifecycle ownership, checked before any signals above.
|
|
2343
2686
|
let externalStopped = [];
|
|
2344
2687
|
let externalErrors = [];
|
|
2688
|
+
let unavailable = [];
|
|
2345
2689
|
try {
|
|
2346
2690
|
if (!instance)
|
|
2347
2691
|
throw new Error('World instance unavailable');
|
|
2348
2692
|
const result = await teardownExternalServices(instance);
|
|
2349
2693
|
externalStopped = result.stopped;
|
|
2350
2694
|
externalErrors = result.errors;
|
|
2695
|
+
unavailable = result.unavailable;
|
|
2351
2696
|
}
|
|
2352
2697
|
catch {
|
|
2353
2698
|
// no instance.json (never fully came up) — only owned processes needed stopping.
|
|
@@ -2357,12 +2702,22 @@ async function downWorldClaimed(name, root, options) {
|
|
|
2357
2702
|
// Escalate AFTER the external teardown so externals never wait on the grace, and
|
|
2358
2703
|
// poll the full pids list rather than `stopped`: a pid whose SIGTERM errored is almost always
|
|
2359
2704
|
// already dead, and livePids() re-checks anyway.
|
|
2360
|
-
const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
|
|
2705
|
+
const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren);
|
|
2361
2706
|
recordWorldStop(processServices.filter((service) => escalated.includes(service.pid)).map((service) => service.log), 'world.service.killed', 'down', 'SIGKILL');
|
|
2707
|
+
// A launch which had already entered its spawn seam may publish while TERM is delivered.
|
|
2708
|
+
// Once services/agents retire, join every late receipt before certifying this generation stopped.
|
|
2709
|
+
const lateGuests = readGuestProcesses(dir, instance?.instance).filter(receipt => !pids.includes(receipt.record.pid));
|
|
2710
|
+
try {
|
|
2711
|
+
await retireOwnedProcessGroups(lateGuests.map(receipt => receipt.record), options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
|
|
2712
|
+
}
|
|
2713
|
+
catch (error) {
|
|
2714
|
+
externalErrors.push(errorMessage(error));
|
|
2715
|
+
}
|
|
2716
|
+
pids.push(...lateGuests.map(receipt => receipt.record.pid));
|
|
2362
2717
|
if (holderPid && !externalErrors.length && !undeadPids(pids).length) {
|
|
2363
2718
|
pids.push(holderPid);
|
|
2364
|
-
stopped.push(...signalPids([holderPid], 'SIGTERM'));
|
|
2365
|
-
escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT));
|
|
2719
|
+
stopped.push(...await signalPids([holderPid], 'SIGTERM', identities, spawnedChildren));
|
|
2720
|
+
escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren));
|
|
2366
2721
|
}
|
|
2367
2722
|
// Finalization belongs to this exact generation, not just the reusable name. A new up may
|
|
2368
2723
|
// win once the old PIDs exit; never erase its PID file, lifecycle or instance directory.
|
|
@@ -2379,24 +2734,41 @@ async function downWorldClaimed(name, root, options) {
|
|
|
2379
2734
|
externalErrors.push('Proxy or sharing lifecycle changed during teardown; cleanup refused');
|
|
2380
2735
|
return result;
|
|
2381
2736
|
}
|
|
2382
|
-
const
|
|
2737
|
+
const guestReceipts = readGuestProcesses(dir, instance?.instance);
|
|
2738
|
+
const surviving = undeadPids([...pids, ...guestReceipts.map(receipt => receipt.record.pid)]);
|
|
2383
2739
|
if (surviving.length)
|
|
2384
2740
|
externalErrors.push(`World processes still alive or uninspectable after teardown: ${surviving.join(', ')}; cleanup refused`);
|
|
2741
|
+
if (guestReceipts.some(receipt => foreign.some(entry => entry.pid === receipt.record.pid)))
|
|
2742
|
+
externalErrors.push('Guest spawn identity changed; receipt retained and cleanup refused');
|
|
2743
|
+
let externalAbandoned;
|
|
2744
|
+
if (options.purge && instance && externalErrors.length && externalErrors.length === unavailable.length && !foreign.length) {
|
|
2745
|
+
const inventory = exitedWorldLifecyclePids(resolvedRoot, name, instance.createdAt);
|
|
2746
|
+
if (inventory && !undeadPids([...pids, ...inventory, ...(holderPid ? [holderPid] : [])]).length) {
|
|
2747
|
+
const consumers = worldConsumers(resolvedRoot, name);
|
|
2748
|
+
if (!consumers.warnings.length && consumers.consumers.every(c => c.state === 'exited')) {
|
|
2749
|
+
externalAbandoned = [...externalErrors];
|
|
2750
|
+
recordWorldLifecycleEvent(resolvedRoot, name, `explicit purge surrendered state; unavailable backend not certified stopped:\n${externalAbandoned.join('\n')}`);
|
|
2751
|
+
externalErrors.splice(0);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2385
2755
|
if (externalErrors.length)
|
|
2386
2756
|
return result;
|
|
2757
|
+
releaseGuestProcesses(guestReceipts);
|
|
2387
2758
|
rmSync(pidsFile, { force: true });
|
|
2388
2759
|
if (before) {
|
|
2389
2760
|
writeTextAtomic(join(dir, 'stopped.json'), `${JSON.stringify({
|
|
2390
2761
|
instanceHash: createHash('sha256').update(before).digest('hex'), stoppedAt: new Date().toISOString(),
|
|
2762
|
+
...(externalAbandoned ? { externalAbandoned } : {}),
|
|
2391
2763
|
})}\n`);
|
|
2392
2764
|
}
|
|
2393
|
-
releaseWorldLifecycle(resolvedRoot, name);
|
|
2765
|
+
releaseWorldLifecycle(resolvedRoot, name, externalAbandoned ? 'recorded processes exited; explicit purge surrendered state; ownership released' : undefined);
|
|
2394
2766
|
if (!options.purge)
|
|
2395
2767
|
return result;
|
|
2396
2768
|
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
2397
2769
|
if (purged && instance)
|
|
2398
2770
|
clearBrowserFiles(instance.dirs.data, resolvedRoot);
|
|
2399
|
-
return { ...result, purged };
|
|
2771
|
+
return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
|
|
2400
2772
|
}));
|
|
2401
2773
|
}
|
|
2402
2774
|
export function statusWorld(name, root = process.cwd(), options = {}) {
|
|
@@ -2499,7 +2871,7 @@ function worldCleanupEntry(name, root) {
|
|
|
2499
2871
|
assertWorldStateRemovable(root, path);
|
|
2500
2872
|
}
|
|
2501
2873
|
catch (error) {
|
|
2502
|
-
return { ...entry, reason: error
|
|
2874
|
+
return { ...entry, reason: errorMessage(error) };
|
|
2503
2875
|
}
|
|
2504
2876
|
return { ...entry, eligible: true, reason: 'successfully stopped; disposable instance data only', stoppedAt: receipt.stoppedAt, logicalBytes: logicalTreeBytes(path) };
|
|
2505
2877
|
}
|
|
@@ -2540,7 +2912,7 @@ function worldServiceControlRoot(name, service, root = process.cwd()) {
|
|
|
2540
2912
|
const instance = readWorldInstance(name, resolve(root));
|
|
2541
2913
|
if (!instance.services[service])
|
|
2542
2914
|
throw new Error(`World ${name} has no service "${service}"`);
|
|
2543
|
-
return
|
|
2915
|
+
return ownedPath(instance.dirs.data, service);
|
|
2544
2916
|
}
|
|
2545
2917
|
function worldServiceStateService(controlRoot, requestedService) {
|
|
2546
2918
|
const stateRoot = join(controlRoot, stateDirName(), 'world');
|
|
@@ -2639,7 +3011,7 @@ async function waitForPublicUrl(child, logPath, timeoutMs, requireTryCloudflare)
|
|
|
2639
3011
|
});
|
|
2640
3012
|
child.once('error', (error) => {
|
|
2641
3013
|
exited = true;
|
|
2642
|
-
exitSummary = error
|
|
3014
|
+
exitSummary = errorMessage(error);
|
|
2643
3015
|
});
|
|
2644
3016
|
const started = Date.now();
|
|
2645
3017
|
while (Date.now() - started < timeoutMs) {
|
|
@@ -2671,18 +3043,18 @@ function commandExists(command) {
|
|
|
2671
3043
|
// Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
|
|
2672
3044
|
return OWN_COMMANDS[command] !== undefined || onPath(command);
|
|
2673
3045
|
}
|
|
2674
|
-
/**
|
|
2675
|
-
*
|
|
2676
|
-
* answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
|
|
2677
|
-
* PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
|
|
2678
|
-
* command on PATH still wins, as before. */
|
|
3046
|
+
/** Built-in lifecycle names belong to the executing runtime. PATH may contain another
|
|
3047
|
+
* checkout's package-manager bin links; an operator override names an explicit executable path. */
|
|
2679
3048
|
const OWN_COMMANDS = { 'volter-world-infra': 'infra-cli' };
|
|
2680
3049
|
export function ownCommand(command) {
|
|
2681
3050
|
const [bin, ...args] = command;
|
|
2682
3051
|
const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
|
|
2683
|
-
if (stem === undefined
|
|
3052
|
+
if (stem === undefined)
|
|
2684
3053
|
return command;
|
|
2685
|
-
|
|
3054
|
+
const script = siblingScript(import.meta.url, stem);
|
|
3055
|
+
const loader = typeof globalThis.Bun === 'undefined' && script.endsWith('.ts')
|
|
3056
|
+
? ['--experimental-transform-types', '--no-warnings'] : [];
|
|
3057
|
+
return [process.execPath, ...loader, script, ...args];
|
|
2686
3058
|
}
|
|
2687
3059
|
/** A command answers when it names an executable: a path is checked as a file (what `which` does with a path),
|
|
2688
3060
|
* never by spawning `which`. `ownCommand` hands this the running executable's own path (process.execPath), and a
|
|
@@ -2828,7 +3200,7 @@ async function shareWorldReserved(name, options) {
|
|
|
2828
3200
|
throw new Error(`World "${name}" service "${serviceId}" has no local URL to share (external/self-managed services are not assigned one)`);
|
|
2829
3201
|
}
|
|
2830
3202
|
const tunnel = shareCommand(options, service.url);
|
|
2831
|
-
const log =
|
|
3203
|
+
const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
2832
3204
|
// Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
|
|
2833
3205
|
// share from accepting the previous process's URL handshake as the new process's endpoint.
|
|
2834
3206
|
const out = openPrivateLog(log, 'w');
|
|
@@ -2838,11 +3210,18 @@ async function shareWorldReserved(name, options) {
|
|
|
2838
3210
|
detached: true,
|
|
2839
3211
|
stdio: ['ignore', out, out],
|
|
2840
3212
|
});
|
|
2841
|
-
|
|
3213
|
+
const birth = retainSpawn(child);
|
|
3214
|
+
const identities = {};
|
|
3215
|
+
const handles = new Map(child.pid ? [[child.pid, child]] : []);
|
|
2842
3216
|
let publicUrl;
|
|
2843
3217
|
let publicVerification;
|
|
2844
3218
|
let pidsWritten = false;
|
|
2845
3219
|
try {
|
|
3220
|
+
closeSync(out);
|
|
3221
|
+
const identity = await birth;
|
|
3222
|
+
if (!identity?.start)
|
|
3223
|
+
throw new Error('Tunnel birth capture failed; retiring held child');
|
|
3224
|
+
identities[identity.pid] = identity.start;
|
|
2846
3225
|
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
|
|
2847
3226
|
if (options.verifyPath !== false) {
|
|
2848
3227
|
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
@@ -2877,8 +3256,8 @@ async function shareWorldReserved(name, options) {
|
|
|
2877
3256
|
// gives owned services (a cloudflared that ignores SIGTERM must not keep serving after a
|
|
2878
3257
|
// failed/rejected `share`).
|
|
2879
3258
|
if (child.pid) {
|
|
2880
|
-
signalPids([child.pid], 'SIGTERM');
|
|
2881
|
-
await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
|
|
3259
|
+
await signalPids([child.pid], 'SIGTERM', identities, handles);
|
|
3260
|
+
await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT, identities, handles);
|
|
2882
3261
|
}
|
|
2883
3262
|
const cleanupProblems = [];
|
|
2884
3263
|
if (child.pid && undeadPids([child.pid]).length > 0)
|
|
@@ -2891,11 +3270,11 @@ async function shareWorldReserved(name, options) {
|
|
|
2891
3270
|
writePidsFromInstance(instance);
|
|
2892
3271
|
}
|
|
2893
3272
|
catch (restoreError) {
|
|
2894
|
-
cleanupProblems.push(`could not restore pids file: ${
|
|
3273
|
+
cleanupProblems.push(`could not restore pids file: ${errorMessage(restoreError)}`);
|
|
2895
3274
|
}
|
|
2896
3275
|
}
|
|
2897
3276
|
if (cleanupProblems.length > 0) {
|
|
2898
|
-
throw new Error(`${
|
|
3277
|
+
throw new Error(`${errorMessage(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
|
|
2899
3278
|
}
|
|
2900
3279
|
throw error;
|
|
2901
3280
|
}
|
|
@@ -2944,11 +3323,9 @@ export async function shareWorldServices(name, options = {}) {
|
|
|
2944
3323
|
* services — SIGTERM, confirm dead (escalating to SIGKILL past the grace), and only THEN erase the
|
|
2945
3324
|
* tunnel facts/pids. A best-effort SIGTERM with no confirmation can leave a `cloudflared` that
|
|
2946
3325
|
* ignores SIGTERM serving the public URL forever while instance.json (and the pids file) say
|
|
2947
|
-
* nothing is shared — the exact "half-shared" state docs/concepts/worlds.md promises cannot exist.
|
|
2948
|
-
* synchronous (via `killSurvivorsAfterGraceSync`) to match the existing signature/call sites
|
|
2949
|
-
* (the CLI does not await it). Returns the instance plus the tunnel pids that needed escalating,
|
|
3326
|
+
* 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,
|
|
2950
3327
|
* for callers that want to report it. */
|
|
2951
|
-
export function unshareWorld(name, options = {}) {
|
|
3328
|
+
export async function unshareWorld(name, options = {}) {
|
|
2952
3329
|
const root = resolve(options.root ?? process.cwd());
|
|
2953
3330
|
const instance = readWorldInstance(name, root);
|
|
2954
3331
|
const serviceIds = options.service ? [options.service] : Object.keys(instance.services);
|
|
@@ -2960,13 +3337,14 @@ export function unshareWorld(name, options = {}) {
|
|
|
2960
3337
|
throw new Error(`World "${name}" has no service "${serviceId}"`);
|
|
2961
3338
|
if (service.tunnel?.pid) {
|
|
2962
3339
|
const pid = service.tunnel.pid;
|
|
2963
|
-
|
|
2964
|
-
|
|
3340
|
+
const identities = readIdentities(root, name);
|
|
3341
|
+
await signalPids([pid], 'SIGTERM', identities);
|
|
3342
|
+
const survivors = await killSurvivorsAfterGrace([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities);
|
|
2965
3343
|
if (survivors.length > 0)
|
|
2966
3344
|
escalated.push(...survivors);
|
|
2967
3345
|
// `undeadPids` (not the raw `livePids`) is the right "confirmed dead" test here: a zombie
|
|
2968
|
-
// (exited but unreaped
|
|
2969
|
-
//
|
|
3346
|
+
// (exited but unreaped, possibly awaiting its parent's reap) still
|
|
3347
|
+
// occupies a process-table entry and
|
|
2970
3348
|
// answers `kill(pid, 0)` yet is dead for every practical purpose — see `survivingOwnedGroups`' own
|
|
2971
3349
|
// comment. Using plain `livePids` here would treat every zombie as "still shared".
|
|
2972
3350
|
if (undeadPids([pid]).length > 0) {
|
|
@@ -3018,7 +3396,7 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
|
|
|
3018
3396
|
return { ok: false, message: `probe returned HTTP ${response.status}` };
|
|
3019
3397
|
}
|
|
3020
3398
|
catch (error) {
|
|
3021
|
-
return { ok: false, message: `probe failed: ${
|
|
3399
|
+
return { ok: false, message: `probe failed: ${errorMessage(error)}` };
|
|
3022
3400
|
}
|
|
3023
3401
|
}
|
|
3024
3402
|
if (ready.stdoutMatch !== undefined) {
|
|
@@ -3046,7 +3424,7 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3046
3424
|
checks.push({ id: 'instance', ok: true, message: `instance=${instanceFile(root, name)}` });
|
|
3047
3425
|
}
|
|
3048
3426
|
catch (error) {
|
|
3049
|
-
const message =
|
|
3427
|
+
const message = errorMessage(error);
|
|
3050
3428
|
return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
|
|
3051
3429
|
}
|
|
3052
3430
|
checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
|
|
@@ -3100,7 +3478,7 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3100
3478
|
checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; probe ok (status)` });
|
|
3101
3479
|
}
|
|
3102
3480
|
catch (error) {
|
|
3103
|
-
checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${
|
|
3481
|
+
checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${errorMessage(error)}` });
|
|
3104
3482
|
}
|
|
3105
3483
|
}
|
|
3106
3484
|
else {
|
|
@@ -3143,7 +3521,7 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3143
3521
|
checks.push({
|
|
3144
3522
|
id: `service:${service.id}:tcp`,
|
|
3145
3523
|
ok: false,
|
|
3146
|
-
message:
|
|
3524
|
+
message: errorMessage(error),
|
|
3147
3525
|
});
|
|
3148
3526
|
}
|
|
3149
3527
|
if (service.tunnel || service.publicUrl) {
|
|
@@ -3173,7 +3551,7 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3173
3551
|
checks.push({
|
|
3174
3552
|
id: `service:${service.id}:public-health`,
|
|
3175
3553
|
ok: false,
|
|
3176
|
-
message:
|
|
3554
|
+
message: errorMessage(error),
|
|
3177
3555
|
});
|
|
3178
3556
|
}
|
|
3179
3557
|
}
|
|
@@ -3263,7 +3641,7 @@ export async function runWorldOwned(configId, command, options, abort) {
|
|
|
3263
3641
|
let failed = false;
|
|
3264
3642
|
let retired = true;
|
|
3265
3643
|
try {
|
|
3266
|
-
outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { abort, verbose: options.verbose });
|
|
3644
|
+
outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { cwd: options.cwd, abort, verbose: options.verbose });
|
|
3267
3645
|
instance.lastRun = outcome;
|
|
3268
3646
|
saveWorldInstance(instance);
|
|
3269
3647
|
}
|
|
@@ -3284,7 +3662,7 @@ export async function runWorldOwned(configId, command, options, abort) {
|
|
|
3284
3662
|
}
|
|
3285
3663
|
catch (error) {
|
|
3286
3664
|
if (failed)
|
|
3287
|
-
throw new AggregateError([failure, error], `World execution and cleanup failed: ${
|
|
3665
|
+
throw new AggregateError([failure, error], `World execution and cleanup failed: ${errorMessage(failure)}; ${errorMessage(error)}`);
|
|
3288
3666
|
throw error;
|
|
3289
3667
|
}
|
|
3290
3668
|
}
|
|
@@ -3297,7 +3675,7 @@ async function finishTaskWorld(instance) {
|
|
|
3297
3675
|
let uncertainSince;
|
|
3298
3676
|
for (;;) {
|
|
3299
3677
|
try {
|
|
3300
|
-
const result = await downWorld(instance.name, instance.root, {
|
|
3678
|
+
const result = await downWorld(instance.name, instance.root, { expectedInstance: instance.instance });
|
|
3301
3679
|
if (result.externalErrors.length) {
|
|
3302
3680
|
const message = `World "${instance.name}" cleanup incomplete: ${result.externalErrors.join('; ')}. Reservation and diagnostics retained.`;
|
|
3303
3681
|
// The caller that would have read this on stderr may be gone; the lifecycle log keeps it.
|
|
@@ -3373,7 +3751,7 @@ function worldAttachedCommandEnv(name, root, verbose = false) {
|
|
|
3373
3751
|
if (error instanceof CaTrustError)
|
|
3374
3752
|
throw error;
|
|
3375
3753
|
if (sealed && hostHasRawEgress())
|
|
3376
|
-
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${
|
|
3754
|
+
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
|
|
3377
3755
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
3378
3756
|
}
|
|
3379
3757
|
return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
@@ -3410,8 +3788,15 @@ export function runWithWorldEnv(name, command, root = process.cwd(), options = {
|
|
|
3410
3788
|
env: { ...attached.env, ...(options.env ?? {}) },
|
|
3411
3789
|
stdio: 'inherit',
|
|
3412
3790
|
});
|
|
3413
|
-
const forward = (signal) => {
|
|
3414
|
-
|
|
3791
|
+
const forward = (signal) => {
|
|
3792
|
+
try {
|
|
3793
|
+
if (child.pid && child.exitCode === null && child.signalCode === null)
|
|
3794
|
+
signalChild(child, signal);
|
|
3795
|
+
}
|
|
3796
|
+
catch (error) {
|
|
3797
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
3798
|
+
}
|
|
3799
|
+
};
|
|
3415
3800
|
process.on('SIGINT', forward);
|
|
3416
3801
|
process.on('SIGTERM', forward);
|
|
3417
3802
|
child.once('error', () => { offSignal('SIGINT', forward); offSignal('SIGTERM', forward); resolveExit(1); });
|
|
@@ -3584,7 +3969,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
3584
3969
|
attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
3585
3970
|
}
|
|
3586
3971
|
catch (cause) {
|
|
3587
|
-
return finish(1, undefined,
|
|
3972
|
+
return finish(1, undefined, errorMessage(cause));
|
|
3588
3973
|
}
|
|
3589
3974
|
return await new Promise((resolveOutcome, rejectOutcome) => {
|
|
3590
3975
|
let spawnError;
|
|
@@ -3598,7 +3983,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
3598
3983
|
});
|
|
3599
3984
|
}
|
|
3600
3985
|
catch (cause) {
|
|
3601
|
-
resolveOutcome(finish(1, undefined,
|
|
3986
|
+
resolveOutcome(finish(1, undefined, errorMessage(cause)));
|
|
3602
3987
|
return;
|
|
3603
3988
|
}
|
|
3604
3989
|
const lifetime = commandLifetime(child, options.abort);
|
|
@@ -3608,7 +3993,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
3608
3993
|
writeSync(logFd, chunk);
|
|
3609
3994
|
}
|
|
3610
3995
|
catch (error) {
|
|
3611
|
-
spawnError =
|
|
3996
|
+
spawnError = errorMessage(error);
|
|
3612
3997
|
lifetime.cancel('SIGTERM');
|
|
3613
3998
|
}
|
|
3614
3999
|
});
|
|
@@ -3618,12 +4003,12 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
3618
4003
|
writeSync(logFd, chunk);
|
|
3619
4004
|
}
|
|
3620
4005
|
catch (error) {
|
|
3621
|
-
spawnError =
|
|
4006
|
+
spawnError = errorMessage(error);
|
|
3622
4007
|
lifetime.cancel('SIGTERM');
|
|
3623
4008
|
}
|
|
3624
4009
|
});
|
|
3625
4010
|
child.once('error', (cause) => {
|
|
3626
|
-
spawnError = cause
|
|
4011
|
+
spawnError = errorMessage(cause);
|
|
3627
4012
|
});
|
|
3628
4013
|
const activeRecord = {
|
|
3629
4014
|
state: 'running',
|
|
@@ -3636,7 +4021,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
3636
4021
|
writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(activeRecord, null, 2)}\n`);
|
|
3637
4022
|
}
|
|
3638
4023
|
catch (error) {
|
|
3639
|
-
spawnError =
|
|
4024
|
+
spawnError = errorMessage(error);
|
|
3640
4025
|
lifetime.cancel('SIGTERM');
|
|
3641
4026
|
}
|
|
3642
4027
|
void lifetime.finish().then(({ code, signal }) => {
|
|
@@ -3690,8 +4075,8 @@ export async function shellWorld(name, root = process.cwd()) {
|
|
|
3690
4075
|
if (error instanceof CaTrustError)
|
|
3691
4076
|
throw error;
|
|
3692
4077
|
if (sealed)
|
|
3693
|
-
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${
|
|
3694
|
-
process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${
|
|
4078
|
+
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${errorMessage(error)})`);
|
|
4079
|
+
process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${errorMessage(error)}); falling back to env-only redirect.\n`);
|
|
3695
4080
|
}
|
|
3696
4081
|
}
|
|
3697
4082
|
else {
|
|
@@ -3746,17 +4131,7 @@ function currentProxyState(root, name) {
|
|
|
3746
4131
|
return null;
|
|
3747
4132
|
if (existing.guestUrl)
|
|
3748
4133
|
return existing;
|
|
3749
|
-
|
|
3750
|
-
process.kill(existing.pid, 'SIGTERM');
|
|
3751
|
-
}
|
|
3752
|
-
catch { /* gone already */ }
|
|
3753
|
-
for (let i = 0; i < 50 && isAlive(existing.pid); i += 1)
|
|
3754
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
3755
|
-
try {
|
|
3756
|
-
rmSync(proxyStateFile(root, name), { force: true });
|
|
3757
|
-
}
|
|
3758
|
-
catch { /* ignore */ }
|
|
3759
|
-
return null;
|
|
4134
|
+
throw new Error('World proxy lacks its guest listener; stop this World before restarting its proxy');
|
|
3760
4135
|
}
|
|
3761
4136
|
function ensureWorldProxyUnlocked(name, root, caller) {
|
|
3762
4137
|
const resolvedRoot = resolve(root);
|
|
@@ -3814,16 +4189,36 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
|
|
|
3814
4189
|
child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', root, ...(envFile ? [`--env-file=${envFile}`] : [])], { windowsHide: true,
|
|
3815
4190
|
cwd: root, env: process.env, detached: true, stdio: ['ignore', proxyOut, proxyOut],
|
|
3816
4191
|
});
|
|
4192
|
+
if (child.pid) {
|
|
4193
|
+
spawnedChildren.set(child.pid, child);
|
|
4194
|
+
if (boot) {
|
|
4195
|
+
boot.pids.add(child.pid);
|
|
4196
|
+
boot.handles.set(child.pid, child);
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
3817
4199
|
}
|
|
3818
4200
|
finally {
|
|
3819
4201
|
closeSync(proxyOut);
|
|
3820
4202
|
}
|
|
3821
4203
|
}
|
|
3822
4204
|
catch (error) {
|
|
4205
|
+
if (child?.pid) {
|
|
4206
|
+
try {
|
|
4207
|
+
signalChild(child, 'SIGTERM', true);
|
|
4208
|
+
killHeldSurvivorsSync(child, DOWN_GRACE_MS_DEFAULT);
|
|
4209
|
+
if (undeadPids([child.pid]).length)
|
|
4210
|
+
throw new CommandRetirementError('Proxy retirement unconfirmed; startup intent retained');
|
|
4211
|
+
}
|
|
4212
|
+
catch (cleanup) {
|
|
4213
|
+
throw new AggregateError([error, cleanup], 'Proxy spawn/descriptor close and retirement failed');
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
3823
4216
|
try {
|
|
3824
4217
|
rmSync(marker, { force: true });
|
|
3825
4218
|
}
|
|
3826
|
-
catch {
|
|
4219
|
+
catch (cleanup) {
|
|
4220
|
+
throw new AggregateError([error, cleanup], 'Proxy spawn and intent cleanup failed');
|
|
4221
|
+
}
|
|
3827
4222
|
throw error;
|
|
3828
4223
|
}
|
|
3829
4224
|
child.on('error', () => { }); // a failed spawn can emit after this synchronous operation throws
|
|
@@ -3832,20 +4227,23 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
|
|
|
3832
4227
|
rmSync(marker, { force: true });
|
|
3833
4228
|
throw new Error(`World "${name}": ambient proxy process did not start`);
|
|
3834
4229
|
}
|
|
3835
|
-
boot
|
|
4230
|
+
if (boot) {
|
|
4231
|
+
boot.pids.add(pid);
|
|
4232
|
+
boot.handles.set(pid, child);
|
|
4233
|
+
} // before any fallible filesystem operation
|
|
3836
4234
|
child.unref();
|
|
3837
4235
|
let retirementAttempted = false;
|
|
3838
4236
|
const retire = () => {
|
|
3839
4237
|
retirementAttempted = true;
|
|
3840
|
-
|
|
3841
|
-
|
|
4238
|
+
signalChild(child, 'SIGTERM', true);
|
|
4239
|
+
killHeldSurvivorsSync(child, DOWN_GRACE_MS_DEFAULT);
|
|
3842
4240
|
if (undeadPids([pid]).length) {
|
|
3843
4241
|
const error = new CommandRetirementError(`World "${name}": proxy process ${pid} retirement is unconfirmed; ownership retained`);
|
|
3844
4242
|
try {
|
|
3845
4243
|
retainWorldLifecycleForCleanup(root, name);
|
|
3846
4244
|
}
|
|
3847
4245
|
catch (cause) {
|
|
3848
|
-
error.message += `; claim update failed: ${
|
|
4246
|
+
error.message += `; claim update failed: ${errorMessage(cause)}`;
|
|
3849
4247
|
}
|
|
3850
4248
|
throw error;
|
|
3851
4249
|
}
|
|
@@ -3859,12 +4257,21 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
|
|
|
3859
4257
|
boot.recordPids();
|
|
3860
4258
|
else
|
|
3861
4259
|
writeOwnedPids(root, name, [pid], [pid]);
|
|
3862
|
-
rmSync(marker, { force: true }); // canonical ownership is now durable
|
|
3863
4260
|
const deadlineMs = proxyDaemonDeadlineMs();
|
|
3864
4261
|
const deadline = Date.now() + deadlineMs;
|
|
3865
4262
|
while (Date.now() < deadline) {
|
|
3866
4263
|
const state = readProxyState(root, name);
|
|
3867
4264
|
if (state?.pid === pid) {
|
|
4265
|
+
if (!state.birth)
|
|
4266
|
+
throw new Error('Proxy birth token missing; retiring held child');
|
|
4267
|
+
spawnedIdentities.set(pid, { pid, start: state.birth });
|
|
4268
|
+
if (boot) {
|
|
4269
|
+
boot.identities[pid] = state.birth;
|
|
4270
|
+
boot.recordPids();
|
|
4271
|
+
}
|
|
4272
|
+
else
|
|
4273
|
+
writeOwnedPids(root, name, [pid], [pid]);
|
|
4274
|
+
rmSync(marker, { force: true }); // both numeric ownership and birth are durable
|
|
3868
4275
|
if (!boot)
|
|
3869
4276
|
handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
|
|
3870
4277
|
return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath, caller, state.guestUrl) };
|
|
@@ -3888,14 +4295,13 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
|
|
|
3888
4295
|
retire();
|
|
3889
4296
|
}
|
|
3890
4297
|
catch (retirement) {
|
|
3891
|
-
const cause =
|
|
4298
|
+
const cause = errorMessage(error);
|
|
3892
4299
|
if (retirement instanceof CommandRetirementError) {
|
|
3893
|
-
Object.assign(retirement, { retirement: retirement
|
|
3894
|
-
retirement.message = `${cause}; ${retirement
|
|
4300
|
+
Object.assign(retirement, { retirement: errorMessage(retirement), cause: error });
|
|
4301
|
+
retirement.message = `${cause}; ${errorMessage(retirement)}`;
|
|
3895
4302
|
throw retirement;
|
|
3896
4303
|
}
|
|
3897
|
-
|
|
3898
|
-
error.message += `; retiring the proxy failed too: ${retirement instanceof Error ? retirement.message : String(retirement)}`;
|
|
4304
|
+
throw new AggregateError([error, retirement], 'Proxy startup and retirement failed');
|
|
3899
4305
|
}
|
|
3900
4306
|
}
|
|
3901
4307
|
throw error;
|
|
@@ -3931,8 +4337,13 @@ export async function runProxyDaemon(name, root = process.cwd(), envFile) {
|
|
|
3931
4337
|
env = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
|
|
3932
4338
|
}
|
|
3933
4339
|
const proxy = await startRedirectProxy({ env, envLoader, tlsDir: tlsDir(resolvedRoot, name), note: (line) => process.stderr.write(`${line}\n`) });
|
|
3934
|
-
const
|
|
3935
|
-
|
|
4340
|
+
const identity = await spawnIdentity(process.pid);
|
|
4341
|
+
if (!identity.start) {
|
|
4342
|
+
await proxy.close();
|
|
4343
|
+
throw new Error('Proxy birth capture failed');
|
|
4344
|
+
}
|
|
4345
|
+
const state = { pid: process.pid, birth: identity.start, url: proxy.url, caCertPath: proxy.caCertPath, guestUrl: proxy.guestUrl };
|
|
4346
|
+
writeTextAtomic(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
|
|
3936
4347
|
// Not a recorded service, so it records its own start and end in the World's events (a crash included; a SIGKILL
|
|
3937
4348
|
// leaves none, which doctor's proxy check then names).
|
|
3938
4349
|
const events = worldEventsPath(join(instanceDir(resolvedRoot, name), 'logs'));
|
|
@@ -3960,13 +4371,19 @@ export async function runProxyDaemon(name, root = process.cwd(), envFile) {
|
|
|
3960
4371
|
}
|
|
3961
4372
|
/** Stop a world's ambient-redirect proxy daemon (if any) and drop its session CA. Best-effort —
|
|
3962
4373
|
* called from `downWorld` so the trusted CA never outlives the world. */
|
|
3963
|
-
function stopWorldProxy(root, name) {
|
|
4374
|
+
async function stopWorldProxy(root, name, handles) {
|
|
3964
4375
|
const state = readProxyState(root, name);
|
|
3965
4376
|
if (state && isAlive(state.pid)) {
|
|
3966
|
-
|
|
3967
|
-
|
|
4377
|
+
const identities = readIdentities(root, name);
|
|
4378
|
+
const identity = { pid: state.pid, start: state.birth ?? identities[state.pid] };
|
|
4379
|
+
const child = handles?.get(state.pid);
|
|
4380
|
+
if (child)
|
|
4381
|
+
await signalHeldChild(child, 'SIGTERM', false, Promise.resolve(identity));
|
|
4382
|
+
else {
|
|
4383
|
+
if (!state.birth && !Object.hasOwn(identities, state.pid))
|
|
4384
|
+
throw new Error('Proxy spawn identity missing; retirement refused; ownership retained');
|
|
4385
|
+
await signalOwnedProcess(identity, 'SIGTERM');
|
|
3968
4386
|
}
|
|
3969
|
-
catch { /* ignore */ }
|
|
3970
4387
|
}
|
|
3971
4388
|
try {
|
|
3972
4389
|
rmSync(proxyStateFile(root, name), { force: true });
|
|
@@ -4016,7 +4433,7 @@ export function activateScript(name, root = process.cwd()) {
|
|
|
4016
4433
|
if (error instanceof CaTrustError)
|
|
4017
4434
|
throw error;
|
|
4018
4435
|
if (sealed && hostHasRawEgress())
|
|
4019
|
-
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${
|
|
4436
|
+
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${errorMessage(error)})`);
|
|
4020
4437
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
4021
4438
|
}
|
|
4022
4439
|
env = withAppUrl(name, root, env);
|