@volter/world-runtime 3.0.38 → 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.
Files changed (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
@@ -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 the MongoDB twin (@volter/twin-mongodb: the MongoDB wire protocol over
4
- // the World state kernel). Selected by `volter-world-infra` when no container
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, writeFileSync } from 'node:fs';
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, spawnSync } from 'node:child_process';
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 { findInstalledPackage, packCli } from './catalog.ts';
21
- import { vendorPackageDir } from '@volter/world-core/runtime';
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
- /** Parse the services out of world.infrastructure.yml. This is NOT a YAML
26
- * parser: init's renderComposeFile emits a deterministic shape, and this
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
- let current: string | null = null;
33
- let inServices = false;
34
- for (const line of text.split(/\r?\n/)) {
35
- if (/^services:\s*$/.test(line)) { inServices = true; continue; }
36
- if (!inServices) continue;
37
- const service = line.match(/^ {2}([a-z][a-z0-9_-]*):\s*$/);
38
- if (service) { current = service[1]!; continue; }
39
- const port = line.match(/^ {6}- "127\.0\.0\.1:(\d+):\d+"\s*$/);
40
- if (port && current) {
41
- services.push({ kind: current, hostPort: Number(port[1]) });
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: 'twin-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
- /** The MongoDB twin's cli: the package installed above the World's config (an app repo), else the one
81
- * this runtime was installed with (its dependency, or the checkout's workspace link). */
82
- export function mongodbTwinCli(worldConfig?: string, froms: string[] = [...(worldConfig ? [dirname(worldConfig)] : []), here()]): string {
83
- for (const from of froms) {
84
- const dir = vendorPackageDir(from, 'mongodb') ?? findInstalledPackage(from, MONGODB_TWIN);
85
- const cli = dir === undefined ? undefined : packCli(dir);
86
- if (cli !== undefined) return cli;
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
- throw new Error(`the containerless mongodb service is served by ${MONGODB_TWIN} (an optional dependency of @volter/world-runtime), which is not installed above ${froms.join(' or ')} — install it, or run the World with a container runtime`);
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, env: NodeJS.ProcessEnv): 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
- try { process.kill(pid, 0); return true; } catch { return false; }
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(kind: string, port: number): string {
143
- return kind === 'mongodb' ? `listening on mongodb://127.0.0.1:${port}` : `serving postgres wire protocol on 127.0.0.1:${port}`;
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
- try { return readFileSync(log).subarray(offset).toString('utf8'); } catch { return ''; }
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
- if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim()))) continue;
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, env) });
174
+ plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
164
175
  }
165
- const started: Array<{ pid: number; pidFile: string }> = [];
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.unref();
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
- writeFileSync(pidFile, `${child.pid}\n`);
183
- started.push({ pid: child.pid!, pidFile });
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.kind, service.hostPort);
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
- for (const { pid, pidFile } of started) {
199
- if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
200
- rmSync(pidFile, { force: true });
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
- if (!existsSync(pidFile)) continue;
211
- const pid = Number(readFileSync(pidFile, 'utf8').trim());
212
- if (alive(pid) && await listening(service.hostPort)) ready += 1;
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
- const pidFile = pidPath(dataDir, service.kind);
220
- if (!existsSync(pidFile)) continue;
221
- const pid = Number(readFileSync(pidFile, 'utf8').trim());
222
- if (alive(pid)) {
223
- try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
224
- const deadline = Date.now() + 5000;
225
- while (alive(pid) && Date.now() < deadline) await sleep(100);
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
  }
@@ -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 { PGlite } from '@electric-sql/pglite';
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?.message ?? error}\n`);
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 = Date.now();
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 { /* the backend's own failure surfaces on the next real message */ }
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 { /* the savepoint went with the transaction */ }
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 && Date.now() - guestStartedAt > GUEST_TURN_MS) {
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 += Date.now() - guestStartedAt;
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 += Date.now() - guestStartedAt;
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 (Date.now() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
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: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
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 += Date.now() - guestStartedAt;
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?.message ?? error}\n`);
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?.message ?? error}\n`);
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 = Date.now();
884
+ session.idleSince = machineNow();
855
885
  const response = withoutEarlyReady(data, raw);
856
886
  settleClose(session, closes, response);
857
887
  const status = lastReadyStatus(response);
@@ -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 file, seed. */
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, envFile: before.envFile });
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,2 @@
1
+ export function survivingOwnedGroups(pids: number[]): number[];
2
+ export function processStarts(pids: number[]): Map<number, string>;
@@ -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
+