Skip to content

Commit f68a9ab

Browse files
committed
fix: additional improvements to compound command handling
- Enhanced compound command detection logic - Added more comprehensive test coverage - Fixed edge cases in process completion tracking
1 parent 7d557a8 commit f68a9ab

File tree

6 files changed

+572
-26
lines changed

6 files changed

+572
-26
lines changed

src/integrations/terminal/BaseTerminal.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export abstract class BaseTerminal implements RooTerminal {
2727
// Compound command tracking
2828
public isCompoundCommand: boolean = false
2929
public compoundProcessCompletions: CompoundProcessCompletion[] = []
30-
private expectedCompoundProcessCount: number = 0
30+
public expectedCompoundProcessCount: number = 0
3131
private compoundCommandWaitTimeout?: NodeJS.Timeout
3232

3333
constructor(provider: RooTerminalProvider, id: number, cwd: string) {
@@ -80,6 +80,16 @@ export abstract class BaseTerminal implements RooTerminal {
8080
* @param command The command to check
8181
*/
8282
public detectCompoundCommand(command: string): void {
83+
// Reset previous compound command state
84+
this.compoundProcessCompletions = []
85+
this.expectedCompoundProcessCount = 0
86+
87+
// Clear any existing timeout
88+
if (this.compoundCommandWaitTimeout) {
89+
clearTimeout(this.compoundCommandWaitTimeout)
90+
this.compoundCommandWaitTimeout = undefined
91+
}
92+
8393
// Common shell operators that create compound commands
8494
const compoundOperators = ["&&", "||", ";", "|", "&"]
8595

@@ -99,7 +109,8 @@ export abstract class BaseTerminal implements RooTerminal {
99109
const orMatches = command.match(/\|\|/g)
100110
const semiMatches = command.match(/;/g)
101111
const pipeMatches = command.match(/\|(?!\|)/g) // Match single | but not ||
102-
const bgMatches = command.match(/&(?!&)/g) // Match single & but not &&
112+
// Match single & but not &&, and not preceded by &
113+
const bgMatches = command.match(/(?<!&)&(?!&)/g)
103114

104115
if (andMatches) processCount += andMatches.length
105116
if (orMatches) processCount += orMatches.length
@@ -123,9 +134,6 @@ export abstract class BaseTerminal implements RooTerminal {
123134
this.finalizeCompoundCommand()
124135
}
125136
}, 10000) // 10 second timeout for compound commands
126-
} else {
127-
this.compoundProcessCompletions = []
128-
this.expectedCompoundProcessCount = 0
129137
}
130138
}
131139

@@ -152,7 +160,8 @@ export abstract class BaseTerminal implements RooTerminal {
152160
)
153161

154162
// Check if all expected processes have completed
155-
if (this.compoundProcessCompletions.length >= this.expectedCompoundProcessCount) {
163+
// Note: We check this after adding, so the finalization happens after the last process is added
164+
if (this.allCompoundProcessesComplete()) {
156165
console.info(`[Terminal ${this.id}] All compound processes complete, finalizing`)
157166
this.finalizeCompoundCommand()
158167
}
@@ -201,7 +210,7 @@ export abstract class BaseTerminal implements RooTerminal {
201210
/**
202211
* Finalizes a compound command execution
203212
*/
204-
private finalizeCompoundCommand(): void {
213+
public finalizeCompoundCommand(): void {
205214
// Clear the timeout if it exists
206215
if (this.compoundCommandWaitTimeout) {
207216
clearTimeout(this.compoundCommandWaitTimeout)
@@ -216,13 +225,18 @@ export abstract class BaseTerminal implements RooTerminal {
216225
`[Terminal ${this.id}] Finalizing compound command with ${this.compoundProcessCompletions.length} processes`,
217226
)
218227

219-
// Reset compound command tracking
228+
// Reset compound command tracking BEFORE calling shellExecutionComplete
229+
// to prevent re-entrance issues
230+
const wasCompound = this.isCompoundCommand
220231
this.isCompoundCommand = false
221232
this.compoundProcessCompletions = []
222233
this.expectedCompoundProcessCount = 0
223234

224235
// Complete the terminal process with the final exit details
225-
this.shellExecutionComplete(finalExitDetails)
236+
// Only if we were actually tracking a compound command
237+
if (wasCompound) {
238+
this.shellExecutionComplete(finalExitDetails)
239+
}
226240
}
227241

228242
/**

src/integrations/terminal/TerminalRegistry.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,38 @@ export class TerminalRegistry {
9595
}
9696

9797
// For compound commands, we need to track if this is just one part of a multi-process command
98-
// Check if the terminal has pending compound processes
99-
if (terminal.isCompoundCommand && !terminal.allCompoundProcessesComplete()) {
100-
console.info(
101-
"[TerminalRegistry] Compound command process completed, waiting for remaining processes:",
102-
{ terminalId: terminal.id, command: e.execution?.commandLine?.value, exitCode: e.exitCode },
103-
)
104-
105-
// Store this process completion but don't mark terminal as not busy yet
98+
if (terminal.isCompoundCommand) {
99+
// Check if this is the last process before adding the completion
100+
const wasLastProcess =
101+
terminal.compoundProcessCompletions.length ===
102+
(terminal.expectedCompoundProcessCount || 0) - 1
103+
104+
// Store this process completion
105+
// This may trigger finalization if it's the last process
106106
terminal.addCompoundProcessCompletion(exitDetails, e.execution?.commandLine?.value || "")
107-
return
107+
108+
// If this was the last process, the compound command has been finalized
109+
// and terminal.busy has been set to false by finalizeCompoundCommand
110+
if (wasLastProcess) {
111+
console.info("[TerminalRegistry] All compound command processes completed and finalized:", {
112+
terminalId: terminal.id,
113+
busy: terminal.busy,
114+
})
115+
// The terminal has been finalized, just return
116+
return
117+
} else {
118+
console.info(
119+
"[TerminalRegistry] Compound command process completed, waiting for remaining processes:",
120+
{
121+
terminalId: terminal.id,
122+
command: e.execution?.commandLine?.value,
123+
exitCode: e.exitCode,
124+
completedCount: terminal.compoundProcessCompletions.length,
125+
},
126+
)
127+
// Still waiting for more processes
128+
return
129+
}
108130
}
109131

110132
if (!terminal.running) {
@@ -115,8 +137,7 @@ export class TerminalRegistry {
115137
"[TerminalRegistry] Shell execution end event received before terminal marked as running (compound command scenario):",
116138
{ terminalId: terminal?.id, command: process?.command, exitCode: e.exitCode },
117139
)
118-
// Store this completion for later processing
119-
terminal.addCompoundProcessCompletion(exitDetails, e.execution?.commandLine?.value || "")
140+
// Already stored this completion above
120141
} else {
121142
console.error(
122143
"[TerminalRegistry] Shell execution end event received, but process is not running for terminal:",
@@ -137,6 +158,7 @@ export class TerminalRegistry {
137158
}
138159

139160
// Signal completion to any waiting processes.
161+
// For compound commands, this will use the finalized exit details from all processes
140162
terminal.shellExecutionComplete(exitDetails)
141163
terminal.busy = false // Mark terminal as not busy when shell execution ends
142164
},

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,17 @@ describe("Compound Command Handling", () => {
208208
it("should format compound process outputs correctly", () => {
209209
terminal.detectCompoundCommand("cd /tmp && ls")
210210
terminal.addCompoundProcessCompletion({ exitCode: 0 }, "cd /tmp")
211-
terminal.addCompoundProcessCompletion({ exitCode: 1 }, "ls")
212211

213-
const output = terminal.getCompoundProcessOutputs()
212+
// Get output before the second completion triggers finalization
213+
const outputBeforeFinalization = terminal.getCompoundProcessOutputs()
214+
expect(outputBeforeFinalization).toContain("[Command: cd /tmp]")
215+
expect(outputBeforeFinalization).toContain("[Exit Code: 0]")
216+
217+
terminal.addCompoundProcessCompletion({ exitCode: 1 }, "ls")
214218

215-
expect(output).toContain("[Command: cd /tmp]")
216-
expect(output).toContain("[Exit Code: 0]")
217-
expect(output).toContain("[Command: ls]")
218-
expect(output).toContain("[Exit Code: 1]")
219+
// After finalization, completions are cleared
220+
const outputAfterFinalization = terminal.getCompoundProcessOutputs()
221+
expect(outputAfterFinalization).toBe("")
219222
})
220223

221224
it("should include signal information when present", () => {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { BaseTerminal } from "../BaseTerminal"
3+
import type { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcessResultPromise } from "../types"
4+
5+
// Create a concrete test implementation of BaseTerminal
6+
class TestTerminal extends BaseTerminal {
7+
constructor(id: number, cwd: string) {
8+
super("vscode", id, cwd)
9+
}
10+
11+
isClosed(): boolean {
12+
return false
13+
}
14+
15+
runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise {
16+
throw new Error("Not implemented for test")
17+
}
18+
}
19+
20+
describe("Compound Command Simple Tests", () => {
21+
let terminal: TestTerminal
22+
23+
beforeEach(() => {
24+
terminal = new TestTerminal(1, "/test/path")
25+
vi.clearAllMocks()
26+
vi.useFakeTimers()
27+
})
28+
29+
it("should properly finalize compound command and set busy to false", () => {
30+
// Set up initial state
31+
terminal.busy = true
32+
terminal.running = true
33+
34+
// Create a mock process - need to implement EventEmitter interface
35+
const mockProcess = {
36+
command: "cd dir && ls",
37+
emit: vi.fn(),
38+
on: vi.fn(),
39+
once: vi.fn(),
40+
hasUnretrievedOutput: vi.fn(() => false),
41+
getUnretrievedOutput: vi.fn(() => ""),
42+
isHot: false,
43+
run: vi.fn(),
44+
continue: vi.fn(),
45+
abort: vi.fn(),
46+
// Add EventEmitter methods
47+
addListener: vi.fn(),
48+
removeListener: vi.fn(),
49+
removeAllListeners: vi.fn(),
50+
setMaxListeners: vi.fn(),
51+
getMaxListeners: vi.fn(() => 10),
52+
listeners: vi.fn(() => []),
53+
rawListeners: vi.fn(() => []),
54+
listenerCount: vi.fn(() => 0),
55+
prependListener: vi.fn(),
56+
prependOnceListener: vi.fn(),
57+
eventNames: vi.fn(() => []),
58+
off: vi.fn(),
59+
}
60+
terminal.process = mockProcess as any
61+
62+
// Detect compound command
63+
terminal.detectCompoundCommand("cd dir && ls")
64+
expect(terminal.isCompoundCommand).toBe(true)
65+
expect(terminal.expectedCompoundProcessCount).toBe(2)
66+
67+
// Add first completion
68+
terminal.addCompoundProcessCompletion({ exitCode: 0 }, "cd dir")
69+
expect(terminal.busy).toBe(true) // Should still be busy
70+
expect(terminal.isCompoundCommand).toBe(true) // Should still be compound
71+
expect(terminal.compoundProcessCompletions).toHaveLength(1)
72+
73+
// Add second completion - this should trigger finalization
74+
terminal.addCompoundProcessCompletion({ exitCode: 0 }, "ls")
75+
76+
// After finalization, terminal should not be busy
77+
expect(terminal.busy).toBe(false)
78+
expect(terminal.isCompoundCommand).toBe(false)
79+
expect(terminal.compoundProcessCompletions).toHaveLength(0)
80+
81+
// Verify that shell_execution_complete was emitted
82+
expect(mockProcess.emit).toHaveBeenCalledWith("shell_execution_complete", { exitCode: 0 })
83+
84+
// Process should be cleared
85+
expect(terminal.process).toBeUndefined()
86+
})
87+
88+
it("should handle timeout and finalize", () => {
89+
terminal.busy = true
90+
terminal.running = true
91+
92+
// Create a mock process
93+
const mockProcess = {
94+
command: "cd dir && ls",
95+
emit: vi.fn(),
96+
on: vi.fn(),
97+
once: vi.fn(),
98+
hasUnretrievedOutput: vi.fn(() => false),
99+
getUnretrievedOutput: vi.fn(() => ""),
100+
}
101+
terminal.process = mockProcess as any
102+
103+
terminal.detectCompoundCommand("cd dir && ls")
104+
terminal.addCompoundProcessCompletion({ exitCode: 0 }, "cd dir")
105+
106+
expect(terminal.busy).toBe(true)
107+
108+
// Fast forward to trigger timeout
109+
vi.advanceTimersByTime(10001)
110+
111+
// Should be finalized after timeout
112+
expect(terminal.busy).toBe(false)
113+
expect(terminal.isCompoundCommand).toBe(false)
114+
})
115+
})

0 commit comments

Comments
 (0)