From 2193e884239966574abc111ec704565bea836e75 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Tue, 7 Apr 2026 19:31:35 +0800 Subject: [PATCH] Pass job group down to builtin_print_help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repro: $ abbr -h | less fish: Job 1, 'abbr --help | less' has stopped Bisects to 5ece9bec6c6 (__fish_print_help: use man as-is, 2025-08-29). Root cause is similar to https://github.com/fish-shell/fish-shell/issues/8699#issuecomment-1030762748 except it's specific to how __fish_print_help is invoked, since "__fish_print_help abbr | less" works fine. Fix this by letting __fish_print_help access the terminal. Fixes #12612 Solved by Claude in a single prompt: --- ▐▛███▜▌ Claude Code v2.1.92 ▝▜█████▛▘ Sonnet 4.6 ▘▘ ▝▝ ~/git/fish-shell ❯ "abbr -h | less" gets a signal, resulting in the message "fish: Job 1, 'abbr -h | less' has stopped" whereas "__fish_print_help abbr | less" works just fine, even though "abbr -h" should run exactly that. Why this difference? [...] ● Now I can give you a clear answer. --- The difference comes down to job group inheritance when man is spawned. The deferred execution order In abbr -h | less, fish uses the "deferred process" optimization: since abbr is a non-external process piping into an external one, fish launches less first, transfers the terminal to less's process group, then runs the deferred abbr builtin. By the time abbr's help function spawns man, the terminal already belongs to less's pgroup. Same thing happens with __fish_print_help abbr | less — the outer less is launched first and gets the terminal. Where the paths diverge Function case (__fish_print_help abbr | less): get_performer_for_function (exec.rs:1050) captures job.group.clone() and passes it to eval_node. So when command man runs inside the function, it's given the outer pipeline's job group — the same one that owns the terminal. man's inner less pager joins that group and can read from the terminal freely. ✓ Builtin case (abbr -h | less): builtin_print_help calls parser.eval(&cmd, streams.io_chain) (shared.rs:652). parser.eval is defined as: pub fn eval(&self, cmd: &wstr, io: &IoChain) -> EvalRes { self.eval_with(cmd, io, None, BlockType::top, false) // ^^^^ job_group = None } job_group = None means man creates a brand-new job group with no terminal access. When man spawns its less pager, the pager tries to read keyboard input from the terminal, but the terminal belongs to the outer less's process group. The pager receives SIGTTIN → stops → the whole job is reported as stopped. The fix builtin_print_help should pass streams.job_group to the eval call (which is already populated at exec.rs:1192). Changing line 652 from: let res = parser.eval(&cmd, streams.io_chain); to: let res = parser.eval_with(&cmd, streams.io_chain, streams.job_group.as_ref(), BlockType::top, false); would give man the outer job's job group, letting its pager access the terminal just like the function case. ✻ Sautéed for 13m 0s --- src/builtins/shared.rs | 8 +++++++- tests/checks/tmux-abbr.fish | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/builtins/shared.rs b/src/builtins/shared.rs index d6717fae1..a1d502adf 100644 --- a/src/builtins/shared.rs +++ b/src/builtins/shared.rs @@ -649,7 +649,13 @@ pub fn builtin_print_help(parser: &Parser, streams: &mut IoStreams, cmd: &wstr) } let name_esc = escape(cmd); let cmd = sprintf!("__fish_print_help %s ", &name_esc); - let res = parser.eval(&cmd, streams.io_chain); + let res = parser.eval_with( + &cmd, + streams.io_chain, + streams.job_group.as_ref(), + BlockType::top, + false, + ); if res.status.normal_exited() && res.status.exit_code() == 2 { streams .err diff --git a/tests/checks/tmux-abbr.fish b/tests/checks/tmux-abbr.fish index 52e1fd3b6..b5a10a72f 100644 --- a/tests/checks/tmux-abbr.fish +++ b/tests/checks/tmux-abbr.fish @@ -1,5 +1,6 @@ #RUN: %fish %s #REQUIRES: command -v tmux +#REQUIRES: command -v less isolated-tmux-start -C ' set -g fish_autosuggestion_enabled 0 @@ -8,6 +9,14 @@ isolated-tmux-start -C ' abbr -g abbr-test "abbr-test [expanded]" ' +isolated-tmux send-keys "abbr --help | sed 1q | less" Enter +tmux-sleep +isolated-tmux capture-pane -p +# CHECK: (END) +# CHECK: prompt 0> abbr --help | sed 1q | less +# CHECK: Documentation for abbr +isolated-tmux send-keys q + # Expand abbreviations on space. isolated-tmux send-keys abbr-test Space arg1 Enter tmux-sleep