@volter/world-runtime 3.0.38 → 3.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/known-external-services.json +3 -10
- package/dist/src/actions-runner-cli.js +3 -3
- package/dist/src/actions-runner.js +2 -1
- package/dist/src/app-url.js +2 -1
- package/dist/src/backings.d.ts +10 -1
- package/dist/src/backings.js +66 -7
- package/dist/src/branch.js +85 -52
- package/dist/src/browser-routes.cjs +59 -0
- package/dist/src/ca-trust.js +4 -3
- package/dist/src/changeset.js +4 -3
- package/dist/src/cli-commands.js +25 -9
- package/dist/src/cli.js +2 -1
- package/dist/src/command-lifetime.d.mts +12 -0
- package/dist/src/command-lifetime.d.ts +1 -15
- package/dist/src/command-lifetime.js +1 -98
- package/dist/src/command-lifetime.mjs +90 -0
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.js +3 -2
- package/dist/src/covers.d.ts +3 -2
- package/dist/src/covers.js +95 -18
- package/dist/src/error-message.d.mts +1 -0
- package/dist/src/error-message.d.ts +1 -0
- package/dist/src/error-message.js +2 -0
- package/dist/src/error-message.mjs +10 -0
- package/dist/src/guest-command.d.mts +4 -0
- package/dist/src/guest-command.mjs +26 -0
- package/dist/src/guest-control.d.mts +1 -0
- package/dist/src/guest-control.mjs +17 -0
- package/dist/src/guest-entry.mjs +146 -0
- package/dist/src/guest-processes.d.mts +9 -0
- package/dist/src/guest-processes.mjs +47 -0
- package/dist/src/guest-runner.d.ts +6 -0
- package/dist/src/guest-runner.js +567 -0
- package/dist/src/host.js +5 -4
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/infra-cli.js +4 -0
- package/dist/src/infra.js +133 -23
- package/dist/src/init.js +60 -23
- package/dist/src/lifecycle-record.d.ts +3 -1
- package/dist/src/lifecycle-record.js +29 -6
- package/dist/src/livekit-backing.mjs +37 -0
- package/dist/src/local-branches.js +11 -3
- package/dist/src/mongodb-backing.d.ts +7 -0
- package/dist/src/mongodb-backing.js +58 -0
- package/dist/src/native-postgres-backing.d.ts +4 -0
- package/dist/src/native-postgres-backing.js +404 -0
- package/dist/src/origin.js +2 -1
- package/dist/src/owned-path.d.ts +4 -0
- package/dist/src/owned-path.js +37 -0
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pglite-backing.d.ts +4 -13
- package/dist/src/pglite-backing.js +198 -106
- package/dist/src/pglite-host.mjs +47 -17
- package/dist/src/placeholder.d.ts +3 -2
- package/dist/src/placeholder.js +9 -4
- package/dist/src/process-groups.d.mts +2 -0
- package/dist/src/process-groups.d.ts +1 -4
- package/dist/src/process-groups.js +1 -49
- package/dist/src/process-groups.mjs +59 -0
- package/dist/src/process-receipt.d.mts +6 -0
- package/dist/src/process-receipt.d.ts +11 -0
- package/dist/src/process-receipt.js +3 -0
- package/dist/src/process-receipt.mjs +45 -0
- package/dist/src/process-signal.d.mts +9 -0
- package/dist/src/process-signal.mjs +92 -0
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +4 -2
- package/dist/src/proxy-daemon.js +2 -1
- package/dist/src/redirect-proxy.js +186 -19
- package/dist/src/redis-backing.js +99 -35
- package/dist/src/reflect.js +2 -1
- package/dist/src/root.js +7 -6
- package/dist/src/run-task-worker.js +2 -1
- package/dist/src/runtime.d.ts +24 -7
- package/dist/src/runtime.js +703 -286
- package/dist/src/schema.d.ts +12 -1
- package/dist/src/schema.js +20 -5
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +26 -14
- package/dist/src/service-recorder.js +8 -3
- package/dist/src/signals.d.mts +1 -0
- package/dist/src/signals.d.ts +1 -1
- package/dist/src/signals.js +1 -11
- package/dist/src/signals.mjs +10 -0
- package/dist/src/tail.d.ts +2 -0
- package/dist/src/tail.js +9 -2
- package/dist/src/tcp-port.js +27 -13
- package/dist/src/temporal-backing.mjs +28 -0
- package/dist/src/up-cli.js +8 -3
- package/dist/src/up-task-worker.js +2 -1
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/websocket-relay.js +23 -10
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +3 -10
- package/package.json +12 -7
- package/src/actions-runner-cli.ts +3 -3
- package/src/actions-runner.ts +2 -1
- package/src/app-url.ts +2 -1
- package/src/backings.ts +65 -7
- package/src/branch.ts +73 -34
- package/src/browser-routes.cjs +59 -0
- package/src/ca-trust.ts +4 -3
- package/src/changeset.ts +4 -3
- package/src/cli-commands.ts +21 -9
- package/src/cli.ts +2 -1
- package/src/command-lifetime.d.mts +12 -0
- package/src/command-lifetime.mjs +90 -0
- package/src/command-lifetime.ts +1 -79
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +2 -1
- package/src/covers.ts +97 -19
- package/src/error-message.d.mts +1 -0
- package/src/error-message.mjs +10 -0
- package/src/error-message.ts +2 -0
- package/src/guest-command.d.mts +4 -0
- package/src/guest-command.mjs +26 -0
- package/src/guest-control.d.mts +1 -0
- package/src/guest-control.mjs +17 -0
- package/src/guest-entry.mjs +146 -0
- package/src/guest-processes.d.mts +9 -0
- package/src/guest-processes.mjs +47 -0
- package/src/guest-runner.ts +414 -0
- package/src/host.ts +5 -4
- package/src/index.ts +1 -0
- package/src/infra-cli.ts +4 -0
- package/src/infra.ts +103 -22
- package/src/init.ts +55 -22
- package/src/lifecycle-record.ts +21 -6
- package/src/livekit-backing.mjs +37 -0
- package/src/local-branches.ts +6 -3
- package/src/mongodb-backing.ts +55 -0
- package/src/native-postgres-backing.ts +329 -0
- package/src/origin.ts +2 -1
- package/src/owned-path.ts +32 -0
- package/src/pack-facts.ts +1 -0
- package/src/pglite-backing.ts +155 -79
- package/src/pglite-host.mjs +47 -17
- package/src/placeholder.ts +10 -5
- package/src/process-groups.d.mts +2 -0
- package/src/process-groups.mjs +59 -0
- package/src/process-groups.ts +1 -33
- package/src/process-receipt.d.mts +6 -0
- package/src/process-receipt.mjs +45 -0
- package/src/process-receipt.ts +5 -0
- package/src/process-signal.d.mts +9 -0
- package/src/process-signal.mjs +92 -0
- package/src/project-inspect.ts +4 -2
- package/src/proxy-daemon.ts +2 -1
- package/src/redirect-proxy.ts +153 -18
- package/src/redis-backing.ts +74 -25
- package/src/reflect.ts +2 -1
- package/src/root.ts +7 -6
- package/src/run-task-worker.ts +2 -1
- package/src/runtime.ts +596 -265
- package/src/schema.ts +30 -6
- package/src/served-world.ts +21 -15
- package/src/service-recorder.ts +4 -2
- package/src/signals.d.mts +1 -0
- package/src/signals.mjs +10 -0
- package/src/signals.ts +1 -10
- package/src/tail.ts +10 -3
- package/src/tcp-port.ts +24 -13
- package/src/temporal-backing.mjs +28 -0
- package/src/up-cli.ts +6 -3
- package/src/up-task-worker.ts +2 -1
- package/src/websocket-relay.ts +18 -11
- package/src/world-view.ts +2 -2
package/src/pglite-backing.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// The containerless backing for World-managed infrastructure, instead of docker
|
|
2
2
|
// compose: a POSTGRES service is a PGlite host (see pglite-host.mjs), a MONGODB
|
|
3
|
-
// service is
|
|
4
|
-
//
|
|
5
|
-
// runtime is available (or by explicit VOLTER_WORLD_INFRA_BACKING=pglite); the
|
|
3
|
+
// service is MongoDB's own mongod (mongodb-backing.ts). MongoDB is always native; Postgres is selected
|
|
4
|
+
// when no container runtime is available (or by VOLTER_WORLD_INFRA_BACKING=pglite). The
|
|
6
5
|
// world config, definition file, injected env, and declared-service contract are
|
|
7
6
|
// byte-identical either way — backing is private, exactly as the boundary comment
|
|
8
7
|
// in infra-cli.ts promises. Each service's bytes live under VOLTER_WORLD_DATA, so
|
|
@@ -12,35 +11,42 @@
|
|
|
12
11
|
// services only. A definition declaring mysql without a container runtime is
|
|
13
12
|
// refused with the kinds named, never half-booted. Redis never reaches it: every
|
|
14
13
|
// redis service is the redis twin's (redis-backing.ts).
|
|
15
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync
|
|
14
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
16
15
|
import { connect } from 'node:net';
|
|
17
16
|
import { dirname, join } from 'node:path';
|
|
18
|
-
import { spawn,
|
|
17
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
18
|
+
import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from './command-lifetime.ts';
|
|
19
|
+
import { survivingOwnedGroups } from './process-groups.ts';
|
|
20
|
+
import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess, type SpawnIdentity } from './process-receipt.ts';
|
|
21
|
+
import { errorMessage } from './error-message.ts';
|
|
19
22
|
import { fileURLToPath } from 'node:url';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
23
|
+
import { parse } from 'yaml';
|
|
24
|
+
import { mongodbArgv, mongodbListening, mongodbReady } from './mongodb-backing.ts';
|
|
22
25
|
|
|
23
|
-
export interface InfraService { kind: string; hostPort: number }
|
|
26
|
+
export interface InfraService { kind: string; hostPort: number; replicaSet?: string }
|
|
24
27
|
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
* reads exactly that shape back (service key at 2-space indent = the kind;
|
|
28
|
-
* the single loopback port mapping beneath it). Anything else is a malformed
|
|
29
|
-
* definition and fails loudly. */
|
|
28
|
+
/** Read the generated definition's loopback port mappings. MongoDB may explicitly request
|
|
29
|
+
* one replica-set member through its Compose-compatible command; other mongod options are refused. */
|
|
30
30
|
export function parseInfraDefinition(text: string): InfraService[] {
|
|
31
|
+
const definition = parse(text);
|
|
31
32
|
const services: InfraService[] = [];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
current = null;
|
|
33
|
+
for (const [kind, value] of Object.entries(definition?.services ?? {})) {
|
|
34
|
+
const declared = value as { ports?: unknown[]; command?: unknown };
|
|
35
|
+
let replicaSet: string | undefined;
|
|
36
|
+
if (kind === 'mongodb' && declared.command !== undefined) {
|
|
37
|
+
const command = declared.command;
|
|
38
|
+
if (!Array.isArray(command) || command.length !== 3 || command[0] !== 'mongod' || command[1] !== '--replSet'
|
|
39
|
+
|| typeof command[2] !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(command[2])) {
|
|
40
|
+
throw new Error('World-managed MongoDB command must be ["mongod", "--replSet", "<name>"] or absent for standalone');
|
|
41
|
+
}
|
|
42
|
+
replicaSet = command[2];
|
|
43
43
|
}
|
|
44
|
+
const ports = (declared.ports ?? []).flatMap((port) => {
|
|
45
|
+
const match = typeof port === 'string' ? port.match(/^127\.0\.0\.1:(\d+):\d+$/) : null;
|
|
46
|
+
return match ? [Number(match[1])] : [];
|
|
47
|
+
});
|
|
48
|
+
if (ports.length !== 1) throw new Error(`managed infrastructure ${kind} needs exactly one declared loopback port`);
|
|
49
|
+
services.push({ kind, hostPort: ports[0]!, ...(replicaSet ? { replicaSet } : {}) });
|
|
44
50
|
}
|
|
45
51
|
return services;
|
|
46
52
|
}
|
|
@@ -68,34 +74,35 @@ export function infraConnections(services: InfraService[], env: Record<string, s
|
|
|
68
74
|
const here = (): string => fileURLToPath(new URL('.', import.meta.url));
|
|
69
75
|
const hostScript = (): string => join(here(), 'pglite-host.mjs');
|
|
70
76
|
const READY_TIMEOUT_MS = 60_000;
|
|
71
|
-
const MONGODB_TWIN = '@volter/twin-mongodb';
|
|
72
77
|
|
|
73
78
|
/** The kinds this backing serves, and the name its files carry under the data dir. */
|
|
74
|
-
const CONTAINERLESS: Record<string, string> = { postgres: 'pglite-postgres', mongodb: '
|
|
79
|
+
const CONTAINERLESS: Record<string, string> = { postgres: 'pglite-postgres', mongodb: 'mongod-mongodb' };
|
|
80
|
+
|
|
81
|
+
// Shared with stopped-state branching: only these backings own cold-copied database bytes.
|
|
82
|
+
export function containerlessDataDirectory(kind: string): string | undefined {
|
|
83
|
+
const name = CONTAINERLESS[kind];
|
|
84
|
+
return name === undefined ? undefined : `${name}-data`;
|
|
85
|
+
}
|
|
75
86
|
|
|
76
87
|
function pidPath(dataDir: string, kind: string): string {
|
|
77
88
|
return join(dataDir, `${CONTAINERLESS[kind] ?? `pglite-${kind}`}.pid`);
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
91
|
+
interface Host { child: ChildProcess; lifetime: ReturnType<typeof commandLifetime>; closed: boolean; identity?: SpawnIdentity }
|
|
92
|
+
const hosts = new Map<string, Host>();
|
|
93
|
+
function hostOwnership(pidFile: string) {
|
|
94
|
+
const receipt = readProcessReceipt(pidFile, text => {
|
|
95
|
+
if (!/^[1-9][0-9]*\s*$/.test(text)) throw new Error('Invalid managed host PID');
|
|
96
|
+
const pid = Number(text.trim());
|
|
97
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('Invalid managed host PID');
|
|
98
|
+
return pid;
|
|
99
|
+
});
|
|
100
|
+
const spawn = readSpawnReceipt(pidFile);
|
|
101
|
+
const errors = [receipt.error, spawn.error].filter((error): error is Error => !!error);
|
|
102
|
+
if (receipt.value && spawn.value && receipt.value !== spawn.value.pid) {
|
|
103
|
+
errors.push(new Error('Managed host receipts disagree; ownership uncertain'));
|
|
87
104
|
}
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The runtime a twin's cli runs under. A published package's cli is JavaScript and runs under this
|
|
92
|
-
* process's own runtime; a checkout's is TypeScript whose kernel needs more than Node's type
|
|
93
|
-
* stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
|
|
94
|
-
* and without Bun it is refused by name rather than started to fail. */
|
|
95
|
-
export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true }).status === 0, underBun = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'): string {
|
|
96
|
-
if (!cli.endsWith('.ts') || underBun) return process.execPath;
|
|
97
|
-
if (bunOnPath()) return 'bun';
|
|
98
|
-
throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
|
|
105
|
+
return { present: receipt.present || spawn.present, pid: spawn.value?.pid ?? receipt.value, identity: spawn.value, errors, recordedPid: receipt.value, readyReceipt: receipt.value !== undefined, spawnPresent: spawn.present };
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
/** How a backing starts its hosts: the environment they run in (the World's service environment, which
|
|
@@ -106,17 +113,14 @@ export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSy
|
|
|
106
113
|
export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal }
|
|
107
114
|
|
|
108
115
|
/** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
|
|
109
|
-
function hostArgv(service: InfraService, serviceData: string
|
|
110
|
-
if (service.kind === 'mongodb')
|
|
111
|
-
const cli = mongodbTwinCli(env.VOLTER_WORLD_CONFIG);
|
|
112
|
-
// blocking (spawnSync), as it was in the tool: reached only for a checkout's TypeScript cli under Node
|
|
113
|
-
return [twinRunner(cli, () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true, env }).status === 0), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
|
|
114
|
-
}
|
|
116
|
+
function hostArgv(service: InfraService, serviceData: string): string[] {
|
|
117
|
+
if (service.kind === 'mongodb') return mongodbArgv(service, serviceData);
|
|
115
118
|
return ['node', hostScript(), '--port', String(service.hostPort), '--data', serviceData];
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
function alive(pid: number): boolean {
|
|
119
|
-
|
|
122
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
123
|
+
try { process.kill(pid, 0); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; throw error; }
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
function listening(port: number): Promise<boolean> {
|
|
@@ -139,12 +143,12 @@ export function unsupportedKinds(services: InfraService[]): string[] {
|
|
|
139
143
|
|
|
140
144
|
/** The line each host prints once ITS OWN listener is bound: readiness is taken from the child, never
|
|
141
145
|
* from a connect to the port, which any other process holding the port would answer. */
|
|
142
|
-
function readyLine(
|
|
143
|
-
return
|
|
146
|
+
function readyLine(port: number): string {
|
|
147
|
+
return `serving postgres wire protocol on 127.0.0.1:${port}`;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
function logSince(log: string, offset: number): string {
|
|
147
|
-
|
|
151
|
+
return readFileSync(log).subarray(offset).toString('utf8');
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
export async function pgliteUp(services: InfraService[], dataDir: string, launch: HostLaunch = {}): Promise<void> {
|
|
@@ -153,16 +157,23 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
|
|
|
153
157
|
const plan: Array<{ service: InfraService; name: string; serviceData: string; log: string; pidFile: string; argv: string[] }> = [];
|
|
154
158
|
for (const service of services) {
|
|
155
159
|
const pidFile = pidPath(dataDir, service.kind);
|
|
156
|
-
|
|
160
|
+
const ownership = hostOwnership(pidFile);
|
|
161
|
+
if (ownership.present || hosts.has(pidFile)) {
|
|
162
|
+
if (!ownership.errors.length && ownership.readyReceipt && ownership.pid && alive(ownership.pid)) {
|
|
163
|
+
if (ownership.identity) await verifySpawnIdentity(ownership.identity);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
await stopHost(pidFile);
|
|
167
|
+
}
|
|
157
168
|
const name = CONTAINERLESS[service.kind] ?? `pglite-${service.kind}`;
|
|
158
|
-
const serviceData = join(dataDir, `${name}-data`);
|
|
169
|
+
const serviceData = join(dataDir, containerlessDataDirectory(service.kind) ?? `${name}-data`);
|
|
159
170
|
const log = join(dataDir, `${name}.log`);
|
|
160
171
|
if (await listening(service.hostPort)) {
|
|
161
172
|
throw new Error(`${name} cannot serve port ${service.hostPort}: another process is already listening on it`);
|
|
162
173
|
}
|
|
163
|
-
plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData
|
|
174
|
+
plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
|
|
164
175
|
}
|
|
165
|
-
const started:
|
|
176
|
+
const started: string[] = [];
|
|
166
177
|
try {
|
|
167
178
|
for (const { service, name, serviceData, log, pidFile, argv } of plan) {
|
|
168
179
|
mkdirSync(serviceData, { recursive: true });
|
|
@@ -177,28 +188,43 @@ export async function pgliteUp(services: InfraService[], dataDir: string, launch
|
|
|
177
188
|
detached: true,
|
|
178
189
|
stdio: ['ignore', logFd, logFd],
|
|
179
190
|
});
|
|
180
|
-
child.
|
|
191
|
+
// Retain the actual child BEFORE descriptor close or either publication can fail.
|
|
192
|
+
const lifetime = commandLifetime(child, launch.signal);
|
|
193
|
+
const host: Host = { child, lifetime, closed: false };
|
|
194
|
+
child.once('close', () => { host.closed = true; });
|
|
195
|
+
hosts.set(pidFile, host); started.push(pidFile);
|
|
196
|
+
let spawnError: Error | undefined;
|
|
197
|
+
child.on('error', (error) => { spawnError = error; });
|
|
181
198
|
closeSync(logFd);
|
|
182
|
-
|
|
183
|
-
|
|
199
|
+
if (!child.pid) throw new Error(service.kind === 'mongodb'
|
|
200
|
+
? "World-managed MongoDB needs MongoDB's own mongod on PATH; see cookbook/mongodb-backing/README.md"
|
|
201
|
+
: `${name} could not start ${command}`);
|
|
202
|
+
host.identity = await lifetime.identity;
|
|
203
|
+
if (!host.identity?.start) throw new Error('Managed host birth capture failed; retiring held child');
|
|
204
|
+
publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(host.identity) + '\n');
|
|
205
|
+
publishProcessReceipt(pidFile, `${child.pid}\n`);
|
|
184
206
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
185
|
-
const line = readyLine(service.
|
|
207
|
+
const line = readyLine(service.hostPort);
|
|
186
208
|
// ready when OUR child says its listener is bound (a squatter that took the port after the check
|
|
187
209
|
// above makes the child fail its bind and exit, never print this) and the port answers
|
|
188
|
-
while (!(alive(child.pid!) && logSince(log, offset).includes(line) && await listening(service.hostPort))) {
|
|
210
|
+
while (!(alive(child.pid!) && (service.kind === 'mongodb' ? mongodbListening(logSince(log, offset), service.hostPort) : logSince(log, offset).includes(line)) && await listening(service.hostPort))) {
|
|
211
|
+
if (spawnError) throw spawnError;
|
|
189
212
|
if (!alive(child.pid!)) throw new Error(`${name} exited before serving — see ${log}`);
|
|
190
213
|
if (Date.now() > deadline) throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
191
214
|
// an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
|
|
192
215
|
launch.signal?.throwIfAborted();
|
|
193
216
|
await sleep(200);
|
|
194
217
|
}
|
|
218
|
+
if (service.kind === 'mongodb') await mongodbReady(service, launch, deadline);
|
|
219
|
+
child.unref();
|
|
195
220
|
}
|
|
196
221
|
} catch (error) {
|
|
197
222
|
// tear down what this up started, so a failed up leaves nothing running
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
const errors: unknown[] = [error];
|
|
224
|
+
for (const pidFile of started) {
|
|
225
|
+
try { await stopHost(pidFile); } catch (cleanupError) { errors.push(cleanupError); }
|
|
201
226
|
}
|
|
227
|
+
if (errors.length > 1) throw new AggregateError(errors, 'Managed infrastructure startup and cleanup failed');
|
|
202
228
|
throw error;
|
|
203
229
|
}
|
|
204
230
|
}
|
|
@@ -207,24 +233,74 @@ export async function pgliteStatus(services: InfraService[], dataDir: string): P
|
|
|
207
233
|
let ready = 0;
|
|
208
234
|
for (const service of services) {
|
|
209
235
|
const pidFile = pidPath(dataDir, service.kind);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (alive(pid)
|
|
236
|
+
const ownership = hostOwnership(pidFile);
|
|
237
|
+
if (ownership.errors.length) throw new AggregateError(ownership.errors, 'Managed infrastructure ownership uncertain; retry down');
|
|
238
|
+
if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await listening(service.hostPort))) continue;
|
|
239
|
+
if (ownership.identity) await verifySpawnIdentity(ownership.identity);
|
|
240
|
+
if (service.kind === 'mongodb') {
|
|
241
|
+
await mongodbReady(service, {}, Date.now() + READY_TIMEOUT_MS, false);
|
|
242
|
+
}
|
|
243
|
+
ready += 1;
|
|
213
244
|
}
|
|
214
245
|
return { ok: ready === services.length && services.length > 0, ready };
|
|
215
246
|
}
|
|
216
247
|
|
|
248
|
+
/** Retain evidence until both the child and its private group are confirmed stopped. */
|
|
249
|
+
async function stopHost(pidFile: string): Promise<void> {
|
|
250
|
+
const held = hosts.get(pidFile);
|
|
251
|
+
const ownership = hostOwnership(pidFile);
|
|
252
|
+
if (ownership.errors.length) process.stderr.write(`${errorMessage(new AggregateError(ownership.errors, 'Managed host ownership uncertain; retrying established identity'))}\n`);
|
|
253
|
+
if (held) {
|
|
254
|
+
const pid = held.child.pid;
|
|
255
|
+
const retired = () => held.closed && (!pid || survivingOwnedGroups([pid]).length === 0);
|
|
256
|
+
// A failed lifetime promise is a past diagnosis. A later close and vanished
|
|
257
|
+
// owned group are current retirement evidence, even when finish stays rejected.
|
|
258
|
+
if (!retired()) {
|
|
259
|
+
held.child.ref(); held.lifetime.cancel('SIGTERM');
|
|
260
|
+
try { await held.lifetime.finish(); }
|
|
261
|
+
catch (error) { if (!retired()) throw error; }
|
|
262
|
+
}
|
|
263
|
+
} else if (ownership.pid) {
|
|
264
|
+
const pid = ownership.pid;
|
|
265
|
+
const live = () => survivingOwnedGroups([pid]).length > 0;
|
|
266
|
+
if (live()) {
|
|
267
|
+
if (ownership.identity) await verifySpawnIdentity(ownership.identity);
|
|
268
|
+
else throw new AggregateError(ownership.errors, 'Managed host spawn identity missing or unreadable; ownership retained');
|
|
269
|
+
// Reuse commandLifetime's existing grace and confirmation budgets; no new limit.
|
|
270
|
+
for (const [signal, limit] of [['SIGTERM', COMMAND_GRACE_MS], ['SIGKILL', COMMAND_CONFIRM_MS]] as const) {
|
|
271
|
+
if (!live()) break;
|
|
272
|
+
await signalOwnedProcess(ownership.identity, signal, true);
|
|
273
|
+
const deadline = Date.now() + limit;
|
|
274
|
+
while (live() && Date.now() < deadline) await sleep(20);
|
|
275
|
+
}
|
|
276
|
+
if (live()) throw new Error(`managed infrastructure group ${pid} did not stop; retained ${pidFile}`);
|
|
277
|
+
}
|
|
278
|
+
} else if (ownership.present) {
|
|
279
|
+
throw new AggregateError(ownership.errors, 'Managed host ownership uncertain; no established identity; receipt retained');
|
|
280
|
+
}
|
|
281
|
+
if (ownership.recordedPid && survivingOwnedGroups([ownership.recordedPid]).length) {
|
|
282
|
+
throw new AggregateError(ownership.errors, 'Managed host receipt still names a live or uncertain group; ownership retained');
|
|
283
|
+
}
|
|
284
|
+
const failures: unknown[] = [];
|
|
285
|
+
try { rmSync(pidFile, { force: true }); } catch (error) { failures.push(error); }
|
|
286
|
+
// A later CLI must still have this identity if clearing the damaged PID receipt failed.
|
|
287
|
+
if (!failures.length) {
|
|
288
|
+
try { rmSync(spawnReceiptPath(pidFile), { force: true }); } catch (error) { failures.push(error); }
|
|
289
|
+
}
|
|
290
|
+
if (failures.length) throw new AggregateError(failures, 'Managed host stopped but receipt cleanup failed');
|
|
291
|
+
hosts.delete(pidFile);
|
|
292
|
+
}
|
|
293
|
+
|
|
217
294
|
export async function pgliteDown(services: InfraService[], dataDir: string): Promise<void> {
|
|
295
|
+
const failures: unknown[] = [];
|
|
218
296
|
for (const service of services) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* raced */ } }
|
|
227
|
-
}
|
|
228
|
-
rmSync(pidFile, { force: true });
|
|
297
|
+
try {
|
|
298
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
299
|
+
if (unsupportedKinds([service]).length && (hostOwnership(pidFile).present || hosts.has(pidFile))) {
|
|
300
|
+
throw new Error(`cannot stop recorded ${service.kind} infrastructure with this backing; use its owning runtime`);
|
|
301
|
+
}
|
|
302
|
+
await stopHost(pidFile);
|
|
303
|
+
} catch (error) { failures.push(error); }
|
|
229
304
|
}
|
|
305
|
+
if (failures.length) throw new AggregateError(failures, 'Managed infrastructure retirement failed');
|
|
230
306
|
}
|
package/src/pglite-host.mjs
CHANGED
|
@@ -84,12 +84,37 @@
|
|
|
84
84
|
// pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
|
|
85
85
|
// to the connection that sent it, not to the one that ran LISTEN.
|
|
86
86
|
// CREATE ROLE succeeds but connections never run as that role.
|
|
87
|
+
//
|
|
88
|
+
// Database timestamps use the World clock: PGlite reads Date.now() for now(),
|
|
89
|
+
// CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
|
|
90
|
+
// at each connection message; without a file the host uses machine time.
|
|
91
|
+
// Gateway timers retain machineNow. A frozen database clock also freezes
|
|
92
|
+
// pg_sleep; elapsed-time SQL and timeouts are not promised in that mode.
|
|
93
|
+
// Randomness remains the host's own; no database schema objects are installed.
|
|
94
|
+
import { errorMessage } from './error-message.mjs';
|
|
87
95
|
import net from 'node:net';
|
|
88
|
-
import { mkdirSync } from 'node:fs';
|
|
89
|
-
import
|
|
90
|
-
import { vector } from '@electric-sql/pglite-pgvector';
|
|
96
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
97
|
+
import clockForm from '@volter/world-core/world-clock';
|
|
91
98
|
import { fromNodeSocket } from 'pg-gateway/node';
|
|
92
99
|
|
|
100
|
+
// ---- the World's time (above) --------------------------------
|
|
101
|
+
const machineNow = Date.now.bind(Date);
|
|
102
|
+
const clockFile = process.env.TWIN_WORLD_CLOCK_FILE;
|
|
103
|
+
let worldClock = null;
|
|
104
|
+
/** Read the World clock again: at each message a connection sends. */
|
|
105
|
+
function readWorldClock() {
|
|
106
|
+
if (!clockFile) return;
|
|
107
|
+
try {
|
|
108
|
+
worldClock = clockForm.parseClock(readFileSync(clockFile, 'utf8'));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// no clock: the machine's time; a clock caught mid-write keeps the last one read until the next message
|
|
111
|
+
if (error?.code === 'ENOENT') worldClock = null;
|
|
112
|
+
else process.stderr.write(`pglite-host: clock read failed: ${errorMessage(error)}\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
readWorldClock();
|
|
116
|
+
Date.now = () => (worldClock ? clockForm.clockNowMs(worldClock, machineNow()) : machineNow());
|
|
117
|
+
|
|
93
118
|
// The contrib extensions shipped in @electric-sql/pglite/dist/contrib (the
|
|
94
119
|
// module name; `uuid_ossp` installs as "uuid-ossp"). auto_explain is omitted:
|
|
95
120
|
// it is a preload library with no CREATE EXTENSION control file.
|
|
@@ -101,6 +126,8 @@ const CONTRIB = [
|
|
|
101
126
|
'pg_trgm', 'pg_visibility', 'pg_walinspect', 'pgcrypto', 'seg', 'tablefunc',
|
|
102
127
|
'tcn', 'tsm_system_rows', 'tsm_system_time', 'unaccent', 'uuid_ossp',
|
|
103
128
|
];
|
|
129
|
+
const { PGlite } = await import('@electric-sql/pglite');
|
|
130
|
+
const { vector } = await import('@electric-sql/pglite-pgvector');
|
|
104
131
|
const extensions = { vector };
|
|
105
132
|
for (const name of CONTRIB) {
|
|
106
133
|
const extension = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
|
|
@@ -134,7 +161,7 @@ process.stderr.on('error', () => {});
|
|
|
134
161
|
const initKeepAlive = setInterval(() => {}, 500);
|
|
135
162
|
const dbReady = PGlite.create({ dataDir, extensions });
|
|
136
163
|
dbReady.catch((error) => {
|
|
137
|
-
process.stderr.write(`pglite-host: backend failed to start: ${error
|
|
164
|
+
process.stderr.write(`pglite-host: backend failed to start: ${errorMessage(error)}\n`);
|
|
138
165
|
process.exit(1);
|
|
139
166
|
});
|
|
140
167
|
|
|
@@ -184,7 +211,7 @@ async function enter(token, session, data) {
|
|
|
184
211
|
if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
|
|
185
212
|
&& session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
|
|
186
213
|
guestBatch = token;
|
|
187
|
-
guestStartedAt =
|
|
214
|
+
guestStartedAt = machineNow();
|
|
188
215
|
return 'guest';
|
|
189
216
|
}
|
|
190
217
|
}
|
|
@@ -451,7 +478,7 @@ async function flushClosed(db) {
|
|
|
451
478
|
try {
|
|
452
479
|
await db.execProtocolRaw(new Uint8Array(close));
|
|
453
480
|
await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
|
|
454
|
-
} catch {
|
|
481
|
+
} catch (error) { process.stderr.write(`pglite-host: statement cleanup failed: ${errorMessage(error)}\n`); }
|
|
455
482
|
if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
|
|
456
483
|
}
|
|
457
484
|
}
|
|
@@ -500,7 +527,9 @@ const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
|
|
|
500
527
|
|
|
501
528
|
/** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
|
|
502
529
|
async function quiet(db, sql) {
|
|
503
|
-
try { await db.query(sql); } catch {
|
|
530
|
+
try { await db.query(sql); } catch (error) {
|
|
531
|
+
if (error?.code !== '3B001' && error?.code !== '25P01') throw error;
|
|
532
|
+
}
|
|
504
533
|
}
|
|
505
534
|
|
|
506
535
|
/** `response` with its last ReadyForQuery reporting `status`. */
|
|
@@ -532,7 +561,7 @@ function idleInTransactionFatal() {
|
|
|
532
561
|
// the holder idle in its transaction past the limit while others wait: end it
|
|
533
562
|
const idleWatch = setInterval(() => {
|
|
534
563
|
// a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
|
|
535
|
-
if (guestBatch !== null && waiters.length > 0 &&
|
|
564
|
+
if (guestBatch !== null && waiters.length > 0 && machineNow() - guestStartedAt > GUEST_TURN_MS) {
|
|
536
565
|
const guest = sessions.get(guestBatch);
|
|
537
566
|
const expired = guestBatch;
|
|
538
567
|
if (guest && guest.guestRefused && !guest.guestRunning) {
|
|
@@ -541,7 +570,7 @@ const idleWatch = setInterval(() => {
|
|
|
541
570
|
guest.guestExpired = true;
|
|
542
571
|
guest.guestErrorSent = true;
|
|
543
572
|
const host = sessions.get(holder);
|
|
544
|
-
if (host) host.idleSince +=
|
|
573
|
+
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
545
574
|
guestBatch = null;
|
|
546
575
|
wakeAll();
|
|
547
576
|
} else if (guest && guest.guestOpen && !guest.guestRunning) {
|
|
@@ -554,7 +583,7 @@ const idleWatch = setInterval(() => {
|
|
|
554
583
|
}).then(() => {
|
|
555
584
|
if (guestBatch !== expired) return;
|
|
556
585
|
const host = sessions.get(holder);
|
|
557
|
-
if (host) host.idleSince +=
|
|
586
|
+
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
558
587
|
guestBatch = null;
|
|
559
588
|
wakeAll();
|
|
560
589
|
});
|
|
@@ -565,7 +594,7 @@ const idleWatch = setInterval(() => {
|
|
|
565
594
|
// armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
|
|
566
595
|
// a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
|
|
567
596
|
if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
|
|
568
|
-
if (
|
|
597
|
+
if (machineNow() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
|
|
569
598
|
session.terminated = true;
|
|
570
599
|
process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
|
|
571
600
|
// the socket's close handler rolls the transaction back and releases the lock
|
|
@@ -727,7 +756,7 @@ function withoutEarlyReady(message, response) {
|
|
|
727
756
|
const server = net.createServer(async (socket) => {
|
|
728
757
|
const token = {};
|
|
729
758
|
let holding = false;
|
|
730
|
-
const session = { socket, busy: false, idleSince:
|
|
759
|
+
const session = { socket, busy: false, idleSince: machineNow(), terminated: false, closed: false, status: null, midBatch: false,
|
|
731
760
|
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
|
|
732
761
|
names: new Map() };
|
|
733
762
|
sessions.set(token, session);
|
|
@@ -747,7 +776,7 @@ const server = net.createServer(async (socket) => {
|
|
|
747
776
|
// next connection in — never leak a half-open transaction into a stranger
|
|
748
777
|
void dbReady
|
|
749
778
|
.then((db) => db.query('ROLLBACK'))
|
|
750
|
-
.catch(
|
|
779
|
+
.catch(error => { process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`); })
|
|
751
780
|
.then(() => {
|
|
752
781
|
holding = false;
|
|
753
782
|
release(token);
|
|
@@ -765,6 +794,7 @@ const server = net.createServer(async (socket) => {
|
|
|
765
794
|
// after the closed connections' names are released (a reconnect's `s0` is its own again)
|
|
766
795
|
data = scopeNames(session, client, false);
|
|
767
796
|
const db = await dbReady;
|
|
797
|
+
readWorldClock();
|
|
768
798
|
// a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
|
|
769
799
|
if (session.guestExpired) {
|
|
770
800
|
if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
|
|
@@ -785,7 +815,7 @@ const server = net.createServer(async (socket) => {
|
|
|
785
815
|
session.guestUnnamed = false;
|
|
786
816
|
// the holder's idle clock stops while a guest runs, and goes on from where it was
|
|
787
817
|
const host = sessions.get(holder);
|
|
788
|
-
if (host) host.idleSince +=
|
|
818
|
+
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
789
819
|
wakeAll();
|
|
790
820
|
};
|
|
791
821
|
// a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
|
|
@@ -811,7 +841,7 @@ const server = net.createServer(async (socket) => {
|
|
|
811
841
|
try {
|
|
812
842
|
raw = await db.execProtocolRaw(refuseStatements(data, own));
|
|
813
843
|
} catch (error) {
|
|
814
|
-
process.stderr.write(`pglite-host: backend aborted: ${error
|
|
844
|
+
process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
|
|
815
845
|
process.exit(1);
|
|
816
846
|
}
|
|
817
847
|
const response = withoutEarlyReady(data, raw);
|
|
@@ -847,11 +877,11 @@ const server = net.createServer(async (socket) => {
|
|
|
847
877
|
// SQL errors arrive as ErrorResponse bytes; a throw means the WASM
|
|
848
878
|
// backend itself aborted and every later call would hang. Exit so the
|
|
849
879
|
// World sees the service down (status fails) instead of a wedged port.
|
|
850
|
-
process.stderr.write(`pglite-host: backend aborted: ${error
|
|
880
|
+
process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
|
|
851
881
|
process.exit(1);
|
|
852
882
|
}
|
|
853
883
|
session.busy = false;
|
|
854
|
-
session.idleSince =
|
|
884
|
+
session.idleSince = machineNow();
|
|
855
885
|
const response = withoutEarlyReady(data, raw);
|
|
856
886
|
settleClose(session, closes, response);
|
|
857
887
|
const status = lastReadyStatus(response);
|
package/src/placeholder.ts
CHANGED
|
@@ -5,17 +5,18 @@
|
|
|
5
5
|
// `diff` reads empty, nothing is pending. `reset` is a pull from the placeholder again — down
|
|
6
6
|
// with purge, up, seed — so a world returns to its defaults at any time; local work you want to
|
|
7
7
|
// keep is a fork, taken before.
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { claimEnvOutput } from './env-output.ts';
|
|
8
10
|
import { materializeRoots } from './root.ts';
|
|
9
11
|
import { loadWorldConfig } from './configs.ts';
|
|
10
12
|
import { WorldDoors, servedName } from './served-world.ts';
|
|
11
|
-
import { randomUUID } from 'node:crypto';
|
|
12
13
|
import { existsSync, readdirSync } from 'node:fs';
|
|
13
14
|
import { dirname, join, resolve } from 'node:path';
|
|
14
|
-
import { serveHttp, beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, worldStateRoot } from '@volter/world-core/runtime';
|
|
15
|
+
import { serveHttp, beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, stateDirName, worldStateRoot } from '@volter/world-core/runtime';
|
|
15
16
|
import { downWorld, runWithWorldEnv, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
|
|
16
17
|
import type { WorldInstance } from './schema.ts';
|
|
17
18
|
|
|
18
|
-
export type SeedWorldOptions = { root?: string; entry?: string; cwd?: string };
|
|
19
|
+
export type SeedWorldOptions = { root?: string; entry?: string; cwd?: string; /** Reset writes a new live environment output; never an input file. */ envFile?: string };
|
|
19
20
|
export type SeedWorldOutcome = { world: string; entry: string; exitCode: number; observed: Record<string, number> };
|
|
20
21
|
|
|
21
22
|
/** The seed entry `init` emits beside the world config, unless the caller names one. */
|
|
@@ -87,17 +88,21 @@ export async function seedWorld(name: string, options: SeedWorldOptions = {}): P
|
|
|
87
88
|
return { world: name, entry, exitCode, observed };
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
/** Back to the defaults: down with purge, up from the same config and env
|
|
91
|
+
/** Back to the defaults: down with purge, up from the same config and a fresh env output, seed. */
|
|
91
92
|
export async function resetWorld(name: string, options: SeedWorldOptions = {}): Promise<SeedWorldOutcome & { instance: WorldInstance }> {
|
|
92
93
|
const root = resolve(options.root ?? process.cwd());
|
|
93
94
|
const before = statusWorld(name, root);
|
|
94
95
|
// A world whose twins ship no default data has no seed entry: back to the defaults is back to
|
|
95
96
|
// empty, and that is a reset too — not an error.
|
|
96
97
|
const entry = existsSync(resolve(options.entry ?? join(dirname(before.configPath), 'seed.ts'))) ? seedEntryFor(before, options.entry) : null;
|
|
98
|
+
// Purge removes the previous output's ownership receipt. Select and validate a fresh output before
|
|
99
|
+
// destroying state; never force an overwrite of a file that may hold credentials.
|
|
100
|
+
const envFile = resolve(options.envFile ?? join(dirname(before.envFile), `${name}.reset-${randomUUID()}.world.env`));
|
|
101
|
+
claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'));
|
|
97
102
|
const stopped = await downWorld(name, root, { purge: true });
|
|
98
103
|
if (!stopped.purged || stopped.externalErrors.length) throw new Error(`World reset refused: teardown did not purge the old instance (${stopped.externalErrors.join('; ')})`);
|
|
99
104
|
// a world booted from a config PATH re-ups from that path; one booted by id, by id
|
|
100
|
-
const booted = await upWorld(existsSync(before.configPath) ? before.configPath : before.config, { name, root, mode: before.mode,
|
|
105
|
+
const booted = await upWorld(existsSync(before.configPath) ? before.configPath : before.config, { name, root, mode: before.mode, owner: before.lifecycle?.owner ?? before.resources?.owner, envFile });
|
|
101
106
|
// the origin belongs to the branch, not to its running state (git: a reset keeps the remote);
|
|
102
107
|
// the cursors do not — the mirror is gone, so the next fetch starts from the beginning
|
|
103
108
|
const instance = before.origin === undefined ? booted : { ...booted, origin: { url: before.origin.url, namespace: before.origin.namespace, ...(before.origin.clonedAt ? { clonedAt: before.origin.clonedAt } : {}) } };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/** Inspect only groups already owned by the lifecycle. A dead leader does not prove that its
|
|
5
|
+
* descendants exited. Zombies hold no resources; unknown inspection remains live. This is
|
|
6
|
+
* retirement evidence, never an ownership token or a way to discover processes to kill. */
|
|
7
|
+
export function survivingOwnedGroups(pids) {
|
|
8
|
+
const candidates = pids.filter(pid => {
|
|
9
|
+
try { process.kill(process.platform === 'win32' ? pid : -pid, 0); return true; }
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code !== 'ESRCH') return true;
|
|
12
|
+
try { process.kill(pid, 0); return true; }
|
|
13
|
+
catch (cause) { return cause.code !== 'ESRCH'; }
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
if (!candidates.length || process.platform === 'win32') return candidates;
|
|
17
|
+
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
|
|
18
|
+
encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
|
|
19
|
+
});
|
|
20
|
+
if (result.status !== 0 || result.error) return candidates;
|
|
21
|
+
const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
|
|
22
|
+
return candidates.filter(pid => {
|
|
23
|
+
const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
|
|
24
|
+
// Visibility restrictions can omit a live group. Only ESRCH proves absence.
|
|
25
|
+
if (!members.length) {
|
|
26
|
+
for (const target of [-pid, pid]) {
|
|
27
|
+
try { process.kill(target, 0); return true; }
|
|
28
|
+
catch (error) { if (error.code !== 'ESRCH') return true; }
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return members.some(([, , state]) => !state || !state.startsWith('Z'));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
|
|
37
|
+
* process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
|
|
38
|
+
* given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
|
|
39
|
+
* processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
|
|
40
|
+
* paid. */
|
|
41
|
+
export function processStarts(pids) {
|
|
42
|
+
const starts = new Map();
|
|
43
|
+
if (!pids.length || process.platform === 'win32') return starts;
|
|
44
|
+
if (process.platform === 'linux' && existsSync('/proc/self/stat')) {
|
|
45
|
+
// Linux: the start tick since boot (field 22), which a wall-clock step does not move (lstart would).
|
|
46
|
+
for (const pid of pids) {
|
|
47
|
+
try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); starts.set(pid, `tick ${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`); } catch { /* no process */ }
|
|
48
|
+
}
|
|
49
|
+
return starts;
|
|
50
|
+
}
|
|
51
|
+
const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
|
|
52
|
+
for (const pid of pids) {
|
|
53
|
+
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
|
|
54
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
|
|
55
|
+
if (match && Number(match[1]) === pid) starts.set(pid, match[2]);
|
|
56
|
+
}
|
|
57
|
+
return starts;
|
|
58
|
+
}
|
|
59
|
+
|