Skip to content

Use SIGKILL for command execution timeouts in the "execa" variant #6071

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web-evals/src/actions/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function createRun({ suite, exercises = [], systemPrompt, timeout,

const dockerArgs = [
`--name evals-controller-${run.id}`,
// "--rm",
"--rm",
"--network evals_default",
"-v /var/run/docker.sock:/var/run/docker.sock",
"-v /tmp/evals:/var/log/evals",
Expand Down
23 changes: 21 additions & 2 deletions packages/evals/src/cli/runTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import * as os from "node:os"
import pWaitFor from "p-wait-for"
import { execa } from "execa"

import { type TaskEvent, TaskCommandName, RooCodeEventName, IpcMessageType, EVALS_SETTINGS } from "@roo-code/types"
import {
type TaskEvent,
type ClineSay,
TaskCommandName,
RooCodeEventName,
IpcMessageType,
EVALS_SETTINGS,
} from "@roo-code/types"
import { IpcClient } from "@roo-code/ipc"

import {
Expand Down Expand Up @@ -203,6 +210,16 @@ export const runTask = async ({ run, task, publish, logger }: RunTaskOptions) =>
log: [RooCodeEventName.TaskTokenUsageUpdated, RooCodeEventName.TaskAskResponded],
}

const loggableSays: ClineSay[] = [
"error",
"completion_result",
"command_output",
"rooignore_error",
"diff_error",
"condense_context",
"condense_context_error",
]

client.on(IpcMessageType.TaskEvent, async (taskEvent) => {
const { eventName, payload } = taskEvent

Expand All @@ -215,7 +232,9 @@ export const runTask = async ({ run, task, publish, logger }: RunTaskOptions) =>
// For message events we only log non-partial messages.
if (
!ignoreEvents.log.includes(eventName) &&
(eventName !== RooCodeEventName.Message || payload[0].message.partial !== true)
(eventName !== RooCodeEventName.Message ||
(payload[0].message.say && loggableSays.includes(payload[0].message.say)) ||
payload[0].message.partial !== true)
) {
logger.info(`${eventName} ->`, payload)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
alwaysAllowUpdateTodoList: true,
followupAutoApproveTimeoutMs: 0,
allowedCommands: ["*"],
commandExecutionTimeout: 30_000,
commandExecutionTimeout: 30,
commandTimeoutAllowlist: [],
preventCompletionWithOpenTodos: false,

Expand Down
93 changes: 43 additions & 50 deletions src/core/tools/executeCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { t } from "../../i18n"
class ShellIntegrationError extends Error {}

export async function executeCommandTool(
cline: Task,
task: Task,
block: ToolUse,
askApproval: AskApproval,
handleError: HandleError,
Expand All @@ -33,25 +33,25 @@ export async function executeCommandTool(

try {
if (block.partial) {
await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
await task.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
return
} else {
if (!command) {
cline.consecutiveMistakeCount++
cline.recordToolError("execute_command")
pushToolResult(await cline.sayAndCreateMissingParamError("execute_command", "command"))
task.consecutiveMistakeCount++
task.recordToolError("execute_command")
pushToolResult(await task.sayAndCreateMissingParamError("execute_command", "command"))
return
}

const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(command)
const ignoredFileAttemptedToAccess = task.rooIgnoreController?.validateCommand(command)

if (ignoredFileAttemptedToAccess) {
await cline.say("rooignore_error", ignoredFileAttemptedToAccess)
await task.say("rooignore_error", ignoredFileAttemptedToAccess)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess)))
return
}

cline.consecutiveMistakeCount = 0
task.consecutiveMistakeCount = 0

command = unescapeHtmlEntities(command) // Unescape HTML entities.
const didApprove = await askApproval("command", command)
Expand All @@ -60,14 +60,15 @@ export async function executeCommandTool(
return
}

const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
const clineProvider = await cline.providerRef.deref()
const clineProviderState = await clineProvider?.getState()
const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
const provider = await task.providerRef.deref()
const providerState = await provider?.getState()

const {
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationDisabled = false,
} = clineProviderState ?? {}
} = providerState ?? {}

// Get command execution timeout from VSCode configuration (in seconds)
const commandExecutionTimeoutSeconds = vscode.workspace
Expand Down Expand Up @@ -96,26 +97,26 @@ export async function executeCommandTool(
}

try {
const [rejected, result] = await executeCommand(cline, options)
const [rejected, result] = await executeCommand(task, options)

if (rejected) {
cline.didRejectTool = true
task.didRejectTool = true
}

pushToolResult(result)
} catch (error: unknown) {
const status: CommandExecutionStatus = { executionId, status: "fallback" }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
await cline.say("shell_integration_warning")
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
await task.say("shell_integration_warning")

if (error instanceof ShellIntegrationError) {
const [rejected, result] = await executeCommand(cline, {
const [rejected, result] = await executeCommand(task, {
...options,
terminalShellIntegrationDisabled: true,
})

if (rejected) {
cline.didRejectTool = true
task.didRejectTool = true
}

pushToolResult(result)
Expand Down Expand Up @@ -143,7 +144,7 @@ export type ExecuteCommandOptions = {
}

export async function executeCommand(
cline: Task,
task: Task,
{
executionId,
command,
Expand All @@ -154,16 +155,16 @@ export async function executeCommand(
commandExecutionTimeout = 0,
}: ExecuteCommandOptions,
): Promise<[boolean, ToolResponse]> {
// Convert milliseconds back to seconds for display purposes
// Convert milliseconds back to seconds for display purposes.
const commandExecutionTimeoutSeconds = commandExecutionTimeout / 1000
let workingDir: string

if (!customCwd) {
workingDir = cline.cwd
workingDir = task.cwd
} else if (path.isAbsolute(customCwd)) {
workingDir = customCwd
} else {
workingDir = path.resolve(cline.cwd, customCwd)
workingDir = path.resolve(task.cwd, customCwd)
}

try {
Expand All @@ -180,7 +181,7 @@ export async function executeCommand(
let shellIntegrationError: string | undefined

const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
const clineProvider = await cline.providerRef.deref()
const provider = await task.providerRef.deref()

let accumulatedOutput = ""
const callbacks: RooTerminalCallbacks = {
Expand All @@ -192,14 +193,14 @@ export async function executeCommand(
terminalOutputCharacterLimit,
)
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })

if (runInBackground) {
return
}

try {
const { response, text, images } = await cline.ask("command_output", "")
const { response, text, images } = await task.ask("command_output", "")
runInBackground = true

if (response === "messageResponse") {
Expand All @@ -214,29 +215,30 @@ export async function executeCommand(
terminalOutputLineLimit,
terminalOutputCharacterLimit,
)
cline.say("command_output", result)

task.say("command_output", result)
completed = true
},
onShellExecutionStarted: (pid: number | undefined) => {
console.log(`[executeCommand] onShellExecutionStarted: ${pid}`)
const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
},
onShellExecutionComplete: (details: ExitCodeDetails) => {
const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
exitDetails = details
},
}

if (terminalProvider === "vscode") {
callbacks.onNoShellIntegration = async (error: string) => {
TelemetryService.instance.captureShellIntegrationError(cline.taskId)
TelemetryService.instance.captureShellIntegrationError(task.taskId)
shellIntegrationError = error
}
}

const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, cline.taskId, terminalProvider)
const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, task.taskId, terminalProvider)

if (terminal instanceof Terminal) {
terminal.terminal.show(true)
Expand All @@ -248,20 +250,17 @@ export async function executeCommand(
}

const process = terminal.runCommand(command, callbacks)
cline.terminalProcess = process
task.terminalProcess = process

// Implement command execution timeout (skip if timeout is 0)
// Implement command execution timeout (skip if timeout is 0).
if (commandExecutionTimeout > 0) {
let timeoutId: NodeJS.Timeout | undefined
let isTimedOut = false

const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
isTimedOut = true
// Try to abort the process
if (cline.terminalProcess) {
cline.terminalProcess.abort()
}
task.terminalProcess?.abort()
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
}, commandExecutionTimeout)
})
Expand All @@ -270,17 +269,10 @@ export async function executeCommand(
await Promise.race([process, timeoutPromise])
} catch (error) {
if (isTimedOut) {
// Handle timeout case
const status: CommandExecutionStatus = { executionId, status: "timeout" }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })

// Add visual feedback for timeout
await cline.say(
"error",
t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }),
)

cline.terminalProcess = undefined
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
task.terminalProcess = undefined

return [
false,
Expand All @@ -292,14 +284,15 @@ export async function executeCommand(
if (timeoutId) {
clearTimeout(timeoutId)
}
cline.terminalProcess = undefined

task.terminalProcess = undefined
}
} else {
// No timeout - just wait for the process to complete
// No timeout - just wait for the process to complete.
try {
await process
} finally {
cline.terminalProcess = undefined
task.terminalProcess = undefined
}
}

Expand All @@ -316,7 +309,7 @@ export async function executeCommand(

if (message) {
const { text, images } = message
await cline.say("user_feedback", text, images)
await task.say("user_feedback", text, images)

return [
true,
Expand Down
22 changes: 13 additions & 9 deletions src/integrations/terminal/ExecaTerminalProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
let timeoutId: NodeJS.Timeout | undefined

const kill = new Promise<void>((resolve) => {
console.log(`[ExecaTerminalProcess#run] SIGKILL -> ${this.pid}`)

timeoutId = setTimeout(() => {
try {
subprocess.kill("SIGKILL")
Expand All @@ -86,7 +88,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
await Promise.race([subprocess, kill])
} catch (error) {
console.log(
`[ExecaTerminalProcess] subprocess termination error: ${error instanceof Error ? error.message : String(error)}`,
`[ExecaTerminalProcess#run] subprocess termination error: ${error instanceof Error ? error.message : String(error)}`,
)
}

Expand All @@ -98,12 +100,13 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
this.emit("shell_execution_complete", { exitCode: 0 })
} catch (error) {
if (error instanceof ExecaError) {
console.error(`[ExecaTerminalProcess] shell execution error: ${error.message}`)
console.error(`[ExecaTerminalProcess#run] shell execution error: ${error.message}`)
this.emit("shell_execution_complete", { exitCode: error.exitCode ?? 0, signalName: error.signal })
} else {
console.error(
`[ExecaTerminalProcess] shell execution error: ${error instanceof Error ? error.message : String(error)}`,
`[ExecaTerminalProcess#run] shell execution error: ${error instanceof Error ? error.message : String(error)}`,
)

this.emit("shell_execution_complete", { exitCode: 1 })
}
}
Expand All @@ -128,29 +131,30 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
psTree(this.pid, async (err, children) => {
if (!err) {
const pids = children.map((p) => parseInt(p.PID))
console.error(`[ExecaTerminalProcess#abort] SIGKILL children -> ${pids.join(", ")}`)

for (const pid of pids) {
try {
process.kill(pid, "SIGINT")
process.kill(pid, "SIGKILL")
} catch (e) {
console.warn(
`[ExecaTerminalProcess] Failed to send SIGINT to child PID ${pid}: ${e instanceof Error ? e.message : String(e)}`,
`[ExecaTerminalProcess#abort] Failed to send SIGKILL to child PID ${pid}: ${e instanceof Error ? e.message : String(e)}`,
)
// Optionally try SIGTERM or SIGKILL on failure, depending on desired behavior.
}
}
} else {
console.error(
`[ExecaTerminalProcess] Failed to get process tree for PID ${this.pid}: ${err.message}`,
`[ExecaTerminalProcess#abort] Failed to get process tree for PID ${this.pid}: ${err.message}`,
)
}
})

try {
process.kill(this.pid, "SIGINT")
console.error(`[ExecaTerminalProcess#abort] SIGKILL parent -> ${this.pid}`)
process.kill(this.pid, "SIGKILL")
} catch (e) {
console.warn(
`[ExecaTerminalProcess] Failed to send SIGINT to main PID ${this.pid}: ${e instanceof Error ? e.message : String(e)}`,
`[ExecaTerminalProcess#abort] Failed to send SIGKILL to main PID ${this.pid}: ${e instanceof Error ? e.message : String(e)}`,
)
}
}
Expand Down