@@ -12,6 +12,7 @@ import { split } from 'shlex'
1212import path from 'path'
1313import * as vscode from 'vscode'
1414import { isInDirectory } from '../../shared/filesystemUtilities'
15+ import { ConversationTracker } from '../storages/conversationTracker'
1516
1617export enum CommandCategory {
1718 ReadOnly ,
@@ -114,6 +115,7 @@ export interface ExecuteBashParams {
114115 command : string
115116 cwd ?: string
116117 explanation ?: string
118+ triggerId ?: string
117119}
118120
119121export interface CommandValidation {
@@ -134,10 +136,22 @@ export class ExecuteBash {
134136 private readonly workingDirectory ?: string
135137 private readonly logger = getLogger ( 'executeBash' )
136138 private childProcess ?: ChildProcess
139+ // Make triggerId writable so it can be set after construction
140+ private _triggerId ?: string
137141
138142 constructor ( params : ExecuteBashParams ) {
139143 this . command = params . command
140144 this . workingDirectory = params . cwd ? sanitizePath ( params . cwd ) : fs . getUserHomeDir ( )
145+ this . _triggerId = params . triggerId
146+ }
147+
148+ // Getter and setter for triggerId
149+ get triggerId ( ) : string | undefined {
150+ return this . _triggerId
151+ }
152+
153+ set triggerId ( id : string | undefined ) {
154+ this . _triggerId = id
141155 }
142156
143157 public async validate ( ) : Promise < void > {
@@ -232,18 +246,56 @@ export class ExecuteBash {
232246 }
233247 }
234248
235- public async invoke ( updates ?: Writable , cancellationToken ?: vscode . CancellationToken ) : Promise < InvokeOutput > {
249+ /**
250+ * Check if the trigger has been cancelled using ConversationTracker
251+ */
252+ private isTriggerCancelled ( ) : boolean {
253+ if ( ! this . triggerId ) {
254+ return false
255+ }
256+ const cancellationtracker = ConversationTracker . getInstance ( )
257+ return cancellationtracker . isTriggerCancelled ( this . triggerId )
258+ }
259+
260+ public async invoke ( updates ?: Writable ) : Promise < InvokeOutput > {
236261 this . logger . info ( `Invoking bash command: "${ this . command } " in cwd: "${ this . workingDirectory } "` )
237262
238263 return new Promise ( async ( resolve , reject ) => {
239- // Check if cancelled before starting
240- if ( cancellationToken ?. isCancellationRequested ) {
264+ // Check if cancelled before starting using triggerId
265+ if ( this . isTriggerCancelled ( ) ) {
241266 this . logger . debug ( 'Bash command execution cancelled before starting' )
242267 reject ( new Error ( 'Command execution cancelled' ) )
243268 return
244269 }
245270
246- this . logger . debug ( `Spawning process with command: bash -c "${ this . command } " (cwd=${ this . workingDirectory } )` )
271+ // Modify the command to make it more cancellable by using process groups
272+ // This ensures that when we kill the parent process, all child processes are also terminated
273+ // The trap ensures cleanup on SIGTERM/SIGINT and sends SIGTERM to the child process group
274+ const modifiedCommand = `
275+ exec bash -c "
276+ # Create a new process group
277+ set -m
278+
279+ # Set up trap to kill the entire process group on exit
280+ trap 'kill -TERM -\\$CMD_PID 2>/dev/null || true; exit' TERM INT
281+
282+ # Run the actual command in background
283+ # Use '()' to create a subshell which becomes the process group leader
284+ (${ this . command } ) &
285+
286+ # Store the PID
287+ CMD_PID=\\$!
288+
289+ # Wait for the command to finish
290+ wait \\$CMD_PID
291+ exit_code=\\$?
292+ exit \\$exit_code
293+ "
294+ `
295+
296+ this . logger . debug (
297+ `Spawning process with modified command for better cancellation support (cwd=${ this . workingDirectory } )`
298+ )
247299
248300 const stdoutBuffer : string [ ] = [ ]
249301 const stderrBuffer : string [ ] = [ ]
@@ -282,16 +334,53 @@ export class ExecuteBash {
282334 }
283335 }
284336
337+ // Setup a periodic check for trigger cancellation
338+ let checkCancellationInterval : NodeJS . Timeout | undefined
339+ if ( this . triggerId ) {
340+ checkCancellationInterval = setInterval ( ( ) => {
341+ if ( this . isTriggerCancelled ( ) ) {
342+ this . logger . debug ( 'Trigger cancellation detected, killing child process' )
343+
344+ // Kill the process
345+ this . childProcess ?. stop ( false , 'SIGTERM' )
346+
347+ // After a short delay, force kill with SIGKILL if still running
348+ setTimeout ( ( ) => {
349+ if ( this . childProcess && ! this . childProcess . stopped ) {
350+ this . logger . debug ( 'Process still running after SIGTERM, sending SIGKILL' )
351+
352+ // Try to kill the process group with SIGKILL
353+ this . childProcess . stop ( true , 'SIGKILL' )
354+ }
355+ } , 500 )
356+
357+ if ( checkCancellationInterval ) {
358+ clearInterval ( checkCancellationInterval )
359+ }
360+
361+ // Return from the function after cancellation
362+ reject ( new Error ( 'Command execution cancelled' ) )
363+ return
364+ }
365+ } , 500 ) // Check every 500ms
366+ }
367+
285368 const childProcessOptions : ChildProcessOptions = {
286369 spawnOptions : {
287370 cwd : this . workingDirectory ,
288371 stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
372+ // Set detached to true to create a new process group
373+ // This allows us to kill the entire process group later
374+ detached : true ,
375+ // On Windows, we need to create a new process group
376+ // On Unix, we need to create a new session
377+ ...( process . platform === 'win32' ? { windowsVerbatimArguments : true } : { } ) ,
289378 } ,
290379 collect : false ,
291380 waitForStreams : true ,
292381 onStdout : async ( chunk : string ) => {
293- if ( cancellationToken ?. isCancellationRequested ) {
294- this . logger . debug ( 'Bash command execution cancelled during stderr processing' )
382+ if ( this . isTriggerCancelled ( ) ) {
383+ this . logger . debug ( 'Bash command execution cancelled during stdout processing' )
295384 return
296385 }
297386 const isFirst = getAndSetFirstChunk ( false )
@@ -305,7 +394,7 @@ export class ExecuteBash {
305394 processQueue ( )
306395 } ,
307396 onStderr : async ( chunk : string ) => {
308- if ( cancellationToken ?. isCancellationRequested ) {
397+ if ( this . isTriggerCancelled ( ) ) {
309398 this . logger . debug ( 'Bash command execution cancelled during stderr processing' )
310399 return
311400 }
@@ -321,21 +410,19 @@ export class ExecuteBash {
321410 } ,
322411 }
323412
324- this . childProcess = new ChildProcess ( 'bash' , [ '-c' , this . command ] , childProcessOptions )
325-
326- // Set up cancellation listener
327- if ( cancellationToken ) {
328- cancellationToken . onCancellationRequested ( ( ) => {
329- this . logger . debug ( 'Cancellation requested, killing child process' )
330- this . childProcess ?. stop ( )
331- } )
332- }
413+ // Use bash directly with the modified command
414+ this . childProcess = new ChildProcess ( 'bash' , [ '-c' , modifiedCommand ] , childProcessOptions )
333415
334416 try {
335417 const result = await this . childProcess . run ( )
336418
419+ // Clean up the interval if it exists
420+ if ( checkCancellationInterval ) {
421+ clearInterval ( checkCancellationInterval )
422+ }
423+
337424 // Check if cancelled after execution
338- if ( cancellationToken ?. isCancellationRequested ) {
425+ if ( this . isTriggerCancelled ( ) ) {
339426 this . logger . debug ( 'Bash command execution cancelled after completion' )
340427 reject ( new Error ( 'Command execution cancelled' ) )
341428 return
@@ -368,8 +455,13 @@ export class ExecuteBash {
368455 } ,
369456 } )
370457 } catch ( err : any ) {
458+ // Clean up the interval if it exists
459+ if ( checkCancellationInterval ) {
460+ clearInterval ( checkCancellationInterval )
461+ }
462+
371463 // Check if this was due to cancellation
372- if ( cancellationToken ?. isCancellationRequested ) {
464+ if ( this . isTriggerCancelled ( ) ) {
373465 reject ( new Error ( 'Command execution cancelled' ) )
374466 } else {
375467 this . logger . error ( `Failed to execute bash command '${ this . command } ': ${ err . message } ` )
0 commit comments