fix(bling): rearm bash-preexec DEBUG trap on each prompt - #943
Conversation
Fedora exposes PROMPT_COMMAND as an array (bash >= 5.1). bash-preexec
0.6.0 queues its deferred installer as a string, but __bp_install only
reads and rewrites "${PROMPT_COMMAND}" — element [0] alone. Once another
hook (mise, direnv, starship, vte, zoxide) has pushed the installer into
a later array element, it is never removed and runs on every prompt:
`trap - DEBUG` clears the trap while __bp_install returns early because
PROMPT_COMMAND already contains __bp_precmd_invoke_cmd. From the second
prompt onward the DEBUG trap is permanently empty, so atuin loads but
never records a command.
Source a bash-only helper at the end of bling.sh that appends a hook
re-installing bash-preexec's own DEBUG trap on each prompt. The helper is
a no-op when bash-preexec is absent or PROMPT_COMMAND is readonly, is
idempotent, and handles both array and scalar PROMPT_COMMAND. bling.sh
stays POSIX so its sh-dialect shellcheck gate keeps passing.
Closes #869
Assisted-by: Claude Opus 5 via GitHub Copilot
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`generate_skill_index.py --check` prints errors but exits 0, so the stale index passed local pre-commit and only failed in CI. Assisted-by: Claude Opus 5 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hanthor
left a comment
There was a problem hiding this comment.
Approving. I independently reproduced every load-bearing claim in this PR rather than taking the writeup on trust, and all five hold exactly.
The functrace discovery — confirmed on bash 5.3.15:
trap -p DEBUG inside a function → [] (empty, as claimed)
trap - DEBUG inside a function → no-op (outer trap survives)
trap ... DEBUG inside a function → sets globally (as claimed)
So a detector-guard genuinely is impossible and the unconditional re-arm is forced, not lazy. Good call writing that up in docs/skills/shell-scripts.md — that's the kind of thing that costs the next person an afternoon.
The PROMPT_COMMAND array asymmetry — confirmed against bash-preexec 0.6.0 source:
# L291 — array-aware
if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then return 1; fi
# L324 — scalar: element [0] ONLY
existing_prompt_command="${PROMPT_COMMAND:-}"
# L336 — scalar assignment: clobbers [0], leaves [1..n] untouched
PROMPT_COMMAND='__bp_precmd_invoke_cmd'That asymmetry is the bug, and it's sharper than the description: the early-return check is array-aware while the self-removal is not, so once the installer is pushed past element [0] it becomes permanently self-sustaining — L291 keeps short-circuiting __bp_install while the stranded trap - DEBUG fires every prompt. And ${PROMPT_COMMAND} on an array does expand to [0] alone (verified: PROMPT_COMMAND=(a b c) → [a]).
The re-armed string '__bp_preexec_invoke_exec "$_"' matches bash-preexec L295 character for character, and single-quoting correctly defers $_ to trap time.
Implementation details I checked:
(unset PROMPT_COMMAND) 2>/dev/nullis a sound readonly probe —unseton a readonly var fails even in a subshell.PROMPT_COMMAND+=('...')on a scalar correctly promotes to an array (declare -a PROMPT_COMMAND=([0]="orig" [1]="new")), so the ≥5.1 branch is safe even when something set it as a plain string.- The 5.1 version gate is right — array
PROMPT_COMMANDis exactly a 5.1 feature. - Idempotency guard uses
${PROMPT_COMMAND[*]-}, array-aware. Correct, and notably not the mistake bash-preexec itself made.
One durability concern, non-blocking. The trap string is hardcoded to bash-preexec 0.6.0's internal. If Fedora ships a version that changes it, this re-arms a stale trap every prompt and the failure is silent in exactly the same way as the original bug — atuin loads, CTRL+R works, nothing records. Worth a guard: assert at build/test time that the literal matches the trap ... DEBUG line in the shipped bash-preexec.sh, so a package bump trips CI instead of user history. A single grep in the bats suite would cover it.
Agreed on the blast radius — bling.sh on every interactive bash shell across three images warrants lab validation before merge, and your two "only real hardware can prove" caveats are the right two.
Housekeeping: DIRTY — conflicts with main/siblings on Justfile + docs/TESTING.md, needs a rebase.
Root cause — confirmed against bash-preexec 0.6.0 source
Verified by reading the tagged 0.6.0 source directly, not from memory:
PROMPT_COMMAND:__bp_trap_string="$(trap -p DEBUG)"; trap - DEBUG; __bp_install__bp_install()returns early if"${PROMPT_COMMAND[*]}"already contains__bp_precmd_invoke_cmdexisting_prompt_command="${PROMPT_COMMAND:-}"and later assignsPROMPT_COMMAND='__bp_precmd_invoke_cmd'On Fedora,
PROMPT_COMMANDis an array (bash ≥ 5.1).${PROMPT_COMMAND}and bare assignment address element[0]only. So once any other hook (mise, direnv, starship, vte, systemd, zoxide) pushes the installer out of element[0], it is never removed — and runs on every prompt. Its first act istrap - DEBUG;__bp_installthen returns early. From prompt 2 onward the DEBUG trap is permanently empty.That is exactly the reporter's
declare -poutput:__bp_precmd_invoke_cmdin[0], installer stranded in[2], atuin's hook absent.Reproduced empirically in a real prompt loop:
cycle1 trap: [trap -- '__bp_preexec_invoke_exec "$_"' DEBUG],cycle2: [],cycle3: []. With this fix, all three cycles retain the trap.Matches upstream rcaloras/bash-preexec#188 (fixed upstream but insufficient alone) and #186 (still open).
Non-obvious discovery
Bash does not inherit the DEBUG trap into functions without
functrace:trap -p DEBUGinside a function always returns empty, andtrap - DEBUGinside a function is a no-op — buttrap ... DEBUGinside a function does set it globally.Two consequences: a detector-guard is impossible (hence the unconditional re-arm), and it invalidated the first simulation, which passed vacuously. Written up in
docs/skills/shell-scripts.md.Changes
system_files/shared/.../bling/bash-preexec-rearm.sh— re-arms the exact trap bash-preexec installs, once per promptbling.shsources it last, under the bash guard, so the hook is the finalPROMPT_COMMANDentrybling.shstays POSIX (it is under an sh-dialect shellcheck gate); the bash-only logic lives in the sibling file.BLING_DIRallows bats to point at the repo tree.No vendored copy of bash-preexec.
Tests — 14 cases, all pass
Tests 1 and 4 are negative controls that pin the bash-preexec stand-in to the real defect, so the positive cases cannot pass vacuously.
Covered: array
PROMPT_COMMANDtrap survival, atuin-style hook still firing, idempotency on double-source, scalarPROMPT_COMMAND(no regression), bash-preexec absent, readonlyPROMPT_COMMAND, missing helper, zsh source-safety.Also green:
bash -n/sh -n, both CI shellcheck invocations,just check,pre-commit run --all-files.Confidence and limits
High that the DEBUG trap now survives — the failure and its repair are both directly reproduced. Two things only real hardware can prove:
trap - DEBUGafter our entryOrdering is safe for everything bling itself initializes.
Reviewer note
The re-install is unconditional per prompt, forced by the functrace scoping above; it matches the reporter's validated workaround. A third party's own raw DEBUG trap set after bling would be overwritten each prompt — but such a trap already breaks bash-preexec today.
Blast radius — highest in this batch
bling.shis sourced by every interactive bash shell on bluefin, bluefin-lts, and dakota. Recommend lab validation before merge.Closes #869
Branch state
Updated onto
mainafter #926 landed (viagh pr update-branch). No conflict —this PR's
docs/TESTING.mdrow andJustfileentry merged cleanly.Overlap with the sibling PRs in this batch
#942 also edits
docs/skills/shell-scripts.md, but adds different sections(this PR:
Bash DEBUG traps are invisible inside functions,POSIX-sh files cannot hold bash array code; #942:image-info.json is build-time state).Verified non-conflicting with a real sequential merge.
No file overlap with #941.
Pre-merge checklist for the reviewer
18.18.1, so end-to-end recording is unproven
(see reviewer note above; a detector-guard is impossible due to bash's
functrace scoping)