@@ -12,6 +12,7 @@ import { split } from 'shlex'
12
12
import path from 'path'
13
13
import * as vscode from 'vscode'
14
14
import { isInDirectory } from '../../shared/filesystemUtilities'
15
+ import { ConversationTracker } from '../storages/conversationTracker'
15
16
16
17
export enum CommandCategory {
17
18
ReadOnly ,
@@ -114,6 +115,7 @@ export interface ExecuteBashParams {
114
115
command : string
115
116
cwd ?: string
116
117
explanation ?: string
118
+ triggerId ?: string
117
119
}
118
120
119
121
export interface CommandValidation {
@@ -134,10 +136,22 @@ export class ExecuteBash {
134
136
private readonly workingDirectory ?: string
135
137
private readonly logger = getLogger ( 'executeBash' )
136
138
private childProcess ?: ChildProcess
139
+ // Make triggerId writable so it can be set after construction
140
+ private _triggerId ?: string
137
141
138
142
constructor ( params : ExecuteBashParams ) {
139
143
this . command = params . command
140
144
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
141
155
}
142
156
143
157
public async validate ( ) : Promise < void > {
@@ -232,18 +246,56 @@ export class ExecuteBash {
232
246
}
233
247
}
234
248
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 > {
236
261
this . logger . info ( `Invoking bash command: "${ this . command } " in cwd: "${ this . workingDirectory } "` )
237
262
238
263
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 ( ) ) {
241
266
this . logger . debug ( 'Bash command execution cancelled before starting' )
242
267
reject ( new Error ( 'Command execution cancelled' ) )
243
268
return
244
269
}
245
270
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
+ )
247
299
248
300
const stdoutBuffer : string [ ] = [ ]
249
301
const stderrBuffer : string [ ] = [ ]
@@ -282,16 +334,53 @@ export class ExecuteBash {
282
334
}
283
335
}
284
336
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
+
285
368
const childProcessOptions : ChildProcessOptions = {
286
369
spawnOptions : {
287
370
cwd : this . workingDirectory ,
288
371
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 } : { } ) ,
289
378
} ,
290
379
collect : false ,
291
380
waitForStreams : true ,
292
381
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' )
295
384
return
296
385
}
297
386
const isFirst = getAndSetFirstChunk ( false )
@@ -305,7 +394,7 @@ export class ExecuteBash {
305
394
processQueue ( )
306
395
} ,
307
396
onStderr : async ( chunk : string ) => {
308
- if ( cancellationToken ?. isCancellationRequested ) {
397
+ if ( this . isTriggerCancelled ( ) ) {
309
398
this . logger . debug ( 'Bash command execution cancelled during stderr processing' )
310
399
return
311
400
}
@@ -321,21 +410,19 @@ export class ExecuteBash {
321
410
} ,
322
411
}
323
412
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 )
333
415
334
416
try {
335
417
const result = await this . childProcess . run ( )
336
418
419
+ // Clean up the interval if it exists
420
+ if ( checkCancellationInterval ) {
421
+ clearInterval ( checkCancellationInterval )
422
+ }
423
+
337
424
// Check if cancelled after execution
338
- if ( cancellationToken ?. isCancellationRequested ) {
425
+ if ( this . isTriggerCancelled ( ) ) {
339
426
this . logger . debug ( 'Bash command execution cancelled after completion' )
340
427
reject ( new Error ( 'Command execution cancelled' ) )
341
428
return
@@ -368,8 +455,13 @@ export class ExecuteBash {
368
455
} ,
369
456
} )
370
457
} catch ( err : any ) {
458
+ // Clean up the interval if it exists
459
+ if ( checkCancellationInterval ) {
460
+ clearInterval ( checkCancellationInterval )
461
+ }
462
+
371
463
// Check if this was due to cancellation
372
- if ( cancellationToken ?. isCancellationRequested ) {
464
+ if ( this . isTriggerCancelled ( ) ) {
373
465
reject ( new Error ( 'Command execution cancelled' ) )
374
466
} else {
375
467
this . logger . error ( `Failed to execute bash command '${ this . command } ': ${ err . message } ` )
0 commit comments