Compare commits

...
Author SHA1 Message Date
Robin Newhouse c6dcf63c79 fix(vscode-terminal): address Greptile review feedback
- Call `iterator.return?.()` in the `finally` block so the async
  iterator is signalled to clean up on early exit (previously handled
  implicitly by the `for await` loop this replaced).
- Capture `exitCode` from `onDidEndTerminalShellExecution` when the
  stream never yields its own `]633;D` marker (e.g. parse-rejected
  commands), so downstream can distinguish parse errors from normal
  completions.

Made-with: Cursor
2026-04-17 01:14:23 -07:00
Robin Newhouse 6d26bdc7ad fix(vscode-terminal): unblock run() when shell integration stream never yields
Summary
-------
When the user's shell rejects a command at parse time (e.g. zsh's
`parse error near '|'` for `tail -10 /var/www/html/index.html 2>1& | echo -`),
VS Code's shell-integration read stream yields zero chunks and never closes.
`VscodeTerminalProcess.run()`'s `for await (let data of stream)` then hangs
forever, `run()` never resolves, and `CommandOrchestrator` sits on
`await process` indefinitely. In Cline's UI this manifests as a command
that shows "Skipped" and the task spinning on "Thinking..." with no way
to recover.

This change races each iterator `.next()` against VS Code's
`window.onDidEndTerminalShellExecution` event (which *does* fire for this
case) and breaks the loop when the event arrives, allowing `run()` to
resolve normally and downstream lifecycle events (`completed`) to fire.

Background
----------
Recent context: PR #10269 added an explicit release path for a blocked
`Task.ask("command_output", ...)` inside `CommandOrchestrator`. That fix
unblocks the orchestrator whenever `process` emits `completed`, `error`,
or the orchestrator's own `COMMAND_TIMEOUT` fires. It works correctly for
all three cases.

However, there is an earlier deadlock that happens *before* any of those
signals can fire: if `VscodeTerminalProcess.run()` itself never returns,
none of the lifecycle events are emitted, so PR #10269's release paths
never trigger. That's the case this commit addresses.

The bug was first flagged by Taeksu Kim after shipping PR #10269 into a
release candidate. His diagnostic was the key clue: he instrumented the
`pWaitFor` blocks in `Task` and observed that when the hang occurred, the
code was not even inside either `pWaitFor` wait - i.e. the hang was
upstream of `Task.ask`, in the terminal layer.

Root cause investigation
------------------------
Reproducer (zsh; same behavior on bash for commands the shell rejects at
parse time):

  1. Ensure `terminal.integrated.shellIntegration.enabled = true` and
     Cline is configured to use the VS Code terminal.
  2. Ask Cline to run this exact command:
        tail -10 /var/www/html/index.html 2>1& | echo -
     The `2>1&` sequence terminates the command with `&` (background),
     leaving the `|` with nothing to pipe from. zsh and bash both reject
     this before execution with a parse error.
  3. Cline gets stuck on "Thinking...".

Instrumented the two relevant files and captured runtime logs. Five
hypotheses were tested; all five were confirmed by the captured
evidence:

  H1. The `for await (let data of stream)` in VscodeTerminalProcess.run
      iterates zero times and never exits.
  H2. VS Code still fires `window.onDidEndTerminalShellExecution` for
      this execution, providing an out-of-band signal that the shell
      command ended.
  H3. `CommandOrchestrator.orchestrateCommandExecution` enters the
      no-timeout branch (`timeoutSeconds` is undefined for VS Code
      terminal), so it is blocked on `await process`.
  H4. Because `run()` never resolves, the orchestrator's
      `process.once("completed")` and `process.once("error")`
      listeners are never invoked.
  H5. `terminal.shellIntegration.executeCommand` is the path being
      taken (not the fallback path), so the bug is in the shell-
      integration branch.

Key log evidence from the pre-fix run (timestamps relative to run()
entry):

    [t=0]      CommandOrchestrator enter       timeoutSeconds: undefined  -> H3
    [t=0]      no-timeout branch, await process                            -> H3
    [t+418ms]  VscodeTerminalProcess.run() enter, hasShellIntegration:true -> H5
    [t+419ms]  about to await stream                                       -> H1 (baseline)
    [t+466ms]  onDidEndTerminalShellExecution fired                        -> H2 CONFIRMED
    [t+30s+]   (silence, no stream chunks, no loop exit, no lifecycle)     -> H1, H4

