Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions standalone/sidecar/shell-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
});
27 changes: 20 additions & 7 deletions standalone/sidecar/shell-integration/bash/shellIntegration.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Loading