Skip to content

SIGINT/SIGTERM sent to the TUI process do not terminate it #106

Description

@Flare576

Summary

Sending a real SIGINT/SIGTERM/etc. to a running ei TUI process does not stop it. @opentui/core's
CliRenderer already registers its own handler for every signal in its exitSignals list
(this.addExitListeners(), constructor) — exitHandler calls this.destroy() (tears down the
renderer, restores raw mode, removes its own listeners) but never calls process.exit().
Node/Bun's default "die on unhandled SIGINT/SIGTERM" disposition only applies when nothing is
listening — once OpenTUI's handler is registered, that default is suppressed, and nothing else calls
process.exit() in its place. The process is left running headless: no visible terminal, but the
Processor loop and any TUI timers keep ticking.

Confirmed empirically: a signal listener that doesn't itself call process.exit() (exactly OpenTUI's
shape) leaves the process running indefinitely — confirmed by sending a real SIGINT to a live
process and observing it survive.

Why this isn't a one-line fix

Registering a second SIGINT/SIGTERM handler alongside OpenTUI's own works without conflict
(Node/Bun run every listener for a signal). So the wiring is a few lines: register a handler that
calls the same exitApp()/saveAndExit() path a normal /quit already uses.

The actual lift is a product decision: what happens if an in-flight save/sync takes longer than
whatever patience a kill signal implies?

  1. Best-effort with a timeout. Attempt saveAndExit(), force-exit after N seconds regardless.
    Needs: picking N, deciding what (if anything) tells the user a forced exit happened.
  2. Wait unconditionally. Same guarantees as a normal /quit. Risk: a signal sent during a
    genuinely stuck save now hangs the kill instead of completing it.
  3. Immediate exit, no save attempt. Fastest, but reintroduces a class of failure this codebase's
    own git history has already paid down (a zombie Processor loop left running after an aborted
    quit; a failed sync silently killing the processor without exiting; an unguarded
    renderer.destroy() throw silently keeping the process alive).

None of these is free, and picking wrong reintroduces a real, previously-fixed failure mode.

Acceptance (once ruled)

Depends entirely on which of the three shapes above gets picked. Whoever picks this up should
re-verify against the current @opentui/core/Bun version before building, since the empirical claims
above were verified against a specific version at investigation time.

Provenance

Found 2026-08-07 as a side-finding while investigating a related TUI instance-lock issue. Not part
of that fix's scope — filed separately since it needs its own scoped product decision (the timeout
tradeoff above) before any code lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions