Skip to content

Commit 00609aa

Browse files
KJ7LNWEric Wheeler
andauthored
fix: race in short-running command output capture (#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 4f5796c commit 00609aa

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"
@@ -969,11 +969,14 @@ export class Cline extends EventEmitter<ClineEvents> {
969969

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

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

10181014
await process
@@ -1026,6 +1022,25 @@ export class Cline extends EventEmitter<ClineEvents> {
10261022

10271023
result = Terminal.compressTerminalOutput(result, terminalOutputLineLimit)
10281024

1025+
// keep in case we need it to troubleshoot user issues, but this should be removed in the future
1026+
// if everything looks good:
1027+
console.debug(
1028+
"[execute_command status]",
1029+
JSON.stringify(
1030+
{
1031+
completed,
1032+
userFeedback,
1033+
hasResult: result.length > 0,
1034+
exitDetails,
1035+
terminalId: terminalInfo.id,
1036+
workingDir: workingDirInfo,
1037+
isTerminalBusy: terminalInfo.busy,
1038+
},
1039+
null,
1040+
2,
1041+
),
1042+
)
1043+
10291044
if (userFeedback) {
10301045
await this.say("user_feedback", userFeedback.text, userFeedback.images)
10311046
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)