fix(terminals): stop PTY output duplication on re-attach

Each open/reattach spawned a pump thread feeding pty_bridge[session]. The
broadcast hub added a subscriber per attach without dropping the previous one,
and the stale pump thread kept delivering (its send_output still succeeded
against the new channel) — so every byte was delivered twice (more after
further re-attaches). Accumulated on each tab/layout/agent switch; window
resize made it glaring.

- Broadcast::subscribe() is now single-consumer: a new subscription supersedes
  the previous one, ending the old pump thread's stream.
- PtyBridge gains a per-session generation; register() returns it and
  unregister_if(session, gen) only removes when still current, so a dying
  superseded thread can't tear down the channel that replaced it.
- All three attach sites (open/reattach/agent launch) use unregister_if.

Tests: 6 added (generation guard + single-consumer broadcast). Workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:52:24 +02:00
parent 9b92259429
commit b9fd2fb925
4 changed files with 140 additions and 22 deletions

View File

@ -57,12 +57,14 @@ const READ_BUF: usize = 8 * 1024;
const SCROLLBACK_CAP: usize = 100 * 1024;
/// The shared output hub of one PTY: a bounded scrollback ring buffer plus the
/// set of currently-subscribed receivers. The reader thread feeds both; views
/// subscribe and unsubscribe freely over the PTY's lifetime (re-attach support).
/// **single** currently-subscribed receiver. The reader thread feeds both; a
/// view subscribes on (re-)attach, and each new subscription supersedes the
/// previous one ([`Broadcast::subscribe`]) so a PTY is never fanned to two live
/// consumers at once.
///
/// Each subscriber is a [`Sender`]; a send failing (receiver dropped because the
/// view detached) prunes that subscriber on the next chunk. This is the
/// fan-out/broadcast that replaces the old single-take `output_rx`.
/// A subscriber is a [`Sender`]; a send failing (receiver dropped because the
/// view detached) prunes it on the next chunk. The `Vec` is retained (rather
/// than an `Option`) only to keep the prune-on-send logic uniform.
#[derive(Default)]
struct Broadcast {
/// Bounded ring buffer of the most recent output bytes.
@ -88,11 +90,18 @@ impl Broadcast {
/// Registers a new subscriber, returning its receiver.
///
/// **Single live consumer.** A PTY session is shown by exactly one view at a
/// time; a re-attach supersedes the previous view. So a new subscription
/// **drops every prior subscriber**, which ends the previous attach's pump
/// thread (its receiver closes) instead of leaving it alive to fan the *same*
/// bytes a second time — the root cause of on-resize output duplication.
///
/// If the PTY already hit EOF, the returned stream is immediately closed
/// (its sender is dropped) so a late re-attach to a finished session doesn't
/// block forever waiting for output that will never come.
fn subscribe(&mut self) -> Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
self.subscribers.clear();
if !self.eof {
self.subscribers.push(tx);
}
@ -327,3 +336,43 @@ impl PtyPort for PortablePtyAdapter {
fn exit_code(status: &portable_pty::ExitStatus) -> Option<i32> {
Some(status.exit_code() as i32)
}
#[cfg(test)]
mod tests {
use super::Broadcast;
/// Regression: a re-attach (new subscribe) must end the previous subscriber's
/// stream so only ONE consumer is fed. Two live subscribers would each pump
/// the same bytes to the bridge → the on-resize output duplication.
#[test]
fn subscribe_supersedes_the_previous_subscriber() {
let mut hub = Broadcast::default();
let first = hub.subscribe();
let second = hub.subscribe();
hub.push(b"hello");
// The superseded receiver's stream is closed (sender dropped) and gets
// nothing; only the current subscriber receives the chunk.
assert!(first.recv().is_err(), "old subscriber must be disconnected");
assert_eq!(second.recv().unwrap(), b"hello".to_vec());
}
#[test]
fn subscribe_after_eof_yields_a_closed_stream() {
let mut hub = Broadcast::default();
hub.close_subscribers();
let rx = hub.subscribe();
assert!(rx.recv().is_err(), "no output will ever come after EOF");
}
#[test]
fn snapshot_retains_scrollback_across_resubscribe() {
let mut hub = Broadcast::default();
let _first = hub.subscribe();
hub.push(b"abc");
// A re-attach drops the old subscriber but the scrollback is preserved.
let _second = hub.subscribe();
assert_eq!(hub.snapshot(), b"abc".to_vec());
}
}