Skip to content

fix(bling): rearm bash-preexec DEBUG trap on each prompt - #943

Open
castrojo wants to merge 3 commits into
mainfrom
fix/bash-preexec-debug-trap-rearm
Open

fix(bling): rearm bash-preexec DEBUG trap on each prompt#943
castrojo wants to merge 3 commits into
mainfrom
fix/bash-preexec-debug-trap-rearm

Conversation

@castrojo

@castrojo castrojo commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Root cause — confirmed against bash-preexec 0.6.0 source

Verified by reading the tagged 0.6.0 source directly, not from memory:

  • L74: the installer is deferred by appending a string to PROMPT_COMMAND:
    __bp_trap_string="$(trap -p DEBUG)"; trap - DEBUG; __bp_install
  • L289-293: __bp_install() returns early if "${PROMPT_COMMAND[*]}" already contains __bp_precmd_invoke_cmd
  • The self-removal code reads existing_prompt_command="${PROMPT_COMMAND:-}" and later assigns PROMPT_COMMAND='__bp_precmd_invoke_cmd'

On Fedora, PROMPT_COMMAND is 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 is trap - DEBUG; __bp_install then returns early. From prompt 2 onward the DEBUG trap is permanently empty.

That is exactly the reporter's declare -p output: __bp_precmd_invoke_cmd in [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 DEBUG inside a function always returns empty, and trap - DEBUG inside a function is a no-op — but trap ... DEBUG inside 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

  • New system_files/shared/.../bling/bash-preexec-rearm.sh — re-arms the exact trap bash-preexec installs, once per prompt
  • bling.sh sources it last, under the bash guard, so the hook is the final PROMPT_COMMAND entry

bling.sh stays POSIX (it is under an sh-dialect shellcheck gate); the bash-only logic lives in the sibling file. BLING_DIR allows 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_COMMAND trap survival, atuin-style hook still firing, idempotency on double-source, scalar PROMPT_COMMAND (no regression), bash-preexec absent, readonly PROMPT_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:

  1. atuin actually writing records end-to-end (tests use a preexec stub, not atuin 18.18.1)
  2. that no downstream hook on a real session appends its own trap - DEBUG after our entry

Ordering 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.sh is sourced by every interactive bash shell on bluefin, bluefin-lts, and dakota. Recommend lab validation before merge.

Closes #869


Branch state

Updated onto main after #926 landed (via gh pr update-branch). No conflict —
this PR's docs/TESTING.md row and Justfile entry 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

  • Lab-validate on real hardware — the tests use a preexec stub, not atuin
    18.18.1, so end-to-end recording is unproven
  • Confirm the unconditional per-prompt trap re-install is acceptable
    (see reviewer note above; a detector-guard is impossible due to bash's
    functrace scoping)

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>
@castrojo
castrojo requested review from a team, ahmedadan, hanthor, inffy, ledif and repires as code owners August 7, 2026 02:45
`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>
@castrojo castrojo added the 3-clanker-queue Work admitted to the agent-maintained queue. label Aug 7, 2026

@hanthor hanthor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/null is a sound readonly probe — unset on 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_COMMAND is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3-clanker-queue Work admitted to the agent-maintained queue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bling: atuin history silently stops recording in bash — bash-preexec 0.6.0 + Fedora array PROMPT_COMMAND

2 participants