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
|
import { spawn as nodeSpawn } from "node:child_process";
import { access, readdir, readFile, stat, writeFile } from "node:fs/promises";
import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js";
/**
* LocalExecBackend — wraps `node:fs/promises` + `node:child_process`.
*
* Behavior is IDENTICAL to today's local tools:
* - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same
* `sh -c` invocation, detached process-group kill on abort/timeout,
* close-based resolution, and spawn-error → `{ exitCode: 1 }`.
* - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises`
* calls (utf8, `withFileTypes`) the tools make inline today, and throw the
* same node errors (carrying `.code`) so the tools' existing error branches
* work unchanged.
* - `exists` swallows all errors and returns `false` (an existence check).
*
* This factors the inline node calls out behind the `ExecBackend` interface so
* a remote (SshExecBackend) can swap in transparently. Stateless — safe to
* share as a singleton.
*/
export function createLocalExecBackend(): ExecBackend {
return {
spawn: localSpawn,
readFile: (path) => readFile(path, "utf8"),
writeFile: (path, content) => writeFile(path, content, "utf8"),
stat: async (path): Promise<StatResult> => {
const s = await stat(path);
return { isFile: s.isFile(), isDirectory: s.isDirectory() };
},
readdir: async (path): Promise<readonly DirEntry[]> => {
const entries = await readdir(path, { encoding: "utf8", withFileTypes: true });
return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() }));
},
exists: async (path): Promise<boolean> => {
try {
await access(path);
return true;
} catch {
return false;
}
},
};
}
/** Default singleton — stateless, safe to share across calls. */
export const localExecBackend: ExecBackend = createLocalExecBackend();
/**
* Run a shell command locally via `node:child_process`.
*
* Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so
* behavior is byte-identical: `sh -c <command>`, `detached: true` (own process
* group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild
* cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid
* listener/timer leaks.
*/
function localSpawn(params: SpawnParams): Promise<ExecResult> {
return new Promise<ExecResult>((resolve) => {
// detached: true puts the child in its own process group (pgid = child.pid).
// This lets us kill the entire group (child + any grandchildren that inherit
// the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
// backgrounded grandchild can't keep the stdio pipes open and stall the
// promise on child.on("close").
const child = nodeSpawn("sh", ["-c", params.command], {
cwd: params.cwd,
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let settled = false;
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
/** Kill the entire child process group (best-effort — group may be gone). */
const killGroup = () => {
if (child.pid !== undefined) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
// Process group may already be gone — ignore.
}
}
};
/** Remove the abort listener and clear the timeout timer (no leaks). */
const cleanup = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
params.signal.removeEventListener("abort", onAbort);
};
/** Resolve once, then clean up so listeners/timers never leak. */
const settle = (result: ExecResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const onAbort = () => {
if (settled) return;
killGroup();
// Resolve immediately — do NOT wait for child.on("close"), which may
// never fire if a grandchild holds the pipes open.
settle({ exitCode: null, timedOut: false, aborted: true });
};
params.signal.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
if (settled) return;
timedOut = true;
killGroup();
// Resolve immediately — same reasoning as abort.
settle({ exitCode: null, timedOut: true, aborted: false });
}, params.timeout);
child.stdout.on("data", (chunk: Buffer) => {
params.onOutput(chunk.toString(), "stdout");
});
child.stderr.on("data", (chunk: Buffer) => {
params.onOutput(chunk.toString(), "stderr");
});
// Normal-completion path: wait for "close" so all stdout/stderr is captured.
// If abort/timeout already settled, this is a no-op (settled === true).
child.on("close", (code) => {
settle({ exitCode: code, timedOut, aborted: false });
});
// Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
// and resolve — never leave the promise pending.
child.on("error", () => {
killGroup();
settle({ exitCode: 1, timedOut: false, aborted: false });
});
});
}
|