fix(plugins): notify floating panes when their tab is hidden (#5518)

Tab::visible() only walked the tiled panes, so a plugin living in a
floating pane was never sent Event::Visible(false) when its tab went
away -- including on the last-client-disconnect path in
Screen (`tab.visible(false)` when `has_no_connected_clients()`).

Plugins that idle on a timer keep it armed until they are told they are
no longer visible. The session-manager is the visible case: it is
normally opened as a floating pane and re-arms a 1s timer that calls
get_session_list() on every tick. Once opened, it kept polling the full
session list forever -- through the pane being hidden, through every
client detaching, and with no client attached to the session at all.

Observed in production: a zellij server with zero attached clients and
no visible plugin pane sitting at 130% CPU, with the cost scaling in the
number of sessions the picker enumerates.

Include the floating panes in the visibility broadcast.
This commit is contained in:
Genno
2026-08-25 11:30:27 +02:00
committed by GitHub
parent 4669f37cff
commit 83472766d3
2 changed files with 69 additions and 8 deletions
+12 -4
View File
@@ -6605,10 +6605,18 @@ impl Tab {
Ok(())
}
pub fn visible(&mut self, visible: bool) -> Result<()> {
let pids_in_this_tab = self.tiled_panes.pane_ids().filter_map(|p| match p {
PaneId::Plugin(pid) => Some(pid),
_ => None,
});
// Floating panes must be included here as well: a plugin in a floating pane is just as
// hidden as a tiled one when its tab goes away, and plugins that idle on a timer (eg. the
// session-manager, which polls the session list once a second) keep that timer armed until
// they are told otherwise.
let pids_in_this_tab = self
.tiled_panes
.pane_ids()
.chain(self.floating_panes.pane_ids())
.filter_map(|p| match p {
PaneId::Plugin(pid) => Some(pid),
_ => None,
});
let mut plugin_updates = vec![];
for pid in pids_in_this_tab {
plugin_updates.push((Some(*pid), None, Event::Visible(visible)));
+57 -4
View File
@@ -2,14 +2,16 @@ use super::Tab;
use crate::pane_groups::PaneGroups;
use crate::panes::kitty_graphics::KittyImageStore;
use crate::panes::sixel::SixelImageStore;
use crate::plugins::PluginInstruction;
use crate::pty_writer::PtyWriteInstruction;
use crate::screen::CopyOptions;
use crate::{os_input_output::ServerOsApi, panes::PaneId, thread_bus::ThreadSenders, ClientId};
use std::net::{IpAddr, Ipv4Addr};
use std::path::PathBuf;
use zellij_utils::channels::{unbounded, Receiver, SenderWithContext};
use zellij_utils::data::{Direction, NewPanePlacement, Resize, ResizeStrategy, WebSharing};
use zellij_utils::channels::{unbounded, ChannelWithContext, Receiver, SenderWithContext};
use zellij_utils::data::{Direction, Event, NewPanePlacement, Resize, ResizeStrategy, WebSharing};
use zellij_utils::errors::prelude::*;
use zellij_utils::errors::ErrorContext;
use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout};
use zellij_utils::input::options::PaneFrameStyle;
use zellij_utils::ipc::IpcReceiverWithContext;
@@ -150,11 +152,20 @@ fn tab_resize_right(tab: &mut Tab, id: ClientId) {
}
fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
create_new_tab_with_plugin_receiver(size, stacked_resize).0
}
fn create_new_tab_with_plugin_receiver(
size: Size,
stacked_resize: bool,
) -> (Tab, Receiver<(PluginInstruction, ErrorContext)>) {
let index = 0;
let position = 0;
let name = String::new();
let os_api = Box::new(FakeInputOutput {});
let senders = ThreadSenders::default().silently_fail_on_send();
let mut senders = ThreadSenders::default().silently_fail_on_send();
let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> = unbounded();
senders.replace_to_plugin(SenderWithContext::new(to_plugin));
let max_panes = None;
let mode_info = ModeInfo::default();
let style = Style::default();
@@ -237,7 +248,7 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
None,
)
.unwrap();
tab
(tab, plugin_receiver)
}
fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
@@ -17744,3 +17755,45 @@ pub fn scroll_terminal_down_nonexistent_pane_id_is_a_noop() {
let writes = drain_pty_writer(&rx);
assert!(writes.is_empty());
}
#[test]
fn floating_plugin_panes_are_notified_when_their_tab_is_hidden() {
// Regression test: Tab::visible() used to only walk the tiled panes, so a plugin living in a
// floating pane never learned that its tab had gone away. Plugins that idle on a timer (the
// session-manager re-reads the whole session list once a second) went on doing that work
// forever, even with no client attached to the session at all.
let size = Size {
cols: 121,
rows: 20,
};
let (mut tab, plugin_receiver) = create_new_tab_with_plugin_receiver(size, true);
let plugin_pane_id = PaneId::Plugin(1);
tab.new_pane(
plugin_pane_id,
None,
None,
false,
true,
NewPanePlacement::Floating(None),
Some(1),
None,
)
.unwrap();
tab.visible(false).unwrap();
let mut told_the_floating_plugin = false;
while let Ok((instruction, _)) = plugin_receiver.try_recv() {
if let PluginInstruction::Update(updates) = instruction {
for (pid, _client_id, event) in updates {
if pid == Some(1) && matches!(event, Event::Visible(false)) {
told_the_floating_plugin = true;
}
}
}
}
assert!(
told_the_floating_plugin,
"a plugin in a floating pane should be sent Event::Visible(false) when its tab is hidden"
);
}