Skip to content

bug: missing SIGTSTP handler causes mouse garbling when process is externally suspended #906

@agutmanstein-scale

Description

@agutmanstein-scale

Description

When a process using @opentui/core receives SIGTSTP from an external source (e.g., code-server terminal management, job control, kill -TSTP), the process is suspended by the kernel without any terminal cleanup. Mouse tracking remains enabled, causing mouse events to appear as garbled escape sequences in the shell.

Root Cause

opentui's exitSignals list includes SIGINT, SIGTERM, SIGQUIT, SIGABRT, SIGHUP, SIGBREAK, SIGPIPE, SIGBUS, SIGFPE — but no SIGTSTP.

There is no process.on("SIGTSTP") handler anywhere in the renderer. When SIGTSTP arrives:

  • Mouse tracking (\x1b[?1000h, \x1b[?1003h, \x1b[?1006h) stays enabled
  • Kitty keyboard protocol stays enabled
  • Raw mode stays on (until the shell overrides)
  • The shell receives mouse events as raw escape sequences → garbled text

The Correct Pattern Already Exists

The manual suspend() method does this correctly:

suspend() {
  this._suspendedMouseEnabled = this._useMouse
  this.disableMouse()              // 1. disable mouse
  this.removeExitListeners()
  this.stdinParser?.reset()
  this.stdin.removeListener("data", this.stdinListener)
  this.lib.suspendRenderer(...)    // 2. native suspend
  this.stdin.setRawMode(false)     // 3. raw mode off
  this.stdin.pause()
}

But this is only called from consumer code (e.g., a keybind handler). An external SIGTSTP bypasses it entirely.

Proposed Fix

Register SIGTSTP/SIGCONT handlers in the renderer that call suspend()/resume():

private sigtstpHandler = () => {
  this.suspend()
  // Remove handler to allow default behavior (suspend)
  process.removeListener("SIGTSTP", this.sigtstpHandler)
  // Re-raise to actually suspend the process
  process.kill(process.pid, "SIGTSTP")
}

private sigcontHandler = () => {
  // Re-register SIGTSTP handler
  process.on("SIGTSTP", this.sigtstpHandler)
  this.resume()
}

Register in setupTerminal(), remove in cleanupBeforeDestroy().

Note: In Node/Bun, registering a SIGTSTP handler overrides the default behavior (immediate suspend). The handler must remove itself and re-raise SIGTSTP to actually suspend. The SIGCONT handler re-registers it.

Reproduction

  1. Run any opentui TUI application with mouse tracking enabled
  2. In another terminal: kill -TSTP $(pgrep -f <app>)
  3. Move the mouse in the shell
  4. Garbled escape sequences appear: 35;89;19M35;84;20M35...

Observed in opencode running in code-server (VS Code web terminal) where the terminal management layer can send SIGTSTP during inactivity.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions