-
Notifications
You must be signed in to change notification settings - Fork 101
Expand file tree
/
Copy pathdisposableExec.ts
More file actions
204 lines (182 loc) · 6.41 KB
/
disposableExec.ts
File metadata and controls
204 lines (182 loc) · 6.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
import { log } from "@/node/services/log";
/**
* Disposable wrapper for child processes that ensures immediate cleanup.
* Implements TypeScript's explicit resource management (using) for process lifecycle.
*
* All registered cleanup callbacks execute immediately when disposed, either:
* - Explicitly via Symbol.dispose
* - Automatically when exiting a `using` block
* - On process exit
*
* Usage:
* const process = spawn("command");
* const disposable = new DisposableProcess(process);
* disposable.addCleanup(() => stream.destroy());
* // Cleanup runs automatically on process exit
*/
export class DisposableProcess implements Disposable {
private cleanupCallbacks: Array<() => void> = [];
private disposed = false;
constructor(private readonly process: ChildProcess) {
// No auto-cleanup - callers explicitly dispose via timeout/abort handlers
// Process streams close naturally when process exits
}
/**
* Register cleanup callback to run when process is disposed.
* If already disposed, runs immediately.
*/
addCleanup(callback: () => void): void {
if (this.disposed) {
// Already disposed, run immediately
try {
callback();
} catch {
// Ignore errors during cleanup
}
} else {
this.cleanupCallbacks.push(callback);
}
}
/**
* Get the underlying child process
*/
get underlying(): ChildProcess {
return this.process;
}
/**
* Cleanup: kill process + run all cleanup callbacks immediately.
* Safe to call multiple times (idempotent).
*/
[Symbol.dispose](): void {
if (this.disposed) return;
this.disposed = true;
// Kill process if still running
// Check both exitCode and signalCode to avoid calling kill() on already-dead processes
// When a process exits via signal (e.g., segfault, kill $$), exitCode is null but signalCode is set
if (
!this.process.killed &&
this.process.exitCode === null &&
this.process.signalCode === null
) {
try {
this.process.kill("SIGKILL");
} catch {
// Ignore ESRCH errors - process may have exited between check and kill
}
}
// Run all cleanup callbacks
for (const callback of this.cleanupCallbacks) {
try {
callback();
} catch {
// Ignore cleanup errors - we're tearing down anyway
}
}
this.cleanupCallbacks = [];
}
}
/**
* Disposable wrapper for exec that ensures child process cleanup.
* Prevents zombie processes by killing child when scope exits.
*
* Usage:
* using proc = execAsync("git status");
* const { stdout } = await proc.result;
*/
class DisposableExec implements Disposable {
constructor(
private readonly promise: Promise<{ stdout: string; stderr: string }>,
private readonly child: ChildProcess
) {}
[Symbol.dispose](): void {
// Only kill if process hasn't exited naturally
// Check the child's actual exit state, not promise state (avoids async timing issues)
const hasExited = this.child.exitCode !== null || this.child.signalCode !== null;
if (!hasExited && !this.child.killed) {
this.child.kill();
}
}
get result() {
return this.promise;
}
}
/**
* Execute command with automatic cleanup via `using` declaration.
* Prevents zombie processes by ensuring child is reaped even on error.
*
* Commands are always wrapped in `bash -c` for consistent behavior across platforms.
* On Windows, this uses the detected bash runtime (Git for Windows or WSL).
* For WSL, Windows paths in the command are automatically translated.
*
* @example
* using proc = execAsync("git status");
* const { stdout } = await proc.result;
*/
export function execAsync(command: string): DisposableExec {
// Wrap command in bash -c for consistent cross-platform behavior
// For WSL, this also translates Windows paths to /mnt/... format
const { command: bashCmd, args } = getPreferredSpawnConfig(command);
// Debug logging for Windows WSL issues
log.info(`[execAsync] Original command: ${command}`);
log.info(`[execAsync] Spawn command: ${bashCmd}`);
log.info(`[execAsync] Spawn args: ${JSON.stringify(args)}`);
const child = spawn(bashCmd, args, {
stdio: ["ignore", "pipe", "pipe"],
// Prevent console window from appearing on Windows
windowsHide: true,
});
log.info(`[execAsync] Spawned process PID: ${child.pid}`);
const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
let exitSignal: string | null = null;
child.stdout?.on("data", (data) => {
stdout += data;
});
child.stderr?.on("data", (data) => {
stderr += data;
});
// Use 'close' event instead of 'exit' - close fires after all stdio streams are closed
// This ensures we've received all buffered output before resolving/rejecting
child.on("exit", (code, signal) => {
exitCode = code;
exitSignal = signal;
log.info(`[execAsync] Process exited with code: ${code}, signal: ${signal}`);
});
child.on("close", () => {
log.info(`[execAsync] Process closed. stdout length: ${stdout.length}, stderr length: ${stderr.length}`);
log.info(`[execAsync] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`);
if (stderr) {
log.info(`[execAsync] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`);
}
// Only resolve if process exited cleanly (code 0, no signal)
if (exitCode === 0 && exitSignal === null) {
resolve({ stdout, stderr });
} else {
// Include stderr in error message for better debugging
const errorMsg =
stderr.trim() ||
(exitSignal
? `Command killed by signal ${exitSignal}`
: `Command failed with exit code ${exitCode ?? "unknown"}`);
const error = new Error(errorMsg) as Error & {
code: number | null;
signal: string | null;
stdout: string;
stderr: string;
};
error.code = exitCode;
error.signal = exitSignal;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
}
});
child.on("error", reject);
});
return new DisposableExec(promise, child);
}