Skip to content

fix: keep a permanent SIGUSR1 listener so resizes cannot kill dmux - #102

Open
hozantaher wants to merge 1 commit into
standardagents:mainfrom
hozantaher:fix/sigusr1-permanent-listener
Open

fix: keep a permanent SIGUSR1 listener so resizes cannot kill dmux#102
hozantaher wants to merge 1 commit into
standardagents:mainfrom
hozantaher:fix/sigusr1-permanent-listener

Conversation

@hozantaher

Copy link
Copy Markdown

Fixes #101.

Problem

setupResizeHook() installs a tmux hook that sends SIGUSR1 to dmux on client-resized. The only listener for that signal is registered inside a useEffect in useLayoutManagement.ts whose deps include hasActiveDialog, so it unregisters and re-registers on every dialog toggle. In that gap the process has no SIGUSR1 listener and the default action — terminate — applies, so a resize at the wrong moment kills the control pane.

The failure is quiet and confusing in practice: the control pane drops to a bare shell, and because the dying process never disabled mouse reporting, tmux keeps forwarding SGR mouse sequences into that shell, which reports them as command not found: 73M64.

Why it's intermittent

Worth recording, because it explains the shape of the bug. Node reserves SIGUSR1 for the inspector, so a process that has never registered a JS listener survives the signal — it just starts the debugger. The default disposition is only restored once a JS listener has been added and then removed. So dmux is safe until useLayoutManagement mounts and cleans up for the first time, and vulnerable from then on:

$ node sig.mjs never-registered
Debugger listening on ws://127.0.0.1:9229/...
SURVIVED                                       # exit 0

$ node sig.mjs added-then-removed
                                               # exit 158 = 128 + 30 (SIGUSR1 on darwin)

The window is widest when the effect's cleanup runs without a matching re-registration — e.g. the early return when controlPaneId is falsy, or unmount — since SIGUSR1 then stays fatal rather than being fatal for only a moment.

Change

One permanent listener in setupGlobalSignalHandlers(), alongside the existing SIGINT / SIGTERM / SIGUSR2 registrations — SIGUSR1 was the only signal dmux sends itself that wasn't handled at process level.

useLayoutManagement.ts is deliberately untouched. Node invokes every registered listener, and the effect's process.off('SIGUSR1', handleResize) removes only its own function reference, so the permanent listener survives cleanup and resize behaviour is unchanged. The listener exists purely to keep the default terminate action from applying.

On testing

I tried to add an e2e regression test in the style of __tests__/dmux.e2e.* — start dmux in an isolated tmux server, send it SIGUSR1, assert it's still alive — and deliberately dropped it, because it passed against the unpatched build too. Given the mechanism above, dmux survives the signal at startup regardless of this fix, and reaching the genuinely vulnerable window from outside the process means racing a React effect commit measured in microseconds. A test that green-lights the bug it's named after is worse than no test, and a timing race would just be flaky in CI.

If you'd like coverage here, the seam that would make it testable is extracting the signal registration out of the Dmux class (it's currently private setupGlobalSignalHandlers() on a non-exported class) so a unit test can assert process.listenerCount('SIGUSR1') stays above zero across an effect teardown. Happy to do that in a follow-up if you want it — it felt like more surface than a bug fix should carry.

Verified manually: pnpm install, tsc, and dmux running against the patched build with the control pane stable.

Possibly worth separate issues

  • Hook cleanup on exit — removing the session hooks on shutdown would stop dangling kill -USR1/-USR2 from hitting recycled PIDs.
  • Mouse reporting reset on signal deathcleanTerminalExit covers the graceful paths, but a signal kill leaves the pane in mouse-reporting mode, which is what makes the failure so hard to read.

The client-resized hook sends SIGUSR1, but the only listener lives in a React
effect that unregisters on every dialog toggle, leaving the default terminate
action to kill the control pane.
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.

Control pane killed by its own client-resized SIGUSR1 hook

1 participant