Skip to content

fix(plugins,skills): make reinstall transactions atomic - #762

Merged
kevincodex1 merged 1 commit into
Gitlawb:mainfrom
PierrunoYT:fix/transactional-extension-installs
Jul 22, 2026
Merged

fix(plugins,skills): make reinstall transactions atomic#762
kevincodex1 merged 1 commit into
Gitlawb:mainfrom
PierrunoYT:fix/transactional-extension-installs

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • stage and verify plugin/skill replacements on the destination filesystem before touching active installs
  • serialize install/remove content swaps and lockfile read-modify-writes with a cross-process lock on Unix and Windows
  • atomically publish lockfiles and restore the prior installation if publication fails
  • preserve retained backups if rollback itself cannot complete
  • cover rollback and concurrent lockfile updates with regression tests

Fixes #754

Verification

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go test -race ./internal/installtxn ./internal/plugins ./internal/skills
  • repeated concurrent install regressions under the race detector (-count=10)
  • Windows cross-compilation of installtxn, plugins, and skills tests
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./...
  • git diff HEAD --check

The pinned advisory golangci-lint command reports the repository's existing 36-item backlog in unrelated files; it reports no finding in the files changed here.

Summary by CodeRabbit

  • Bug Fixes

    • Installations and removals now complete atomically, reducing the risk of partial updates.
    • Failed publishes automatically restore the previous installation or remove incomplete first-time installs.
    • Concurrent plugin and skill installations now preserve every lockfile entry.
    • Lockfile updates are written atomically for improved data safety.
  • Tests

    • Added coverage for rollback behavior, workspace preservation, and concurrent installations across plugins and skills.

Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Copilot AI review requested due to automatic review settings July 19, 2026 17:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a cross-process filesystem transaction layer for plugin and skill reinstalls so replacements are staged and validated before touching the active install, and lockfile updates are serialized and published atomically to prevent lost entries under concurrency.

Changes:

  • Add internal/installtxn to stage installs, take an interprocess lock (Unix + Windows), swap directories with rollback, and atomically publish lockfiles.
  • Update plugin and skill install/remove flows to stage first, then commit content swap + lockfile update under the shared lock.
  • Add regression tests covering rollback behavior and concurrent installs preserving all lockfile entries.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
internal/skills/install.go Stage SKILL.md before locking; commit dir swap + lockfile write atomically; lock Remove and write lockfile atomically.
internal/skills/install_test.go Add concurrent install regression test to ensure lockfile entries aren’t lost.
internal/plugins/install.go Stage full plugin tree before locking; commit dir swap + lockfile write atomically; lock Remove and write lockfile atomically.
internal/plugins/install_test.go Add concurrent install regression test to ensure lockfile entries aren’t lost.
internal/installtxn/lock_windows.go Implement Windows interprocess lock + atomic file replace via Win32 APIs.
internal/installtxn/lock_unix.go Implement Unix interprocess lock via flock + atomic file replace via rename.
internal/installtxn/installtxn.go New transaction helpers: lock, stage, commit with rollback, removal with rollback, atomic file write.
internal/installtxn/installtxn_test.go Add tests for rollback and workspace cleanup behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"path/filepath"
)

const lockFileName = ".zero-install.lock"
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds cross-process locking, staged directory swaps, rollback handling, and atomic file replacement for install operations. Plugins and skills now use these primitives for transactional installation, removal, lockfile publication, and concurrent updates.

Changes

Install transaction coordination

Layer / File(s) Summary
Transaction primitives and platform locking
internal/installtxn/installtxn.go, internal/installtxn/lock_unix.go, internal/installtxn/lock_windows.go
Adds install-root locks, staging workspaces, atomic file replacement, and platform-specific locking implementations.
Directory commit, removal, and rollback
internal/installtxn/installtxn.go, internal/installtxn/installtxn_test.go
Adds staged commits and removals that restore previous content when publication fails, with tests for rollback and workspace preservation.
Plugin transactional install and removal
internal/plugins/install.go, internal/plugins/install_test.go
Stages and validates plugin content, coordinates lockfile updates under the install lock, and tests concurrent installs preserving all lock entries.
Skill transactional install and removal
internal/skills/install.go, internal/skills/install_test.go
Applies transactional staging, removal, and atomic lockfile writes to skills, with concurrent-install coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PluginInstall
  participant installtxn
  participant Filesystem
  participant Lockfile
  PluginInstall->>installtxn: StageDir and prepare plugin content
  PluginInstall->>installtxn: Lock install root
  PluginInstall->>installtxn: CommitDir staged plugin
  installtxn->>Filesystem: Swap target directory
  installtxn->>Lockfile: Publish updated lockfile
  Lockfile-->>installtxn: Failure or success
  installtxn->>Filesystem: Restore previous directory on failure
