Skip to content

Commit 57d4817

Browse files
fix(terminal): retry Ctrl+C for processes needing multiple SIGINT (#266) (#272)
* fix(terminal): retry Ctrl+C for processes needing multiple SIGINT (#266) Some processes (interactive tools, programs that trap SIGINT and prompt for confirmation) need more than one Ctrl+C to exit. The cancel path sent a single Ctrl+C then disposed immediately, leaving such processes running and the terminal stuck busy. abort() now kicks off a bounded, fire-and-forget retry that re-sends Ctrl+C up to 3 times (500ms apart), stopping early once the process exits or we stop listening. The synchronous cancel path is never blocked and the retry window is bounded so dispose() is never delayed indefinitely. The ExecaTerminal backend is unaffected (it sends SIGKILL directly). Follow-up to #245 / #261. Closes #266 * refactor(terminal): clarify Ctrl+C retry naming and comments per review (#266) - rename ABORT_MAX_ATTEMPTS -> CTRL_C_SEND_LIMIT (total sends) and start the retry loop at sent=1 so the bound reads naturally - document why both isListening and terminal.busy are checked - cross-reference the mirrored test constants to the production ones - note the double-abort send-count assumption in the test - drop the unused changeset * fix(terminal): skip Ctrl+C retry when terminal is reused by a different process (#266) * Update src/integrations/terminal/TerminalProcess.ts --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com>
1 parent adf0d72 commit 57d4817

2 files changed

Lines changed: 185 additions & 3 deletions

File tree

src/integrations/terminal/TerminalProcess.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,20 @@ import { BaseTerminalProcess } from "./BaseTerminalProcess"
66
import { Terminal } from "./Terminal"
77

88
export class TerminalProcess extends BaseTerminalProcess {
9+
// #266: Some processes (interactive tools, programs that trap SIGINT and
10+
// prompt for confirmation) need more than one Ctrl+C to actually exit. We
11+
// send Ctrl+C up to this many times in TOTAL — the immediate send in abort()
12+
// plus retries — checking between sends whether the process has exited, before
13+
// giving up and letting dispose() proceed.
14+
private static readonly CTRL_C_SEND_LIMIT = 3
15+
// Delay between Ctrl+C re-sends. Kept short so cancel stays responsive; the
16+
// retry window is bounded by (CTRL_C_SEND_LIMIT - 1) * ABORT_RETRY_DELAY_MS.
17+
private static readonly ABORT_RETRY_DELAY_MS = 500
18+
919
private terminalRef: WeakRef<Terminal>
20+
// Guards against overlapping abort retry loops if abort() is called again
21+
// while a previous loop is still re-sending Ctrl+C.
22+
private aborting = false
1023

1124
constructor(terminal: Terminal) {
1225
super()
@@ -256,9 +269,62 @@ export class TerminalProcess extends BaseTerminalProcess {
256269
}
257270

258271
public override abort() {
259-
if (this.isListening) {
260-
// Send SIGINT using CTRL+C
261-
this.terminal.terminal.sendText("\x03")
272+
if (!this.isListening) {
273+
return
274+
}
275+
276+
// Send SIGINT using CTRL+C.
277+
this.terminal.terminal.sendText("\x03")
278+
279+
// #266: A single Ctrl+C isn't always enough — some processes trap SIGINT
280+
// and keep running. Kick off a bounded retry that re-sends Ctrl+C a few
281+
// times, verifying between attempts whether the process actually exited
282+
// (terminal.busy flips to false on completion). This is intentionally
283+
// fire-and-forget so it never blocks the synchronous cancel path; the
284+
// total retry window is bounded so dispose() is never delayed for long.
285+
if (!this.aborting) {
286+
this.aborting = true
287+
void this.retryAbort()
288+
.finally(() => {
289+
this.aborting = false
290+
})
291+
.catch((err) => console.error("[TerminalProcess] retryAbort error:", err))
292+
}
293+
}
294+
295+
/**
296+
* Re-sends Ctrl+C after the immediate send in abort(), up to CTRL_C_SEND_LIMIT
297+
* total sends, waiting ABORT_RETRY_DELAY_MS between sends and stopping early once
298+
* the process exits (or once we stop listening). Bounded so it can never loop
299+
* indefinitely.
300+
*/
301+
private async retryAbort(): Promise<void> {
302+
// abort() already sent Ctrl+C once, so `sent` starts at 1; re-send until we
303+
// reach CTRL_C_SEND_LIMIT total.
304+
for (let sent = 1; sent < TerminalProcess.CTRL_C_SEND_LIMIT; sent++) {
305+
await new Promise((resolve) => setTimeout(resolve, TerminalProcess.ABORT_RETRY_DELAY_MS))
306+
307+
// Stop as soon as there's nothing left to interrupt. `isListening` (cleared
308+
// by continue()) and `terminal.busy` (cleared by shellExecutionComplete() /
309+
// the "completed" event) are set on different code paths and can diverge, so
310+
// either one being false is a sufficient stop signal — we deliberately check
311+
// both rather than collapsing them into one.
312+
if (!this.isListening) {
313+
return
314+
}
315+
316+
const terminal = this.terminalRef.deref()
317+
318+
// Stop if the terminal is gone, idle, or has already moved on to a different
319+
// command. If the original command exits and the terminal is reused before this
320+
// tick fires, `terminal.busy` can be true for the NEW command while
321+
// `terminal.process` points at a different TerminalProcess — re-sending Ctrl+C
322+
// then would interrupt an unrelated command, so we bail out.
323+
if (!terminal || !terminal.busy || terminal.process !== this) {
324+
return
325+
}
326+
327+
terminal.terminal.sendText("\x03")
262328
}
263329
}
264330

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,122 @@ describe("TerminalProcess", () => {
186186
})
187187
})
188188

189+
describe("abort", () => {
190+
// These MIRROR the private production constants in TerminalProcess.ts
191+
// (ABORT_RETRY_DELAY_MS and CTRL_C_SEND_LIMIT) — they can't be imported, so if
192+
// those values are ever tuned, update them here too or the timing assertions
193+
// below will keep passing while asserting the wrong cadence.
194+
const RETRY_DELAY_MS = 500 // mirrors ABORT_RETRY_DELAY_MS
195+
const MAX_ATTEMPTS = 3 // mirrors CTRL_C_SEND_LIMIT (total Ctrl+C sends)
196+
197+
beforeEach(() => {
198+
vi.useFakeTimers()
199+
// abort() runs against the terminal's *current* process; mirror that wiring so
200+
// the reuse guard (terminal.process === this) lets the retry loop proceed.
201+
mockTerminalInfo.process = terminalProcess
202+
})
203+
204+
afterEach(() => {
205+
vi.runOnlyPendingTimers()
206+
vi.useRealTimers()
207+
})
208+
209+
it("sends a single Ctrl+C immediately and nothing else when the process exits (#266)", async () => {
210+
// Process exits right away: terminal is no longer busy.
211+
mockTerminalInfo.busy = false
212+
213+
terminalProcess.abort()
214+
215+
// Immediate Ctrl+C.
216+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
217+
expect(mockTerminal.sendText).toHaveBeenCalledWith("\x03")
218+
219+
// Advance past the whole retry window; no further Ctrl+C since not busy.
220+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
221+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
222+
})
223+
224+
it("re-sends Ctrl+C up to the bounded maximum while the process stays busy (#266)", async () => {
225+
// Process keeps ignoring SIGINT: terminal stays busy throughout.
226+
mockTerminalInfo.busy = true
227+
228+
terminalProcess.abort()
229+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
230+
231+
// Each retry tick re-sends Ctrl+C while still busy, bounded by MAX_ATTEMPTS.
232+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * (MAX_ATTEMPTS + 2))
233+
234+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(MAX_ATTEMPTS)
235+
expect(mockTerminal.sendText).toHaveBeenCalledWith("\x03")
236+
})
237+
238+
it("stops re-sending Ctrl+C once the process exits mid-retry (#266)", async () => {
239+
mockTerminalInfo.busy = true
240+
241+
terminalProcess.abort()
242+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
243+
244+
// First retry tick: still busy, re-send.
245+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
246+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
247+
248+
// Process exits before the next tick — drive the real completion lifecycle
249+
// (shellExecutionComplete clears busy and releases terminal.process) rather than
250+
// mutating busy directly, so the test exercises the production wiring.
251+
mockTerminalInfo.shellExecutionComplete({ exitCode: 0 })
252+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
253+
254+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
255+
})
256+
257+
it("stops re-sending Ctrl+C if the terminal is reused for a different process (#266)", async () => {
258+
mockTerminalInfo.busy = true
259+
260+
terminalProcess.abort()
261+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
262+
263+
// First retry tick: still busy, re-send.
264+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
265+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
266+
267+
// The original command exits and the terminal is reused for a NEW command before
268+
// the next tick: terminal stays busy, but terminal.process now points at a
269+
// different process. The retry must not interrupt that unrelated command.
270+
mockTerminalInfo.process = new TestTerminalProcess(mockTerminalInfo)
271+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
272+
273+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
274+
})
275+
276+
it("does nothing when the process is no longer listening (#266)", async () => {
277+
terminalProcess["isListening"] = false
278+
279+
terminalProcess.abort()
280+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
281+
282+
expect(mockTerminal.sendText).not.toHaveBeenCalled()
283+
})
284+
285+
it("does not start overlapping retry loops when abort() is called repeatedly (#266)", async () => {
286+
mockTerminalInfo.busy = true
287+
288+
terminalProcess.abort()
289+
terminalProcess.abort()
290+
291+
// Two immediate Ctrl+C from the two abort() calls, but only one retry loop.
292+
// This count of 2 relies on the `aborting` guard being checked AFTER the
293+
// immediate sendText in abort(): the second call still fires its own Ctrl+C
294+
// before the guard short-circuits the duplicate retry loop. If the guard ever
295+
// moves above the send, this would drop to 1 immediate send (total 3, not 4).
296+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
297+
298+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * (MAX_ATTEMPTS + 2))
299+
300+
// 2 immediate + (MAX_ATTEMPTS - 1) retries from the single loop.
301+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2 + (MAX_ATTEMPTS - 1))
302+
})
303+
})
304+
189305
describe("getUnretrievedOutput", () => {
190306
it("returns and clears unretrieved output", () => {
191307
terminalProcess["fullOutput"] = `\x1b]633;C\x07previous\nnew output\x1b]633;D\x07`

0 commit comments

Comments
 (0)