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?
- 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.
- 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.
- 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.
Summary
Sending a real SIGINT/SIGTERM/etc. to a running
eiTUI process does not stop it.@opentui/core'sCliRendereralready registers its own handler for every signal in itsexitSignalslist(
this.addExitListeners(), constructor) —exitHandlercallsthis.destroy()(tears down therenderer, 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 theProcessorloop and any TUI timers keep ticking.Confirmed empirically: a signal listener that doesn't itself call
process.exit()(exactly OpenTUI'sshape) 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/quitalready 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?
saveAndExit(), force-exit after N seconds regardless.Needs: picking N, deciding what (if anything) tells the user a forced exit happened.
/quit. Risk: a signal sent during agenuinely stuck save now hangs the kill instead of completing it.
own git history has already paid down (a zombie
Processorloop left running after an abortedquit; 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 claimsabove 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.