Skip to content

Commit a84430a

Browse files
committed
fix(terminal): await onCompleted callback when exit details are missing to resolve E2E race condition
1 parent c203126 commit a84430a

4 files changed

Lines changed: 49 additions & 3 deletions

File tree

apps/vscode-e2e/fixtures/fast-exit-shell-race.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"toolCalls": [
99
{
1010
"name": "execute_command",
11-
"arguments": "{\"command\":\"python3 -c \\\"\\nimport sys\\nprint('boom', file=sys.stderr)\\nsys.exit(1)\\n\\\"\"}",
11+
"arguments": "{\"command\":\"echo boom >&2\\nfalse\"}",
1212
"id": "call_fast_exit_shell_race_001"
1313
}
1414
]

apps/vscode-e2e/src/suite/tools/fast-exit-shell-race.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,53 @@
2727
* shell-integration event/stream plumbing.
2828
*/
2929
import * as assert from "assert"
30+
import * as vscode from "vscode"
3031

3132
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
3233

3334
import { waitUntilCompleted } from "../utils"
3435
import { setDefaultSuiteTimeout } from "../test-utils"
3536

37+
const PROFILE_NAME = "Zoo E2E Zsh"
38+
3639
suite("Fast-exit shell integration race", function () {
3740
if (process.platform !== "linux") {
3841
return
3942
}
4043

4144
setDefaultSuiteTimeout(this)
4245

46+
let originalProfiles: Record<string, unknown> | undefined
47+
48+
suiteSetup(async () => {
49+
// Save the current global linux profiles so we can restore them in teardown.
50+
originalProfiles = vscode.workspace
51+
.getConfiguration("terminal.integrated.profiles")
52+
.inspect<Record<string, unknown>>("linux")?.globalValue
53+
54+
// Write a Zsh test profile to VS Code global settings to bypass the VS Code Bash parser bug
55+
await vscode.workspace.getConfiguration("terminal.integrated.profiles").update(
56+
"linux",
57+
{
58+
...originalProfiles,
59+
[PROFILE_NAME]: { path: "/bin/zsh", args: ["--no-globalrcs", "--norc"] },
60+
},
61+
vscode.ConfigurationTarget.Global,
62+
)
63+
64+
// Activate the profile override
65+
globalThis.api.setTerminalProfile(PROFILE_NAME)
66+
})
67+
68+
suiteTeardown(async () => {
69+
// Restore profiles
70+
globalThis.api.setTerminalProfile(undefined)
71+
72+
await vscode.workspace
73+
.getConfiguration("terminal.integrated.profiles")
74+
.update("linux", originalProfiles, vscode.ConfigurationTarget.Global)
75+
})
76+
4377
setup(async () => {
4478
try {
4579
await globalThis.api.cancelCurrentTask()

src/core/tools/ExecuteCommandTool.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ export async function executeCommandInTerminal(
340340
resolveOnCompleted = resolve
341341
})
342342

343+
let isCompletedTriggered = false
344+
343345
const callbacks: RooTerminalCallbacks = {
344346
onLine: async (lines: string, process: RooTerminalProcess) => {
345347
accumulatedOutput += lines
@@ -379,6 +381,7 @@ export async function executeCommandInTerminal(
379381
}
380382
},
381383
onCompleted: async (output: string | undefined) => {
384+
isCompletedTriggered = true
382385
try {
383386
clearTimeout(pendingCommandOutputEmitTimer)
384387
pendingCommandOutputEmitTimer = undefined
@@ -511,7 +514,7 @@ export async function executeCommandInTerminal(
511514
// Wait for onCompleted callback to finish if shell execution completed.
512515
// This ensures persistedResult is set before we try to use it, fixing the race
513516
// condition where exitDetails is set (sync) before the async onCompleted finishes.
514-
if (exitDetails && onCompletedPromise) {
517+
if ((exitDetails || isCompletedTriggered) && onCompletedPromise) {
515518
await onCompletedPromise
516519
}
517520

src/integrations/terminal/Terminal.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export class Terminal extends BaseTerminal {
1515

1616
public cmdCounter: number = 0
1717

18+
private isFirstCommand: boolean = true
19+
1820
public activeShellExecution?: vscode.TerminalShellExecution
1921

2022
constructor(id: number, terminal: vscode.Terminal | undefined, cwd: string) {
@@ -121,10 +123,17 @@ export class Terminal extends BaseTerminal {
121123
pWaitFor(() => this.terminal.shellIntegration !== undefined, {
122124
timeout: Terminal.getShellIntegrationTimeout(),
123125
})
124-
.then(() => {
126+
.then(async () => {
125127
// Clean up temporary directory if shell integration is available, zsh did its job:
126128
ShellIntegrationManager.zshCleanupTmpDir(this.id)
127129

130+
// If this is a newly created terminal, give VS Code's shell integration script
131+
// a brief moment to finish activating in the PTY before submitting the command.
132+
if (this.isFirstCommand) {
133+
this.isFirstCommand = false
134+
await new Promise((resolve) => setTimeout(resolve, 1000))
135+
}
136+
128137
// Run the command in the terminal
129138
process.run(command)
130139
})

0 commit comments

Comments
 (0)