Skip to content

Commit ec1a244

Browse files
committed
fix(terminal): address code review findings
1 parent 2f4e5d2 commit ec1a244

4 files changed

Lines changed: 179 additions & 52 deletions

File tree

src/core/tools/ExecuteCommandTool.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,12 @@ export async function executeCommandInTerminal(
518518

519519
// Wait for onCompleted callback to finish. onCompleted is async and sets
520520
// `completed` and `persistedResult`; we must not read them until it resolves.
521-
// Previously this only waited when exitDetails was set (i.e. the normal event
522-
// path), but onCompleted also fires on the D-marker and zero-chunk grace-timer
523-
// paths where exitDetails is undefined — those paths need the same wait.
524-
await onCompletedPromise
521+
// Skip when returning a background result: the command is still running and
522+
// onCompleted will fire later — awaiting it here would block until real completion,
523+
// defeating the purpose of the agent-timeout background transition.
524+
if (!runInBackground) {
525+
await onCompletedPromise
526+
}
525527

526528
if (message) {
527529
const { text, images } = message

src/integrations/terminal/TerminalProcess.ts

Lines changed: 95 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ export class TerminalProcess extends BaseTerminalProcess {
8686
return
8787
}
8888

89-
// Create a promise that resolves when the stream becomes available
89+
// Create a promise that resolves when the stream becomes available.
90+
// cancelStreamWait() lets the early-completion race path abort the pending
91+
// timeout so it doesn't fire (and reject) after we've already returned.
92+
let cancelStreamWait: () => void = () => {}
9093
const streamAvailable = new Promise<AsyncIterable<string>>((resolve, reject) => {
9194
const timeoutId = setTimeout(() => {
9295
// Remove event listener to prevent memory leaks
@@ -106,6 +109,11 @@ export class TerminalProcess extends BaseTerminalProcess {
106109
)
107110
}, Terminal.getShellIntegrationTimeout())
108111

112+
cancelStreamWait = () => {
113+
clearTimeout(timeoutId)
114+
this.removeAllListeners("stream_available")
115+
}
116+
109117
// Clean up timeout if stream becomes available
110118
this.once("stream_available", (stream: AsyncIterable<string>) => {
111119
clearTimeout(timeoutId)
@@ -127,6 +135,16 @@ export class TerminalProcess extends BaseTerminalProcess {
127135
})
128136
})
129137

138+
// Register shell_execution_started listener BEFORE awaiting streamAvailable.
139+
// BaseTerminal.setActiveStream emits shell_execution_started and stream_available
140+
// on the same synchronous tick (in that order). If we register after the await,
141+
// we miss the event and shellExecutionStarted stays false, causing the idle-timeout
142+
// guard to incorrectly treat a running-but-silent command as stalled.
143+
let shellExecutionStarted = false
144+
this.once("shell_execution_started", () => {
145+
shellExecutionStarted = true
146+
})
147+
130148
// Execute command.
131149
// Determine whether the active shell is PowerShell so we can apply the
132150
// PS-specific counter/sleep workarounds. Prefer the Zoo Code profile
@@ -167,16 +185,39 @@ export class TerminalProcess extends BaseTerminalProcess {
167185
// VSCode doesn't buffer retroactively, and zero chunks arrive.
168186
} catch (error) {
169187
this.terminal.activeShellExecution = undefined
188+
this.cleanupScriptFile()
170189
throw error
171190
}
172191

173192
this.isHot = true
174193

175-
// Wait for stream to be available
194+
// Wait for stream to be available, but also race against shellExecutionComplete.
195+
// If the end event fires before the stream arrives (e.g. a zero-output command
196+
// where onDidEndTerminalShellExecution beats onDidStartTerminalShellExecution),
197+
// we would otherwise block until the stream timeout fires. Resolving early on
198+
// completion produces a clean empty-output result instead.
176199
let stream: AsyncIterable<string>
177200

178201
try {
179-
stream = await streamAvailable
202+
const COMPLETED_BEFORE_STREAM = Symbol("completed_before_stream")
203+
const result = await Promise.race([
204+
streamAvailable,
205+
shellExecutionComplete.then(() => COMPLETED_BEFORE_STREAM as typeof COMPLETED_BEFORE_STREAM),
206+
])
207+
208+
if (result === COMPLETED_BEFORE_STREAM) {
209+
console.info("[Terminal Process] shell execution completed before stream arrived — finishing cleanly")
210+
cancelStreamWait()
211+
this.terminal.activeShellExecution = undefined
212+
this.terminal.busy = false
213+
this.isHot = false
214+
this.cleanupScriptFile()
215+
this.emit("completed", "")
216+
this.emit("continue")
217+
return
218+
}
219+
220+
stream = result as AsyncIterable<string>
180221
} catch (error) {
181222
// Stream timeout or other error occurred
182223
console.error("[Terminal Process] Stream error:", error.message)
@@ -188,6 +229,7 @@ export class TerminalProcess extends BaseTerminalProcess {
188229
)
189230

190231
this.terminal.busy = false
232+
this.cleanupScriptFile()
191233

192234
// Emit continue event to allow execution to proceed
193235
this.emit("continue")
@@ -231,19 +273,6 @@ export class TerminalProcess extends BaseTerminalProcess {
231273
let idleTimedOut = false
232274
const streamStartedAt = Date.now()
233275

234-
// Guard against the idle timeout misfiring during shell initialization.
235-
// On cold shells (e.g. zsh with a heavy .zshrc), waitForShellIntegration
236-
// resolves when ]633;A is emitted, but the shell may still be loading by
237-
// the time executeCommand() is called. onDidStartTerminalShellExecution
238-
// (→ "shell_execution_started") fires only once the command actually starts
239-
// running. If it hasn't arrived yet when the 3s idle timer fires, the shell
240-
// is still initializing — self-finalizing would silently drop the command's
241-
// output, so we wait.
242-
let shellExecutionStarted = false
243-
this.once("shell_execution_started", () => {
244-
shellExecutionStarted = true
245-
})
246-
247276
// VSCode's execution.read() AsyncIterable can stay open indefinitely even after
248277
// the command finishes — it has no built-in cancellation. We need to be able to
249278
// break out of the loop when onDidEndTerminalShellExecution fires. We do this by
@@ -291,31 +320,33 @@ export class TerminalProcess extends BaseTerminalProcess {
291320
}
292321

293322
if (raceResult === IDLE_SENTINEL) {
294-
if (!shellExecutionStarted) {
295-
const elapsedMs = Date.now() - streamStartedAt
296-
const shellInitTimeout = Terminal.getShellIntegrationTimeout()
297-
298-
if (elapsedMs < shellInitTimeout) {
299-
// onDidStartTerminalShellExecution hasn't fired yet — the shell is
300-
// still initializing. Don't self-finalize; re-arm the idle timer
301-
// and keep waiting (the DONE_SENTINEL or a real chunk will break
302-
// us out once the command actually starts running).
303-
console.info(
304-
`[Terminal Process] idle timeout fired but shell execution not started yet — waiting for shell init (${elapsedMs}ms elapsed)`,
305-
)
306-
continue
307-
}
308-
309-
// Shell integration timeout exceeded and onDidStartTerminalShellExecution
310-
// never fired — something went wrong during shell init. Fall through to
311-
// self-finalize so we don't wait forever.
323+
if (shellExecutionStarted) {
324+
// The command is confirmed running (shell_execution_started fired). A
325+
// silent command like `sleep 5` can legitimately produce zero output —
326+
// elapsed time alone is not proof of completion. Re-arm the idle timer
327+
// and keep waiting for a real chunk, the D marker, or the end event.
312328
console.info(
313-
`[Terminal Process] shell execution never started after ${elapsedMs}ms — self-finalizing`,
329+
`[Terminal Process] idle timeout fired but shell execution is running — re-arming (${chunkCount} chunks so far)`,
314330
)
331+
continue
315332
}
316333

317-
// No data arrived within the idle window and onDidEndTerminalShellExecution
318-
// hasn't fired either — VSCode is not going to tell us. Self-finalize.
334+
const elapsedMs = Date.now() - streamStartedAt
335+
const shellInitTimeout = Terminal.getShellIntegrationTimeout()
336+
337+
if (elapsedMs < shellInitTimeout) {
338+
// onDidStartTerminalShellExecution hasn't fired yet — the shell is
339+
// still initializing. Don't self-finalize; re-arm the idle timer
340+
// and keep waiting.
341+
console.info(
342+
`[Terminal Process] idle timeout fired but shell execution not started yet — waiting for shell init (${elapsedMs}ms elapsed)`,
343+
)
344+
continue
345+
}
346+
347+
// Shell integration timeout exceeded and onDidStartTerminalShellExecution
348+
// never fired — something went wrong during shell init. Self-finalize.
349+
console.info(`[Terminal Process] shell execution never started after ${elapsedMs}ms — self-finalizing`)
319350
idleTimedOut = true
320351
console.info(
321352
`[Terminal Process] idle timeout (${IDLE_TIMEOUT_MS}ms) after ${chunkCount} chunk(s) — self-finalizing`,
@@ -421,15 +452,7 @@ export class TerminalProcess extends BaseTerminalProcess {
421452

422453
this.terminal.activeShellExecution = undefined
423454

424-
// Clean up the temp script file if one was written for this command.
425-
if (this.scriptPath) {
426-
try {
427-
fs.unlinkSync(this.scriptPath)
428-
} catch {
429-
// Best-effort: if it's already gone, that's fine.
430-
}
431-
this.scriptPath = undefined
432-
}
455+
this.cleanupScriptFile()
433456

434457
this.isHot = false
435458

@@ -493,7 +516,21 @@ export class TerminalProcess extends BaseTerminalProcess {
493516
// We need a known shell executable to run it — if we can't determine one,
494517
// fall back to { ... } wrapping (accepts the VSCode zero-chunk bug as a
495518
// lesser evil than invoking a non-existent "sh" on Windows).
496-
const shellExe = Terminal.getProfileShell()?.shellPath
519+
//
520+
// Try the Zoo Code profile first; if unset, fall back to the VS Code default
521+
// profile so users who haven't configured a Zoo Code profile override still
522+
// get the temp-file path instead of { ... } wrapping.
523+
let shellExe = Terminal.getProfileShell()?.shellPath
524+
if (!shellExe) {
525+
const defaultProfileName = Terminal.getConfiguredDefaultProfileName()
526+
if (defaultProfileName) {
527+
const profiles = Terminal.getConfiguredProfiles()
528+
const profile = profiles?.[defaultProfileName] as { path?: string | string[] } | null | undefined
529+
if (profile) {
530+
shellExe = Terminal.resolveProfilePath(profile.path)
531+
}
532+
}
533+
}
497534
if (!shellExe) {
498535
return `{\n${command}\n}`
499536
}
@@ -509,6 +546,17 @@ export class TerminalProcess extends BaseTerminalProcess {
509546

510547
private scriptPath: string | undefined
511548

549+
private cleanupScriptFile() {
550+
if (this.scriptPath) {
551+
try {
552+
fs.unlinkSync(this.scriptPath)
553+
} catch {
554+
// Best-effort: if it's already gone, that's fine.
555+
}
556+
this.scriptPath = undefined
557+
}
558+
}
559+
512560
public override continue() {
513561
this.emitRemainingBufferIfListening()
514562
this.isListening = false

src/integrations/terminal/TerminalRegistry.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,13 @@ export class TerminalRegistry {
8080
}
8181
const stream = e.execution.read()
8282
terminal.setActiveStream(stream)
83-
terminal.busy = true // Mark terminal as busy when shell execution starts
83+
// Only mark busy when there is a live process to clear it later.
84+
// If the end event already fired (early-completion race), process is
85+
// undefined and setActiveStream returned early — setting busy here would
86+
// leave the terminal stuck busy with nothing to clear it.
87+
if (terminal.process) {
88+
terminal.busy = true
89+
}
8490
} else {
8591
console.error(
8692
"[onDidStartTerminalShellExecution] Shell execution started, but not from a Roo-registered terminal:",

src/integrations/terminal/__tests__/TerminalProcess.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,77 @@ describe("TerminalProcess", () => {
634634
const calledWith = mockTerminal.shellIntegration.executeCommand.mock.calls[0][0] as string
635635
expect(calledWith).toMatch(/^"\/bin\/bash" ".*roo-cmd-.*\.sh"$/)
636636
})
637+
638+
it("uses VS Code default profile shell for temp-script when no Zoo Code profile override is set", async () => {
639+
vi.spyOn(Terminal, "getProfileShell").mockReturnValue(undefined)
640+
vi.spyOn(Terminal, "getConfiguredDefaultProfileName").mockReturnValue("bash")
641+
vi.spyOn(Terminal, "getConfiguredProfiles").mockReturnValue({
642+
bash: { path: "/bin/bash" },
643+
})
644+
645+
const command = "echo a\necho b"
646+
const runPromise = terminalProcess.run(command)
647+
terminalProcess.emit(
648+
"stream_available",
649+
(async function* () {
650+
yield "\x1b]633;C\x07"
651+
yield "a\n"
652+
yield "b\n"
653+
yield "\x1b]633;D\x07"
654+
})(),
655+
)
656+
setTimeout(() => terminalProcess.emit("shell_execution_complete", { exitCode: 0 }), 0)
657+
658+
await runPromise
659+
660+
const calledWith = mockTerminal.shellIntegration.executeCommand.mock.calls[0][0] as string
661+
expect(calledWith).toMatch(/^"\/bin\/bash" ".*roo-cmd-.*\.sh"$/)
662+
})
663+
664+
it("completes cleanly when shellExecutionComplete fires before stream_available", async () => {
665+
const completedOutputs: string[] = []
666+
terminalProcess.on("completed", (output) => completedOutputs.push(output ?? ""))
667+
668+
// Emit shell_execution_complete on the next tick — after run() has registered its
669+
// once("shell_execution_complete") listener but before stream_available fires.
670+
// This simulates a zero-output command where the end event beats the stream event.
671+
setTimeout(() => terminalProcess.emit("shell_execution_complete", { exitCode: 0 }), 0)
672+
673+
await terminalProcess.run("echo hello")
674+
675+
expect(completedOutputs).toEqual([""])
676+
expect(terminalProcess.isHot).toBe(false)
677+
expect(mockTerminalInfo.busy).toBe(false)
678+
expect(mockTerminalInfo.activeShellExecution).toBeUndefined()
679+
})
680+
681+
it("does not leave terminal busy when onDidStartTerminalShellExecution fires after early completion", async () => {
682+
// Simulate the production race: end event arrives before the stream.
683+
// The registry's onDidEndTerminalShellExecution handler (running=false branch) calls
684+
// terminal.shellExecutionComplete(), which clears terminal.process = undefined.
685+
// A late onDidStartTerminalShellExecution then arrives: setActiveStream() returns
686+
// early (no process), and TerminalRegistry must not set busy = true afterward.
687+
688+
// Step 1: end fires before run() sets running=true (the !terminal.running registry branch).
689+
// Drive this by having the shell_execution_complete event clear terminal.process
690+
// the same way shellExecutionComplete() does.
691+
setTimeout(() => mockTerminalInfo.shellExecutionComplete({ exitCode: 0, signal: undefined }), 0)
692+
await terminalProcess.run("echo hello")
693+
694+
// terminal.process was cleared by shellExecutionComplete().
695+
expect(mockTerminalInfo.process).toBeUndefined()
696+
expect(mockTerminalInfo.busy).toBe(false)
697+
698+
// Step 2: late start event arrives — setActiveStream returns early (no process).
699+
// Replicate the TerminalRegistry guard: only set busy when process exists.
700+
const lateStream = (async function* () {})()
701+
mockTerminalInfo.setActiveStream(lateStream)
702+
if (mockTerminalInfo.process) {
703+
mockTerminalInfo.busy = true
704+
}
705+
706+
expect(mockTerminalInfo.busy).toBe(false)
707+
})
637708
})
638709

639710
describe("continue", () => {

0 commit comments

Comments
 (0)