@rynx-ai/runtime 0.1.11-beta.32 → 0.1.11-beta.34

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.
@@ -0,0 +1,320 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { rynxRuntimeDir } from "@rynx-ai/core";
6
+ const REGISTRY_FILE = "process-registry.json";
7
+ const TAG_PREFIX = "rynx_crash_teardown_tag=";
8
+ const STATE_PREFIX = "rynx_crash_teardown_state_dir=";
9
+ export function runtimeProcessRegistryPath() {
10
+ return join(rynxRuntimeDir(), REGISTRY_FILE);
11
+ }
12
+ export function runtimeProcessTagArg(sessionTag) {
13
+ if (!sessionTag)
14
+ throw new Error("sessionTag must be non-empty");
15
+ return `${TAG_PREFIX}${sessionTag}`;
16
+ }
17
+ export function runtimeProcessArgv0(cliPath, sessionTag) {
18
+ return `${basename(cliPath)} ${runtimeProcessTagArg(sessionTag)}`;
19
+ }
20
+ export function withRuntimeProcessStateArg(baseArgs, stateDir) {
21
+ const args = [...baseArgs];
22
+ const marker = ["-c", `${STATE_PREFIX}${JSON.stringify(stateDir)}`];
23
+ const subcommand = args.indexOf("app-server");
24
+ args.splice(subcommand >= 0 ? subcommand : 0, 0, ...marker);
25
+ return args;
26
+ }
27
+ export function registerRuntimeProcess(entry, registryPath = runtimeProcessRegistryPath()) {
28
+ if (!validEntry(entry))
29
+ return;
30
+ mutateRegistry(registryPath, (entries) => [
31
+ ...entries.filter((candidate) => candidate.sessionTag !== entry.sessionTag),
32
+ entry,
33
+ ]);
34
+ }
35
+ export function unregisterRuntimeProcess(sessionTag, registryPath = runtimeProcessRegistryPath()) {
36
+ if (!sessionTag)
37
+ return;
38
+ mutateRegistry(registryPath, (entries) => entries.filter((entry) => entry.sessionTag !== sessionTag));
39
+ }
40
+ export function reconcileRuntimeProcesses(opts = {}) {
41
+ return reconcileEntries(undefined, opts);
42
+ }
43
+ export function reapRuntimeProcessesForStateDir(stateDir, opts = {}) {
44
+ if (process.platform === "win32" && !opts.processListing)
45
+ return 0;
46
+ const listing = opts.processListing?.() ?? listProcesses();
47
+ if (!listing)
48
+ return 0;
49
+ const currentPgid = opts.currentPgid?.() ?? processGroupId(process.pid);
50
+ const signalGroup = opts.signalGroup ?? signalProcessGroup;
51
+ const childAlive = opts.childAlive ?? isProcessAlive;
52
+ const graceMs = opts.graceMs ?? 1_500;
53
+ const victims = new Map();
54
+ for (const line of listing.split("\n")) {
55
+ const match = /^\s*(\d+)\s+(\d+)\s+([\s\S]+)$/.exec(line);
56
+ if (!match)
57
+ continue;
58
+ const pid = Number.parseInt(match[1], 10);
59
+ const pgid = Number.parseInt(match[2], 10);
60
+ const command = match[3];
61
+ if (!command.includes(stateDir) || !command.includes("app-server"))
62
+ continue;
63
+ if (pid === process.pid || pgid <= 0 || pgid === currentPgid)
64
+ continue;
65
+ victims.set(pid, pgid);
66
+ }
67
+ if (victims.size === 0)
68
+ return 0;
69
+ for (const pgid of new Set(victims.values()))
70
+ signalGroup(pgid, "SIGTERM");
71
+ const deadline = Date.now() + graceMs;
72
+ while (Date.now() < deadline && [...victims.keys()].some(childAlive)) {
73
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
74
+ }
75
+ for (const [pid, pgid] of victims) {
76
+ if (childAlive(pid))
77
+ signalGroup(pgid, "SIGKILL");
78
+ }
79
+ return victims.size;
80
+ }
81
+ function reconcileEntries(stateDir, opts) {
82
+ const registryPath = opts.registryPath ?? runtimeProcessRegistryPath();
83
+ const ownerAlive = opts.ownerAlive ?? isProcessAlive;
84
+ const ownerIdentity = opts.ownerIdentity ?? runtimeProcessOwnerIdentity;
85
+ const childAlive = opts.childAlive ?? isProcessAlive;
86
+ const commandLine = opts.commandLine ?? processCommandLine;
87
+ const terminateGroup = opts.terminateGroup ?? terminateProcessGroup;
88
+ let reaped = 0;
89
+ mutateRegistry(registryPath, (entries) => {
90
+ const survivors = [];
91
+ for (const entry of entries) {
92
+ if (stateDir !== undefined && entry.stateDir !== stateDir) {
93
+ survivors.push(entry);
94
+ continue;
95
+ }
96
+ if (ownerAlive(entry.ownerPid)) {
97
+ const currentOwnerIdentity = ownerIdentity(entry.ownerPid);
98
+ // Old registry entries have no start identity. Keep the conservative
99
+ // PID-only behavior for those entries; every newly registered process
100
+ // is protected against PID reuse.
101
+ if (!entry.ownerIdentity ||
102
+ !currentOwnerIdentity ||
103
+ currentOwnerIdentity === entry.ownerIdentity) {
104
+ survivors.push(entry);
105
+ continue;
106
+ }
107
+ }
108
+ if (!childAlive(entry.pid))
109
+ continue;
110
+ if (!commandLine(entry.pid).includes(runtimeProcessTagArg(entry.sessionTag)))
111
+ continue;
112
+ if (!terminateGroup(entry.pgid)) {
113
+ survivors.push(entry);
114
+ continue;
115
+ }
116
+ reaped += 1;
117
+ }
118
+ return survivors;
119
+ });
120
+ return reaped;
121
+ }
122
+ function validEntry(entry) {
123
+ return Number.isSafeInteger(entry.pid) && entry.pid > 0 &&
124
+ Number.isSafeInteger(entry.pgid) && entry.pgid > 0 &&
125
+ Number.isSafeInteger(entry.ownerPid) && entry.ownerPid > 0 &&
126
+ (entry.ownerIdentity === undefined ||
127
+ (typeof entry.ownerIdentity === "string" && entry.ownerIdentity.length > 0)) &&
128
+ Boolean(entry.sessionTag) && Boolean(entry.stateDir);
129
+ }
130
+ function readRegistry(path) {
131
+ let value;
132
+ try {
133
+ value = JSON.parse(readFileSync(path, "utf8"));
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ if (!Array.isArray(value))
139
+ return [];
140
+ return value.filter((entry) => typeof entry === "object" && entry !== null && validEntry(entry));
141
+ }
142
+ function mutateRegistry(path, mutation) {
143
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
144
+ const lockDir = `${path}.lock`;
145
+ const deadline = Date.now() + 2_000;
146
+ while (true) {
147
+ try {
148
+ mkdirSync(lockDir, { mode: 0o700 });
149
+ writeFileSync(join(lockDir, "owner.pid"), JSON.stringify({
150
+ pid: process.pid,
151
+ identity: runtimeProcessOwnerIdentity(process.pid),
152
+ }), { mode: 0o600 });
153
+ break;
154
+ }
155
+ catch (error) {
156
+ if (error.code !== "EEXIST")
157
+ return;
158
+ let ownerPid = 0;
159
+ let ownerIdentity = "";
160
+ try {
161
+ const rawOwner = readFileSync(join(lockDir, "owner.pid"), "utf8");
162
+ try {
163
+ const parsed = JSON.parse(rawOwner);
164
+ ownerPid = typeof parsed.pid === "number" ? parsed.pid : 0;
165
+ ownerIdentity = typeof parsed.identity === "string" ? parsed.identity : "";
166
+ }
167
+ catch {
168
+ // Backward-compatible recovery for a lock written by the earlier
169
+ // PID-only implementation.
170
+ ownerPid = Number.parseInt(rawOwner, 10);
171
+ }
172
+ }
173
+ catch {
174
+ // A creator may be between mkdir and marker write; the bounded retry handles it.
175
+ }
176
+ const currentOwnerIdentity = ownerPid > 0
177
+ ? runtimeProcessOwnerIdentity(ownerPid)
178
+ : "";
179
+ const staleKnownOwner = ownerPid > 0 && (!isProcessAlive(ownerPid) ||
180
+ Boolean(ownerIdentity && currentOwnerIdentity && ownerIdentity !== currentOwnerIdentity));
181
+ let staleUnownedLock = false;
182
+ if (ownerPid <= 0) {
183
+ try {
184
+ staleUnownedLock = Date.now() - statSync(lockDir).mtimeMs >= 1_000;
185
+ }
186
+ catch {
187
+ staleUnownedLock = true;
188
+ }
189
+ }
190
+ if (staleKnownOwner || staleUnownedLock) {
191
+ rmSync(lockDir, { recursive: true, force: true });
192
+ continue;
193
+ }
194
+ if (Date.now() >= deadline)
195
+ return;
196
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
197
+ }
198
+ }
199
+ try {
200
+ const entries = mutation(readRegistry(path));
201
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
202
+ writeFileSync(temp, `${JSON.stringify(entries)}\n`, { mode: 0o600 });
203
+ renameSync(temp, path);
204
+ }
205
+ finally {
206
+ rmSync(lockDir, { recursive: true, force: true });
207
+ }
208
+ }
209
+ function isProcessAlive(pid) {
210
+ try {
211
+ process.kill(pid, 0);
212
+ return true;
213
+ }
214
+ catch (error) {
215
+ return error.code === "EPERM";
216
+ }
217
+ }
218
+ /** Return an OS process-start identity used to distinguish PID reuse. */
219
+ export function runtimeProcessOwnerIdentity(pid) {
220
+ if (!Number.isSafeInteger(pid) || pid <= 0)
221
+ return "";
222
+ if (process.platform === "linux") {
223
+ try {
224
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
225
+ const commandEnd = stat.lastIndexOf(")");
226
+ if (commandEnd >= 0) {
227
+ // Fields after ')' begin at proc field 3; starttime is field 22.
228
+ const startTicks = stat.slice(commandEnd + 2).trim().split(/\s+/)[19];
229
+ if (startTicks)
230
+ return `linux:${startTicks}`;
231
+ }
232
+ }
233
+ catch {
234
+ return "";
235
+ }
236
+ }
237
+ try {
238
+ const startedAt = execFileSync("ps", ["-p", String(pid), "-ww", "-o", "lstart="], {
239
+ encoding: "utf8",
240
+ timeout: 2_000,
241
+ stdio: ["ignore", "pipe", "ignore"],
242
+ }).trim();
243
+ return startedAt ? `${process.platform}:${startedAt}` : "";
244
+ }
245
+ catch {
246
+ return "";
247
+ }
248
+ }
249
+ function processCommandLine(pid) {
250
+ if (process.platform === "linux") {
251
+ try {
252
+ return readFileSync(`/proc/${pid}/cmdline`)
253
+ .toString("utf8")
254
+ .replaceAll("\0", " ");
255
+ }
256
+ catch {
257
+ // Fall through to ps for macOS and restricted procfs environments.
258
+ }
259
+ }
260
+ try {
261
+ return execFileSync("ps", ["-p", String(pid), "-ww", "-o", "command="], {
262
+ encoding: "utf8",
263
+ timeout: 2_000,
264
+ stdio: ["ignore", "pipe", "ignore"],
265
+ }).trim();
266
+ }
267
+ catch {
268
+ return "";
269
+ }
270
+ }
271
+ function listProcesses() {
272
+ try {
273
+ return execFileSync("ps", ["-axww", "-o", "pid=,pgid=,command="], {
274
+ encoding: "utf8",
275
+ timeout: 5_000,
276
+ stdio: ["ignore", "pipe", "ignore"],
277
+ });
278
+ }
279
+ catch {
280
+ return "";
281
+ }
282
+ }
283
+ function processGroupId(pid) {
284
+ try {
285
+ const value = execFileSync("ps", ["-p", String(pid), "-o", "pgid="], {
286
+ encoding: "utf8",
287
+ timeout: 2_000,
288
+ stdio: ["ignore", "pipe", "ignore"],
289
+ });
290
+ return Number.parseInt(value.trim(), 10) || -1;
291
+ }
292
+ catch {
293
+ return -1;
294
+ }
295
+ }
296
+ function signalProcessGroup(pgid, signal) {
297
+ if (process.platform === "win32")
298
+ return false;
299
+ try {
300
+ process.kill(-pgid, signal);
301
+ return true;
302
+ }
303
+ catch (error) {
304
+ return error.code === "ESRCH";
305
+ }
306
+ }
307
+ function terminateProcessGroup(pgid) {
308
+ if (process.platform === "win32")
309
+ return false;
310
+ try {
311
+ process.kill(-pgid, "SIGTERM");
312
+ return true;
313
+ }
314
+ catch (error) {
315
+ return error.code === "ESRCH";
316
+ }
317
+ }
318
+ export function newRuntimeProcessTag() {
319
+ return `native-${randomUUID().replaceAll("-", "")}`;
320
+ }
@@ -8,6 +8,8 @@ export interface WsRpcChannelOptions {
8
8
  logger?: TransportLogger;
9
9
  /** Ms to wait for the app-server to accept a connection. */
10
10
  readyTimeoutMs?: number;
11
+ /** Durable per-session runtime state used for targeted crash reconciliation. */
12
+ stateDir?: string;
11
13
  }
12
14
  export declare class WsRpcChannel implements RpcChannel {
13
15
  private readonly opts;
@@ -16,6 +18,8 @@ export declare class WsRpcChannel implements RpcChannel {
16
18
  private lineCb;
17
19
  private closeCb;
18
20
  private closedEmitted;
21
+ private processTag;
22
+ private stopTask;
19
23
  private readonly readyTimeoutMs;
20
24
  /** The `ws://IP:PORT` the app-server listens on — pass to the TUI's `--remote`. */
21
25
  url: string;
@@ -30,6 +34,9 @@ export declare class WsRpcChannel implements RpcChannel {
30
34
  start(): Promise<void>;
31
35
  send(line: string): Promise<void>;
32
36
  stop(signal?: NodeJS.Signals): Promise<void>;
37
+ private stopOwnedProcess;
38
+ private signalChild;
39
+ private unregisterProcess;
33
40
  private emitClose;
34
41
  private connectWithRetry;
35
42
  private tryConnect;
@@ -16,6 +16,7 @@ import { spawn } from "node:child_process";
16
16
  import { createServer } from "node:net";
17
17
  import { WebSocket } from "ws";
18
18
  import { createCodexChildEnv } from "../codex-child-env.js";
19
+ import { newRuntimeProcessTag, reconcileRuntimeProcesses, registerRuntimeProcess, runtimeProcessArgv0, runtimeProcessOwnerIdentity, unregisterRuntimeProcess, withRuntimeProcessStateArg, } from "./process-registry.js";
19
20
  /** Reserve a free loopback TCP port (best-effort; racy but fine for local use). */
20
21
  async function freeLoopbackPort() {
21
22
  return await new Promise((resolve, reject) => {
@@ -36,6 +37,8 @@ export class WsRpcChannel {
36
37
  lineCb = null;
37
38
  closeCb = null;
38
39
  closedEmitted = false;
40
+ processTag = null;
41
+ stopTask = null;
39
42
  readyTimeoutMs;
40
43
  /** The `ws://IP:PORT` the app-server listens on — pass to the TUI's `--remote`. */
41
44
  url = "";
@@ -53,16 +56,48 @@ export class WsRpcChannel {
53
56
  return this.ws?.readyState === WebSocket.OPEN;
54
57
  }
55
58
  async start() {
59
+ this.stopTask = null;
60
+ this.closedEmitted = false;
61
+ if (this.opts.stateDir) {
62
+ reconcileRuntimeProcesses();
63
+ }
56
64
  const port = await freeLoopbackPort();
57
65
  this.url = `ws://127.0.0.1:${port}`;
58
- const args = [...this.opts.baseArgs, "--listen", this.url];
66
+ const baseArgs = this.opts.stateDir
67
+ ? withRuntimeProcessStateArg(this.opts.baseArgs, this.opts.stateDir)
68
+ : this.opts.baseArgs;
69
+ const args = [...baseArgs, "--listen", this.url];
70
+ const processTag = this.opts.stateDir ? newRuntimeProcessTag() : null;
71
+ this.processTag = processTag;
59
72
  this.child = spawn(this.opts.cliPath, args, {
60
73
  stdio: ["ignore", "ignore", "pipe"],
61
74
  env: { ...createCodexChildEnv(process.env), ...(this.opts.extraEnv ?? {}) },
75
+ detached: process.platform !== "win32",
76
+ ...(processTag ? { argv0: runtimeProcessArgv0(this.opts.cliPath, processTag) } : {}),
77
+ });
78
+ const childPid = this.child.pid;
79
+ if (processTag && this.opts.stateDir && childPid) {
80
+ registerRuntimeProcess({
81
+ pid: childPid,
82
+ pgid: childPid,
83
+ ownerPid: process.pid,
84
+ ownerIdentity: runtimeProcessOwnerIdentity(process.pid),
85
+ sessionTag: processTag,
86
+ stateDir: this.opts.stateDir,
87
+ });
88
+ }
89
+ this.child.on("exit", (code, signal) => {
90
+ this.unregisterProcess();
91
+ this.emitClose(code, signal, null);
62
92
  });
63
- this.child.on("exit", (code, signal) => this.emitClose(code, signal, null));
64
93
  this.child.on("error", (error) => this.emitClose(null, null, error));
65
- this.ws = await this.connectWithRetry(this.url);
94
+ try {
95
+ this.ws = await this.connectWithRetry(this.url);
96
+ }
97
+ catch (error) {
98
+ await this.stop();
99
+ throw error;
100
+ }
66
101
  this.ws.on("message", (data) => {
67
102
  // One JSON-RPC object per frame (codex ws framing).
68
103
  this.lineCb?.(data.toString());
@@ -86,6 +121,12 @@ export class WsRpcChannel {
86
121
  });
87
122
  }
88
123
  async stop(signal = "SIGTERM") {
124
+ if (this.stopTask)
125
+ return await this.stopTask;
126
+ this.stopTask = this.stopOwnedProcess(signal);
127
+ return await this.stopTask;
128
+ }
129
+ async stopOwnedProcess(signal) {
89
130
  try {
90
131
  this.ws?.close();
91
132
  }
@@ -93,14 +134,33 @@ export class WsRpcChannel {
93
134
  /* already closing */
94
135
  }
95
136
  const child = this.child;
96
- if (child) {
97
- try {
98
- child.kill(signal);
99
- }
100
- catch {
101
- /* already gone */
137
+ if (child && child.exitCode === null && child.signalCode === null) {
138
+ this.signalChild(child, signal);
139
+ if (!await waitForExit(child, 5_000)) {
140
+ this.signalChild(child, "SIGKILL");
141
+ await waitForExit(child, 2_000);
102
142
  }
103
143
  }
144
+ this.unregisterProcess();
145
+ this.child = null;
146
+ this.ws = null;
147
+ }
148
+ signalChild(child, signal) {
149
+ try {
150
+ if (process.platform !== "win32" && child.pid)
151
+ process.kill(-child.pid, signal);
152
+ else
153
+ child.kill(signal);
154
+ }
155
+ catch {
156
+ // Already gone.
157
+ }
158
+ }
159
+ unregisterProcess() {
160
+ const tag = this.processTag;
161
+ this.processTag = null;
162
+ if (tag)
163
+ unregisterRuntimeProcess(tag);
104
164
  }
105
165
  emitClose(code, signal, error) {
106
166
  if (this.closedEmitted)
@@ -146,6 +206,22 @@ export class WsRpcChannel {
146
206
  });
147
207
  }
148
208
  }
209
+ function waitForExit(child, timeoutMs) {
210
+ if (child.exitCode !== null || child.signalCode !== null)
211
+ return Promise.resolve(true);
212
+ return new Promise((resolve) => {
213
+ const timer = setTimeout(() => {
214
+ child.off("exit", onExit);
215
+ resolve(false);
216
+ }, timeoutMs);
217
+ timer.unref?.();
218
+ const onExit = () => {
219
+ clearTimeout(timer);
220
+ resolve(true);
221
+ };
222
+ child.once("exit", onExit);
223
+ });
224
+ }
149
225
  /**
150
226
  * Connect-only {@link RpcChannel}: attaches an ADDITIONAL client to an app-server
151
227
  * someone else already started (a {@link WsRpcChannel}'s `url`) — no spawn. This
package/dist/host.d.ts CHANGED
@@ -143,6 +143,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
143
143
  /** Claude forwarders stopped before their terminal is killed. Runner shutdown
144
144
  * finalizes these synchronously afterwards to scrub raw interaction answers. */
145
145
  private readonly pendingClaudeFinalizers;
146
+ private readonly pendingCleanupTasks;
146
147
  private readonly liveEnsuring;
147
148
  private readonly forkingTargets;
148
149
  /** Short-lived dedupe for managed fork notifications delivered after the
@@ -208,11 +209,16 @@ export declare class LocalAgentHost implements CodexCapabilities {
208
209
  * SAME thread the structured data channel drives.
209
210
  */
210
211
  codexTerminalSpec(localThreadId: string): Promise<{
212
+ lifecycle: "required" | "auxiliary";
211
213
  command: string;
212
214
  args: string[];
213
215
  cwd: string;
214
216
  env?: Record<string, string>;
215
217
  skipTraexStartupPrompts?: boolean;
218
+ scrollback?: number;
219
+ tmuxAllowPassthrough?: boolean;
220
+ tmuxStartOnAttach?: boolean;
221
+ keepAliveAfterExit?: boolean;
216
222
  } | null>;
217
223
  /**
218
224
  * Bring up (idempotently) a session's persistent codex forwarder and emit its
@@ -302,9 +308,11 @@ export declare class LocalAgentHost implements CodexCapabilities {
302
308
  * forwarder and per-session app-server as one disposable runtime envelope;
303
309
  * the next message recreates that envelope and cold-resumes the native id. */
304
310
  teardownLiveCodexSession(localThreadId: string, error?: Error): boolean;
311
+ teardownAuxiliaryTerminalRuntime(localThreadId: string): Promise<void>;
305
312
  /** Complete the second shutdown phase after the runner has killed all native
306
313
  * terminals and hook subprocesses. Must run before the runner process exits. */
307
- finalizeStoppedLiveSessions(): void;
314
+ finalizeStoppedLiveSessions(): Promise<void>;
315
+ private trackCleanup;
308
316
  /** The interactive `claude` TUI spec for a claude-native live session: the real
309
317
  * `claude` binary with rynx's hooks (`--settings`), resuming the bound session.
310
318
  * Trust is pre-seeded so the daemon-spawned TUI never blocks on a first-run