Skip to content

global: write cache files atomically to avoid races between concurrent shells (#109) - #727

Merged
akinomyoga merged 1 commit into
akinomyoga:masterfrom
vnz:fix/atomic-cache-writes
Sep 8, 2026
Merged

akinomyoga merged 1 commit into
akinomyoga:masterfrom
vnz:fix/atomic-cache-writes

Conversation

@vnz

@vnz vnz commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #109

Problem

The cache files under $_ble_base_cache are written in place (>| "$file", 3>| "$file"). When many interactive shells start at the same moment with a stale cache, one shell truncates and rewrites a cache file while the others already pass the -s / -nt validity checks and source a half-written file.

I hit this with kitty's session restore (32 tabs) right after ble-update followed by a reboot, so the restored shells were the first to run on the new install. Symptoms in the affected tabs:

  • ble.sh: The keymap 'emacs' is empty. and ble-attach returns 1 (the emacs counterpart of [tmux-resurrect] Cache broken on restoring (Error ble.sh: The keymap 'vi_imap' is empty.) #109).
  • Alternatively bash: syntax error near unexpected token when a half-written decode.bind.*.bind is sourced.
  • Because the shell then falls back to readline, the terminal's replies to the DA2/CPR queries sent during attach show up as typed text (1;4000;48c, RRRR...), and hooks registered with blehook PRECMD (e.g. starship) never run again, so the prompt freezes.

The workaround from #D1562 checks -s, which covers the zero-byte case, but a file that is being written is non-empty and syntactically broken for most of its lifetime.

Fix

Write each attach-time cache to <file>.$$.part and rename it into place with ble/bin/mv -f, removing the temporary on failure. The temporary lives in the same directory, so the rename is atomic and readers only ever see complete files. Covered writers:

  • lib/keymap.emacs.sh, lib/keymap.vi.sh, lib/keymap.vi_digraph.sh (keymap.*)
  • lib/init-cmap.sh (decode.cmap.*.dump)
  • lib/init-bind.sh (decode.bind.*.bind / .unbind)
  • lib/init-term.sh (term.$TERM)
  • src/decode.sh: decode.readline.*.txt already used a .part temporary, but with a name shared by all shells, so two writers could still interleave into it; it now uses the per-process name. decode.inputrc.* (.cache-save) is now written via temporaries too.

Not touched, although they follow the same pattern, because they are not on the attach path: the mandb completion cache in lib/core-complete.sh and the compiled msleep helper in lib/init-msleep.sh. Happy to include them if you prefer.

Testing

Reproduction: spawn 32 interactive bash -i in ptys at the same time with XDG_CACHE_HOME pointing at an empty directory (the harness answers DA1/DA2/CPR queries so attach does not sit on timeouts). Each shell sources ble.sh with --noattach from .bashrc and calls ble-attach at the end.

Build Cold cache, 32 shells Failed attaches
master (690b315) 4 runs 11, 0, 23, 13
this branch 3 runs 0, 0, 0

After the patched runs all cache files are present and no *.part files are left behind. make check: every section passes except two ble/util/sprintf cases ("%#.2f" gives 27,00 instead of 27.00), which fail identically on untouched master in my environment, so they are unrelated to this change.

Environment: bash 5.2.37, kitty 0.48 (TERM=xterm-kitty), Debian trixie.

Refs #109, which I believe has the same root cause.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R2ZY4aNfZ2gnhKw9UBJovV

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZY4aNfZ2gnhKw9UBJovV
Co-Authored-By: Koichi Murase <myoga.murase@gmail.com>
@akinomyoga
akinomyoga force-pushed the fix/atomic-cache-writes branch from e99d9c1 to 0dd5f7d Compare September 7, 2026 13:25
@akinomyoga

Copy link
Copy Markdown
Owner

Thanks for the contribution. I've added adjustments and force-pushed them. Could you test in your environment the PR head with the mentioned 32 interactive Bash sessions? It should be noted that the leftover .$$.part files on failure are intentionally kept until the end of the session to reduce the spawn cost.

@vnz

vnz commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@akinomyoga thanks for the adjustments. Tested the PR head (0dd5f7d) with the same harness: 32 bash -i started simultaneously in ptys against an empty XDG_CACHE_HOME, .bashrc sourcing ble.sh with --noattach and calling ble-attach last, the harness answering DA1/DA2/CPR.

Build Cold cache, 32 shells Failed attaches
master (690b315), today 2 runs 26, 16
PR head (0dd5f7d) 3 runs 0, 0, 0

After the PR-head runs the cache directory holds the 12 expected files and no *.part leftovers. make check gives the same result as master here: everything passes except the two pre-existing ble/util/sprintf "%#.2f" cases that fail under my locale.

Understood on keeping .$$.part files until the end of the session; I see they are swept by ble/base/clean-up-runtime-directory on idle and finalize. I also checked that ble/base/.adjust-bash-options forces set -B, so the {".$$.part",} rename is fine for users running with set +B.

From my side this is good to merge.

@akinomyoga

Copy link
Copy Markdown
Owner

Thanks for the confirmation.

@akinomyoga
akinomyoga merged commit d81fd54 into akinomyoga:master Sep 8, 2026
4 checks passed
@vnz
vnz deleted the fix/atomic-cache-writes branch September 8, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[tmux-resurrect] Cache broken on restoring (Error ble.sh: The keymap 'vi_imap' is empty.)

2 participants