Skip to content

Commit 00377a9

Browse files
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
1 parent b761a0a commit 00377a9

3 files changed

Lines changed: 143 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"zoo-code": patch
3+
---
4+
5+
Retry Ctrl+C when cancelling a task so processes that ignore a single SIGINT actually terminate (#266). The terminal abort path now re-sends Ctrl+C a bounded number of times, verifying between attempts whether the process exited, before the terminal is torn down. Follow-up to #245/#261; the ExecaTerminal backend is unaffected.

src/integrations/terminal/TerminalProcess.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@ 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+
// re-send Ctrl+C up to this many times, checking between attempts whether
12+
// the process has exited, before giving up and letting dispose() proceed.
13+
private static readonly ABORT_MAX_ATTEMPTS = 3
14+
// Delay between Ctrl+C re-sends. Kept short so cancel stays responsive; the
15+
// whole retry window is bounded by ABORT_MAX_ATTEMPTS * ABORT_RETRY_DELAY_MS.
16+
private static readonly ABORT_RETRY_DELAY_MS = 500
17+
918
private terminalRef: WeakRef<Terminal>
19+
// Guards against overlapping abort retry loops if abort() is called again
20+
// while a previous loop is still re-sending Ctrl+C.
21+
private aborting = false
1022

1123
constructor(terminal: Terminal) {
1224
super()
@@ -256,9 +268,48 @@ export class TerminalProcess extends BaseTerminalProcess {
256268
}
257269

258270
public override abort() {
259-
if (this.isListening) {
260-
// Send SIGINT using CTRL+C
261-
this.terminal.terminal.sendText("\x03")
271+
if (!this.isListening) {
272+
return
273+
}
274+
275+
// Send SIGINT using CTRL+C.
276+
this.terminal.terminal.sendText("\x03")
277+
278+
// #266: A single Ctrl+C isn't always enough — some processes trap SIGINT
279+
// and keep running. Kick off a bounded retry that re-sends Ctrl+C a few
280+
// times, verifying between attempts whether the process actually exited
281+
// (terminal.busy flips to false on completion). This is intentionally
282+
// fire-and-forget so it never blocks the synchronous cancel path; the
283+
// total retry window is bounded so dispose() is never delayed for long.
284+
if (!this.aborting) {
285+
this.aborting = true
286+
void this.retryAbort().finally(() => {
287+
this.aborting = false
288+
})
289+
}
290+
}
291+
292+
/**
293+
* Re-sends Ctrl+C up to ABORT_MAX_ATTEMPTS times, waiting ABORT_RETRY_DELAY_MS
294+
* between attempts and stopping early once the process exits (or once we stop
295+
* listening). Bounded so it can never loop indefinitely.
296+
*/
297+
private async retryAbort(): Promise<void> {
298+
for (let attempt = 1; attempt < TerminalProcess.ABORT_MAX_ATTEMPTS; attempt++) {
299+
await new Promise((resolve) => setTimeout(resolve, TerminalProcess.ABORT_RETRY_DELAY_MS))
300+
301+
// Stop if the process already exited or we're no longer listening.
302+
if (!this.isListening) {
303+
return
304+
}
305+
306+
const terminal = this.terminalRef.deref()
307+
308+
if (!terminal || !terminal.busy) {
309+
return
310+
}
311+
312+
terminal.terminal.sendText("\x03")
262313
}
263314
}
264315

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

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

189+
describe("abort", () => {
190+
const RETRY_DELAY_MS = 500
191+
const MAX_ATTEMPTS = 3
192+
193+
beforeEach(() => {
194+
vi.useFakeTimers()
195+
})
196+
197+
afterEach(() => {
198+
vi.runOnlyPendingTimers()
199+
vi.useRealTimers()
200+
})
201+
202+
it("sends a single Ctrl+C immediately and nothing else when the process exits (#266)", async () => {
203+
// Process exits right away: terminal is no longer busy.
204+
mockTerminalInfo.busy = false
205+
206+
terminalProcess.abort()
207+
208+
// Immediate Ctrl+C.
209+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
210+
expect(mockTerminal.sendText).toHaveBeenCalledWith("\x03")
211+
212+
// Advance past the whole retry window; no further Ctrl+C since not busy.
213+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
214+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
215+
})
216+
217+
it("re-sends Ctrl+C up to the bounded maximum while the process stays busy (#266)", async () => {
218+
// Process keeps ignoring SIGINT: terminal stays busy throughout.
219+
mockTerminalInfo.busy = true
220+
221+
terminalProcess.abort()
222+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
223+
224+
// Each retry tick re-sends Ctrl+C while still busy, bounded by MAX_ATTEMPTS.
225+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * (MAX_ATTEMPTS + 2))
226+
227+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(MAX_ATTEMPTS)
228+
expect(mockTerminal.sendText).toHaveBeenCalledWith("\x03")
229+
})
230+
231+
it("stops re-sending Ctrl+C once the process exits mid-retry (#266)", async () => {
232+
mockTerminalInfo.busy = true
233+
234+
terminalProcess.abort()
235+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
236+
237+
// First retry tick: still busy, re-send.
238+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
239+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
240+
241+
// Process exits before the next tick.
242+
mockTerminalInfo.busy = false
243+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
244+
245+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
246+
})
247+
248+
it("does nothing when the process is no longer listening (#266)", async () => {
249+
terminalProcess["isListening"] = false
250+
251+
terminalProcess.abort()
252+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_ATTEMPTS)
253+
254+
expect(mockTerminal.sendText).not.toHaveBeenCalled()
255+
})
256+
257+
it("does not start overlapping retry loops when abort() is called repeatedly (#266)", async () => {
258+
mockTerminalInfo.busy = true
259+
260+
terminalProcess.abort()
261+
terminalProcess.abort()
262+
263+
// Two immediate Ctrl+C from the two abort() calls, but only one retry loop.
264+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2)
265+
266+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * (MAX_ATTEMPTS + 2))
267+
268+
// 2 immediate + (MAX_ATTEMPTS - 1) retries from the single loop.
269+
expect(mockTerminal.sendText).toHaveBeenCalledTimes(2 + (MAX_ATTEMPTS - 1))
270+
})
271+
})
272+
189273
describe("getUnretrievedOutput", () => {
190274
it("returns and clears unretrieved output", () => {
191275
terminalProcess["fullOutput"] = `\x1b]633;C\x07previous\nnew output\x1b]633;D\x07`

0 commit comments

Comments
 (0)