UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

224 lines (223 loc) • 8.84 kB
import { spawn } from 'child_process'; import path from 'path'; import { logger } from './logger.js'; /** * Ending a process together with every process it started. * * Desktop Commander runs commands through a shell (start_process) or a fresh * Node process (node:local), so the process it holds is only the root of what * the command runs: the shell starts the program, which may start more * (npm -> node -> a dev server holding a port). Killing the root alone ends * none of those. Windows never ends a process's children with it, and on * macOS/Linux a signal sent to one PID reaches only that process, so the * program kept running, orphaned, while the session was reported terminated. * * Both platforms find the tree the same way, through each process's parent * PID, so processes are spawned exactly as before: on macOS/Linux they stay * in the server's process group and session, and Ctrl+C, closing the * terminal and password prompts behave as they always did. * - Windows: taskkill /T /F ends the root and its descendants. * - macOS/Linux: a ps snapshot gives every process's parent. The root and its * descendants get SIGTERM; after a grace period the tree is walked again, * from the root and from every process found the first time (children * started meanwhile), and whatever still runs gets SIGKILL. * Limit: a process whose parent exited before a walk has been reparented (to * init or a subreaper) and can no longer be found through its parent. * * Every path that ends a command DC runs for the client (force_terminate on a * start_process session, the node:local timeout) goes through terminateProcessTree. * kill_process ends just the PID it is given, with the same SIGTERM, grace * period and SIGKILL (terminatePid). */ /** How long a process tree gets to exit after SIGTERM before SIGKILL (macOS/Linux) */ const TERMINATE_GRACE_MS = 1000; /** How long the root gets to report its exit once it has been killed */ const EXIT_WAIT_MS = 2000; /** Longest taskkill or ps may run */ const COMMAND_TIMEOUT_MS = 10000; const POLL_MS = 50; const isWindows = () => process.platform === 'win32'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function hasExited(child) { return child.exitCode !== null || child.signalCode !== null; } /** Resolves true once `child` has exited, false if it still runs after `timeoutMs` */ function waitForExit(child, timeoutMs) { if (hasExited(child)) return Promise.resolve(true); return new Promise((resolve) => { const onExit = () => { clearTimeout(timer); resolve(true); }; const timer = setTimeout(() => { child.off('exit', onExit); resolve(false); }, timeoutMs); child.once('exit', onExit); }); } /** Runs a system command to completion: resolves its stdout, rejects with why it failed */ function runCommand(file, args) { const command = [file, ...args].join(' '); return new Promise((resolve, reject) => { const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { child.kill(); reject(new Error(`${command} did not finish within ${COMMAND_TIMEOUT_MS}ms`)); }, COMMAND_TIMEOUT_MS); child.stdout.on('data', (data) => { stdout += data; }); child.stderr.on('data', (data) => { stderr += data; }); // After a spawn error 'close' follows too; the promise keeps the first reason child.once('error', (error) => { clearTimeout(timer); reject(new Error(`${command}: ${error.message}`)); }); child.once('close', (code, signal) => { clearTimeout(timer); if (code === 0) { resolve(stdout); } else { const how = signal ? `was killed by ${signal}` : `exited with code ${code}`; reject(new Error(`${command} ${how}${stderr.trim() ? `: ${stderr.trim()}` : ''}`)); } }); }); } /** macOS/Linux: every running process's children, from one ps snapshot */ async function snapshotChildren() { // -A: all processes; -o pid=,ppid=: just those columns, no header. The same on macOS and Linux ps. const output = await runCommand('ps', ['-A', '-o', 'pid=,ppid=']); const children = new Map(); for (const line of output.split('\n')) { const [pid, ppid] = line.trim().split(/\s+/).map(Number); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; const siblings = children.get(ppid); if (siblings) { siblings.push(pid); } else { children.set(ppid, [pid]); } } return children; } /** `roots` and every process descended from them in `children` */ function withDescendants(roots, children) { const found = new Set(roots); // Iterating a Set also visits the entries added during the iteration for (const pid of found) { for (const child of children.get(pid) ?? []) found.add(child); } return found; } function isRunning(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; // Runs, but as another user } } /** Sends `signal` to each of `pids`. Returns why it failed for any that had not exited already. */ function signalAll(pids, signal) { const failures = []; for (const pid of pids) { try { process.kill(pid, signal); } catch (error) { if (error.code !== 'ESRCH') { failures.push(`${signal} to PID ${pid}: ${error.message}`); } } } return failures; } /** Resolves true once none of `pids` runs, false if some still run after `timeoutMs` */ async function waitUntilGone(pids, timeoutMs) { const list = [...pids]; const deadline = Date.now() + timeoutMs; while (list.some(isRunning)) { if (Date.now() >= deadline) return false; await sleep(POLL_MS); } return true; } /** macOS/Linux: SIGTERM to `root` and its descendants, then SIGKILL to whatever of the tree still runs */ async function endPosixTree(root) { const tree = withDescendants([root], await snapshotChildren()); const failures = signalAll(tree, 'SIGTERM'); if (!(await waitUntilGone(tree, TERMINATE_GRACE_MS))) { const current = withDescendants(tree, await snapshotChildren()); failures.push(...signalAll([...current].filter(isRunning), 'SIGKILL')); } if (failures.length > 0) { throw new Error(failures.join('; ')); } } /** * Ends the process `pid` alone, forcefully: SIGTERM, then SIGKILL if it still * runs after the grace period (on Windows either signal ends it outright). * Resolves once it is gone; throws what process.kill threw (ESRCH, EPERM), or * that it still runs. */ export async function terminatePid(pid) { process.kill(pid, 'SIGTERM'); if (await waitUntilGone([pid], TERMINATE_GRACE_MS)) return; try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code === 'ESRCH') return; // Exited in the meantime throw error; } if (!(await waitUntilGone([pid], EXIT_WAIT_MS))) { throw new Error(`PID ${pid} still runs ${EXIT_WAIT_MS}ms after being killed`); } } /** Windows: taskkill from the Windows directory rather than PATH, which a client may launch us with broken (see #481) */ function taskkillPath() { return path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'); } /** * Ends `child` and every process it started. Resolves once they are gone: * true, or false if some could not be ended (why is logged, and the root is * then killed on its own if it still runs). Never rejects. */ export async function terminateProcessTree(child) { const pid = child.pid; if (pid === undefined || hasExited(child)) return true; let failure; try { if (isWindows()) { await runCommand(taskkillPath(), ['/PID', String(pid), '/T', '/F']); } else { await endPosixTree(pid); } } catch (error) { failure = error instanceof Error ? error.message : String(error); if (!hasExited(child)) child.kill('SIGKILL'); } if (!(await waitForExit(child, EXIT_WAIT_MS))) { failure = [failure, `PID ${pid} still runs ${EXIT_WAIT_MS}ms after being killed`].filter(Boolean).join('; '); } if (failure) { logger.error(`Could not end every process of PID ${pid}: ${failure}`); return false; } return true; }