Repository navigation
[Bug]: Bundle step refresh rollback is not transactional with step operations #4815
Description
Bug Description
Bundle step refresh in src/specify_cli/bundles/primitives.py snapshots, removes, and restores an installed custom step without treating the rollback sequence as one transaction relative to other step operations.
StepRegistry.save() uses atomic file replacement, which prevents a partially written registry but does not prevent a stale in-memory registry snapshot from overwriting entries committed concurrently. After #4769, step add and remove use the shared step lock, but bundle refresh still reads and restores state outside that locking protocol.
The same rollback path can also mask the original reinstall failure if copying the backup or saving the restored registry entry fails.
Steps to Reproduce
The races should be reproduced with deterministic synchronization rather than timing sleeps:
- Install a custom step and begin a bundle refresh of it.
- Pause the refresh after it snapshots the package and registry metadata but before removal.
- Concurrently force-install a newer version of the same step.
- Resume the refresh and make its reinstall fail.
- Observe that the refresh can remove the newer package and restore the stale backup and metadata.
A second synchronized regression can pause rollback after a fresh StepRegistry snapshot is loaded, commit another step from a concurrent operation, then resume save(). The stale snapshot can discard the concurrently committed registry entry.
For the diagnostic failure, make backup restoration or StepRegistry.save() fail after the reinstall has already raised BundlerError.
Expected Behavior
- Snapshot and removal are atomic relative to other step directory and registry mutations.
- Rollback never removes or overwrites a package or registry entry committed by a later operation.
- Restoring one entry cannot discard concurrently committed registry entries.
- If restoration fails, the original reinstall error remains the primary error and the restoration failure is retained as secondary diagnostic context.
Actual Behavior
- Snapshot and backup occur before the locked removal operation.
- Rollback copies the backup and reloads, mutates, and saves the registry outside the step lock.
- A stale rollback can overwrite newer package state or unrelated concurrent registry changes.
- A restoration exception can replace the original reinstall error; cleanup then removes the backup.
Specify CLI Version
Current main; also present in #4769 at 996981d4 because this pre-existing code was intentionally reverted from that PR for separate handling.
AI Agent
GitHub Copilot
Operating System
Not operating-system-specific.
Python Version
All supported Python versions.
Error Logs
N/A — this is a deterministic concurrency and error-preservation defect.
Additional Context
Related review findings:
- feat(workflows): install custom step types from local dirs and archives #4769 (comment)
- feat(workflows): install custom step types from local dirs and archives #4769 (comment)
- feat(workflows): install custom step types from local dirs and archives #4769 (comment)
The existing rollback regression verifies ordinary serial restoration, but does not cover concurrent step mutations or restoration failure. This issue should require positive and negative deterministic regression tests that fail before the fix and pass afterward.
The separate cross-cutting local-directory hardening and extension/preset locking work is already tracked by #4793.
AI Disclosure
Drafted and filed by GitHub Copilot CLI using GPT-5.6 Sol in interactive mode on behalf of @mnriem. AI assistance covered review of the linked findings and affected code, duplicate-issue research, and drafting and filing this issue.
Activity
mvanhorn commentedon Oct 3, 2026
The package and metadata were snapshotted and the step directory was backed up before removal, outside the lock shared with step add and step remove, and the failure path then copied that backup onto the step directory with dirs_exist_ok.
Installed-step refresh with network access now snapshots the package and registry entry, copies the backup, and removes the step inside _step_install_transaction, using command_remove._remove_step_locked so the same non-reentrant flock is not taken twice, while the catalog reinstall stays outside the lock.
github-actions commentedon Oct 15, 2026
Bug assessment — bundle-refresh-rollback: Valid · severity medium
Bug Assessment: Bundle step refresh rollback is not transactional with step operations
Report (summarized)
The report identifies two related defects in refreshing an installed custom workflow step. The refresh snapshots package and registry state, then removes and reinstalls the step, but the snapshot/removal and rollback operations are not coordinated with the shared step lock used by add/remove. A concurrent install can therefore be removed or overwritten by stale rollback state, and a stale registry snapshot can discard another concurrently committed entry. The report also says rollback failures can replace the original reinstall failure. The issue describes deterministic synchronization points and notes that existing tests cover only serial restoration.
One comment confirms that the backup and metadata snapshot occur outside the shared lock, while the proposed change in the related discussion moves the package backup and removal into
_step_install_transactionand leaves catalog reinstall outside the lock.Symptom
Under concurrent custom-step mutations, a bundle refresh can restore an older package and registry entry over a newer committed step, or save a stale registry snapshot that loses an unrelated concurrent entry. If backup restoration or registry saving fails after reinstall failure, the restoration exception can mask the original
BundlerError.Expected behavior is that snapshot/removal/rollback are atomic relative to step directory and registry mutations, later commits are not overwritten, and the reinstall error remains primary with rollback failure retained as diagnostic context.
Reproduction
StepRegistry, commit another step concurrently, then resumesave()and observe that the concurrent entry can be lost.StepRegistry.save()fail after reinstall has raisedBundlerError.The report does not specify the exact test hooks or synchronization mechanism; deterministic barriers should be added rather than timing sleeps.
Suspected Code Paths
src/specify_cli/bundles/primitives.py:641-694—_StepKindManager.refresh()snapshotsmetadataand the step directory before locking, callsself.remove(component)at line 660 (which acquires the lock independently), then restores the directory and creates/saves a newStepRegistryat lines 665 and 678-689 outside the shared lock.src/specify_cli/workflows/step/installer.py:83-101—_step_install_transaction()defines the shared.step-install.lockboundary for step directory and registry mutations.src/specify_cli/workflows/step/command_remove.py:11-30and:114-115—_remove_step_locked()is the lock-held removal primitive, while the public remove command acquires_step_install_transaction()before invoking it. Calling the public remove path from refresh creates separate critical sections rather than one transaction.src/specify_cli/workflows/step/catalog.py(imported byprimitives.py) —StepRegistryloads a snapshot in its constructor andsave()persists that snapshot; concurrent saves based on an older snapshot can overwrite entries committed after the snapshot.tests/specify_cli/bundles/test_primitives.py:1002-1084— existing regression coverage verifies serial restoration of the package and registry entry after reinstall failure, but does not synchronize concurrent mutations or exercise rollback-failure error preservation.Root Cause Hypothesis
The root cause is a transaction-boundary mismatch: step add/remove use
_step_install_transaction(), but bundle refresh performs its initial read, backup, rollback copy, and registry reload/save outside that same lock, and delegates removal through a separately acquired command boundary. BecauseStepRegistryis snapshot-based, a registry loaded before a concurrent mutation can later overwrite newer data. The rollback handler also uses a bareexcept BundlerErrorpath without independently preserving/reporting exceptions from restoration. Confidence: high.Proposed Remediation
Preferred: Add a refresh-specific transaction in the step domain that acquires
_step_install_transaction()once for the package/registry snapshot, lock-held removal, and rollback restoration. Reuse_remove_step_locked()rather than the public remove command to avoid taking the non-reentrant lock twice. Keep catalog resolution/download/reinstall outside the lock if that is required for latency, but reacquire the same lock for any rollback and validate the current state before replacing it so a later committed package or registry entry is never overwritten. Preserve the originalBundlerErroras the raised exception; collect rollback failures as chained or secondary diagnostic context and retain the backup when restoration is incomplete.The transaction should explicitly define behavior when the step changed while the reinstall was in progress: abort stale rollback rather than deleting or overwriting a newer package, and merge/compare registry state rather than saving an old full snapshot.
Alternatives:
Files likely to change:
src/specify_cli/bundles/primitives.pysrc/specify_cli/workflows/step/installer.pyand/orsrc/specify_cli/workflows/step/command_remove.pyfor a reusable lock-held refresh/remove primitivetests/specify_cli/bundles/test_primitives.pyTests to add or update:
BundlerErrorremains primary and rollback failure is surfaced as secondary diagnostic context.Risks & Considerations
Open Questions