Skip to content

Commit fe7fe48

Browse files
KJ7LNWEric Wheeler
authored andcommitted
fix: race in short-running command output capture (RooCodeInc#2624)
Previously, handlers for terminal output were registered after starting the process, which could cause output to be missed for fast-executing commands that complete before handlers are set up. - Adds CommandCallbacks interface to register handlers upfront - Moves handler registration before process start to ensure no output is missed - Provides process instance in callbacks for direct control - Adds debug logging to help diagnose timing issues Signed-off-by: Eric Wheeler <[email protected]> Co-authored-by: Eric Wheeler <[email protected]>
1 parent 8342bfe commit fe7fe48

File tree

2 files changed

+73
-32
lines changed

2 files changed

+73
-32
lines changed

src/core/Cline.ts

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc
2525
import { fetchInstructionsTool } from "./tools/fetchInstructionsTool"
2626
import { listFilesTool } from "./tools/listFilesTool"
2727
import { readFileTool } from "./tools/readFileTool"
28-
import { ExitCodeDetails } from "../integrations/terminal/TerminalProcess"
28+
import { ExitCodeDetails, TerminalProcess } from "../integrations/terminal/TerminalProcess"
2929
import { Terminal } from "../integrations/terminal/Terminal"
3030
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
3131
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
@@ -966,11 +966,14 @@ export class Cline extends EventEmitter<ClineEvents> {
966966

967967
const workingDirInfo = workingDir ? ` from '${workingDir.toPosix()}'` : ""
968968
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
969-
const process = terminalInfo.runCommand(command)
970-
971969
let userFeedback: { text?: string; images?: string[] } | undefined
972970
let didContinue = false
973-
const sendCommandOutput = async (line: string): Promise<void> => {
971+
let completed = false
972+
let result: string = ""
973+
let exitDetails: ExitCodeDetails | undefined
974+
const { terminalOutputLineLimit = 500 } = (await this.providerRef.deref()?.getState()) ?? {}
975+
976+
const sendCommandOutput = async (line: string, terminalProcess: TerminalProcess): Promise<void> => {
974977
try {
975978
const { response, text, images } = await this.ask("command_output", line)
976979
if (response === "yesButtonClicked") {
@@ -979,37 +982,30 @@ export class Cline extends EventEmitter<ClineEvents> {
979982
userFeedback = { text, images }
980983
}
981984
didContinue = true
982-
process.continue() // continue past the await
985+
terminalProcess.continue() // continue past the await
983986
} catch {
984987
// This can only happen if this ask promise was ignored, so ignore this error
985988
}
986989
}
987990

988-
const { terminalOutputLineLimit = 500 } = (await this.providerRef.deref()?.getState()) ?? {}
989-
990-
process.on("line", (line) => {
991-
if (!didContinue) {
992-
sendCommandOutput(Terminal.compressTerminalOutput(line, terminalOutputLineLimit))
993-
} else {
994-
this.say("command_output", Terminal.compressTerminalOutput(line, terminalOutputLineLimit))
995-
}
996-
})
997-
998-
let completed = false
999-
let result: string = ""
1000-
let exitDetails: ExitCodeDetails | undefined
1001-
process.once("completed", (output?: string) => {
1002-
// Use provided output if available, otherwise keep existing result.
1003-
result = output ?? ""
1004-
completed = true
1005-
})
1006-
1007-
process.once("shell_execution_complete", (details: ExitCodeDetails) => {
1008-
exitDetails = details
1009-
})
1010-
1011-
process.once("no_shell_integration", async (message: string) => {
1012-
await this.say("shell_integration_warning", message)
991+
const process = terminalInfo.runCommand(command, {
992+
onLine: (line, process) => {
993+
if (!didContinue) {
994+
sendCommandOutput(Terminal.compressTerminalOutput(line, terminalOutputLineLimit), process)
995+
} else {
996+
this.say("command_output", Terminal.compressTerminalOutput(line, terminalOutputLineLimit))
997+
}
998+
},
999+
onCompleted: (output) => {
1000+
result = output ?? ""
1001+
completed = true
1002+
},
1003+
onShellExecutionComplete: (details) => {
1004+
exitDetails = details
1005+
},
1006+
onNoShellIntegration: async (message) => {
1007+
await this.say("shell_integration_warning", message)
1008+
},
10131009
})
10141010

10151011
await process
@@ -1023,6 +1019,25 @@ export class Cline extends EventEmitter<ClineEvents> {
10231019

10241020
result = Terminal.compressTerminalOutput(result, terminalOutputLineLimit)
10251021

1022+
// keep in case we need it to troubleshoot user issues, but this should be removed in the future
1023+
// if everything looks good:
1024+
console.debug(
1025+
"[execute_command status]",
1026+
JSON.stringify(
1027+
{
1028+
completed,
1029+
userFeedback,
1030+
hasResult: result.length > 0,
1031+
exitDetails,
1032+
terminalId: terminalInfo.id,
1033+
workingDir: workingDirInfo,
1034+
isTerminalBusy: terminalInfo.busy,
1035+
},
1036+
null,
1037+
2,
1038+
),
1039+
)
1040+
10261041
if (userFeedback) {
10271042
await this.say("user_feedback", userFeedback.text, userFeedback.images)
10281043
return [

src/integrations/terminal/Terminal.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ const { TerminalRegistry } = require("./TerminalRegistry")
77

88
export const TERMINAL_SHELL_INTEGRATION_TIMEOUT = 5000
99

10+
export interface CommandCallbacks {
11+
onLine?: (line: string, process: TerminalProcess) => void
12+
onCompleted?: (output: string | undefined, process: TerminalProcess) => void
13+
onShellExecutionComplete?: (details: ExitCodeDetails, process: TerminalProcess) => void
14+
onNoShellIntegration?: (message: string, process: TerminalProcess) => void
15+
}
16+
1017
export class Terminal {
1118
private static shellIntegrationTimeout: number = TERMINAL_SHELL_INTEGRATION_TIMEOUT
1219
private static commandDelay: number = 0
@@ -161,7 +168,7 @@ export class Terminal {
161168
return output
162169
}
163170

164-
public runCommand(command: string): TerminalProcessResultPromise {
171+
public runCommand(command: string, callbacks?: CommandCallbacks): TerminalProcessResultPromise {
165172
// We set busy before the command is running because the terminal may be waiting
166173
// on terminal integration, and we must prevent another instance from selecting
167174
// the terminal for use during that time.
@@ -176,7 +183,26 @@ export class Terminal {
176183
// Set process on terminal
177184
this.process = process
178185

179-
// Create a promise for command completion
186+
// Set up event handlers from callbacks before starting process
187+
// This ensures that we don't miss any events because they are
188+
// configured before the process starts.
189+
if (callbacks) {
190+
if (callbacks.onLine) {
191+
process.on("line", (line) => callbacks.onLine!(line, process))
192+
}
193+
if (callbacks.onCompleted) {
194+
process.once("completed", (output) => callbacks.onCompleted!(output, process))
195+
}
196+
if (callbacks.onShellExecutionComplete) {
197+
process.once("shell_execution_complete", (details) =>
198+
callbacks.onShellExecutionComplete!(details, process),
199+
)
200+
}
201+
if (callbacks.onNoShellIntegration) {
202+
process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration!(msg, process))
203+
}
204+
}
205+
180206
const promise = new Promise<void>((resolve, reject) => {
181207
// Set up event handlers
182208
process.once("continue", () => resolve())

0 commit comments

Comments
 (0)