Skip to content

Commit f4d38d6

Browse files
authored
fix(plugins): release a CLI pipe when the plugin handling it crashes (#5537)
apply_pipe_message_to_plugin sends UnblockCliPipes on every success path, including the path for old plugins that do not export `pipe`, but not when the call into the plugin fails. When a plugin traps while handling a pipe message, both call sites in wasm_bridge only log and call handle_plugin_crash, which paints a loading-indication error on the pane and does not unload the plugin. The (plugin_id, client_id) entry therefore stays in PendingPipes and the `zellij pipe` client blocks until the plugin is unloaded or the session ends. Move the body to apply_pipe_message_to_plugin_inner and have the wrapper release the pipe on any error before re-returning it, so neither call site can forget. Adds a regression test driving a fixture-plugin branch that panics while handling a pipe, asserting UnblockCliPipeInput is emitted at crash time rather than at teardown.
1 parent 035876d commit f4d38d6

4 files changed

Lines changed: 132 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
1616
* feat: allow setting explicit_theme_hue (light/dark) (https://github.com/zellij-org/zellij/pull/5536)
1717
* fix: input mode updates when switching tabs (https://github.com/zellij-org/zellij/pull/5535)
1818
* feat: allow starting a session with an initial command, eg. `zellij attach -a my-session -- htop` (https://github.com/zellij-org/zellij/pull/5543)
19+
* fix: release a CLI pipe when the plugin handling it crashes, instead of blocking the `zellij pipe` client until the plugin is unloaded (https://github.com/zellij-org/zellij/pull/5537)
1920

2021
## [0.45.0] - 2026-08-20
2122
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)

default-plugins/fixture-plugin-for-tests/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,8 @@ impl ZellijPlugin for State {
993993
);
994994
} else if name == "message_to_plugin" {
995995
self.message_to_plugin_payload = payload.clone();
996+
} else if name == "panic_while_handling_pipe" {
997+
panic!("intentional panic for the pipe-release-on-crash test");
996998
}
997999
let should_render = true;
9981000
should_render

zellij-server/src/plugins/pipes.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,47 @@ pub fn apply_pipe_message_to_plugin(
144144
pipe_message: &PipeMessage,
145145
plugin_render_assets: &mut Vec<PluginRenderAsset>,
146146
senders: &ThreadSenders,
147+
) -> Result<()> {
148+
let result = apply_pipe_message_to_plugin_inner(
149+
plugin_id,
150+
client_id,
151+
running_plugin,
152+
pipe_message,
153+
plugin_render_assets,
154+
senders,
155+
);
156+
if result.is_err() {
157+
release_pipe_of_crashed_plugin(plugin_id, client_id, pipe_message, senders);
158+
}
159+
result
160+
}
161+
162+
fn release_pipe_of_crashed_plugin(
163+
plugin_id: PluginId,
164+
client_id: ClientId,
165+
pipe_message: &PipeMessage,
166+
senders: &ThreadSenders,
167+
) {
168+
if let PipeSource::Cli(pipe_id) = &pipe_message.source {
169+
let mut pipe_state_changes = HashMap::new();
170+
pipe_state_changes.insert(pipe_id.to_owned(), PipeStateChange::NoChange);
171+
let plugin_render_asset =
172+
PluginRenderAsset::new(plugin_id, client_id, vec![]).with_pipes(pipe_state_changes);
173+
let _ = senders
174+
.send_to_plugin(PluginInstruction::UnblockCliPipes(vec![
175+
plugin_render_asset,
176+
]))
177+
.context("failed to unblock input pipe of crashed plugin");
178+
}
179+
}
180+
181+
fn apply_pipe_message_to_plugin_inner(
182+
plugin_id: PluginId,
183+
client_id: ClientId,
184+
running_plugin: &mut RunningPlugin,
185+
pipe_message: &PipeMessage,
186+
plugin_render_assets: &mut Vec<PluginRenderAsset>,
187+
senders: &ThreadSenders,
147188
) -> Result<()> {
148189
let instance = &running_plugin.instance;
149190
let rows = running_plugin.rows;

zellij-server/src/plugins/unit/plugin_tests.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13211,3 +13211,91 @@ pub fn reconfiguration_resends_keybinds_to_opted_in_plugins() {
1321113211
last_render
1321213212
);
1321313213
}
13214+
13215+
#[test]
13216+
#[ignore]
13217+
pub fn cli_pipe_is_released_when_plugin_panics_while_handling_it() {
13218+
let temp_folder = tempdir().unwrap();
13219+
let plugin_host_folder = PathBuf::from(temp_folder.path());
13220+
let cache_path = plugin_host_folder.join("permissions_test.kdl");
13221+
let (plugin_thread_sender, server_receiver, screen_receiver, teardown) =
13222+
create_plugin_thread_with_server_receiver(Some(plugin_host_folder), None);
13223+
let plugin_should_float = Some(false);
13224+
let plugin_title = Some("test_plugin".to_owned());
13225+
let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
13226+
_allow_exec_host_cmd: false,
13227+
location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
13228+
configuration: Default::default(),
13229+
..Default::default()
13230+
});
13231+
let tab_index = 1;
13232+
let client_id = 1;
13233+
let size = Size {
13234+
cols: 121,
13235+
rows: 20,
13236+
};
13237+
let received_screen_instructions = Arc::new(Mutex::new(vec![]));
13238+
let _screen_thread = grant_permissions_and_log_actions_in_thread_naked_variant!(
13239+
received_screen_instructions,
13240+
ScreenInstruction::Exit,
13241+
screen_receiver,
13242+
1,
13243+
&PermissionType::ReadCliPipes,
13244+
cache_path,
13245+
plugin_thread_sender,
13246+
client_id
13247+
);
13248+
let received_server_instruction = Arc::new(Mutex::new(vec![]));
13249+
let server_thread = log_actions_in_thread!(
13250+
received_server_instruction,
13251+
ServerInstruction::UnblockCliPipeInput,
13252+
server_receiver,
13253+
1
13254+
);
13255+
13256+
let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
13257+
let _ = plugin_thread_sender.send(PluginInstruction::Load(
13258+
plugin_should_float,
13259+
false,
13260+
false,
13261+
plugin_title,
13262+
run_plugin,
13263+
Some(tab_index),
13264+
None,
13265+
client_id,
13266+
size,
13267+
None,
13268+
None,
13269+
false,
13270+
None,
13271+
None,
13272+
None,
13273+
));
13274+
std::thread::sleep(std::time::Duration::from_millis(500));
13275+
13276+
let _ = plugin_thread_sender.send(PluginInstruction::CliPipe {
13277+
pipe_id: "input_pipe_id".to_owned(),
13278+
name: "panic_while_handling_pipe".to_owned(),
13279+
payload: None,
13280+
plugin: None,
13281+
args: None,
13282+
configuration: None,
13283+
floating: None,
13284+
pane_id_to_replace: None,
13285+
pane_title: None,
13286+
cwd: None,
13287+
skip_cache: false,
13288+
cli_client_id: client_id,
13289+
});
13290+
std::thread::sleep(std::time::Duration::from_millis(1000));
13291+
let unblocked = received_server_instruction.lock().unwrap().iter().any(
13292+
|i| matches!(i, ServerInstruction::UnblockCliPipeInput(pipe_name) if pipe_name == "input_pipe_id"),
13293+
);
13294+
teardown();
13295+
let _ = server_thread.join();
13296+
assert!(
13297+
unblocked,
13298+
"a plugin panicking while handling a CLI pipe must release that pipe when it crashes, \
13299+
otherwise the `zellij pipe` client stays blocked until the plugin is unloaded"
13300+
);
13301+
}

0 commit comments

Comments
 (0)