Skip to content

[Fix]: Make PTY reader thread joinable so old-session output cannot bleed into the next tab#178

Open
matiaspalmac wants to merge 1 commit intoTrixtyAI:mainfrom
matiaspalmac:fix/pty-joinable-reader
Open

[Fix]: Make PTY reader thread joinable so old-session output cannot bleed into the next tab#178
matiaspalmac wants to merge 1 commit intoTrixtyAI:mainfrom
matiaspalmac:fix/pty-joinable-reader

Conversation

@matiaspalmac
Copy link
Copy Markdown
Contributor

Description

spawn_pty started the reader thread with thread::spawn and threw away the JoinHandle. When PtyState was replaced (either via kill_pty or a second spawn_pty on the same slot), the old reader kept running on its cloned reader until it hit EOF. Any bytes the previous shell had already written to the PTY — including a prompt re-drawn by e.g. vim or less during teardown — then surfaced as pty-output events inside the new session's tab.

Related Issue

Fixes #79

Context / Problem

Minimal repro:

  1. Open a terminal, run vim.
  2. Kill the session (kill_pty — e.g. closing the terminal panel).
  3. Immediately spawn a new one.
  4. Observe leftover vim redraw bytes in the fresh tab.

Root cause: the old reader thread is still parked inside reader.read() when the new session starts. It eventually wakes up and calls app.emit("pty-output", ...), which the frontend routes to whichever terminal is currently open.

Change

  • PtyState now owns an Arc<AtomicBool> shutdown flag and an Option<JoinHandle<()>>.
  • The reader loop checks the flag between reads and bails before emitting if it is set, so bytes from a shell the user already killed can never reach a new session.
  • New PtyState::shutdown_and_join(self) flips the flag, drops the master (closing the native handle — this is what actually unblocks the blocking read), then joins the thread.
  • spawn_pty and kill_pty both take the old state out of the mutex first and only then run shutdown_and_join. Joining inside the lock would serialize teardown with every other PTY command and could deadlock on a reader re-entering via AppHandle::emit.

Trade-offs

  • No Drop impl on PtyState. A Drop would run on &mut self, which cannot move master out before handle.join(). Joining with the master still alive deadlocks (the reader is parked inside read and only master closure wakes it up). Teardown is therefore a single explicit path (shutdown_and_join) that both callers always go through.
  • No new unit tests. The fix is wiring that only manifests against a real PTY; the decoding logic already covered by [Fix]: Preserve UTF-8 codepoints across PTY 4 KiB read boundaries #173's test suite is unchanged here. Verified manually against the issue's repro on Windows.

Verification

  • cargo check / cargo clippy --lib --no-deps -- -D warnings / cargo fmt --check clean.
  • cargo test --lib → 30 passed (no regressions).
  • Manual test: spawn → vim → kill_pty → spawn → no stray output.
  • OS: Windows 11
  • Version: v1.0.10

Checklist

  • I have tested this fix thoroughly.
  • I have followed the project's coding guidelines.
  • My changes generate no new warnings or errors.
  • I have verified the fix on:
    • OS: Windows
    • Version: v1.0.10

…eed into the next tab

`spawn_pty` started a reader thread with `thread::spawn` and dropped
the `JoinHandle`. When `PtyState` was replaced (either via `kill_pty`
or a second `spawn_pty`), the old reader kept running on its cloned
reader until it hit EOF. Anything the old shell had already pushed
to the PTY — or any prompt re-drawn by an editor like `vim` as the
slave side flushed — surfaced as `pty-output` events inside the new
session's tab.

Fix:

- `PtyState` now owns an `Arc<AtomicBool>` shutdown flag and an
  `Option<JoinHandle<()>>`. The reader thread checks the flag after
  every read and bails before emitting if it's set.
- `PtyState::shutdown_and_join(self)` flips the flag, drops the
  `master` (which closes the native handle and unblocks the
  reader's blocking `read`), then joins the thread.
- `spawn_pty` and `kill_pty` both take the old state *out of the
  mutex first* and only then run `shutdown_and_join`. Joining
  inside the lock would serialize PTY teardown with every other
  command and could deadlock on a reader re-entering via
  `AppHandle::emit`.

Deliberately no `Drop` impl: dropping by `&mut self` cannot move
`master` out before `handle.join()`, which would deadlock (the
reader is parked inside `read` and only master closure wakes it up).
Teardown is instead a single explicit path that both callers always
go through.

No new unit tests — the change is wiring that only manifests against
a real PTY. Verified manually: spawn → type → kill_pty → spawn → no
stray output in the new session; also with the `vim` / prompt-redraw
repro from the issue.

Fixes TrixtyAI#79
@github-actions github-actions bot added bug Something isn't working dependencies Pull requests that update a dependency file labels Apr 21, 2026
@github-actions
Copy link
Copy Markdown

Thanks for the contribution! I'll review it as soon as possible. If you have still changes, please mark this PR as draft and all reviews will be cancelled. Tests reviews will be re-run only when the PR is marked as ready for review.

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

Labels

bug Something isn't working dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: PTY reader thread is not joinable, bleeding old-session output after respawn

1 participant