Skip to content

Commit e019632

Browse files
roomoteellipsis-dev[bot]mrubensdaniel-lxs
authored
feat: Add configurable timeout for command execution (#5668)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens <[email protected]> Co-authored-by: Daniel Riccio <[email protected]> Co-authored-by: Daniel <[email protected]>
1 parent d7787a2 commit e019632

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+344
-2
lines changed

packages/types/src/terminal.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [
2525
executionId: z.string(),
2626
status: z.literal("fallback"),
2727
}),
28+
z.object({
29+
executionId: z.string(),
30+
status: z.literal("timeout"),
31+
}),
2832
])
2933

3034
export type CommandExecutionStatus = z.infer<typeof commandExecutionStatusSchema>
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Integration tests for command execution timeout functionality
2+
// npx vitest run src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts
3+
4+
import * as vscode from "vscode"
5+
import * as fs from "fs/promises"
6+
import { executeCommand, ExecuteCommandOptions } from "../executeCommandTool"
7+
import { Task } from "../../task/Task"
8+
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
9+
10+
// Mock dependencies
11+
vitest.mock("vscode", () => ({
12+
workspace: {
13+
getConfiguration: vitest.fn(),
14+
},
15+
}))
16+
17+
vitest.mock("fs/promises")
18+
vitest.mock("../../../integrations/terminal/TerminalRegistry")
19+
vitest.mock("../../task/Task")
20+
21+
describe("Command Execution Timeout Integration", () => {
22+
let mockTask: any
23+
let mockTerminal: any
24+
let mockProcess: any
25+
26+
beforeEach(() => {
27+
vitest.clearAllMocks()
28+
29+
// Mock fs.access to resolve successfully for working directory
30+
;(fs.access as any).mockResolvedValue(undefined)
31+
32+
// Mock task
33+
mockTask = {
34+
cwd: "/test/directory",
35+
terminalProcess: undefined,
36+
providerRef: {
37+
deref: vitest.fn().mockResolvedValue({
38+
postMessageToWebview: vitest.fn(),
39+
}),
40+
},
41+
say: vitest.fn().mockResolvedValue(undefined),
42+
}
43+
44+
// Mock terminal process
45+
mockProcess = {
46+
abort: vitest.fn(),
47+
then: vitest.fn(),
48+
catch: vitest.fn(),
49+
}
50+
51+
// Mock terminal
52+
mockTerminal = {
53+
runCommand: vitest.fn().mockReturnValue(mockProcess),
54+
getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/directory"),
55+
}
56+
57+
// Mock TerminalRegistry
58+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal)
59+
60+
// Mock VSCode configuration
61+
const mockGetConfiguration = vitest.fn().mockReturnValue({
62+
get: vitest.fn().mockReturnValue(0), // Default 0 (no timeout)
63+
})
64+
;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration())
65+
})
66+
67+
it("should pass timeout configuration to executeCommand", async () => {
68+
const customTimeoutMs = 15000 // 15 seconds in milliseconds
69+
const options: ExecuteCommandOptions = {
70+
executionId: "test-execution",
71+
command: "echo test",
72+
commandExecutionTimeout: customTimeoutMs,
73+
}
74+
75+
// Mock a quick-completing process
76+
const quickProcess = Promise.resolve()
77+
mockTerminal.runCommand.mockReturnValue(quickProcess)
78+
79+
await executeCommand(mockTask as Task, options)
80+
81+
// Verify that the terminal was called with the command
82+
expect(mockTerminal.runCommand).toHaveBeenCalledWith("echo test", expect.any(Object))
83+
})
84+
85+
it("should handle timeout scenario", async () => {
86+
const shortTimeoutMs = 100 // Very short timeout in milliseconds
87+
const options: ExecuteCommandOptions = {
88+
executionId: "test-execution",
89+
command: "sleep 10",
90+
commandExecutionTimeout: shortTimeoutMs,
91+
}
92+
93+
// Create a process that never resolves but has an abort method
94+
const longRunningProcess = new Promise(() => {
95+
// Never resolves to simulate a hanging command
96+
})
97+
98+
// Add abort method to the promise
99+
;(longRunningProcess as any).abort = vitest.fn()
100+
101+
mockTerminal.runCommand.mockReturnValue(longRunningProcess)
102+
103+
// Execute with timeout
104+
const result = await executeCommand(mockTask as Task, options)
105+
106+
// Should return timeout error
107+
expect(result[0]).toBe(false) // Not rejected by user
108+
expect(result[1]).toContain("terminated after exceeding")
109+
expect(result[1]).toContain("0.1s") // Should show seconds in error message
110+
}, 10000) // Increase test timeout to 10 seconds
111+
112+
it("should abort process on timeout", async () => {
113+
const shortTimeoutMs = 50 // Short timeout in milliseconds
114+
const options: ExecuteCommandOptions = {
115+
executionId: "test-execution",
116+
command: "sleep 10",
117+
commandExecutionTimeout: shortTimeoutMs,
118+
}
119+
120+
// Create a process that can be aborted
121+
const abortSpy = vitest.fn()
122+
123+
// Mock the process to never resolve but be abortable
124+
const neverResolvingPromise = new Promise(() => {})
125+
;(neverResolvingPromise as any).abort = abortSpy
126+
127+
mockTerminal.runCommand.mockReturnValue(neverResolvingPromise)
128+
129+
await executeCommand(mockTask as Task, options)
130+
131+
// Verify abort was called
132+
expect(abortSpy).toHaveBeenCalled()
133+
}, 5000) // Increase test timeout to 5 seconds
134+
135+
it("should clean up timeout on successful completion", async () => {
136+
const options: ExecuteCommandOptions = {
137+
executionId: "test-execution",
138+
command: "echo test",
139+
commandExecutionTimeout: 5000,
140+
}
141+
142+
// Mock a process that completes quickly
143+
const quickProcess = Promise.resolve()
144+
mockTerminal.runCommand.mockReturnValue(quickProcess)
145+
146+
const result = await executeCommand(mockTask as Task, options)
147+
148+
// Should complete successfully without timeout
149+
expect(result[0]).toBe(false) // Not rejected
150+
expect(result[1]).not.toContain("terminated after exceeding")
151+
})
152+
153+
it("should use default timeout when not specified (0 = no timeout)", async () => {
154+
const options: ExecuteCommandOptions = {
155+
executionId: "test-execution",
156+
command: "echo test",
157+
// commandExecutionTimeout not specified, should use default (0)
158+
}
159+
160+
const quickProcess = Promise.resolve()
161+
mockTerminal.runCommand.mockReturnValue(quickProcess)
162+
163+
await executeCommand(mockTask as Task, options)
164+
165+
// Should complete without issues using default (no timeout)
166+
expect(mockTerminal.runCommand).toHaveBeenCalled()
167+
})
168+
169+
it("should not timeout when commandExecutionTimeout is 0", async () => {
170+
const options: ExecuteCommandOptions = {
171+
executionId: "test-execution",
172+
command: "sleep 10",
173+
commandExecutionTimeout: 0, // No timeout
174+
}
175+
176+
// Create a process that resolves after a delay to simulate a long-running command
177+
const longRunningProcess = new Promise((resolve) => {
178+
setTimeout(resolve, 200) // 200ms delay
179+
})
180+
181+
mockTerminal.runCommand.mockReturnValue(longRunningProcess)
182+
183+
const result = await executeCommand(mockTask as Task, options)
184+
185+
// Should complete successfully without timeout
186+
expect(result[0]).toBe(false) // Not rejected
187+
expect(result[1]).not.toContain("terminated after exceeding")
188+
})
189+
})

src/core/tools/__tests__/executeCommandTool.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// npx vitest run src/core/tools/__tests__/executeCommandTool.spec.ts
22

33
import type { ToolUsage } from "@roo-code/types"
4+
import * as vscode from "vscode"
45

56
import { Task } from "../../task/Task"
67
import { formatResponse } from "../../prompts/responses"
@@ -12,6 +13,12 @@ vitest.mock("execa", () => ({
1213
execa: vitest.fn(),
1314
}))
1415

16+
vitest.mock("vscode", () => ({
17+
workspace: {
18+
getConfiguration: vitest.fn(),
19+
},
20+
}))
21+
1522
vitest.mock("../../task/Task")
1623
vitest.mock("../../prompts/responses")
1724

@@ -266,4 +273,40 @@ describe("executeCommandTool", () => {
266273
expect(mockExecuteCommand).not.toHaveBeenCalled()
267274
})
268275
})
276+
277+
describe("Command execution timeout configuration", () => {
278+
it("should include timeout parameter in ExecuteCommandOptions", () => {
279+
// This test verifies that the timeout configuration is properly typed
280+
// The actual timeout logic is tested in integration tests
281+
// Note: timeout is stored internally in milliseconds but configured in seconds
282+
const timeoutSeconds = 15
283+
const options = {
284+
executionId: "test-id",
285+
command: "echo test",
286+
commandExecutionTimeout: timeoutSeconds * 1000, // Convert to milliseconds
287+
}
288+
289+
// Verify the options object has the expected structure
290+
expect(options.commandExecutionTimeout).toBe(15000)
291+
expect(typeof options.commandExecutionTimeout).toBe("number")
292+
})
293+
294+
it("should handle timeout parameter in function signature", () => {
295+
// Test that the executeCommand function accepts timeout parameter
296+
// This is a compile-time check that the types are correct
297+
const mockOptions = {
298+
executionId: "test-id",
299+
command: "echo test",
300+
customCwd: undefined,
301+
terminalShellIntegrationDisabled: false,
302+
terminalOutputLineLimit: 500,
303+
commandExecutionTimeout: 0,
304+
}
305+
306+
// Verify all required properties exist
307+
expect(mockOptions.executionId).toBeDefined()
308+
expect(mockOptions.command).toBeDefined()
309+
expect(mockOptions.commandExecutionTimeout).toBeDefined()
310+
})
311+
})
269312
})

src/core/tools/executeCommandTool.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs/promises"
22
import * as path from "path"
3+
import * as vscode from "vscode"
34

45
import delay from "delay"
56

@@ -14,6 +15,8 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
1415
import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
1516
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
1617
import { Terminal } from "../../integrations/terminal/Terminal"
18+
import { Package } from "../../shared/package"
19+
import { t } from "../../i18n"
1720

1821
class ShellIntegrationError extends Error {}
1922

@@ -62,12 +65,21 @@ export async function executeCommandTool(
6265
const clineProviderState = await clineProvider?.getState()
6366
const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
6467

68+
// Get command execution timeout from VSCode configuration (in seconds)
69+
const commandExecutionTimeoutSeconds = vscode.workspace
70+
.getConfiguration(Package.name)
71+
.get<number>("commandExecutionTimeout", 0)
72+
73+
// Convert seconds to milliseconds for internal use
74+
const commandExecutionTimeout = commandExecutionTimeoutSeconds * 1000
75+
6576
const options: ExecuteCommandOptions = {
6677
executionId,
6778
command,
6879
customCwd,
6980
terminalShellIntegrationDisabled,
7081
terminalOutputLineLimit,
82+
commandExecutionTimeout,
7183
}
7284

7385
try {
@@ -113,6 +125,7 @@ export type ExecuteCommandOptions = {
113125
customCwd?: string
114126
terminalShellIntegrationDisabled?: boolean
115127
terminalOutputLineLimit?: number
128+
commandExecutionTimeout?: number
116129
}
117130

118131
export async function executeCommand(
@@ -123,8 +136,11 @@ export async function executeCommand(
123136
customCwd,
124137
terminalShellIntegrationDisabled = false,
125138
terminalOutputLineLimit = 500,
139+
commandExecutionTimeout = 0,
126140
}: ExecuteCommandOptions,
127141
): Promise<[boolean, ToolResponse]> {
142+
// Convert milliseconds back to seconds for display purposes
143+
const commandExecutionTimeoutSeconds = commandExecutionTimeout / 1000
128144
let workingDir: string
129145

130146
if (!customCwd) {
@@ -211,8 +227,55 @@ export async function executeCommand(
211227
const process = terminal.runCommand(command, callbacks)
212228
cline.terminalProcess = process
213229

214-
await process
215-
cline.terminalProcess = undefined
230+
// Implement command execution timeout (skip if timeout is 0)
231+
if (commandExecutionTimeout > 0) {
232+
let timeoutId: NodeJS.Timeout | undefined
233+
let isTimedOut = false
234+
235+
const timeoutPromise = new Promise<void>((_, reject) => {
236+
timeoutId = setTimeout(() => {
237+
isTimedOut = true
238+
// Try to abort the process
239+
if (cline.terminalProcess) {
240+
cline.terminalProcess.abort()
241+
}
242+
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
243+
}, commandExecutionTimeout)
244+
})
245+
246+
try {
247+
await Promise.race([process, timeoutPromise])
248+
} catch (error) {
249+
if (isTimedOut) {
250+
// Handle timeout case
251+
const status: CommandExecutionStatus = { executionId, status: "timeout" }
252+
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
253+
254+
// Add visual feedback for timeout
255+
await cline.say("text", t("common:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
256+
257+
cline.terminalProcess = undefined
258+
259+
return [
260+
false,
261+
`The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
262+
]
263+
}
264+
throw error
265+
} finally {
266+
if (timeoutId) {
267+
clearTimeout(timeoutId)
268+
}
269+
cline.terminalProcess = undefined
270+
}
271+
} else {
272+
// No timeout - just wait for the process to complete
273+
try {
274+
await process
275+
} finally {
276+
cline.terminalProcess = undefined
277+
}
278+
}
216279

217280
if (shellIntegrationError) {
218281
throw new ShellIntegrationError(shellIntegrationError)

src/i18n/locales/ca/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"url_page_not_found": "No s'ha trobat la pàgina. Comprova si la URL és correcta.",
7272
"url_fetch_failed": "Error en obtenir el contingut de la URL: {{error}}",
7373
"url_fetch_error_with_url": "Error en obtenir contingut per {{url}}: {{error}}",
74+
"command_timeout": "L'execució de la comanda ha superat el temps d'espera de {{seconds}} segons",
7475
"share_task_failed": "Ha fallat compartir la tasca. Si us plau, torna-ho a provar.",
7576
"share_no_active_task": "No hi ha cap tasca activa per compartir",
7677
"share_auth_required": "Es requereix autenticació. Si us plau, inicia sessió per compartir tasques.",

src/i18n/locales/de/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"url_page_not_found": "Die Seite wurde nicht gefunden. Bitte prüfe, ob die URL korrekt ist.",
6868
"url_fetch_failed": "Fehler beim Abrufen des URL-Inhalts: {{error}}",
6969
"url_fetch_error_with_url": "Fehler beim Abrufen des Inhalts für {{url}}: {{error}}",
70+
"command_timeout": "Zeitüberschreitung bei der Befehlsausführung nach {{seconds}} Sekunden",
7071
"share_task_failed": "Teilen der Aufgabe fehlgeschlagen. Bitte versuche es erneut.",
7172
"share_no_active_task": "Keine aktive Aufgabe zum Teilen",
7273
"share_auth_required": "Authentifizierung erforderlich. Bitte melde dich an, um Aufgaben zu teilen.",

0 commit comments

Comments
 (0)