terminal-pty-lifecycle.ts
144 lines · browse/src/terminal-pty-lifecycle.ts at 3f79d81
1/** Existing PTY shutdown grace, also bounds waiting for exit/reader completion. */
2export const PTY_SHUTDOWN_GRACE_MS = 3000;
4export interface PtyProcessExit {
5 exitCode: number | null;
6 signal: string | null;
7 waitFailed: boolean;
8}
10export interface PtyReaderEnd {
11 code: number;
12 signal: string | null;
13}
15export interface PtyCompletion {
16 process: PtyProcessExit | null;
17 reader: PtyReaderEnd | null;
18 drainTimedOut: boolean;
19 exitTimedOut: boolean;
20 /** null: Bun reported an I/O status without enough detail to prove loss/EOF. */
21 outputComplete: boolean | null;
22}
24interface Timer {
25 cancel(): void;
26}
28function scheduleDeadline(callback: () => void, delayMs: number): Timer {
29 const handle = setTimeout(callback, delayMs);
30 handle.unref?.();
31 return { cancel: () => clearTimeout(handle) };
32}
34export interface PtyLifecycleOptions {
35 onData(chunk: Buffer): void;
36 onComplete(completion: PtyCompletion): void;
37 /** An injectable timer keeps ordering/deadline tests independent of wall time. */
38 schedule?: (callback: () => void, delayMs: number) => Timer;
39}
41/**
42 * Process exit does not drain a Bun.Terminal. Continue forwarding until the
43 * reader finishes; bound the missing half of completion by the shutdown grace.
44 * Bun 1.3.13 Terminal.zig reports reader 0=EOF / 1=I/O error (no errno), not
45 * the child's exit code. In particular, never turn reader 1 into clean EOF.
46 */
47export function createPtyLifecycle(options: PtyLifecycleOptions) {
48 let processExit: PtyProcessExit | null = null;
49 let readerEnd: PtyReaderEnd | null = null;
50 let stopped = false;
51 let timer: Timer | null = null;
52 const schedule = options.schedule ?? scheduleDeadline;
54 function finish() {
55 if (stopped) return;
56 stopped = true;
57 timer?.cancel();
58 timer = null;
59 options.onComplete({
60 process: processExit,
61 reader: readerEnd,
62 drainTimedOut: readerEnd === null,
63 exitTimedOut: processExit === null,
64 outputComplete: readerEnd === null ? false
65 : readerEnd.code === 0 && readerEnd.signal === null ? true : null,
66 });
67 }
69 function changed() {
70 if (processExit && readerEnd) finish();
71 else if (!timer) timer = schedule(finish, PTY_SHUTDOWN_GRACE_MS);
72 }
74 return {
75 data(chunk: Buffer) {
76 if (!stopped && !readerEnd) options.onData(chunk);
77 },
78 exited(exitCode: number | null, signal: string | null, waitFailed = false) {
79 if (stopped || processExit) return;
80 processExit = { exitCode, signal, waitFailed };
81 changed();
82 },
83 readerEnded(code: number, signal: string | null) {
84 if (stopped || readerEnd) return;
85 readerEnd = { code, signal };
86 changed();
87 },
88 dispose() {
89 // Must precede Terminal.close(): explicit close itself calls reader EOF.
90 stopped = true;
91 timer?.cancel();
92 timer = null;
93 },
94 };
95}
97export type PtyLifecycle = ReturnType<typeof createPtyLifecycle>;
99interface OwnedPtyProcess {
100 pid: number;
101 exitCode: number | null;
102 signalCode: string | null;
103 exited: Promise<number>;
104 terminal?: { close(): void };
105 kill(signal: string): unknown;
106}
108/** Call only after cancelling the lifecycle: Terminal.close() can report EOF. */
109export function disposePtyProcess(
110 proc: OwnedPtyProcess | null,
111 schedule = scheduleDeadline,
112): void {
113 try { proc?.terminal?.close(); } catch {}
114 if (!proc?.pid || proc.exitCode != null || proc.signalCode != null) return;
115 let exited = false;
116 let timer: Timer | null = null;
117 proc.exited.then(() => {
118 exited = true;
119 timer?.cancel();
120 }, () => {});
121 try { proc.kill('SIGINT'); } catch {}
122 // `killed` only proves a signal was sent. Retain this exact process object,
123 // never a mutable session.proc that could now refer to a replacement child.
124 timer = schedule(() => {
125 if (!exited) {
126 try { proc.kill('SIGKILL'); } catch {}
127 }
128 }, PTY_SHUTDOWN_GRACE_MS);
129}
131/** Transport close 1000 means this session ended, not that its child succeeded. */
132export function ptyCompletionReason(completion: PtyCompletion): string {
133 const child = completion.process;
134 if (child?.waitFailed) return 'pty process wait failed';
135 if (child?.signal) return `pty exited (${child.signal})`;
136 if (child && child.exitCode !== null && child.exitCode !== 0) {
137 return `pty exited (code ${child.exitCode})`;
138 }
139 if (completion.drainTimedOut) return 'pty output drain timed out';
140 if (completion.exitTimedOut) return 'pty process exit timed out';
141 // Reader status 1 also occurs on ordinary Linux PTY shutdown. Retain it in
142 // the completion record, without asserting either clean EOF or output loss.
143 return 'pty exited';
144}