Skip to content

fix(ui): exit cleanly on Ctrl-C#292

Merged
benvinegar merged 2 commits into
mainfrom
fix/ctrl-c-shutdown
May 11, 2026
Merged

fix(ui): exit cleanly on Ctrl-C#292
benvinegar merged 2 commits into
mainfrom
fix/ctrl-c-shutdown

Conversation

@benvinegar
Copy link
Copy Markdown
Member

Summary

  • Route Ctrl-C through Hunk's full shutdown path instead of OpenTUI's renderer-only destroy path.
  • Add SIGINT/SIGTERM shutdown handling alongside raw-mode Ctrl-C handling.
  • Cover the new interrupt handler with unit tests and document the fix in the changelog.

Tests

  • bun test src/core/jobControl.test.ts
  • bun run typecheck

This PR description was generated by Pi using OpenAI GPT-5.1 Codex

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 11, 2026

Greptile Summary

Routes Ctrl-C through Hunk's full shutdown() path by disabling OpenTUI's exitOnCtrlC handler and replacing it with a dedicated keypress listener and SIGINT/SIGTERM process signal handlers. Both paths share the same idempotent shutdown() function, which cleans up listeners, stops the host client, and calls shutdownSession.

  • installJobControlInterruptSupport mirrors the existing suspend-support pattern with a disposed guard and explicit keyInput.off on dispose; the lazy no-op stub pattern lets the closure capture the live support object even though the signal handlers are installed before the support objects are assigned.
  • Tests cover the happy path and dispose/key-filter behaviour but leave the renderer.isDestroyed guard branch untested.

Confidence Score: 4/5

Safe to merge; the shutdown path is idempotent and both the keypress and signal routes are correctly guarded against double-invocation.

The core logic is sound — disabling OpenTUI's renderer-only teardown and wiring the full shutdown path through both Ctrl-C keypresses and process signals. The lazy no-op stub pattern ensures the closure in shutdown() always reads the final support objects. The only gap is a missing test for the renderer.isDestroyed branch inside the new keypress listener.

src/core/jobControl.test.ts — the isDestroyed guard in installJobControlInterruptSupport is untested.

Important Files Changed

Filename Overview
src/main.tsx Switches exitOnCtrlC to false, installs SIGINT/SIGTERM process handlers, and wires installJobControlInterruptSupport — all using lazy-initialized no-op stubs captured by closure so shutdown() always sees the live support objects.
src/core/jobControl.ts Adds installJobControlInterruptSupport following the same pattern as the existing suspend support; guards against double-fire with a disposed flag; correctly removes the keypress listener on dispose.
src/core/jobControl.test.ts Adds two tests for installJobControlInterruptSupport covering the happy path and dispose/key-filter behaviour; misses a case for when renderer.isDestroyed is true mid-keypress.
CHANGELOG.md Adds a user-facing changelog entry for the Ctrl-C fix under the correct version header.

Sequence Diagram

sequenceDiagram
    participant User
    participant Terminal
    participant OpenTUI as OpenTUI Renderer
    participant JCI as installJobControlInterruptSupport
    participant Main as main() shutdown()
    participant Process as Node.js Process

    Note over User,Process: Ctrl-C in raw mode (new path)
    User->>Terminal: press Ctrl-C
    Terminal->>OpenTUI: keypress event (raw mode)
    OpenTUI->>JCI: keypressListener(key)
    JCI->>JCI: isCtrlC(key)? disposed? isDestroyed?
    JCI->>Main: onInterrupt() → shutdown()
    Main->>Process: process.off("SIGINT", shutdown)
    Main->>Process: process.off("SIGTERM", shutdown)
    Main->>JCI: jobControlInterruptSupport.dispose()
    Main->>Main: shutdownSession()

    Note over User,Process: External SIGINT/SIGTERM (new path)
    User->>Process: kill -INT / kill -TERM
    Process->>Main: shutdown() (via process.once handler)
    Main->>Main: "shuttingDown = true"
    Main->>Process: process.off(signal, shutdown) × 2
    Main->>JCI: jobControlInterruptSupport.dispose()
    Main->>Main: shutdownSession()
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/core/jobControl.test.ts:108-141
**Missing test for `renderer.isDestroyed` guard**

`installJobControlInterruptSupport`'s keypress listener bails out when `renderer.isDestroyed` is `true`, but neither of the new tests exercises that branch. If the renderer is destroyed before the keypress fires (e.g. the renderer tears itself down before the keypress event is fully dispatched), `onInterrupt` should stay silent — worth covering alongside the existing `disposed` path to lock in that contract.

Reviews (1): Last reviewed commit: "fix(ui): exit cleanly on Ctrl-C" | Re-trigger Greptile

Comment on lines +108 to +141
describe("installJobControlInterruptSupport", () => {
test("routes Ctrl-C through the provided shutdown callback", () => {
const renderer = createMockRenderer();
let interruptCalls = 0;

installJobControlInterruptSupport(renderer, () => {
interruptCalls += 1;
});

const ctrlC = createTestKey({ ctrl: true, name: "c" });
renderer.emitKeypress(ctrlC);

expect(ctrlC.defaultPrevented).toBe(true);
expect(ctrlC.propagationStopped).toBe(true);
expect(interruptCalls).toBe(1);
});

test("ignores non-Ctrl-C keys and removes its listener on dispose", () => {
const renderer = createMockRenderer();
let interruptCalls = 0;
const support = installJobControlInterruptSupport(renderer, () => {
interruptCalls += 1;
});

renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" }));
expect(interruptCalls).toBe(0);

support.dispose();
expect(renderer.keypressListeners.size).toBe(0);

renderer.emitKeypress(createTestKey({ ctrl: true, name: "c" }));
expect(interruptCalls).toBe(0);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for renderer.isDestroyed guard

installJobControlInterruptSupport's keypress listener bails out when renderer.isDestroyed is true, but neither of the new tests exercises that branch. If the renderer is destroyed before the keypress fires (e.g. the renderer tears itself down before the keypress event is fully dispatched), onInterrupt should stay silent — worth covering alongside the existing disposed path to lock in that contract.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/jobControl.test.ts
Line: 108-141

Comment:
**Missing test for `renderer.isDestroyed` guard**

`installJobControlInterruptSupport`'s keypress listener bails out when `renderer.isDestroyed` is `true`, but neither of the new tests exercises that branch. If the renderer is destroyed before the keypress fires (e.g. the renderer tears itself down before the keypress event is fully dispatched), `onInterrupt` should stay silent — worth covering alongside the existing `disposed` path to lock in that contract.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a regression test that sets renderer.isDestroyed = true before emitting Ctrl-C and verifies the interrupt callback is not called and the event is left untouched.

This comment was generated by Pi using OpenAI GPT-5.1 Codex

@benvinegar benvinegar merged commit 0f668e6 into main May 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant