Version / branch / commit
main at 1b5db17 (Go 1.25, built from source).
OS and environment
Linux 6.17 / Go 1.25. Not platform specific: the affected code is plain os.Rename sequencing.
Steps to reproduce
installtxn.CommitDir publishes an install with two renames: the live target moves into a
transaction workspace as previous, then the staged copy is renamed into place.
- Install a plugin:
zero plugins install <source>.
- Reinstall or update it, and kill the process between those two renames. In a test this is
easier to stage directly: installtxn.StageDir(dir), then rename <dir>/<id> to
<workspace>/previous, then stop.
- Start
zero again and list plugins.
Expected behavior
The plugin is still installed. The transaction retained a complete copy of it, so an interrupted
publish should leave the previous install in place, not lose it.
Actual behavior
The plugin is gone. plugins.Load enumerates directories, so with <dir>/<id> absent the plugin
simply disappears, while plugins.lock still lists it. The only copy sits in
<dir>/.zero-install-txn-*/previous, and nothing ever reads it back:
- The rollback in
CommitDir is in-process only, so process death runs none of it.
- Nothing outside
internal/installtxn/installtxn.go references the workspace or its previous
directory, and neither internal/plugins nor internal/skills has any recovery entry point.
cleanupWorkspace deliberately keeps a workspace that still holds previous, so the copy is
retained permanently but is unreachable.
Reinstalling from source recovers it, so the loss is permanent only when the source is not
reachable: a remote git source while offline, or a source that has since moved or been deleted.
A first install is unaffected, since with no existing target CommitDir makes no backup.
internal/skills uses the same transaction and has the same window. internal/terminalpet
(client.go:288) is a third caller with the same lock-then-commit shape.
To recover an interrupted commit at all, the transaction has to record which install a backup
belonged to; today the workspace holds no attribution, so a repair pass would have nothing to key
on.
Relevant logs, screenshots, or error messages
Driving the real Install and Load through that state, with a control:
CONTROL installed, Load sees [zero.demo]
AFTER Load sees []
AFTER lockfile still claims zero.demo installed: true
AFTER only copy stranded at .zero-install-txn-1318677082/previous
RECOVERY Load sees [zero.other]
RECOVERY backup STILL stranded at .zero-install-txn-1318677082/previous
The RECOVERY lines are after three further Load + ReadLock cycles and a full fresh install of a
second plugin through the same cross-process lock over the same directory. Nothing puts it back.
Related: #921 looks like a fourth site of the same shape. PR #762 introduced installtxn and
scoped itself to in-process failure, so this gap was never claimed as covered.
Version / branch / commit
mainat 1b5db17 (Go 1.25, built from source).OS and environment
Linux 6.17 / Go 1.25. Not platform specific: the affected code is plain
os.Renamesequencing.Steps to reproduce
installtxn.CommitDirpublishes an install with two renames: the live target moves into atransaction workspace as
previous, then the staged copy is renamed into place.zero plugins install <source>.easier to stage directly:
installtxn.StageDir(dir), then rename<dir>/<id>to<workspace>/previous, then stop.zeroagain and list plugins.Expected behavior
The plugin is still installed. The transaction retained a complete copy of it, so an interrupted
publish should leave the previous install in place, not lose it.
Actual behavior
The plugin is gone.
plugins.Loadenumerates directories, so with<dir>/<id>absent the pluginsimply disappears, while
plugins.lockstill lists it. The only copy sits in<dir>/.zero-install-txn-*/previous, and nothing ever reads it back:CommitDiris in-process only, so process death runs none of it.internal/installtxn/installtxn.goreferences the workspace or itspreviousdirectory, and neither
internal/pluginsnorinternal/skillshas any recovery entry point.cleanupWorkspacedeliberately keeps a workspace that still holdsprevious, so the copy isretained permanently but is unreachable.
Reinstalling from source recovers it, so the loss is permanent only when the source is not
reachable: a remote git source while offline, or a source that has since moved or been deleted.
A first install is unaffected, since with no existing target
CommitDirmakes no backup.internal/skillsuses the same transaction and has the same window.internal/terminalpet(
client.go:288) is a third caller with the same lock-then-commit shape.To recover an interrupted commit at all, the transaction has to record which install a backup
belonged to; today the workspace holds no attribution, so a repair pass would have nothing to key
on.
Relevant logs, screenshots, or error messages
Driving the real
InstallandLoadthrough that state, with a control:The RECOVERY lines are after three further
Load+ReadLockcycles and a full fresh install of asecond plugin through the same cross-process lock over the same directory. Nothing puts it back.
Related: #921 looks like a fourth site of the same shape. PR #762 introduced
installtxnandscoped itself to in-process failure, so this gap was never claimed as covered.