Second bug found while validating the previous commit on hardware.
install_plugin's new set-aside/restore had no lock. The web UI runs Flask
threaded, so a double-clicked Install button gives two threads the same
plugin_id; interleaved, one thread's restore deletes the other's freshly
installed copy. _reinstall_with_rollback already guards exactly this with a
per-plugin lock, and install_plugin needs the same one.
Taking that lock naively deadlocks. _reinstall_with_rollback holds it across
its call to install_plugin, and threading.Lock is not reentrant -- so the
request thread hangs forever on the standard monorepo update path
(update_plugin -> _reinstall_with_rollback -> install_plugin), which is to say
on every plugin update. Verified by reverting to a plain Lock: the regression
test times out after 10s instead of passing.
The per-plugin locks are now RLocks, and install_plugin holds one for its
whole set-aside/install/restore sequence.
Verified on devpi (Pi, Python 3.13.5, real registry and network):
- update_plugin on an up-to-date plugin: True in 5.4s
- update_plugin forced through the full reinstall-with-rollback path:
True in 13.1s, correct version restored, old copy replaced, no backup
directories left behind
- install -> reinstall-over-existing -> failed-reinstall-restores: all pass
against real downloads
- 22 plugins load, no tracebacks, web API and UI 200, steady-state journal
50 lines/min
791 core unit tests pass, including 2 new concurrency tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5