Skip to content

Control pane killed by its own client-resized SIGUSR1 hook #101

Description

@hozantaher

Summary

dmux installs a session hook client-resized → run-shell "kill -USR1 <dmux pid>", but its only SIGUSR1 listener is registered inside a React effect that unregisters itself whenever a dialog opens or closes. In that window the process has no SIGUSR1 handler, so the default action — terminate — applies, and a terminal resize kills the dmux control pane.

I hit this on three separate dmux sessions on one machine within a few hours.

Symptoms

It presents as three seemingly unrelated problems, which is what made it hard to place:

  1. The control pane silently becomes a bare shell. The death line is easy to miss because it scrolls away:
    zsh: user-defined signal 1  node "/opt/homebrew/lib/node_modules/dmux/dist/index.js"
    
  2. That pane then floods with garbage. The dying process never got to disable mouse reporting, so tmux still reports mouse_any_flag=YES for a pane now running a plain shell and keeps forwarding SGR mouse sequences into it:
    zsh: command not found: 73M64
    zsh: command not found: 17
    
    Typing is impossible; mouse movement inserts characters mid-command.
  3. The hooks outlive the process. tmux show-hooks -t <session> still points kill -USR1 at a dead PID. Since PIDs wrap, an unrelated process that later lands on that PID gets SIGUSR1 on every resize. after-select-pane / after-split-window have the same issue with SIGUSR2.

Cause

At HEAD:

  • src/hooks/useLayoutManagement.ts:98process.on('SIGUSR1', handleResize) inside useEffect
  • src/hooks/useLayoutManagement.ts:104process.off('SIGUSR1', handleResize) in the cleanup
  • src/hooks/useLayoutManagement.ts:109 — deps [controlPaneId, hasActiveDialog], so the effect re-runs on every dialog toggle

Meanwhile src/index.ts registers SIGINT (:1455), SIGTERM (:1458) and SIGUSR2 (:1464) at process level, permanently. SIGUSR1 is the only one that isn't — which looks like an oversight rather than a design choice, since the tmux hook that sends it is installed by dmux itself in setupResizeHook().

Reproduction

Standalone, no tmux and no dmux needed. It models the exact sequence: effect mounts, effect cleans up, hook fires.

// node repro.mjs unpatched  ->  exit 158  (128 + 30; SIGUSR1 is signal 30 on darwin)
// node repro.mjs patched    ->  SURVIVED
if (process.argv[2] === 'patched') process.on('SIGUSR1', () => {})

const effectHandler = () => {}
process.on('SIGUSR1', effectHandler)   // effect mounts
process.off('SIGUSR1', effectHandler)  // dialog toggled -> cleanup runs

process.kill(process.pid, 'SIGUSR1')   // client-resized hook fires
setTimeout(() => { console.log('SURVIVED'); process.exit(0) }, 300)

Suggested fix

Register a permanent SIGUSR1 listener next to the existing SIGUSR2 one in src/index.ts:

process.on('SIGUSR1', () => {
  LogService.getInstance().debug('Received SIGUSR1 from tmux client-resized hook', 'ResizeDebug');
});

Node invokes every registered listener, and the effect's process.off('SIGUSR1', handleResize) removes only its own reference — so resize behaviour is unchanged and useLayoutManagement.ts needs no edit. The permanent listener only closes the window in which the default terminate action applies.

I've been running this patch locally and the control pane has been stable since. Happy to open a PR.

Two things possibly worth handling separately:

  • Hook cleanup on exit. Removing the session hooks when dmux shuts down would stop dangling kill -USR1/-USR2 commands from targeting recycled PIDs.
  • Mouse reporting reset. cleanTerminalExit handles the graceful paths, but a signal death leaves the pane in mouse-reporting mode. A trap-style reset, or having the hooks target a PID file that is cleaned up, would avoid the unusable-shell symptom.

Environment

dmux 5.10.0 (npm global)
node v26.5.0
tmux 3.7b
OS macOS 15.7.7 (24G720), arm64

Ruled out while diagnosing: memory pressure (no jetsam kills in the kernel log), auto-update races (package mtime unchanged), and crashes (SIGUSR1 leaves no crash report — it isn't a crash, which is why nothing showed up in ~/Library/Logs/DiagnosticReports).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions