Skip to content

Commit edbcc7b

Browse files
fix: preserve inherited descriptors in launcher
1 parent d2d4d62 commit edbcc7b

5 files changed

Lines changed: 96 additions & 73 deletions

File tree

bin/pty

Lines changed: 6 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
#!/usr/bin/env node
2-
// Give this signal-forwarding wrapper a meaningful process name. Under Node 24+
2+
// Give the CLI process a meaningful process name. Under Node 24+
33
// V8 names the main thread "MainThread", so without this every pty process shows
44
// up as `MainThread` in ps/top/htop/btm. `process.title` is the only thing that
55
// overrides /proc/<pid>/comm, and only when set from within the running process
66
// after V8 init. Linux caps comm at 15 chars (TASK_COMM_LEN), so keep it short.
77
try { process.title = 'pty'; } catch {}
88

99
import { existsSync } from 'node:fs';
10-
import { spawn } from 'node:child_process';
11-
import { fileURLToPath } from 'node:url';
10+
import { fileURLToPath, pathToFileURL } from 'node:url';
1211
import { dirname, join } from 'node:path';
13-
import { constants as osConstants } from 'node:os';
1412

1513
const __dirname = dirname(fileURLToPath(import.meta.url));
1614
const cli = join(__dirname, '..', 'dist', 'cli.js');
@@ -20,38 +18,7 @@ if (!existsSync(cli)) {
2018
process.exit(1);
2119
}
2220

23-
// `spawn` (not `spawnSync`) so the wrapper stays event-loop-live and can
24-
// react to signals it receives. Without this, signals delivered to the
25-
// wrapper (e.g. systemd's SIGTERM under KillMode=process) terminated the
26-
// wrapper without ever reaching the child supervisor, leaving an orphaned
27-
// supervisor that kept holding `supervisor.lock` and made every subsequent
28-
// service restart fail with "another supervisor is already running".
29-
const child = spawn(process.execPath, [cli, ...process.argv.slice(2)], {
30-
stdio: 'inherit',
31-
env: process.env,
32-
});
33-
34-
const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT', 'SIGUSR1', 'SIGUSR2'];
35-
for (const sig of FORWARDED_SIGNALS) {
36-
process.on(sig, () => {
37-
// Forward and let the child decide when to exit. The wrapper's own
38-
// exit is driven by the child's 'exit' event so the child has time
39-
// to flush before we propagate its status.
40-
try { child.kill(sig); } catch {}
41-
});
42-
}
43-
44-
child.on('exit', (code, signal) => {
45-
if (signal) {
46-
// Mirror shell convention (128 + signum) so callers can distinguish
47-
// signal death from a nonzero exit code.
48-
const signum = osConstants.signals[signal] ?? 0;
49-
process.exit(128 + signum);
50-
}
51-
process.exit(code ?? 1);
52-
});
53-
54-
child.on('error', (err) => {
55-
console.error(`pty: failed to spawn cli: ${err.message}`);
56-
process.exit(1);
57-
});
21+
// Load the CLI in this process. Besides avoiding an otherwise redundant Node
22+
// child and signal-forwarding layer, this preserves every descriptor inherited
23+
// by the launcher, including caller-owned machine-stream descriptors above 2.
24+
await import(pathToFileURL(cli).href);

src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2796,8 +2796,8 @@ function printLaunchdPlist(interval: number): void {
27962796
const escape = (s: string) =>
27972797
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27982798

2799-
// We point ProgramArguments at node + the resolved CLI script so the
2800-
// plist doesn't depend on the `pty` shim staying on PATH at launchd's
2799+
// We point ProgramArguments at node + the invoked launcher or CLI script so
2800+
// the plist doesn't depend on the `pty` shim staying on PATH at launchd's
28012801
// (minimal) shell. EnvironmentVariables still carries PATH so the
28022802
// spawned children (and any `which` inside pty itself) find the user's
28032803
// tools. PTY_ROOT (canonical) pins the target registry.

tests/attach-stream.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919

2020
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2121
const cliPath = path.join(__dirname, "..", "dist", "cli.js");
22+
const binPath = path.join(__dirname, "..", "bin", "pty");
2223
const clientUrl = pathToFileURL(path.join(__dirname, "..", "dist", "client.js")).href;
2324
const nodeBin = process.execPath;
2425
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-attach-stream-"));
@@ -122,6 +123,63 @@ describe("pty attach --attach-stream-fd-v1", () => {
122123
expect(result.stdout).toBe("");
123124
});
124125

126+
it("streams framed events through fd 3 from the shipped bin launcher", async () => {
127+
const root = fs.mkdtempSync(path.join(testRoot, "launcher-"));
128+
const name = `launcher-${process.pid}`;
129+
const launcherEnv: NodeJS.ProcessEnv = {
130+
...process.env,
131+
PTY_ROOT: root,
132+
PTY_ROOT_LEGACY_SILENT: "1",
133+
};
134+
delete launcherEnv.PTY_SESSION;
135+
delete launcherEnv.PTY_SERVER_CONFIG;
136+
const created = spawnSync(
137+
nodeBin,
138+
[cliPath, "run", "-d", "--id", name, "--", "sh", "-c", "printf LAUNCHER_READY; read value"],
139+
{ env: launcherEnv, encoding: "utf8" },
140+
);
141+
expect(created.status, created.stderr).toBe(0);
142+
const child = spawn(
143+
nodeBin,
144+
[binPath, "attach", "--attach-stream-fd-v1", "3", name],
145+
{
146+
env: launcherEnv,
147+
stdio: ["pipe", "pipe", "pipe", "pipe"],
148+
},
149+
);
150+
const stdout = collect(child.stdout);
151+
const stderr = collect(child.stderr);
152+
const streamChunks: Buffer[] = [];
153+
const reader = new PacketReader();
154+
let sentInput = false;
155+
(child.stdio[3] as NodeJS.ReadableStream).on("data", (chunk) => {
156+
const data = Buffer.from(chunk);
157+
streamChunks.push(data);
158+
for (const packet of reader.feed(data)) {
159+
if (packet.type === MessageType.SCREEN && !sentInput) {
160+
sentInput = true;
161+
child.stdin.write("done\n");
162+
}
163+
}
164+
});
165+
const status = await new Promise<number | null>((resolve, reject) => {
166+
const timer = setTimeout(() => reject(new Error("bin launcher attach timed out")), 10_000);
167+
child.once("exit", (code) => {
168+
clearTimeout(timer);
169+
resolve(code);
170+
});
171+
});
172+
173+
expect(status).toBe(0);
174+
expect(await stdout).toEqual(Buffer.alloc(0));
175+
expect(await stderr).toEqual(Buffer.alloc(0));
176+
const packets = new PacketReader().feed(Buffer.concat(streamChunks));
177+
expect(packets[0].type).toBe(MessageType.GEOMETRY);
178+
expect(packets[1].type).toBe(MessageType.SCREEN);
179+
expect(packets[1].payload.toString()).toContain("LAUNCHER_READY");
180+
expect(packets.at(-1)?.type).toBe(MessageType.EXIT);
181+
}, 15_000);
182+
125183
it("reframes fragmented and coalesced daemon packets in order without stdout output", async () => {
126184
const geometry = encodeGeometry(31, 97);
127185
const screen = encodeScreen("\x1b[31mred\x1b[0m");

tests/gc.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { spawn, spawnSync } from "node:child_process";
99
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1010
const nodeBin = process.execPath;
1111
const cliPath = path.join(__dirname, "..", "dist", "cli.js");
12+
const binPath = path.join(__dirname, "..", "bin", "pty");
1213
const serverModule = path.join(__dirname, "..", "dist", "server.js");
1314

1415
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gc-"));
@@ -299,6 +300,17 @@ describe("pty gc", () => {
299300
expect(result.stdout).toContain("<key>PTY_ROOT</key>");
300301
});
301302

303+
it("--print-launchd-plist preserves the invoked bin launcher", () => {
304+
const dir = makeSessionDir();
305+
const result = spawnSync(nodeBin, [binPath, "gc", "--print-launchd-plist"], {
306+
env: { ...process.env, PTY_SESSION_DIR: dir },
307+
encoding: "utf-8",
308+
timeout: 10000,
309+
});
310+
expect(result.status).toBe(0);
311+
expect(result.stdout).toContain(`<string>${binPath}</string>`);
312+
});
313+
302314
it("--print-launchd-plist --interval=N sets the interval", () => {
303315
const dir = makeSessionDir();
304316
const result = runCli(dir, "gc", "--print-launchd-plist", "--interval=15");

tests/wrapper-signal-forwarding.test.ts

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1-
// Verifies that bin/pty forwards SIGTERM/SIGINT to the inner cli.js child.
2-
// systemd's `KillMode=process` only signals the leader (the bin/pty shell
3-
// shim) and lets children become orphans unless the leader propagates the
4-
// signal. Without forwarding, the inner cli.js survives a unit `stop` and
5-
// the next start fails because the orphan still holds whatever resource
6-
// it owned (file watchers, sockets, etc.).
1+
// Verifies that bin/pty runs the CLI in the launcher's process. Keeping a
2+
// single process makes inherited descriptors and signal ownership identical
3+
// to a direct dist/cli.js invocation.
74

85
import { describe, it, expect, afterEach } from "vitest";
96
import * as fs from "node:fs";
107
import * as os from "node:os";
118
import * as path from "node:path";
129
import { fileURLToPath } from "node:url";
13-
import { spawn } from "node:child_process";
10+
import { execFileSync, spawn } from "node:child_process";
1411

1512
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1613
const wrapperPath = path.join(__dirname, "..", "bin", "pty");
@@ -49,18 +46,16 @@ function isAlive(pid: number): boolean {
4946
try { process.kill(pid, 0); return true; } catch { return false; }
5047
}
5148

52-
describe("bin/pty signal forwarding", () => {
53-
it("propagates SIGTERM to the inner cli.js (events --all is long-lived)", async () => {
49+
describe("bin/pty process lifecycle", () => {
50+
it("runs a long-lived command as one process that exits on SIGTERM", async () => {
5451
const sessionDir = makeSessionDir();
52+
const socketPath = path.join(sessionDir, "remote.sock");
5553

56-
// `events --all` is a long-lived command that registers a SIGINT
57-
// handler and runs an EventFollower until the process is signalled.
58-
// Same shape as the supervisor used to be: long-running, owns file
59-
// watchers, must exit cleanly when the wrapper relays a signal.
60-
const wrapper = spawn(nodeBin, [wrapperPath, "events", "--all"], {
54+
const wrapper = spawn(nodeBin, [wrapperPath, "remote-serve", "--socket", socketPath], {
6155
env: { ...process.env, PTY_SESSION_DIR: sessionDir },
6256
stdio: ["ignore", "pipe", "pipe"],
6357
});
58+
trackedPids.push(wrapper.pid!);
6459

6560
let stdout = "";
6661
let stderr = "";
@@ -71,36 +66,27 @@ describe("bin/pty signal forwarding", () => {
7166
let exitSignal: NodeJS.Signals | null = null;
7267
wrapper.on("exit", (c, s) => { exitCode = c; exitSignal = s; });
7368

74-
// Wait long enough for the wrapper to fork the inner node cli.js.
75-
// No specific marker; sleep briefly then look at the process tree.
76-
await new Promise((r) => setTimeout(r, 800));
69+
const started = await waitFor(
70+
() => stdout.includes(`pty remote-serve listening on ${socketPath}`),
71+
5000,
72+
);
73+
expect(started, `bin/pty did not become ready; stdout=${stdout} stderr=${stderr}`).toBe(true);
7774

78-
// Find the inner cli.js by walking the wrapper's child processes via
79-
// /proc on Linux, or via `pgrep -P` everywhere. We use ps -o pid -p
80-
// <wrapper-pid> first then list children with a tree walk fallback.
81-
const psResult = (() => {
75+
const childPids = (() => {
8276
try {
83-
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
8477
return execFileSync("pgrep", ["-P", String(wrapper.pid)], { encoding: "utf-8" }).trim();
8578
} catch {
8679
return "";
8780
}
8881
})();
89-
expect(psResult, `pgrep failed to find a child of pid ${wrapper.pid}; stdout=${stdout} stderr=${stderr}`).not.toBe("");
82+
expect(childPids, `bin/pty unexpectedly spawned a child; stdout=${stdout} stderr=${stderr}`).toBe("");
9083

91-
const innerPid = parseInt(psResult.split("\n")[0]!.trim(), 10);
92-
trackedPids.push(innerPid);
93-
expect(innerPid).toBeGreaterThan(0);
94-
expect(innerPid).not.toBe(wrapper.pid);
95-
expect(isAlive(innerPid)).toBe(true);
96-
97-
// The actual test: SIGTERM the wrapper, expect the inner cli.js to also die.
9884
wrapper.kill("SIGTERM");
9985

10086
const wrapperExited = await waitFor(() => exitCode !== null || exitSignal !== null, 5000);
10187
expect(wrapperExited, "wrapper did not exit after SIGTERM").toBe(true);
10288

103-
const innerDied = await waitFor(() => !isAlive(innerPid), 5000);
104-
expect(innerDied, `inner cli.js (pid ${innerPid}) survived wrapper SIGTERM — signal not forwarded`).toBe(true);
89+
expect(exitCode).toBe(0);
90+
expect(exitSignal).toBeNull();
10591
}, 20000);
10692
});

0 commit comments

Comments
 (0)