diff --git a/standalone/sidecar/shell-integration.test.js b/standalone/sidecar/shell-integration.test.js index 0c44eb88b..796f2cc7e 100644 --- a/standalone/sidecar/shell-integration.test.js +++ b/standalone/sidecar/shell-integration.test.js @@ -8,7 +8,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { execFileSync, spawnSync } = require('node:child_process'); -const { existsSync, mkdtempSync, rmSync } = require('node:fs'); +const { existsSync, mkdtempSync, rmSync, writeFileSync } = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -119,17 +119,19 @@ for (const [name, bin] of shells) { }); } -// bash 4.0 is where a `bind -x` command runs with READLINE_LINE bound. -const BASH_MAJOR = Number(execFileSync(BASH, ['-c', 'echo "${BASH_VERSINFO[0]}"'], { encoding: 'utf8' }).trim()); +// bash 4.0 is where a `bind -x` command runs with READLINE_LINE bound; 5.1 +// is where every element of an array PROMPT_COMMAND runs. +const [BASH_MAJOR, BASH_MINOR] = execFileSync(BASH, ['-c', 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"'], { encoding: 'utf8' }).trim().split(' ').map(Number); /** * Drive the real bash integration on a PTY, one submitted line at a time. * `run` resolves with every `E` the line emitted (decoded as the parser decodes * it) and the text the shell printed, once the next prompt is drawn. */ -function interactiveBash() { +function interactiveBash(profile) { const pty = require('node-pty'); const home = mkdtempSync(path.join(os.tmpdir(), 'dormouse-bash-')); + if (profile !== undefined) writeFileSync(path.join(home, '.profile'), profile); const shell = pty.spawn(BASH, ['--init-file', path.join(dir, 'shell-integration/bash/shellIntegration.bash')], { cols: 200, rows: 50, @@ -256,3 +258,33 @@ test('bash: E is the whole submitted line, and never a stale history entry', { t await bash.close(); } }); + +// systemd's /etc/profile.d/80-systemd-osc-context.sh (Ubuntu 26.04) turns +// PROMPT_COMMAND into an array with an empty first slot and appends its hook; +// a legacy rc file then assigns element 0. Startup replaced only element 0, so +// the hook ran after ours re-armed the trap and every line reported the hook's +// name instead of itself. +test('bash: an array PROMPT_COMMAND from the profile keeps E the submitted line', { timeout: 60_000, skip: BASH_MAJOR < 5 || (BASH_MAJOR === 5 && BASH_MINOR < 1) }, async () => { + const bash = interactiveBash([ + '__sfx_ran() { __sfx_status=$?; __sfx_count=$((${__sfx_count:-0} + 1)); }', + // systemd's own two lines, verbatim. + '[ -n "$(declare -p PROMPT_COMMAND 2>/dev/null)" ] || PROMPT_COMMAND+=(\'\')', + 'PROMPT_COMMAND+=(__sfx_ran)', + 'PROMPT_COMMAND=false', + '', + ].join('\n')); + try { + for (const line of ['true', 'echo p && echo q']) { + const { commands, starts } = await bash.run(line); + assert.deepEqual(commands, [line], `bash reported ${JSON.stringify(commands)} for ${JSON.stringify(line)}`); + assert.equal(starts, 1); + } + // Each element sees the command's $?, not the status of the element before it. + await bash.run('(exit 7)'); + // The profile's hook still runs once per prompt: the first, then three lines. + const { printed } = await bash.run('echo "n=$__sfx_count s=$__sfx_status"'); + assert.match(printed, /n=4 s=7/); + } finally { + await bash.close(); + } +}); diff --git a/standalone/sidecar/shell-integration/bash/shellIntegration.bash b/standalone/sidecar/shell-integration/bash/shellIntegration.bash index 34d0eecc7..a2b099a25 100644 --- a/standalone/sidecar/shell-integration/bash/shellIntegration.bash +++ b/standalone/sidecar/shell-integration/bash/shellIntegration.bash @@ -8,8 +8,8 @@ # the OSC 633 prompt/command hooks. # # Written for bash 3.2 (the macOS system bash) and newer: a DEBUG trap for -# command-start and a string PROMPT_COMMAND for the prompt — no PS0 (4.4+) and no -# array PROMPT_COMMAND (5.1+). +# command-start and a string PROMPT_COMMAND for the prompt — no PS0 (4.4+), and an +# array PROMPT_COMMAND (5.1+) is only read, never installed. # --- Replicate login-shell startup (we are spawned without --login) ---------- if [ -r /etc/profile ]; then . /etc/profile; fi @@ -67,7 +67,17 @@ __dormouse_633_safe_cwd() { __dormouse_633_armed= # set at the END of the prompt hook: "the next command is the user's" __dormouse_633_ran= # a command actually executed since the last prompt -__dormouse_633_user_pc="$PROMPT_COMMAND" # preserve the user's PROMPT_COMMAND +# Preserve the user's PROMPT_COMMAND, one entry per element. bash 5.1+ runs every +# element of an array PROMPT_COMMAND, which systemd's 80-systemd-osc-context.sh +# makes it; one left past ours would run after the re-arm and report as the +# command. Older bash runs only element 0. +__dormouse_633_user_pcs=("$PROMPT_COMMAND") +if (( BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 501 )); then + __dormouse_633_user_pcs=("${PROMPT_COMMAND[@]}") +fi + +# Set $? to $1 without the fork of `( exit "$1" )`. +__dormouse_633_status() { return "$1"; } # precmd: runs via PROMPT_COMMAND just before each prompt. Reports the previous # command's exit (D), the cwd (P), and the prompt start (A). Disarms first so its @@ -83,10 +93,12 @@ __dormouse_633_prompt() { __dormouse_633_safe_cwd "$PWD" printf '\033]633;P;Cwd=%s\007' "$__dormouse_633_out" printf '\033]633;A\007' - if [ -n "$__dormouse_633_user_pc" ]; then - ( exit "$exit_code" ) # restore $? for the user's PROMPT_COMMAND - eval "$__dormouse_633_user_pc" - fi + local __dormouse_633_pc + for __dormouse_633_pc in "${__dormouse_633_user_pcs[@]}"; do + [ -n "$__dormouse_633_pc" ] || continue + __dormouse_633_status "$exit_code" # each element sees the command's $?, as bash gives it + eval "$__dormouse_633_pc" + done __dormouse_633_armed=1 } @@ -140,6 +152,7 @@ __dormouse_633_preexec() { } trap '__dormouse_633_preexec "$_"' DEBUG +unset PROMPT_COMMAND # an array keeps its other elements through a plain assignment PROMPT_COMMAND='__dormouse_633_prompt' # Prompt-end / input-start (B) at the tail of PS1, wrapped in \[ \] so bash counts # it as zero width. Best-effort: a prompt rebuilt every render loses B, but