Skip to content

Commit a597cd2

Browse files
authored
fix(chat): fix to stop bash executions for stop button (aws#7100)
## Problem Long running ExecuteBash commands keep running after clicking on stop button. ## Solution - Added force Stop to kill bash executions - Added tabTotrigger Id mapping to maintain trigger Id consistency across agentic loop. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 67a3b4b commit a597cd2

File tree

5 files changed

+180
-39
lines changed

5 files changed

+180
-39
lines changed

packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class Connector extends BaseConnector {
4444
private readonly onChatAnswerUpdated
4545
private readonly onAsyncEventProgress
4646
private chatItems: Map<string, Map<string, ChatItem>> = new Map() // tabId -> messageId -> ChatItem
47+
private tabIdToTriggerId: Map<string, string> = new Map() // tabId -> triggerId
4748

4849
override getTabType(): TabType {
4950
return 'cwc'
@@ -135,6 +136,10 @@ export class Connector extends BaseConnector {
135136
}
136137
}
137138

139+
if (messageData.tabID && messageData.triggerID && messageData.triggerID !== '') {
140+
this.storetriggerId(messageData.tabID, messageData.triggerID)
141+
}
142+
138143
if (answer.messageId) {
139144
this.storeChatItem(messageData.tabID, answer.messageId, answer)
140145
}
@@ -213,6 +218,14 @@ export class Connector extends BaseConnector {
213218
this.chatItems.get(tabId)?.set(messageId, { ...item })
214219
}
215220

221+
private storetriggerId(tabId: string, triggerId: string): void {
222+
this.tabIdToTriggerId.set(tabId, triggerId)
223+
}
224+
225+
private getTriggerId(tabId: string): string | undefined {
226+
return this.tabIdToTriggerId.get(tabId)
227+
}
228+
216229
private getCurrentChatItem(tabId: string, messageId: string | undefined): ChatItem | undefined {
217230
if (!messageId) {
218231
return
@@ -351,7 +364,7 @@ export class Connector extends BaseConnector {
351364
formSelectedValues: action.formItemValues,
352365
tabType: this.getTabType(),
353366
tabID: tabId,
354-
triggerId: triggerId,
367+
triggerId: this.getTriggerId(tabId),
355368
})
356369

357370
if (

packages/core/src/codewhispererChat/controllers/chat/controller.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -788,11 +788,7 @@ export class ChatController {
788788
return
789789
}
790790

791-
const output = await ToolUtils.invoke(
792-
tool,
793-
chatStream,
794-
ConversationTracker.getInstance().getTokenForTrigger(triggerID)
795-
)
791+
const output = await ToolUtils.invoke(tool, chatStream, triggerID)
796792
ToolUtils.validateOutput(output, tool.type)
797793

798794
let status: ToolResultStatus = ToolResultStatus.SUCCESS
@@ -810,13 +806,21 @@ export class ChatController {
810806
status,
811807
})
812808
} catch (e: any) {
809+
if (this.isTriggerCancelled(triggerID)) {
810+
getLogger().debug(`Tool execution cancelled before invoke for tabID: ${tabID}`)
811+
return
812+
}
813813
toolResults.push({
814814
content: [{ text: e.message }],
815815
toolUseId: toolUse.toolUseId,
816816
status: ToolResultStatus.ERROR,
817817
})
818818
}
819819
} else {
820+
if (this.isTriggerCancelled(triggerID)) {
821+
getLogger().debug(`Tool execution cancelled before invoke for tabID: ${tabID}`)
822+
return
823+
}
820824
const toolResult: ToolResult = result
821825
toolResults.push(toolResult)
822826
}
@@ -1634,7 +1638,6 @@ export class ChatController {
16341638
})}`
16351639
)
16361640
let response: MessengerResponseType | undefined = undefined
1637-
session.createNewTokenSource()
16381641
// TODO: onProfileChanged, abort previous response?
16391642
try {
16401643
if (!session.context && triggerPayload.context.length) {

packages/core/src/codewhispererChat/tools/executeBash.ts

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { split } from 'shlex'
1212
import path from 'path'
1313
import * as vscode from 'vscode'
1414
import { isInDirectory } from '../../shared/filesystemUtilities'
15+
import { ConversationTracker } from '../storages/conversationTracker'
1516

1617
export 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

119121
export 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}`)

packages/core/src/codewhispererChat/tools/toolUtils.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import * as vscode from 'vscode'
65
import { Writable } from 'stream'
76
import { FsRead, FsReadParams } from './fsRead'
87
import { FsWrite, FsWriteParams } from './fsWrite'
@@ -56,23 +55,18 @@ export class ToolUtils {
5655
}
5756
}
5857

59-
static async invoke(
60-
tool: Tool,
61-
updates?: Writable,
62-
cancellationToken?: vscode.CancellationToken
63-
): Promise<InvokeOutput> {
64-
// Check if cancelled before executing
65-
if (cancellationToken?.isCancellationRequested) {
66-
throw new Error('Tool execution cancelled')
67-
}
68-
58+
static async invoke(tool: Tool, updates?: Writable, triggerId?: string): Promise<InvokeOutput> {
6959
switch (tool.type) {
7060
case ToolType.FsRead:
7161
return tool.tool.invoke(updates)
7262
case ToolType.FsWrite:
7363
return tool.tool.invoke(updates)
7464
case ToolType.ExecuteBash:
75-
return tool.tool.invoke(updates ?? undefined, cancellationToken)
65+
// If triggerId is provided, update the tool's triggerId
66+
if (triggerId) {
67+
;(tool.tool as ExecuteBash).triggerId = triggerId
68+
}
69+
return tool.tool.invoke(updates)
7670
case ToolType.ListDirectory:
7771
return tool.tool.invoke(updates)
7872
}

packages/core/src/shared/utilities/processUtils.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,41 @@ export class ChildProcess {
409409
return this.#childProcess?.exitCode ?? -1
410410
}
411411

412+
/**
413+
* Attempts to kill a process group on Unix systems, falling back to regular kill if it fails
414+
* @param child The child process to kill
415+
* @param signal The signal to send (defaults to SIGTERM)
416+
* @param checkSpawnProps Whether to check for child.spawnargs and child.spawnfile (used in first call only)
417+
* @returns true if process group kill was attempted, false if regular kill was used
418+
*/
419+
public killProcessGroup(
420+
child: proc.ChildProcess,
421+
signal: NodeJS.Signals = 'SIGTERM',
422+
checkSpawnProps: boolean = false
423+
): boolean {
424+
// First call in stop() uses the stricter condition with spawnargs/spawnfile checks
425+
// Second call in the force kill uses the less restrictive condition
426+
const condition = checkSpawnProps
427+
? process.platform !== 'win32' && child.spawnargs && child.spawnfile
428+
: process.platform !== 'win32'
429+
430+
if (condition && child.pid) {
431+
try {
432+
// On Unix systems, negative PID kills the process group
433+
this.#log.debug(`Attempting to kill process group -${child.pid} with ${signal}`)
434+
process.kill(-child.pid, signal)
435+
return true
436+
} catch (err) {
437+
this.#log.debug(`Failed to kill process group with ${signal}: ${err}, falling back to regular kill`)
438+
child.kill(signal)
439+
return false
440+
}
441+
} else {
442+
child.kill(signal)
443+
return false
444+
}
445+
}
446+
412447
/**
413448
* Stops the process.
414449
*
@@ -426,13 +461,17 @@ export class ChildProcess {
426461
const command = this.#command
427462
const pid = this.pid()
428463
if (!this.stopped) {
429-
child.kill(signal)
464+
// Try to kill the process group if possible (Unix systems only)
465+
// First call uses the stricter condition with spawnargs/spawnfile checks
466+
this.killProcessGroup(child, signal || 'SIGTERM', true)
430467

431468
if (force === true) {
432469
waitUntil(async () => this.stopped, { timeout: ChildProcess.stopTimeout, interval: 200, truthy: true })
433470
.then((stopped) => {
434471
if (!stopped) {
435-
child.kill('SIGKILL')
472+
// Try to kill the process group with SIGKILL if possible
473+
// Second call uses the less restrictive condition
474+
this.killProcessGroup(child, 'SIGKILL', false)
436475
}
437476
})
438477
.catch((e) => {

0 commit comments

Comments
 (0)