`onDidEndTerminalShellExecution` fired roughly 47ms after run() began,
even though the stream produced nothing. This is the signal we can use
to detect the bug at runtime.

First fix attempt and why it initially failed
---------------------------------------------
The first attempt subscribed to `onDidEndTerminalShellExecution` and
compared the event's `execution` to the object returned by
`executeCommand(command)`:

    if (e.execution === execution) {
        resolveEndOfExecution()
    }

This did not unblock the hang. A second instrumented run captured:

    scoped end-of-execution listener fired
      matchesByTerminal: true
      matchesByShellIntegration: true
      matchesByExecution: false          <-- THIS
      eventCommandLine: "tail -10 /var/www/html/index.html 2>1& | echo -zsh: parse error near \`|'"

VS Code re-wraps the `TerminalShellExecution` inside the end event, so
`e.execution` is NOT reference-equal to the execution returned by
`executeCommand()`. Matching on `e.execution === execution` therefore
never fires. Two other identities DO hold stably:

  - `e.terminal === terminal`                                   (works)
  - `e.shellIntegration === terminal.shellIntegration`          (works)

Of those, `shellIntegration` is part of the documented public API
(`TerminalShellExecutionEndEvent.shellIntegration`), so that is the
identity used in this fix.

Also note: the event's `commandLine.value` is contaminated with the
shell's parse-error text appended to the original command
(`"... | echo -zsh: parse error near \`|'"`). Matching on commandLine
string would be fragile; reference matching is not.

The fix
-------
Replace the raw `for await (let data of stream)` with an explicit
iterator walk that races each `.next()` call against a promise resolved
by the end-of-execution event:

    const endOfExecutionDisposable =
        (vscode.window as any).onDidEndTerminalShellExecution?.((e: any) => {
            if (e?.shellIntegration === terminal.shellIntegration) {
                resolveEndOfExecution()
            }
        })

    const iterator = stream[Symbol.asyncIterator]()
    try {
        while (true) {
            const next = await Promise.race([iterator.next(), endOfExecutionPromise])
            if (next === END_SENTINEL) break          // broken-stream fallback
            if (next.done) break                       // normal end of stream
            let data = next.value
            // ... existing chunk processing ...
        }
    } finally {
        endOfExecutionDisposable?.dispose?.()
    }

Properties:

  - Healthy commands are unaffected: `iterator.next()` resolves with a
    value before the end-of-execution event fires (or the stream closes
    naturally via `]633;D`), so behavior is byte-for-byte identical to
    the previous `for await`.
  - Broken-stream commands (parse errors, and likely other edge cases
    where shell integration never emits `]633;D`) break out of the loop
    on end-of-execution, `run()` resolves, the existing post-loop
    `returnCurrentTerminalContents()` fallback captures whatever is
    visible in the terminal, and the `completed` event fires so PR
    #10269's release path can also run if needed.
  - The listener is scoped to a single `run()` call and disposed in a
    `finally` block, so there are no leaked VS Code event subscriptions.
  - Filtering by `shellIntegration` reference means end-of-execution
    events from other terminals (concurrent commands on other
    terminals) are ignored.

Note on types: `@types/vscode` is pinned at 1.84.0 in this repo, which
predates the stabilized `onDidEndTerminalShellExecution` event (added
in VS Code 1.93). The API exists and works at runtime in every
supported VS Code version; this change uses a localized `as any` cast
to sidestep the stale type without bumping the types package (which
would ripple into other files). A follow-up to bump `@types/vscode` is
worth doing in a separate PR.

Verification
------------
Five-hypothesis instrumented debug session with the same reproducer
ran end-to-end. Post-fix run captured for the buggy command
`tail -10 /var/www/html/index.html 2>1& | echo -`:

    [t=0]      entering new while-loop (proves new code loaded)
    [t+36ms]   scoped end-of-execution listener fired
                  matchesByTerminal: true
                  matchesByShellIntegration: true
                  matchesByExecution: false
    [t+37ms]   broke loop on end-of-execution   totalChunks: 0
    [t+37ms]   stream loop exited               endOfExecutionTriggered: true
    [t+678ms]  CommandOrchestrator 'completed' listener fired
    [t+678ms]  no-timeout branch: process awaited and resolved

