Skip to content

plugins/skills: an install killed mid-commit is lost, with its only copy stranded in the transaction workspace #996

Description

@beardthelion

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.

  1. Install a plugin: zero plugins install <source>.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions