55import { spawn } from "node:child_process" ;
66import { waitForChildProcess } from "../utils/child-process.js" ;
77
8+ const DEFAULT_OUTPUT_LIMIT_CHARS = 16 * 1024 * 1024 ;
9+ const FORCE_KILL_GRACE_MS = 5000 ;
10+
811/**
912 * Options for executing shell commands.
1013 */
@@ -15,6 +18,8 @@ export interface ExecOptions {
1518 timeout ?: number ;
1619 /** Working directory */
1720 cwd ?: string ;
21+ /** Optional maximum retained stdout/stderr characters per stream. */
22+ maxOutputChars ?: number ;
1823}
1924
2025/**
@@ -23,10 +28,46 @@ export interface ExecOptions {
2328export interface ExecResult {
2429 stdout : string ;
2530 stderr : string ;
31+ stdoutTruncatedChars ?: number ;
32+ stderrTruncatedChars ?: number ;
33+ outputLimitExceeded ?: "stdout" | "stderr" ;
2634 code : number ;
2735 killed : boolean ;
2836}
2937
38+ type OutputCapture = {
39+ text : string ;
40+ truncatedChars : number ;
41+ } ;
42+
43+ function clampMaxOutputChars ( value : number | undefined ) : number {
44+ if ( typeof value !== "number" || ! Number . isFinite ( value ) || value <= 0 ) {
45+ return DEFAULT_OUTPUT_LIMIT_CHARS ;
46+ }
47+ return Math . max ( 1 , Math . floor ( value ) ) ;
48+ }
49+
50+ function appendCapturedOutput (
51+ current : OutputCapture ,
52+ chunk : Buffer | string ,
53+ maxOutputChars : number ,
54+ truncateTail : boolean ,
55+ ) : OutputCapture {
56+ const text = String ( chunk ) ;
57+ const combined = `${ current . text } ${ text } ` ;
58+ const overflowChars = Math . max ( 0 , combined . length - maxOutputChars ) ;
59+ if ( overflowChars === 0 ) {
60+ return {
61+ text : combined ,
62+ truncatedChars : current . truncatedChars ,
63+ } ;
64+ }
65+ return {
66+ text : truncateTail ? combined . slice ( overflowChars ) : combined . slice ( 0 , maxOutputChars ) ,
67+ truncatedChars : current . truncatedChars + overflowChars ,
68+ } ;
69+ }
70+
3071/**
3172 * Execute a shell command and return stdout/stderr/code.
3273 * Supports timeout and abort signal.
@@ -44,21 +85,64 @@ export async function execCommand(
4485 stdio : [ "ignore" , "pipe" , "pipe" ] ,
4586 } ) ;
4687
47- let stdout = "" ;
48- let stderr = "" ;
88+ let stdout : OutputCapture = { text : "" , truncatedChars : 0 } ;
89+ let stderr : OutputCapture = { text : "" , truncatedChars : 0 } ;
4990 let killed = false ;
5091 let timeoutId : NodeJS . Timeout | undefined ;
92+ let forceKillTimer : NodeJS . Timeout | undefined ;
93+ let settled = false ;
94+ const maxOutputChars = clampMaxOutputChars ( options ?. maxOutputChars ) ;
95+ const truncateOutput = options ?. maxOutputChars !== undefined ;
96+ let outputLimitExceeded : "stdout" | "stderr" | undefined ;
97+ const markOutputLimitExceeded = ( stream : "stdout" | "stderr" ) => {
98+ if ( ! truncateOutput && ! outputLimitExceeded ) {
99+ outputLimitExceeded = stream ;
100+ killProcess ( ) ;
101+ }
102+ } ;
103+ const finish = ( code : number ) => {
104+ if ( settled ) {
105+ return ;
106+ }
107+ settled = true ;
108+ if ( timeoutId ) {
109+ clearTimeout ( timeoutId ) ;
110+ }
111+ if ( forceKillTimer ) {
112+ clearTimeout ( forceKillTimer ) ;
113+ }
114+ if ( options ?. signal ) {
115+ options . signal . removeEventListener ( "abort" , killProcess ) ;
116+ }
117+ if ( outputLimitExceeded ) {
118+ stderr = appendCapturedOutput (
119+ stderr ,
120+ `${ stderr . text ? "\n" : "" } exec ${ outputLimitExceeded } exceeded output limit ${ maxOutputChars } chars` ,
121+ maxOutputChars ,
122+ true ,
123+ ) ;
124+ }
125+ resolve ( {
126+ stdout : stdout . text ,
127+ stderr : stderr . text ,
128+ stdoutTruncatedChars : stdout . truncatedChars || undefined ,
129+ stderrTruncatedChars : stderr . truncatedChars || undefined ,
130+ outputLimitExceeded,
131+ code : outputLimitExceeded ? 1 : code ,
132+ killed,
133+ } ) ;
134+ } ;
51135
52136 const killProcess = ( ) => {
53137 if ( ! killed ) {
54138 killed = true ;
55139 proc . kill ( "SIGTERM" ) ;
56- // Force kill after 5 seconds if SIGTERM doesn't work
57- setTimeout ( ( ) => {
58- if ( ! proc . killed ) {
140+ forceKillTimer = setTimeout ( ( ) => {
141+ if ( ! settled ) {
59142 proc . kill ( "SIGKILL" ) ;
60143 }
61- } , 5000 ) ;
144+ } , FORCE_KILL_GRACE_MS ) ;
145+ forceKillTimer . unref ?.( ) ;
62146 }
63147 } ;
64148
@@ -79,33 +163,29 @@ export async function execCommand(
79163 }
80164
81165 proc . stdout ?. on ( "data" , ( data ) => {
82- stdout += data . toString ( ) ;
166+ const before = stdout . truncatedChars ;
167+ stdout = appendCapturedOutput ( stdout , data , maxOutputChars , truncateOutput ) ;
168+ if ( stdout . truncatedChars > before ) {
169+ markOutputLimitExceeded ( "stdout" ) ;
170+ }
83171 } ) ;
84172
85173 proc . stderr ?. on ( "data" , ( data ) => {
86- stderr += data . toString ( ) ;
174+ const before = stderr . truncatedChars ;
175+ stderr = appendCapturedOutput ( stderr , data , maxOutputChars , truncateOutput ) ;
176+ if ( stderr . truncatedChars > before ) {
177+ markOutputLimitExceeded ( "stderr" ) ;
178+ }
87179 } ) ;
88180
89181 // Wait for process termination without hanging on inherited stdio handles
90182 // held open by detached descendants.
91183 waitForChildProcess ( proc )
92184 . then ( ( code ) => {
93- if ( timeoutId ) {
94- clearTimeout ( timeoutId ) ;
95- }
96- if ( options ?. signal ) {
97- options . signal . removeEventListener ( "abort" , killProcess ) ;
98- }
99- resolve ( { stdout, stderr, code : code ?? 0 , killed } ) ;
185+ finish ( code ?? 0 ) ;
100186 } )
101187 . catch ( ( ) => {
102- if ( timeoutId ) {
103- clearTimeout ( timeoutId ) ;
104- }
105- if ( options ?. signal ) {
106- options . signal . removeEventListener ( "abort" , killProcess ) ;
107- }
108- resolve ( { stdout, stderr, code : 1 , killed } ) ;
188+ finish ( 1 ) ;
109189 } ) ;
110190 } ) ;
111191}
0 commit comments