1- import { spawn , spawnSync , type SpawnOptions } from "node:child_process" ;
1+ import { spawn , spawnSync , type SpawnOptions , type SpawnSyncReturns } from "node:child_process" ;
22import { writeFile } from "node:fs/promises" ;
33import path from "node:path" ;
44import { fileURLToPath } from "node:url" ;
@@ -9,6 +9,13 @@ import type { CommandResult, RunOptions } from "./types.ts";
99
1010export const repoRoot = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , "../../.." ) ;
1111
12+ const HOST_COMMAND_MAX_BUFFER_BYTES = 50 * 1024 * 1024 ;
13+ const HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES = 1024 * 1024 ;
14+ const HOST_COMMAND_WRAPPER_BACKSTOP_MS = 5_000 ;
15+ const HOST_COMMAND_CHILD_PID_PREFIX = "__OPENCLAW_HOST_COMMAND_CHILD_PID__" ;
16+ const HOST_COMMAND_SPAWN_ERROR_PREFIX = "__OPENCLAW_HOST_COMMAND_SPAWN_ERROR__" ;
17+ const HOST_COMMAND_TIMEOUT_PREFIX = "__OPENCLAW_HOST_COMMAND_TIMEOUT__" ;
18+
1219type HostCommandInvocation = {
1320 args : string [ ] ;
1421 command : string ;
@@ -47,6 +54,161 @@ export function die(message: string): never {
4754 process . exit ( 1 ) ;
4855}
4956
57+ function signalHostCommandProcess ( pid : number | undefined , signal : NodeJS . Signals ) : void {
58+ if ( ! pid ) {
59+ return ;
60+ }
61+ try {
62+ if ( process . platform === "win32" ) {
63+ process . kill ( pid , signal ) ;
64+ } else {
65+ process . kill ( - pid , signal ) ;
66+ }
67+ } catch ( error ) {
68+ const code = ( error as NodeJS . ErrnoException ) . code ;
69+ if ( code !== "ESRCH" ) {
70+ warn (
71+ `failed to send ${ signal } to timed host command process ${ pid } : ${
72+ code ?? String ( error )
73+ } `,
74+ ) ;
75+ }
76+ }
77+ }
78+
79+ const POSIX_TIMEOUT_WRAPPER = String . raw `
80+ const { spawn } = require("node:child_process");
81+ const { readFileSync, writeSync } = require("node:fs");
82+
83+ const payload = JSON.parse(readFileSync(0, "utf8"));
84+ const child = spawn(payload.command, payload.args, {
85+ cwd: payload.cwd,
86+ detached: true,
87+ env: payload.env,
88+ shell: payload.shell,
89+ stdio: ["pipe", "pipe", "pipe"],
90+ });
91+ writeSync(
92+ 3,
93+ ${ JSON . stringify ( HOST_COMMAND_CHILD_PID_PREFIX ) } + JSON.stringify({
94+ pid: child.pid || null,
95+ }) + "\n",
96+ );
97+
98+ let timedOut = false;
99+ let killTimer;
100+ let outputExceeded = false;
101+ let stderrBytes = 0;
102+ let stdoutBytes = 0;
103+
104+ function writeAllSync(fd, chunk) {
105+ let offset = 0;
106+ while (offset < chunk.byteLength) {
107+ offset += writeSync(fd, chunk, offset, chunk.byteLength - offset);
108+ }
109+ }
110+
111+ function signalGroup(signal) {
112+ if (!child.pid) {
113+ return;
114+ }
115+ try {
116+ process.kill(-child.pid, signal);
117+ } catch (error) {
118+ if (error && error.code !== "ESRCH") {
119+ process.stderr.write("failed to send " + signal + " to timed host command process " + child.pid + ": " + (error.code || String(error)) + "\n");
120+ }
121+ }
122+ }
123+
124+ function forwardBounded(stream, chunk) {
125+ const currentBytes = stream === "stdout" ? stdoutBytes : stderrBytes;
126+ const nextBytes = currentBytes + chunk.byteLength;
127+ const limit = payload.maxBufferBytes;
128+ if (stream === "stdout") {
129+ stdoutBytes = nextBytes;
130+ } else {
131+ stderrBytes = nextBytes;
132+ }
133+ if (outputExceeded) {
134+ return;
135+ }
136+ if (nextBytes <= limit) {
137+ writeAllSync(stream === "stdout" ? 1 : 2, chunk);
138+ return;
139+ }
140+ outputExceeded = true;
141+ const allowedBytes = Math.max(0, limit - currentBytes);
142+ if (allowedBytes > 0) {
143+ writeAllSync(stream === "stdout" ? 1 : 2, chunk.subarray(0, allowedBytes));
144+ }
145+ writeAllSync(
146+ 2,
147+ Buffer.from("host command output exceeded " + limit + " bytes; terminating process group\n"),
148+ );
149+ signalGroup("SIGKILL");
150+ }
151+
152+ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
153+ process.once(signal, () => {
154+ signalGroup(signal);
155+ process.kill(process.pid, signal);
156+ });
157+ }
158+
159+ const timeout = setTimeout(() => {
160+ timedOut = true;
161+ signalGroup("SIGTERM");
162+ killTimer = setTimeout(() => signalGroup("SIGKILL"), 100);
163+ killTimer.unref();
164+ }, payload.timeoutMs);
165+ timeout.unref();
166+
167+ child.stdout.on("data", (chunk) => forwardBounded("stdout", chunk));
168+ child.stderr.on("data", (chunk) => forwardBounded("stderr", chunk));
169+ child.stdin.on("error", (error) => {
170+ if (error && error.code !== "EPIPE" && error.code !== "ECONNRESET") {
171+ writeAllSync(2, Buffer.from("host command stdin write failed: " + (error.code || String(error)) + "\n"));
172+ }
173+ });
174+ child.on("error", (error) => {
175+ clearTimeout(timeout);
176+ if (killTimer) {
177+ clearTimeout(killTimer);
178+ }
179+ writeSync(
180+ 3,
181+ ${ JSON . stringify ( HOST_COMMAND_SPAWN_ERROR_PREFIX ) } + JSON.stringify({
182+ code: error.code || null,
183+ message: error.message,
184+ }) + "\n",
185+ );
186+ process.stderr.write(error.message + "\n");
187+ process.exit(127);
188+ });
189+ child.on("close", (code, signal) => {
190+ clearTimeout(timeout);
191+ if (killTimer) {
192+ clearTimeout(killTimer);
193+ }
194+ if (timedOut) {
195+ signalGroup("SIGKILL");
196+ writeSync(3, ${ JSON . stringify ( HOST_COMMAND_TIMEOUT_PREFIX ) } + "{}\n");
197+ process.exit(124);
198+ }
199+ if (outputExceeded) {
200+ process.exit(1);
201+ }
202+ process.exit(code ?? (signal ? 128 : 1));
203+ });
204+
205+ if (payload.input != null) {
206+ child.stdin.end(payload.input);
207+ } else {
208+ child.stdin.end();
209+ }
210+ ` ;
211+
50212export function shellQuote ( value : string ) : string {
51213 return `'${ value . replaceAll ( "'" , `'"'"'` ) } '` ;
52214}
@@ -116,20 +278,46 @@ export function resolveHostCommandInvocation(
116278export function run ( command : string , args : string [ ] , options : RunOptions = { } ) : CommandResult {
117279 const env = { ...process . env , ...options . env } ;
118280 const invocation = resolveHostCommandInvocation ( command , args , { env } ) ;
119- const result = spawnSync ( invocation . command , invocation . args , {
120- cwd : options . cwd ?? repoRoot ,
121- encoding : "utf8" ,
122- env : invocation . env ?? env ,
123- input : options . input ,
124- killSignal : "SIGKILL" ,
125- maxBuffer : 50 * 1024 * 1024 ,
126- stdio : options . quiet ? [ "pipe" , "pipe" , "pipe" ] : [ "pipe" , "pipe" , "pipe" ] ,
127- shell : invocation . shell ,
128- timeout : options . timeoutMs ,
129- windowsVerbatimArguments : invocation . windowsVerbatimArguments ,
130- } ) ;
281+ const usesPosixTimedWrapper = process . platform !== "win32" && options . timeoutMs !== undefined ;
282+ const result =
283+ usesPosixTimedWrapper
284+ ? runPosixTimedCommandSync ( invocation , env , options )
285+ : spawnSync ( invocation . command , invocation . args , {
286+ cwd : options . cwd ?? repoRoot ,
287+ encoding : "utf8" ,
288+ env : invocation . env ?? env ,
289+ input : options . input ,
290+ killSignal : "SIGKILL" ,
291+ maxBuffer : HOST_COMMAND_MAX_BUFFER_BYTES ,
292+ stdio : options . quiet ? [ "pipe" , "pipe" , "pipe" ] : [ "pipe" , "pipe" , "pipe" ] ,
293+ shell : invocation . shell ,
294+ timeout : options . timeoutMs ,
295+ windowsVerbatimArguments : invocation . windowsVerbatimArguments ,
296+ } ) ;
131297
132- const timedOut = ( result . error as NodeJS . ErrnoException | undefined ) ?. code === "ETIMEDOUT" ;
298+ let wrapperTimedOut = false ;
299+ if ( usesPosixTimedWrapper ) {
300+ const wrapperControl = typeof result . output [ 3 ] === "string" ? result . output [ 3 ] : "" ;
301+ const outerWrapperTimedOut =
302+ ( result . error as NodeJS . ErrnoException | undefined ) ?. code === "ETIMEDOUT" ;
303+ if ( outerWrapperTimedOut ) {
304+ signalHostCommandProcess ( parsePosixTimedWrapperChildPid ( wrapperControl ) , "SIGKILL" ) ;
305+ }
306+ wrapperTimedOut = outerWrapperTimedOut || hasPosixTimedWrapperTimeout ( wrapperControl ) ;
307+ const spawnError = parsePosixTimedWrapperSpawnError ( wrapperControl ) ;
308+ if ( spawnError ) {
309+ throw spawnError ;
310+ }
311+ }
312+ const timedOut =
313+ wrapperTimedOut || ( result . error as NodeJS . ErrnoException | undefined ) ?. code === "ETIMEDOUT" ;
314+ if ( wrapperTimedOut && options . check !== false ) {
315+ const error = new Error (
316+ `${ command } ${ args . join ( " " ) } timed out after ${ options . timeoutMs } ms` ,
317+ ) as NodeJS . ErrnoException ;
318+ error . code = "ETIMEDOUT" ;
319+ throw error ;
320+ }
133321 if ( result . error && ! ( timedOut && options . check === false ) ) {
134322 throw result . error ;
135323 }
@@ -152,6 +340,76 @@ export function run(command: string, args: string[], options: RunOptions = {}):
152340 return commandResult ;
153341}
154342
343+ function hasPosixTimedWrapperTimeout ( controlOutput : string ) : boolean {
344+ return controlOutput . split ( "\n" ) . some ( ( entry ) => entry . startsWith ( HOST_COMMAND_TIMEOUT_PREFIX ) ) ;
345+ }
346+
347+ function parsePosixTimedWrapperChildPid ( controlOutput : string ) : number | undefined {
348+ const line = controlOutput
349+ . split ( "\n" )
350+ . find ( ( entry ) => entry . startsWith ( HOST_COMMAND_CHILD_PID_PREFIX ) ) ;
351+ if ( ! line ) {
352+ return undefined ;
353+ }
354+ try {
355+ const parsed = JSON . parse ( line . slice ( HOST_COMMAND_CHILD_PID_PREFIX . length ) ) as {
356+ pid ?: unknown ;
357+ } ;
358+ return typeof parsed . pid === "number" ? parsed . pid : undefined ;
359+ } catch {
360+ return undefined ;
361+ }
362+ }
363+
364+ function parsePosixTimedWrapperSpawnError ( stderr : string ) : NodeJS . ErrnoException | null {
365+ const line = stderr
366+ . split ( "\n" )
367+ . find ( ( entry ) => entry . startsWith ( HOST_COMMAND_SPAWN_ERROR_PREFIX ) ) ;
368+ if ( ! line ) {
369+ return null ;
370+ }
371+ const raw = line . slice ( HOST_COMMAND_SPAWN_ERROR_PREFIX . length ) ;
372+ try {
373+ const parsed = JSON . parse ( raw ) as { code ?: unknown ; message ?: unknown } ;
374+ const error = new Error (
375+ typeof parsed . message === "string" ? parsed . message : "host command spawn failed" ,
376+ ) as NodeJS . ErrnoException ;
377+ if ( typeof parsed . code === "string" ) {
378+ error . code = parsed . code ;
379+ }
380+ return error ;
381+ } catch {
382+ return new Error ( "host command spawn failed" ) as NodeJS . ErrnoException ;
383+ }
384+ }
385+
386+ function runPosixTimedCommandSync (
387+ invocation : HostCommandInvocation ,
388+ env : NodeJS . ProcessEnv ,
389+ options : RunOptions ,
390+ ) : SpawnSyncReturns < string > {
391+ const payload = JSON . stringify ( {
392+ args : invocation . args ,
393+ command : invocation . command ,
394+ cwd : options . cwd ?? repoRoot ,
395+ env : invocation . env ?? env ,
396+ input : options . input ,
397+ maxBufferBytes : HOST_COMMAND_MAX_BUFFER_BYTES ,
398+ shell : invocation . shell ,
399+ timeoutMs : options . timeoutMs ,
400+ } ) ;
401+ return spawnSync ( process . execPath , [ "-e" , POSIX_TIMEOUT_WRAPPER ] , {
402+ cwd : options . cwd ?? repoRoot ,
403+ encoding : "utf8" ,
404+ env,
405+ input : payload ,
406+ killSignal : "SIGKILL" ,
407+ maxBuffer : HOST_COMMAND_MAX_BUFFER_BYTES * 2 + HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES ,
408+ stdio : [ "pipe" , "pipe" , "pipe" , "pipe" ] ,
409+ timeout : ( options . timeoutMs ?? 0 ) + HOST_COMMAND_WRAPPER_BACKSTOP_MS ,
410+ } ) ;
411+ }
412+
155413export function sh ( script : string , options : RunOptions = { } ) : CommandResult {
156414 return run ( "bash" , [ "-lc" , script ] , options ) ;
157415}
@@ -166,11 +424,31 @@ export async function runStreaming(
166424 const invocation = resolveHostCommandInvocation ( command , args , { env } ) ;
167425 const child = spawn ( invocation . command , invocation . args , {
168426 cwd : options . cwd ?? repoRoot ,
427+ detached : process . platform !== "win32" && options . timeoutMs != null ,
169428 env : invocation . env ?? env ,
170429 shell : invocation . shell ,
171430 stdio : [ "pipe" , "pipe" , "pipe" ] ,
172431 windowsVerbatimArguments : invocation . windowsVerbatimArguments ,
173432 } satisfies SpawnOptions ) ;
433+ const childPid = child . pid ;
434+ const parentSignalHandlers = new Map < NodeJS . Signals , ( ) => void > ( ) ;
435+ const removeParentSignalHandlers = ( ) : void => {
436+ for ( const [ signal , handler ] of parentSignalHandlers ) {
437+ process . off ( signal , handler ) ;
438+ }
439+ parentSignalHandlers . clear ( ) ;
440+ } ;
441+ if ( process . platform !== "win32" && options . timeoutMs != null ) {
442+ for ( const signal of [ "SIGHUP" , "SIGINT" , "SIGTERM" ] as const ) {
443+ const handler = ( ) : void => {
444+ signalHostCommandProcess ( childPid , signal ) ;
445+ removeParentSignalHandlers ( ) ;
446+ process . kill ( process . pid , signal ) ;
447+ } ;
448+ parentSignalHandlers . set ( signal , handler ) ;
449+ process . once ( signal , handler ) ;
450+ }
451+ }
174452
175453 let log = "" ;
176454 const append = ( chunk : Buffer ) : void => {
@@ -195,21 +473,36 @@ export async function runStreaming(
195473 }
196474
197475 let timedOut = false ;
476+ let killTimer : NodeJS . Timeout | undefined ;
198477 const timer =
199478 options . timeoutMs == null
200479 ? undefined
201480 : setTimeout ( ( ) => {
202481 timedOut = true ;
203- child . kill ( "SIGTERM" ) ;
204- setTimeout ( ( ) => child . kill ( "SIGKILL" ) , 2_000 ) . unref ( ) ;
482+ signalHostCommandProcess ( childPid , "SIGTERM" ) ;
483+ killTimer = setTimeout ( ( ) => signalHostCommandProcess ( childPid , "SIGKILL" ) , 2_000 ) ;
484+ killTimer . unref ( ) ;
205485 } , options . timeoutMs ) ;
206486
207- child . on ( "error" , reject ) ;
487+ child . on ( "error" , ( error ) => {
488+ if ( killTimer ) {
489+ clearTimeout ( killTimer ) ;
490+ }
491+ removeParentSignalHandlers ( ) ;
492+ reject ( error ) ;
493+ } ) ;
208494 child . on ( "close" , ( code , signal ) => {
209495 void ( async ( ) => {
210496 if ( timer ) {
211497 clearTimeout ( timer ) ;
212498 }
499+ if ( killTimer ) {
500+ clearTimeout ( killTimer ) ;
501+ }
502+ removeParentSignalHandlers ( ) ;
503+ if ( timedOut ) {
504+ signalHostCommandProcess ( childPid , "SIGKILL" ) ;
505+ }
213506 if ( options . logPath ) {
214507 await writeFile ( options . logPath , log , "utf8" ) ;
215508 }
0 commit comments