From 4edd13040725e5a4f4cc365e6520cf9d1dfc23bf Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Tue, 14 Jul 2026 11:30:16 +0200 Subject: [PATCH] Handle bash-5.1+ PROMPT_COMMAND arrays in the bash integration systemd >= 258 ships a profile.d script that converts PROMPT_COMMAND into an array before ghostel.bash loads. The scalar capture saw only element 0 and the scalar overwrite left the other elements running at top level, where each prompt cycle they fired the DEBUG trap after the wrapper finished - unwrapping PS1 and emitting a stray 133;C after 133;A, so no row was ever marked as a prompt and prompt navigation found nothing. Capture every PROMPT_COMMAND element and collapse the variable to the wrapper alone, run the captured hooks inside the wrapper with the user command's $? restored, and guard the DEBUG trap against PROMPT_COMMAND content by matching BASH_COMMAND against ';'/newline-split fragments (nounset-safe, bash 3.2 compatible). Fixes #540 --- etc/shell/ghostel.bash | 40 ++++++++++-- test/ghostel-shell-test.el | 115 ++++++++++++++++++++++++++++++++--- test/ghostel-test-helpers.el | 12 ++++ 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/etc/shell/ghostel.bash b/etc/shell/ghostel.bash index 06956969..6d1a76a4 100644 --- a/etc/shell/ghostel.bash +++ b/etc/shell/ghostel.bash @@ -75,10 +75,25 @@ __ghostel_prompt_start() { # Emit "command output start" (C) via the DEBUG trap, and restore the # unmarked PS1/PS2 so the user's command (and any other DEBUG-trap # observers) doesn't see our markers. -# Guard: skip when running inside PROMPT_COMMAND itself. +# Guards: skip when running inside PROMPT_COMMAND itself, and skip +# PROMPT_COMMAND content executing at top level — hooks appended to +# the bash-5.1+ PROMPT_COMMAND array after this file loaded (e.g. +# systemd's osc-context profile.d script) run as separate top-level +# commands and must not unwrap PS1 or emit 133;C. DEBUG fires once +# per simple command, so a compound element (`history -a; history -n') +# is matched fragment-by-fragment, split on `;'/newline like +# bash-preexec does. Known limitation (shared with bash-preexec): a +# user-typed command byte-identical to a fragment is skipped too. __ghostel_in_prompt_command=0 __ghostel_preexec() { [[ "$__ghostel_in_prompt_command" = 1 ]] && return + local __ghostel_frags __ghostel_f IFS=$';\n' + read -rd '' -a __ghostel_frags <<< "${PROMPT_COMMAND[*]:-}" + for __ghostel_f in ${__ghostel_frags[@]+"${__ghostel_frags[@]}"}; do + __ghostel_f="${__ghostel_f#"${__ghostel_f%%[![:space:]]*}"}" + __ghostel_f="${__ghostel_f%"${__ghostel_f##*[![:space:]]}"}" + [[ -n "$__ghostel_f" && "$BASH_COMMAND" == "$__ghostel_f" ]] && return + done if [[ -n "${__ghostel_marked_ps1+x}" && "$PS1" == "$__ghostel_marked_ps1" ]]; then PS1=$__ghostel_saved_ps1 PS2=$__ghostel_saved_ps2 @@ -104,7 +119,14 @@ __ghostel_wrapped_prompt_command() { __ghostel_prompt_start - eval "${__ghostel_original_prompt_command:-}" + # Run the captured PROMPT_COMMAND hooks, restoring $? before each + # so hooks that report the last command's exit status (systemd's + # osc-context, vte) see the user command's status, not ours. + local __ghostel_cmd + for __ghostel_cmd in ${__ghostel_original_prompt_commands[@]+"${__ghostel_original_prompt_commands[@]}"}; do + __ghostel_set_status "$__ghostel_last_status" + eval "$__ghostel_cmd" + done # OSC 7 must fire AFTER the user/system PROMPT_COMMAND so we win the race # against competing OSC 7 emitters. Fedora's /etc/profile.d/vte.sh @@ -141,8 +163,18 @@ __ghostel_wrapped_prompt_command() { __ghostel_in_prompt_command=0 } -# Preserve any existing PROMPT_COMMAND. -__ghostel_original_prompt_command="${PROMPT_COMMAND:+$PROMPT_COMMAND}" +# Restore $? for a hook about to run: `return N' makes N the visible +# exit status of the next command. +__ghostel_set_status() { return "${1:-0}"; } + +# Preserve any existing PROMPT_COMMAND — scalar, or bash-5.1+ array as +# populated by e.g. systemd's osc-context profile.d script. Capture +# every element, then unset (dropping array-ness) so the wrapper is +# the sole element and sees the user command's $?. +__ghostel_original_prompt_commands=() +[[ -n "${PROMPT_COMMAND[*]:-}" ]] && + __ghostel_original_prompt_commands=("${PROMPT_COMMAND[@]}") +builtin unset PROMPT_COMMAND PROMPT_COMMAND="__ghostel_wrapped_prompt_command" trap '__ghostel_preexec' DEBUG diff --git a/test/ghostel-shell-test.el b/test/ghostel-shell-test.el index 1ea54a76..13d1b6f2 100644 --- a/test/ghostel-shell-test.el +++ b/test/ghostel-shell-test.el @@ -338,15 +338,7 @@ the buffer is misclassified as remote, switching on TRAMP." ;; back to $HOSTNAME - pre-#276 behavior, no regression for those ;; users - so the assertion below would not hold and the test would ;; be testing the wrong invariant. - (let ((ver (with-temp-buffer - (call-process "bash" nil t nil "-c" - "printf '%s.%s' \"$BASH_VERSINFO\" \"${BASH_VERSINFO[1]}\"") - (buffer-string)))) - (skip-unless - (and (string-match "\\`\\([0-9]+\\)\\.\\([0-9]+\\)\\'" ver) - (let ((major (string-to-number (match-string 1 ver))) - (minor (string-to-number (match-string 2 ver)))) - (or (> major 4) (and (= major 4) (>= minor 4))))))) + (skip-unless (ghostel-test--bash-at-least-p 4 4)) (let* ((root (or (ghostel--resource-root) (file-name-directory (locate-library "ghostel")))) (shell-bash (expand-file-name "etc/shell/ghostel.bash" root))) @@ -422,6 +414,111 @@ output must be ours, not the competing one." ;; stores whichever fires last per cycle. (should-not (string-match-p "competing-host" (car (last osc7s))))))) +(ert-deftest ghostel-test-bash-prompt-command-array-captured () + "Array PROMPT_COMMAND (systemd osc-context style) is captured whole. + +systemd >= 258 ships /etc/profile.d/80-systemd-osc-context.sh, which +turns PROMPT_COMMAND into a bash-5.1+ array before ghostel.bash loads +\(issue #540). Sourcing ghostel.bash must capture every element, +collapse PROMPT_COMMAND to the wrapper alone, and run the captured +hooks inside the wrapper with the user command's $? restored, before +emitting 133;A." + :tags '(native) + (skip-unless (executable-find "bash")) + (skip-unless (ghostel-test--bash-at-least-p 5 1)) + (let* ((root (or (ghostel--resource-root) + (file-name-directory (locate-library "ghostel")))) + (shell-bash (expand-file-name "etc/shell/ghostel.bash" root))) + (skip-unless (file-exists-p shell-bash)) + (let* ((probe + (concat + ;; Simulate systemd's profile.d script: seed element 0, + ;; append a hook that reports $? like + ;; __systemd_osc_context_precmdline does. + "fake_sd_hook() { printf '\\e]3008;end=x;status=%d\\a' \"$?\"; };" + " PROMPT_COMMAND+=('');" + " PROMPT_COMMAND+=(fake_sd_hook);" + (format " source %s;" shell-bash) + " declare -p PROMPT_COMMAND;" + " PS1='$ '; PS2='> ';" + ;; First cycle discarded: 133;D is skipped on the very + ;; first prompt, so exit-status reporting needs a second + ;; cycle. + " __ghostel_wrapped_prompt_command >/dev/null;" + " (exit 42); __ghostel_wrapped_prompt_command")) + (process-environment + (append '("INSIDE_EMACS=ghostel") process-environment)) + (output (with-temp-buffer + (call-process "bash" nil (current-buffer) nil + "--noprofile" "--norc" "-c" probe) + (buffer-string)))) + ;; PROMPT_COMMAND collapsed to the wrapper alone - no leftover + ;; array elements that would re-run outside the wrapper. + (should (string-match-p + "declare -- PROMPT_COMMAND=\"__ghostel_wrapped_prompt_command\"" + output)) + ;; The captured hook ran and saw the user command's exit status. + (should (string-match "\e\\]3008;end=x;status=42\a" output)) + ;; 133;D reports the same status. + (should (string-match-p "\e\\]133;D;42\a" output)) + ;; 133;A fires after the captured hook. + (let ((hook-pos (string-match "\e\\]3008;end=x" output)) + (a-pos (string-match "\e\\]133;A" output))) + (should (and hook-pos a-pos (> a-pos hook-pos))) + ;; No stray 133;C after 133;A (the wrapper is the probe's last + ;; command, so nothing legitimate emits C afterwards). + (should-not (string-match-p "\e\\]133;C" (substring output a-pos))))))) + +(ert-deftest ghostel-test-bash-prompt-command-array-sibling-hook-harmless () + "A hook appended to the PROMPT_COMMAND array after load is harmless. + +When something appends to the bash-5.1+ PROMPT_COMMAND array after +ghostel.bash loaded (manual setup sourcing ghostel.bash before +profile.d, `direnv hook bash', ...), the sibling element executes at +top level each prompt cycle. The DEBUG trap must not treat it as a +user command: no 133;C after 133;A, and PS1 keeps its 133;P/B wrap +\(issue #540). The element is compound (two commands joined by `;') +because DEBUG fires once per simple command - the guard must match +each fragment, not just whole elements." + :tags '(native) + (skip-unless (executable-find "bash")) + (skip-unless (ghostel-test--bash-at-least-p 5 1)) + (let* ((root (or (ghostel--resource-root) + (file-name-directory (locate-library "ghostel")))) + (shell-bash (expand-file-name "etc/shell/ghostel.bash" root))) + (skip-unless (file-exists-p shell-bash)) + (let* ((probe + (concat + ;; The sibling hook checks PS1 from inside its function + ;; body (the DEBUG trap does not fire there), so it sees + ;; the state the prompt would be displayed with. + "fake_sd_hook() { case $PS1 in" + " *'133;P;k=i'*) printf SIBLINGWRAPPED;;" + " *) printf SIBLINGUNWRAPPED;; esac;" + " printf '\\e]3008;SD\\a'; };" + " fake_hist_hook() { :; };" + (format " source %s;" shell-bash) + " PS1='$ '; PS2='> ';" + " PROMPT_COMMAND+=('fake_hist_hook; fake_sd_hook');" + ;; Simulate one prompt cycle the way bash runs an array: + ;; DEBUG fires once per simple command of each element. + " __ghostel_wrapped_prompt_command;" + " fake_hist_hook;" + " fake_sd_hook")) + (process-environment + (append '("INSIDE_EMACS=ghostel") process-environment)) + (output (with-temp-buffer + (call-process "bash" nil (current-buffer) nil + "--noprofile" "--norc" "-c" probe) + (buffer-string)))) + ;; The sibling saw the marked PS1 - the DEBUG trap didn't unwrap. + (should (string-match-p "SIBLINGWRAPPED" output)) + (should-not (string-match-p "SIBLINGUNWRAPPED" output)) + ;; No 133;C between 133;A and the sibling's output. + (let ((a-pos (string-match "\e\\]133;A" output))) + (should a-pos) + (should-not (string-match-p "\e\\]133;C" (substring output a-pos))))))) + (ert-deftest ghostel-test-zsh-osc7-wins-race-vs-precmd () "Zsh `__ghostel_osc7' must run last among precmd_functions emitters. diff --git a/test/ghostel-test-helpers.el b/test/ghostel-test-helpers.el index a7f9338d..fb615f1f 100644 --- a/test/ghostel-test-helpers.el +++ b/test/ghostel-test-helpers.el @@ -503,5 +503,17 @@ runs are unaffected." "Run all ghostel tests for this platform." (ert-run-tests-batch-and-exit (ghostel-test--all-selector))) +(defun ghostel-test--bash-at-least-p (major minor) + "Return non-nil when the bash in PATH is at least MAJOR.MINOR." + (let ((ver (with-temp-buffer + (call-process "bash" nil t nil "-c" + "printf '%s.%s' \"$BASH_VERSINFO\" \"${BASH_VERSINFO[1]}\"") + (buffer-string)))) + (and (string-match "\\`\\([0-9]+\\)\\.\\([0-9]+\\)\\'" ver) + (let ((got-major (string-to-number (match-string 1 ver))) + (got-minor (string-to-number (match-string 2 ver)))) + (or (> got-major major) + (and (= got-major major) (>= got-minor minor))))))) + (provide 'ghostel-test-helpers) ;;; ghostel-test-helpers.el ends here