Skip to content

fix(pane): commit a pane's exit with its remain-on-exit message, and reflow its wrapped row like tmux - #245

Open
xanth wants to merge 4 commits into
Helvesec:mainfrom
xanth:fix/dead-pane-exit
Open

xanth wants to merge 4 commits into
Helvesec:mainfrom
xanth:fix/dead-pane-exit

Conversation

@xanth

@xanth xanth commented Oct 26, 2026 •

Copy link
Copy Markdown

Closes #241
Closes #242

Summary

A program that drives RMUX polls #{pane_dead} and then captures the pane. Four commits make what it sees match tmux: the exit is always committed, pane_dead never reads 1 before the remain-on-exit message, the message is joined to the row above it as tmux joins it, and a resize keeps that joined row's written cells only. Each commit builds and passes its own tests, so it can be reviewed commit by commit.

End the deferred start of a pane whose process exited (3941944b, Windows). A Windows initial pane keeps a deferred start for 2 s after new-session, so input sent at once is queued until its ConPTY console is isolated from the launching client. While that start was open, observe_runtime_pane_exit reported no exit at all, and the exit handler gave up after 20 fast retries (about 200 ms). A pane whose program exited at once never committed its exit: no remain-on-exit message, no pane-died hook, and without remain-on-exit the session never closed. The start exists to protect input for a live console. Once the pane's process tree has exited there is no console left, so the observation now ends that start, as kill-pane and kill-session already do, and the exit commits at once. The start task skips its queued-input flush when the start has ended this way.

Leave a kept pane's death to the exit commit that writes its message (5f95dc68). A format query (display-message, list-panes) refreshes #{pane_dead} by observing the pane's exit itself. That observation is cached in the dead-pane map, and the exit handler reads the same map to tell a first exit from a repeated one. When a query observed the exit first, the handler took it for one it had already committed and wrote no message; a capture taken as soon as pane_dead read 1 also came before the message. In tmux the pane becomes dead in server_destroy_pane, which writes the message in the same step. A query now leaves a pane whose remain-on-exit is not off to its exit commit, which stays prompt, and still observes the exit of a pane that closes. Panes without remain-on-exit keep the prompt refresh.

Wrap the row above the message like tmux (4cd1bdb1). tmux writes the dead-pane message after a wrapped linefeed from the bottom row (screen_write_linefeed(ctx, 1, 8)), so capture-pane -J joins the row above it with the message. RMUX wrote a plain newline and an erase-line, so -J kept one more line break than tmux, and the program's scroll region was not reset. The message is now written after a scroll-region reset, and then the row above it is marked wrapped (text written at column 0 clears that mark, so the order matters). The mark needs the screen writer under the transcript lock, so the byte-only transcript append becomes one case of a transcript mutation (mutate_runtime_pane_transcript).

Join a short wrapped row by its written cells when reflowing (f5604c56). Reflow joined each wrapped row with the next one at the row's full width. That is right for a row filled by auto-wrap, and for a wrapped row that DCH or ICH shortened later: tmux keeps such a row's cellused, so its blanks stay data (tmux 3.7b; pinned by the existing screen and keyframe tests). It is wrong for a row marked wrapped while short, which is what the previous commit (and tmux) does to the row above the message: tmux joins it at its written cells only. RMUX padded it, so after a dead pane was resized from 80 to 20 columns the row above "Pane is dead" became 80 spaces over four rows. A wrapped compact ASCII row now joins at its stored data end, which keeps written trailing spaces (GridLine::wrapped_end() in crates/rmux-core/src/grid/cell.rs), and a wrapped cell row keeps its full width. Neither history compaction nor an ASCII write over a blank row turns a wrapped cell row back into a compact one, so a DCH- or ICH-edited row keeps its blanks. The unused line_width helper goes.

Testing

Tested on: Windows 11 Pro 10.0.26300 (build 26300), with these commits on RMUX 0.10.0.

  • 3941944b: new Windows test pane_exiting_during_deferred_start_closes_its_session_before_the_start_ends in handler_pane_exit_format_tests.rs. new-session with the command exit 3; has-session reports no session within 1.5 s, before the 2 s start would have closed. It fails at the fork point (the session never closes) and passes with the fix. By hand, the session closes with status 3 and pane-died fires; the death is seen after about 0.23 s.
  • 5f95dc68: new test pane_dead_query_leaves_a_kept_pane_to_its_exit_commit. It holds the state lock past the child's exit, runs the query, and checks that the pane stays alive until the commit and then shows the message. It fails at the fork point and passes with the fix. A probe that captures at the first pane_dead 1 had the message in all of 20 runs. The existing display_message_pane_dead_observes_exited_child_promptly test still passes.
  • 4cd1bdb1: new pane_transcript test dead_pane_footer_joins_the_row_above_like_tmux. A 20x3 pane with a scroll region writes error, then the footer; a plain capture gives \n\nPane is dead\n, and a capture with join_wrapped gives \nPane is dead\n. It calls the method this commit adds, so the probe for the old behaviour is capture-pane -p -e -J -S - of a dead pane compared with tmux 3.6: it differed before and is equal now.
  • f5604c56: new screen test width_resize_joins_a_short_wrapped_row_by_its_written_cells. An 8-column screen with ab on row 1 and cd on row 2, row 1 marked wrapped, resized to 4 columns: the first non-empty row is abcd. It fails at the fork point (ab, a blank row, cd) and passes with the fix. The existing DCH, ICH and keyframe tests still pass, including width_resize_line_edits_materialize_a_wide_placement_gap_as_space and insert_character_full_suffix_clears_requested_cells_product_divergence.
  • Together: a dead remain-on-exit pane, captured plain, with -S -, with -e -J -S -, and after a resize to 80x24, 80x40 and 20x24, gives the same capture-pane output as tmux 3.6.
  • rmux-core and rmux-server lib tests pass on a tree with these commits and the other proposed fixes merged, apart from timing-sensitive tests that pass when run alone.

Linux and macOS are covered by this PR's CI.

Cargo gates on the workspace:

  • cargo fmt --all -- --check: pass.
  • cargo clippy --workspace --all-targets -- -D warnings: pass with the pinned toolchain (1.96.1).
  • cargo build --workspace: pass.
  • cargo test --workspace --all-targets: run once on Windows with all of these fixes merged, with the same settings as the Windows CI job. 7,018 passed. 45 tests in the release and bench specs fail on this machine because Python is not on PATH (43) or the temp path is too long (2); they fail the same way without these fixes. Not run here, because they open console windows: windows_attach_exit, windows_attach_fuzz, windows_nested_mouse_issue96, windows_prompt_overlay_chain, windows_mouse_border_resize, rmux-pty's windows_conpty, and rmux-client's managed_attach_stdout_has_single_console_mode_owner.

🤖 Generated with Claude Code

xanth and others added 4 commits October 26, 2026 10:21
A Windows initial pane keeps a deferred start for 2 s after new-session, so
input sent at once is queued until its ConPTY console is isolated from the
launching client. While that start is open, observe_runtime_pane_exit
reported no exit at all, and the exit handler gave up after 20 fast retries
(about 200 ms). A pane whose program exited at once never committed its
exit: no remain-on-exit message, no pane-died hook, and without
remain-on-exit the session never closed.

The start exists to protect input for a live console. Once the pane's
process tree has exited there is no console left to receive it, so the
observation now ends that start, as kill-pane and kill-session already do,
and the exit commits at once. The start task skips its queued-input flush
when the start has ended this way.

Tested on Windows 11 (build 26300): the new test fails at the fork point
(the session never closes) and passes with the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rhys <5460583+xanth@users.noreply.github.com>
…ts message

A format query (display-message, list-panes) refreshes #{pane_dead} by
observing the pane's exit itself, so pane_dead flips without waiting for
the exit handler. That observation is cached in the dead-pane map, and the
exit handler reads the same map to tell a first exit from a repeated one.
When a query observed the exit first, the handler took the exit for one
it had already committed and wrote no remain-on-exit message; a capture
taken as soon as pane_dead read 1 also preceded the message.

In tmux the pane becomes dead in server_destroy_pane, which writes the
message in the same step, so pane_dead=1 always comes with it. A query now
leaves a pane whose remain-on-exit is not off to its exit commit, which
remains prompt, and still observes the exit of a pane that closes. Panes
without remain-on-exit keep the prompt refresh.

Tested on Windows 11 (build 26300): the new test holds the state lock past
the child's exit, runs the query, and checks that the pane stays alive
until the commit and then shows the message; it fails at the fork point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rhys <5460583+xanth@users.noreply.github.com>
tmux writes the dead-pane message after a wrapped linefeed from the bottom
row (server_destroy_pane: screen_write_linefeed(ctx, 1, 8)), so
`capture-pane -J` joins the row above it with the message. RMUX wrote a plain
newline and an erase-line, so -J kept one more line break than tmux, and its
scroll region was not reset. Reset the scroll region, write the message, then
mark the row above it wrapped: text written at column 0 clears that mark.
The mark needs the screen writer under the transcript lock, so the byte-only
transcript append becomes one case of a transcript mutation.

Tested on Windows 11 (build 26300): the new pane_transcript test passes (it
calls the method this commit adds); the probe that shows the old behaviour is
capture-pane -p -e -J -S - of a dead pane against tmux 3.6, now equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rhys <5460583+xanth@users.noreply.github.com>
…eflowing

When a pane narrows or widens, reflow joins each wrapped row with the row
after it, at the row's full width. That is right for a row filled by
auto-wrap, and for a wrapped row DCH or ICH shortened later: tmux keeps
such a row's cellused, so its blanks stay data (tmux 3.7b, pinned by the
existing screen and keyframe tests). It is wrong for a row marked wrapped
while short, as tmux marks the row above the remain-on-exit message
(screen_write_linefeed(ctx, 1, 8)): tmux joins it at its cellused, the
written cells only. RMUX padded it, so after a dead pane was resized from
80 to 20 columns the row above "Pane is dead" became 80 spaces over four
rows.

Such a row is a compact ASCII row whose stored data end, which keeps
written trailing spaces, is short of the width. A wrapped compact row now
joins at that data end; a wrapped cell row keeps its full width. Neither
history compaction nor an ASCII write over a blank row turns a wrapped cell
row back into a compact one, so a DCH- or ICH-edited row keeps its blanks.

Tested on Windows 11 (build 26300): the new screen test fails at the fork
point and passes with the fix; rmux-core and rmux-server lib tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: rhys <5460583+xanth@users.noreply.github.com>
@xanth xanth changed the title fix(pane): commit a pane's exit with its remain-on-exit message, as tmux does fix(pane): commit a pane's exit with its remain-on-exit message, and reflow its wrapped row like tmux Oct 26, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant