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:
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user