Total time to unhang: 678ms (previously infinite).

Regression check with a non-buggy command
(`tail -10 /var/www/html/index.html 2>&1 | echo -`, valid shell):

    [t=0]      entering new while-loop
    [t+75ms]   stream yielded chunk, chunkIndex: 1, dataLength: 368
    [t+76ms]   stream loop exited               endOfExecutionTriggered: false
    [t+533ms]  'completed' listener fired
    [t+534ms]  no-timeout branch: process awaited and resolved

`endOfExecutionTriggered: false` confirms the end-of-execution fallback
was not exercised for healthy commands and the stream closed naturally
via its own `]633;D` marker - no behavior change for the healthy path.

Out of scope
------------
  - PR #10269's release logic is untouched. It is still the correct
    mechanism for unblocking a pending `ask("command_output", ...)` on
    `completed` / `error` / `COMMAND_TIMEOUT`, and it continues to work
    as designed.
  - Dangling `Task` instances after a new session starts (memory/leak
    concern also raised in the same thread) is a separate issue and is
    not addressed here.
  - v1 VS Code extension maintenance plan discussion is orthogonal to
    this bug.

Credit
------
Root cause isolation (including the critical observation that
`timeoutSeconds = undefined` and that neither `pWaitFor` is entered
when the hang occurs) belongs to Taeksu Kim.

Made-with: Cursor
2026-04-17 01:04:04 -07:00
+152 -115
View File
@@ -66,26 +66,57 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
let didOutputNonCommand = false
let didEmitEmptyLine = false
for await (let data of stream) {
// Parse shell integration completion markers when present.
// Sequence format: ]633;D;<exitCode>
const completionMatches = [...data.matchAll(/\]633;D(?:;(-?\d+))?/g)]
const latestCompletionMatch = completionMatches[completionMatches.length - 1]
if (latestCompletionMatch?.[1] !== undefined) {
const parsedExitCode = Number.parseInt(latestCompletionMatch[1], 10)
if (Number.isInteger(parsedExitCode)) {
this.exitCode = parsedExitCode
// Fallback end signal: if the shell rejects a command at parse time, the stream
// yields nothing and never closes. VS Code still fires end-of-execution for the
// terminal's shell integration, so race the stream against that event and exit
// the loop if it fires. The event's `execution` property is not reference-equal
// to the one returned by executeCommand(); match on `shellIntegration` instead.
const END_SENTINEL: unique symbol = Symbol("end-of-execution")
let resolveEndOfExecution: () => void = () => {}
const endOfExecutionPromise = new Promise<typeof END_SENTINEL>((resolve) => {
resolveEndOfExecution = () => resolve(END_SENTINEL)
})
const endOfExecutionDisposable = (vscode.window as any).onDidEndTerminalShellExecution?.((e: any) => {
if (e?.shellIntegration === terminal.shellIntegration) {
// Capture exit code from the event when the stream never yields its own ]633;D marker
// (e.g. parse-rejected commands). Available on VS Code 1.93+.
if (this.exitCode === undefined && typeof e?.exitCode === "number") {
this.exitCode = e.exitCode
}
resolveEndOfExecution()
}
})
// 1. Process chunk and remove artifacts
if (isFirstChunk) {
/*
const iterator = stream[Symbol.asyncIterator]()
try {
while (true) {
const next = await Promise.race([iterator.next(), endOfExecutionPromise])
if (next === END_SENTINEL) {
break
}
if (next.done) {
break
}
let data = next.value
// Parse shell integration completion markers when present.
// Sequence format: ]633;D;<exitCode>
const completionMatches = [...data.matchAll(/\]633;D(?:;(-?\d+))?/g)]
const latestCompletionMatch = completionMatches[completionMatches.length - 1]
if (latestCompletionMatch?.[1] !== undefined) {
const parsedExitCode = Number.parseInt(latestCompletionMatch[1], 10)
if (Number.isInteger(parsedExitCode)) {
this.exitCode = parsedExitCode
}
}
// 1. Process chunk and remove artifacts
if (isFirstChunk) {
/*
The first chunk we get from this stream needs to be processed to be more human readable, ie remove vscode's custom escape sequences and identifiers, removing duplicate first char bug, etc.
*/
// bug where sometimes the command output makes its way into vscode shell integration metadata
/*
// bug where sometimes the command output makes its way into vscode shell integration metadata
/*
]633 is a custom sequence number used by VSCode shell integration:
- OSC 633 ; A ST - Mark prompt start
- OSC 633 ; B ST - Mark prompt end
@@ -93,119 +124,125 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
- OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
- OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
*/
// if you print this data you might see something like "eecho hello worldo hello world;5ba85d14-e92a-40c4-b2fd-71525581eeb0]633;C" but this is actually just a bunch of escape sequences, ignore up to the first ;C
/* ddateb15026-6a64-40db-b21f-2a621a9830f0]633;CTue Sep 17 06:37:04 EDT 2024 % ]633;D;0]633;P;Cwd=/Users/saoud/Repositories/test */
// Gets output between ]633;C (command start) and ]633;D (command end)
const outputBetweenSequences = this.removeLastLineArtifacts(
data.match(/\]633;C([\s\S]*?)\]633;D/)?.[1] || "",
).trim()
// if you print this data you might see something like "eecho hello worldo hello world;5ba85d14-e92a-40c4-b2fd-71525581eeb0]633;C" but this is actually just a bunch of escape sequences, ignore up to the first ;C
/* ddateb15026-6a64-40db-b21f-2a621a9830f0]633;CTue Sep 17 06:37:04 EDT 2024 % ]633;D;0]633;P;Cwd=/Users/saoud/Repositories/test */
// Gets output between ]633;C (command start) and ]633;D (command end)
const outputBetweenSequences = this.removeLastLineArtifacts(
data.match(/\]633;C([\s\S]*?)\]633;D/)?.[1] || "",
).trim()
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
if (lastMatch && lastMatch.index !== undefined) {
data = data.slice(lastMatch.index + lastMatch[0].length)
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
if (lastMatch && lastMatch.index !== undefined) {
data = data.slice(lastMatch.index + lastMatch[0].length)
}
// Place output back after removing vscode sequences
if (outputBetweenSequences) {
data = outputBetweenSequences + "\n" + data
}
// remove ansi
data = stripAnsi(data)
// Split data by newlines
const lines = data ? data.split("\n") : []
// Remove non-human readable characters from the first line
if (lines.length > 0) {
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
}
// Check for duplicated first character that might be a terminal artifact
// But skip this check for known syntax characters like {, [, ", etc.
if (
lines.length > 0 &&
lines[0].length >= 2 &&
lines[0][0] === lines[0][1] &&
!["[", "{", '"', "'", "<", "("].includes(lines[0][0])
) {
lines[0] = lines[0].slice(1)
}
// Only remove specific terminal artifacts from line beginnings while preserving JSON syntax
if (lines.length > 0) {
// This regex only removes common terminal artifacts (%, $, >, #) and invisible control chars
// but preserves important syntax chars like {, [, ", etc.
lines[0] = lines[0].replace(/^[\x00-\x1F%$>#\s]*/, "")
}
if (lines.length > 1) {
lines[1] = lines[1].replace(/^[\x00-\x1F%$>#\s]*/, "")
}
// Join lines back
data = lines.join("\n")
isFirstChunk = false
} else {
data = stripAnsi(data)
}
// Place output back after removing vscode sequences
if (outputBetweenSequences) {
data = outputBetweenSequences + "\n" + data
}
// remove ansi
data = stripAnsi(data)
// Split data by newlines
const lines = data ? data.split("\n") : []
// Remove non-human readable characters from the first line
if (lines.length > 0) {
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
}
// Check for duplicated first character that might be a terminal artifact
// But skip this check for known syntax characters like {, [, ", etc.
if (
lines.length > 0 &&
lines[0].length >= 2 &&
lines[0][0] === lines[0][1] &&
!["[", "{", '"', "'", "<", "("].includes(lines[0][0])
) {
lines[0] = lines[0].slice(1)
}
// Only remove specific terminal artifacts from line beginnings while preserving JSON syntax
if (lines.length > 0) {
// This regex only removes common terminal artifacts (%, $, >, #) and invisible control chars
// but preserves important syntax chars like {, [, ", etc.
lines[0] = lines[0].replace(/^[\x00-\x1F%$>#\s]*/, "")
}
if (lines.length > 1) {
lines[1] = lines[1].replace(/^[\x00-\x1F%$>#\s]*/, "")
}
// Join lines back
data = lines.join("\n")
isFirstChunk = false
} else {
data = stripAnsi(data)
}
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
if (data.includes("^C") || data.includes("\u0003")) {
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
if (data.includes("^C") || data.includes("\u0003")) {
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
this.isHot = false
break
}
// first few chunks could be the command being echoed back, so we must ignore
// note this means that 'echo' commands won't work
if (!didOutputNonCommand) {
const lines = data.split("\n")
for (let i = 0; i < lines.length; i++) {
if (command.includes(lines[i].trim())) {
lines.splice(i, 1)
i-- // Adjust index after removal
} else {
didOutputNonCommand = true
break
}
}
data = lines.join("\n")
}
// 2. Set isHot depending on the command
// Set to hot to stall API requests until terminal is cool again
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
this.isHot = false
break
}
// these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
const isCompiling = isCompilingOutput(data)
this.hotTimer = setTimeout(
() => {
this.isHot = false
},
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
)
// first few chunks could be the command being echoed back, so we must ignore
// note this means that 'echo' commands won't work
if (!didOutputNonCommand) {
const lines = data.split("\n")
for (let i = 0; i < lines.length; i++) {
if (command.includes(lines[i].trim())) {
lines.splice(i, 1)
i-- // Adjust index after removal
} else {
didOutputNonCommand = true
break
}
// For non-immediately returning commands we want to show loading spinner right away but this wouldn't happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
// This is only done for the sake of unblocking the UI, in case there may be some time before the command emits a full line
if (!didEmitEmptyLine && !this.fullOutput && data) {
this.emit("line", "") // empty line to indicate start of command output stream
didEmitEmptyLine = true
}
data = lines.join("\n")
}
// 2. Set isHot depending on the command
// Set to hot to stall API requests until terminal is cool again
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
// these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
const isCompiling = isCompilingOutput(data)
this.hotTimer = setTimeout(
() => {
this.isHot = false
},
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
)
this.fullOutput += data
// For non-immediately returning commands we want to show loading spinner right away but this wouldn't happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
// This is only done for the sake of unblocking the UI, in case there may be some time before the command emits a full line
if (!didEmitEmptyLine && !this.fullOutput && data) {
this.emit("line", "") // empty line to indicate start of command output stream
didEmitEmptyLine = true
}
// Cap fullOutput at MAX_FULL_OUTPUT_SIZE to prevent memory exhaustion
if (this.fullOutput.length > MAX_FULL_OUTPUT_SIZE) {
// Keep last half of max size
this.fullOutput = this.fullOutput.slice(-MAX_FULL_OUTPUT_SIZE / 2)
// Reset lastRetrievedIndex since we truncated the beginning
this.lastRetrievedIndex = 0
}
this.fullOutput += data
// Cap fullOutput at MAX_FULL_OUTPUT_SIZE to prevent memory exhaustion
if (this.fullOutput.length > MAX_FULL_OUTPUT_SIZE) {
// Keep last half of max size
this.fullOutput = this.fullOutput.slice(-MAX_FULL_OUTPUT_SIZE / 2)
// Reset lastRetrievedIndex since we truncated the beginning
this.lastRetrievedIndex = 0
}
if (this.isListening) {
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
if (this.isListening) {
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
}
} finally {
endOfExecutionDisposable?.dispose?.()
// Signal the async iterator to clean up on early exit. `for await` does this
// automatically on `break`; we're using a manual while loop so call it explicitly.
await iterator.return?.()
}
this.emitRemainingBufferIfListening()