Loading

Possibly related PRs

  • Gitlawb/zero#184: Modifies the same plugin and skill installation and lockfile paths.
  • Gitlawb/zero#653: Overlaps with earlier transactional plugin installation and atomic lockfile logic.

Suggested reviewers: copilot, vasanthdev2004, kevincodex1

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: making plugin and skill reinstall transactions atomic.
Linked Issues check ✅ Passed The changes add transactional staging, interprocess locking, atomic lockfile writes, and rollback paths that match issue #754's requirements.
Out of Scope Changes check ✅ Passed The added install transaction helpers, plugin/skill integration, and regression tests are all directly in scope for the linked issue.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/installtxn/lock_unix.go (1)

27-29: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Sync parent directory after rename for full durability.

The Windows implementation of replaceFile uses MOVEFILE_WRITE_THROUGH to ensure the updated directory entry is immediately persisted. To achieve equivalent durability guarantees on Unix and prevent the atomic file replacement from being lost or corrupted during a sudden power loss, you should fsync the parent directory after renaming.

♻️ Proposed refactor to add directory sync

Add "path/filepath" to the imports, then apply this diff:

-func replaceFile(source string, target string) error {
-	return os.Rename(source, target)
-}
+func replaceFile(source string, target string) error {
+	if err := os.Rename(source, target); err != nil {
+		return err
+	}
+	if dir, err := os.Open(filepath.Dir(target)); err == nil {
+		_ = dir.Sync()
+		_ = dir.Close()
+	}
+	return nil
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/installtxn/lock_unix.go` around lines 27 - 29, Update replaceFile to
fsync the parent directory after os.Rename succeeds: derive the target’s parent
with filepath.Dir, open that directory, call Sync, and close it while
propagating any errors. Preserve the rename error path and ensure the directory
is always closed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/installtxn/lock_unix.go`:
- Around line 27-29: Update replaceFile to fsync the parent directory after
os.Rename succeeds: derive the target’s parent with filepath.Dir, open that
directory, call Sync, and close it while propagating any errors. Preserve the
rename error path and ensure the directory is always closed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae91b4f2-e90e-4178-8910-c0ae28302560

📥 Commits

Reviewing files that changed from the base of the PR and between fbf8598 and ab0b17d.

📒 Files selected for processing (8)
  • internal/installtxn/installtxn.go
  • internal/installtxn/installtxn_test.go
  • internal/installtxn/lock_unix.go
  • internal/installtxn/lock_windows.go
  • internal/plugins/install.go
  • internal/plugins/install_test.go
  • internal/skills/install.go
  • internal/skills/install_test.go

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — the core mechanism is correct and does what it claims: I confirmed TestConcurrentInstallsPreserveEveryLockEntry fails on the merge base (only 1–3 of 12 lock entries survive across 5 runs, plus a torn plugins.lock that fails to parse) and passes here. A 30-iteration same-name concurrent install/remove stress run under -race produced no lockfile/disk inconsistency and no leaked workspaces. Everything below is an edge case or an incidental permission regression, not a failure of the design.

What's genuinely well done

Several non-obvious hazards are already handled correctly, and it's worth saying so explicitly:

  • Staging inside the target install dir (internal/installtxn/installtxn.go:31) means the commit rename can never hit EXDEV.
  • The extra staged/previous nesting level is what keeps transient workspaces invisible to the loaders, which look exactly one level down for dir/<entry>/plugin.json (internal/plugins/plugins.go:253) and dir/<entry>/SKILL.md (internal/skills/skills.go:307). A flatter workspace layout would have made every in-flight install briefly visible as a broken extension.
  • Renaming the old target out before renaming the new one in (installtxn.go:48 then :56) is precisely what makes the swap work on Windows, where rename-over-an-existing-directory fails.
  • The network fetch is deliberately kept outside the lock, and re-reading the lockfile after acquiring it (internal/plugins/install.go:157-159, internal/skills/install.go:153-155) is the detail that actually closes the lost-update window. That's the crux of the fix.
  • cleanupWorkspace's refusal to delete a workspace still holding previous (installtxn.go:114-119) is the right call, and the comment explains why.

Findings

[Minor] WriteFileAtomically resets the lockfile to 0644, discarding umask and any user-hardened mode

internal/installtxn/installtxn.go:131

temp.Chmod(perm) applies the literal perm argument (0o644 from internal/plugins/install.go:271 and internal/skills/install.go:332) before the rename, so the published lockfile always ends up world-readable regardless of process umask or the mode the existing lockfile had. os.WriteFile on the base only applied perm at creation, so this is a behavior change.

Confirmed empirically against the base: (1) after chmod 600 ~/.zero/skills/skills.lock, the base preserves 0600 across a reinstall, this PR republishes it as 0644 (-rw------- -> -rw-r--r--); (2) under umask 077 the base creates it 0600, this PR creates it 0644.

This matters because the lockfile stores the source string verbatim (internal/skills/install.go:166, internal/plugins/install.go:169) and remote sources are not rewritten — canonicalSource returns non-local sources unchanged (internal/skills/install.go:428-440). A private repo installed via https://user:token@host/repo puts that token into a file whose permissions were just silently widened on a multi-user host.

Fix: stat the destination first — if it exists, reuse existing.Mode().Perm() so an operator-hardened mode survives; if it doesn't, drop the explicit Chmod and let CreateTemp's 0600 stand (or apply the umask explicitly) rather than forcing the literal perm.

[Minor] CommitDir uses os.Stat, so installing over a dangling symlink now fails instead of replacing it

internal/installtxn/installtxn.go:47

os.Stat(target) follows symlinks, so a dangling symlink at the install target reports ErrNotExist, sets hadPrevious=false, and leaves the symlink in place. The subsequent os.Rename(staged, target) at :56 then fails, because renaming a directory onto an existing non-directory returns ENOTDIR.

Confirmed empirically: with ~/.zero/skills/probe a symlink pointing at a nonexistent path, skills.Install succeeds on the merge base (the old RemoveAll unlinked the dangling symlink) and records the lock entry. On this PR the same call returns publish staged install: rename .../staged .../probe: not a directory, the lockfile stays empty, and the dangling symlink is left behind — the skill can never be installed until the user manually deletes the link. Same path applies to internal/plugins/install.go:170.

Fix: use os.Lstat(target) for the hadPrevious probe, so any existing entry — symlink, dangling or not — is renamed into the workspace before the staged directory moves into place. Worth also considering the matching os.Stat presence checks in Remove (internal/plugins/install.go:215, internal/skills/install.go:210).

[Nit] Remove creates the install dir and a lock file as a side effect of a lookup that then fails

internal/installtxn/installtxn.go:18

Lock unconditionally MkdirAlls the install root and opens .zero-install.lock inside it, and Remove takes the lock (internal/plugins/install.go:203, internal/skills/install.go:198) before checking whether the item exists. On a fresh machine with no ~/.zero/skills, zero skill remove foo now creates ~/.zero/skills/ and ~/.zero/skills/.zero-install.lock before returning skill "foo" is not installed. Verified: after Remove(dir, "nope") against a nonexistent dir, the directory exists and contains the lock file. Nothing was created on the base.

Fix: do the cheap ReadLock/os.Stat existence check before taking the lock and return the not-installed error early, or give Lock a non-creating variant that returns a no-op unlock when the install root doesn't exist.

[Nit] Lockfile write failures during Remove are reported as directory-removal failures

internal/plugins/install.go:228, internal/skills/install.go:224

RemoveDir returns the publish callback's error verbatim (internal/installtxn/installtxn.go:92), but both call sites wrap everything it returns in remove plugin dir: / remove skill dir:. If the directory rename succeeds and writeLock then fails, the rollback restores the directory correctly — but the user sees remove plugin dir: write plugins.lock: ..., pointing at a directory removal that in fact succeeded and was rolled back. It sends anyone debugging toward directory permissions rather than the lockfile.

Fix: have RemoveDir distinguish its own failures from the callback's (typed or sentinel-wrapped publish error), or drop the blanket wrap at the call sites and let the already-descriptive inner errors surface.

Checked and cleared

Three things looked like defects on first read and are not — flagging them so they don't get re-raised:

  • Stranded previous workspaces. Looks like silent data hiding, but it's reported and recoverable: the os.Rename failure is a *os.LinkError wrapped with %w at installtxn.go:104 and surfaced to stderr (internal/cli/distribution.go:83 and :204), printing the full path to the stranded previous directory — I forced the state and observed it. It's a plain directory recoverable with mv. The behavior is deliberate, documented at installtxn.go:110-113, and pinned by installtxn_test.go:66. More importantly it's strictly better than the base, which did os.RemoveAll(target) before copyTree, i.e. destroyed the previous install unrecoverably. Note the cited Windows mechanism doesn't apply anyway: an open file under target makes os.RemoveAll at :99 fail and return at :100, so :103 is never reached on that path.
  • Missing fsync on the staged tree / parent dir. Not a regression — the base had zero syncs anywhere in the install path (grep '\.Sync()' on both trees); installtxn.go:139 is the only one and this PR adds it. Also, temp.Sync() runs before replaceFile at :146, and on ext4 that fsync forces the running journal transaction which already contains the os.Rename(staged, target) issued at :56 — so the ordering concern is backwards. And the package docs (installtxn.go:1-3) promise cross-process mutual exclusion, not power-loss durability. Staged file data is still unsynced (internal/plugins/install.go:397-411), but that's a pre-existing codebase-wide hardening opportunity, not something introduced here.
  • Concurrency tests asserting only len(entries). Accurate description, but not a real gap. I mutation-tested it: no-op'ing the os.Rename at installtxn.go:57 does let the concurrency test pass alone, but fails 8 other tests in the same go test ./internal/plugins/ ./internal/skills/ invocation (TestInstallCopiesLocalPluginAndRecordsHash, TestInstallCopiesEntireTree, TestInstallFromRealLocalGitRepo, +5 in skills). Deleting the retained-previous guard at installtxn.go:110-113 immediately fails the PR's own TestCleanupWorkspacePreservesRetainedPreviousInstall. Entry count is the correct and only assertion that discriminates fixed-from-broken for the bug this PR targets; on-disk content is covered elsewhere.

Build & tests

gofmt -l . clean repo-wide, go build ./... clean, go vet clean on all three touched packages. internal/installtxn, internal/plugins, internal/skills all pass under -race -count=5 with no flakiness and no race reports.

No test failure is attributable to this PR. There are failures in internal/tools, internal/tui, and internal/cli (sandbox-escape and doctor tests), but I re-ran each of them in a worktree at the true merge base and the failure sets are byte-for-byte identical on both sides. Root cause is environmental — the sandbox backend reports {"name":"unavailable","available":false,"platform":"windows","fallback":true} while running on darwin, so workspace-escape writes aren't blocked and doctor exits 3. Pre-existing, not yours.

Downstream check: internal/cli, internal/tools, internal/tui, internal/zerocommands all import the changed packages; full suites run on both sides with identical results, so the plugins/skills API changes broke no consumer.

Cross-platform: lock_windows.go is behind //go:build windows and never compiled by the darwin or linux toolchain, so I verified it explicitly — GOOS=windows GOARCH=amd64 go build and go vet are both clean, as is GOOS=linux. Flagging that the Windows path (LockFileEx/UnlockFileEx, MoveFileEx with MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH) has no test coverageinstalltxn's tests only exercise the unix implementation, so it's build-verified only. Worth a manual smoke test on Windows before this ships, given the whole point is a cross-process lock.

One housekeeping note: this branch is based on ce4a996, which is behind current main — a rebase before merge would avoid a surprising diff.

Merge is kevin's call per the program gate.

@kevincodex1
kevincodex1 merged commit baa4be1 into Gitlawb:main Jul 22, 2026
7 checks passed
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.

fix(plugins,skills): reinstall is non-transactional and can lose lockfile entries

5 participants