@@ -23,6 +23,40 @@ const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2
2323
2424export const log = Log . create ( { service : "bash-tool" } )
2525
26+ // Registry for active bash processes — enables server-level watchdog
27+ const active = new Map <
28+ string ,
29+ {
30+ pid : number
31+ timeout : number
32+ started : number
33+ kill : ( ) => void
34+ done : ( ) => void
35+ }
36+ > ( )
37+
38+ export function stale ( ) {
39+ const result : string [ ] = [ ]
40+ const now = Date . now ( )
41+ for ( const [ id , entry ] of active ) {
42+ if ( now - entry . started > entry . timeout + 5000 ) result . push ( id )
43+ }
44+ return result
45+ }
46+
47+ export function reap ( id : string ) {
48+ const entry = active . get ( id )
49+ if ( ! entry ) return
50+ log . info ( "reaping stuck process" , {
51+ callID : id ,
52+ pid : entry . pid ,
53+ age : Date . now ( ) - entry . started ,
54+ } )
55+ entry . kill ( )
56+ entry . done ( )
57+ active . delete ( id )
58+ }
59+
2660const resolveWasm = ( asset : string ) => {
2761 if ( asset . startsWith ( "file://" ) ) return fileURLToPath ( asset )
2862 if ( asset . startsWith ( "/" ) || / ^ [ a - z ] : / i. test ( asset ) ) return asset
@@ -180,6 +214,21 @@ export const BashTool = Tool.define("bash", async () => {
180214 detached : process . platform !== "win32" ,
181215 } )
182216
217+ if ( ! proc . pid ) {
218+ if ( proc . exitCode !== null ) {
219+ log . info ( "process exited before pid could be read" , { exitCode : proc . exitCode } )
220+ } else {
221+ throw new Error ( `Failed to spawn process: pid is undefined for command "${ params . command } "` )
222+ }
223+ }
224+
225+ log . info ( "spawned process" , {
226+ pid : proc . pid ,
227+ command : params . command . slice ( 0 , 100 ) ,
228+ cwd,
229+ timeout,
230+ } )
231+
183232 let output = ""
184233
185234 // Initialize metadata with empty output
@@ -216,34 +265,143 @@ export const BashTool = Tool.define("bash", async () => {
216265 }
217266
218267 const abortHandler = ( ) => {
268+ log . info ( "process abort triggered" , { pid : proc . pid } )
219269 aborted = true
220270 void kill ( )
221271 }
222272
223273 ctx . abort . addEventListener ( "abort" , abortHandler , { once : true } )
224274
225275 const timeoutTimer = setTimeout ( ( ) => {
276+ log . info ( "process timeout triggered" , { pid : proc . pid , timeout } )
226277 timedOut = true
227278 void kill ( )
228279 } , timeout + 100 )
229280
281+ const started = Date . now ( )
282+
283+ const callID = ctx . callID
284+ if ( callID ) {
285+ active . set ( callID , {
286+ pid : proc . pid ! ,
287+ timeout,
288+ started,
289+ kill : ( ) => Shell . killTree ( proc , { exited : ( ) => exited } ) ,
290+ done : ( ) => { } ,
291+ } )
292+ }
293+
230294 await new Promise < void > ( ( resolve , reject ) => {
295+ let resolved = false
296+
231297 const cleanup = ( ) => {
298+ if ( resolved ) return
299+ resolved = true
232300 clearTimeout ( timeoutTimer )
301+ clearInterval ( poll )
233302 ctx . abort . removeEventListener ( "abort" , abortHandler )
303+ proc . stdout ?. removeListener ( "end" , check )
304+ proc . stderr ?. removeListener ( "end" , check )
234305 }
235306
236- proc . once ( "exit" , ( ) => {
307+ const done = ( ) => {
308+ if ( resolved ) return
237309 exited = true
238310 cleanup ( )
239311 resolve ( )
240- } )
312+ }
313+
314+ // Update the active entry with the real done callback
315+ if ( callID ) {
316+ const entry = active . get ( callID )
317+ if ( entry ) {
318+ entry . done = ( ) => {
319+ if ( resolved ) return
320+ exited = true
321+ cleanup ( )
322+ resolve ( )
323+ }
324+ }
325+ }
241326
242- proc . once ( "error" , ( error ) => {
327+ const fail = ( error : Error ) => {
328+ if ( resolved ) return
243329 exited = true
244330 cleanup ( )
245331 reject ( error )
332+ }
333+
334+ proc . once ( "exit" , ( ) => {
335+ log . info ( "process exit detected via 'exit' event" , { pid : proc . pid , exitCode : proc . exitCode } )
336+ done ( )
337+ } )
338+ proc . once ( "close" , ( ) => {
339+ log . info ( "process exit detected via 'close' event" , { pid : proc . pid , exitCode : proc . exitCode } )
340+ done ( )
246341 } )
342+ proc . once ( "error" , fail )
343+
344+ // Redundancy: stdio end events fire when pipe file descriptors close
345+ // independent of process exit monitoring — catches missed exit events
346+ let streams = 0
347+ const total = ( proc . stdout ? 1 : 0 ) + ( proc . stderr ? 1 : 0 )
348+ const check = ( ) => {
349+ streams ++
350+ if ( streams < total ) return
351+ if ( proc . exitCode !== null || proc . signalCode !== null ) {
352+ log . info ( "stdio end detected exit (exitCode already set)" , {
353+ pid : proc . pid ,
354+ exitCode : proc . exitCode ,
355+ } )
356+ done ( )
357+ return
358+ }
359+ setTimeout ( ( ) => {
360+ log . info ( "stdio end deferred check" , {
361+ pid : proc . pid ,
362+ exitCode : proc . exitCode ,
363+ } )
364+ done ( )
365+ } , 50 )
366+ }
367+ proc . stdout ?. once ( "end" , check )
368+ proc . stderr ?. once ( "end" , check )
369+
370+ // Polling watchdog: detect process exit when Bun's event loop
371+ // fails to deliver the "exit" event (confirmed Bun bug in containers)
372+ const poll = setInterval ( ( ) => {
373+ if ( proc . exitCode !== null || proc . signalCode !== null ) {
374+ log . info ( "polling watchdog detected exit via exitCode/signalCode" , {
375+ exitCode : proc . exitCode ,
376+ signalCode : proc . signalCode ,
377+ } )
378+ done ( )
379+ return
380+ }
381+
382+ // Check 2: process.kill(pid, 0) throws ESRCH if process is dead
383+ if ( proc . pid && process . platform !== "win32" ) {
384+ try {
385+ process . kill ( proc . pid , 0 )
386+ } catch {
387+ log . info ( "polling watchdog detected exit via kill(0) ESRCH" , {
388+ pid : proc . pid ,
389+ } )
390+ done ( )
391+ return
392+ }
393+ }
394+ } , 1000 )
395+ } )
396+
397+ if ( callID ) active . delete ( callID )
398+
399+ log . info ( "process completed" , {
400+ pid : proc . pid ,
401+ exitCode : proc . exitCode ,
402+ duration : Date . now ( ) - started ,
403+ timedOut,
404+ aborted,
247405 } )
248406
249407 const resultMetadata : string [ ] = [ ]
0 commit comments