Skip to content

fix: atomic directory swap for bindings generation prevents chokidar rename loop (#3976)#5367

Merged
leaanthony merged 4 commits into
masterfrom
agent/engineer-windows/5c265c20
May 20, 2026
Merged

fix: atomic directory swap for bindings generation prevents chokidar rename loop (#3976)#5367
leaanthony merged 4 commits into
masterfrom
agent/engineer-windows/5c265c20

Conversation

@leaanthony

@leaanthony leaanthony commented May 8, 2026

Copy link
Copy Markdown
Member

Problem

When wails dev is running and Go bindings are regenerated (via wails3 generate bindings -clean=true), the node process gradually consumes 2–6 MB/s of memory. This was root-caused in #3976 by @WolfyBytes86:

Vite's chokidar watcher detects the rapid directory delete+recreate on frontend/bindings/ as a continuous stream of rename events. This drives the watcher into a tight CPU+allocation loop, growing the node process without bound.

The trigger is the sequence in GenerateBindings:

os.RemoveAll(absPath)          // directory vanishes — chokidar sees 'rename'
// ... incremental os.Create calls ...   // chokidar fires on every new file

Chokidar sees the directory disappear then immediately reappear with files being created one by one, and re-fires on each event, creating a loop.

Fix

Generate bindings into a dot-prefixed sibling temp directory (e.g. frontend/.bindings-tmp-1234), then atomically swap it into place with a single os.Rename:

// chokidar's default ignored pattern skips dot-prefixed paths,
// so no events fire during file creation in the temp dir
tmpDir, _ := os.MkdirTemp(filepath.Dir(absPath), ".bindings-tmp-")

// ... generate all files into tmpDir ...

os.RemoveAll(absPath)           // one 'unlink dir' event
os.Rename(tmpDir, absPath)      // one 'rename' event — chokidar handles cleanly

Chokidar now sees a single coherent rename rather than cascading events, and the watcher loop does not form.

Test plan

  • go build github.com/wailsapp/wails/v3/internal/commands — compiles clean
  • go test github.com/wailsapp/wails/v3/internal/commands — all tests pass
  • Manual: create a new v3 Svelte project, run wails dev, observe node RAM stays flat after bindings regeneration (requires Windows + Vite dev server — see windows-vm-fix for a full repro environment)

Notes

  • The fix only applies when Clean=true && DryRun=false (the hot path during wails dev). Dry-run and non-clean modes are unchanged.
  • os.MkdirAll is called on the parent before os.MkdirTemp to handle first-run cases where the output directory's parent doesn't yet exist.
  • The deferred cleanup ensures the temp dir is removed if generation fails before the rename.
  • On Windows, os.Rename within the same volume is atomic; placing the temp dir in the same parent as absPath guarantees same-volume placement.

CC @leaanthony

Summary by CodeRabbit

  • Bug Fixes
    • Generation now writes to a hidden temporary sibling directory and atomically swaps it into place, making output updates safer and avoiding partial-delete/rename loops.
    • The parent output directory is created when needed, temp dirs are cleaned up on error, and the reported output path remains the original absolute location.

Review Change Stack

…kidar loop

When wails3 generates bindings with -clean=true, the old pattern of
os.RemoveAll followed by incremental file creation caused chokidar
(Vite's file watcher) to enter a rename-event loop. This loop drives
the node process to consume 2-6 MB/s of memory continuously.

Fix: write bindings into a dot-prefixed sibling temp directory
(invisible to chokidar by default), then atomically replace the
output directory with a single os.Rename. Chokidar sees one coherent
rename event instead of a rapid delete+recreate cascade.

Fixes #3976

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

When clean mode is enabled and not a dry run, GenerateBindings writes output into a dot-prefixed temporary sibling directory, then swaps that directory into the final path via os.RemoveAll + os.Rename, deferring temp cleanup on error and keeping the reported output path as the original absolute path.

Changes

Atomic Bindings Output Swap

Layer / File(s) Summary
Temp Directory Setup
v3/internal/commands/bindings.go
Create parent dir if needed and a dot-prefixed temp sibling directory for clean+non-dry-run; register deferred cleanup unless swapped. Dry-run clean falls back to os.RemoveAll.
Generation Configuration
v3/internal/commands/bindings.go
FileCreator is initialized to target generationDir (the temp sibling when used) so generation writes to the temp location.
Atomic Swap Completion
v3/internal/commands/bindings.go
After generation, remove the existing absPath and rename the temp generationDir into absPath (os.RemoveAll + os.Rename), mark swap success and disable deferred temp cleanup.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • wailsapp/wails#4263: Modifies GenerateBindings behavior; related to file-write/atomic-swap changes.

Suggested labels

cli

Poem

🐰 I craft a hidden hollow neat,
then hop and make the old path meet,
a swift small swap—no messy heat,
temp tucked, the final sits just right,
bindings snug beneath moonlight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed The title clearly and concisely describes the main change: using atomic directory swap to prevent a chokidar rename loop in bindings generation, which directly matches the core fix in the changeset.
Description check ✅ Passed The PR description provides comprehensive context: problem statement (memory leak during wails dev), root cause analysis, detailed fix explanation with code examples, test plan with checkboxes, and implementation notes covering edge cases.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/engineer-windows/5c265c20

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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 and usage tips.

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 changes the v3 bindings generator to avoid Vite/chokidar watcher rename loops (and resulting Node memory growth) by generating bindings into a hidden temp directory and then swapping it into place via rename at the end.

Changes:

  • Generate bindings into a dot-prefixed sibling temp directory when Clean=true and DryRun=false.
  • Replace the existing bindings output directory by removing it and renaming the temp directory into place.

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

Comment thread v3/internal/commands/bindings.go Outdated
Comment on lines +122 to +129
// RemoveAll on a non-existent path is a no-op, so this is safe for first runs.
if generationDir != absPath {
if err := os.RemoveAll(absPath); err != nil {
return fmt.Errorf("failed to replace output directory: %w", err)
}
if err := os.Rename(generationDir, absPath); err != nil {
return fmt.Errorf("failed to install new bindings: %w", err)
}

@fbbdev fbbdev May 8, 2026

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.

I don't think this is necessary: the rollback might fail for the same reasons.

Bindings are generated anyways so it's ok to leave the user empty-handed if it's a transient condition.

I would rather improve the error message: "failed to install new bindings" is not very clear.

Moreover, I suggest removing the deferred deletion above so that the temp dir survives in case of errors (and again, make this very clear to the user).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in the latest commit. The backup strategy has been removed entirely — as @fbbdev noted, a restore rename fails for the same reason the forward rename just failed, making things strictly worse. The swap block is now a simple followed by , which matches the existing contract. Error messages now describe the resulting state (old bindings still in place, or gone) so the user knows whether to retry or re-run.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
v3/internal/commands/bindings.go (1)

39-66: 💤 Low value

Clean+!DryRun temp-dir setup looks correct.

Sibling placement under filepath.Dir(absPath) keeps the rename on the same volume, the dot-prefix matches chokidar's default ignore, and the deferred cleanup is correctly gated so a successful swap (which sets generationDir = absPath) skips removal of the now-renamed directory.

One small readability nit: the defer overloads generationDir as both a path and a "swap completed" sentinel. A dedicated boolean would make the intent obvious without changing behavior.

♻️ Optional clarity tweak
 	generationDir := absPath
+	swapped := false
 	if options.Clean && !options.DryRun {
 		if err := os.MkdirAll(filepath.Dir(absPath), 0o777); err != nil {
 			return fmt.Errorf("failed to create bindings parent directory: %w", err)
 		}
 		tmpDir, err := os.MkdirTemp(filepath.Dir(absPath), ".bindings-tmp-")
 		if err != nil {
 			return fmt.Errorf("failed to create temp directory for bindings: %w", err)
 		}
 		generationDir = tmpDir
 		defer func() {
-			// Clean up temp dir on error (rename sets generationDir = absPath).
-			if generationDir != absPath {
+			if !swapped {
 				_ = os.RemoveAll(tmpDir)
 			}
 		}()
 	} else if options.Clean {

…and set swapped = true after the successful os.Rename below.

🤖 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 `@v3/internal/commands/bindings.go` around lines 39 - 66, The deferred cleanup
currently uses generationDir as a sentinel which overloads its meaning;
introduce a dedicated boolean (e.g., swapped or renameSucceeded) alongside
generationDir/tmpDir and have the defer check that boolean rather than comparing
paths. Leave the temp-dir creation logic in place (tmpDir, generationDir =
tmpDir) and after the successful os.Rename(tmpDir, absPath) set swapped = true
(or renameSucceeded = true); update the defer to only call os.RemoveAll(tmpDir)
when swapped is false. This preserves behavior but makes intent explicit for
generationDir, tmpDir, absPath and the deferred cleanup.
🤖 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.

Inline comments:
In `@v3/internal/commands/bindings.go`:
- Around line 121-132: The current RemoveAll+Rename sequence can delete the old
output then fail to rename, and because the defer cleanup uses generationDir it
will remove the freshly-generated temp dir; change the logic in the block
referencing generationDir and absPath so that on os.Rename failure you do NOT
update generationDir to absPath (leave the temp dir value intact) and return the
fmt.Errorf while preserving the temp dir for inspection; additionally, on
success set generationDir = absPath as now, and update the comment above the
block (around the lines that mention "Atomically replace the output directory")
to indicate this is a best-effort/near-atomic swap (RemoveAll then Rename)
rather than strictly atomic.

---

Nitpick comments:
In `@v3/internal/commands/bindings.go`:
- Around line 39-66: The deferred cleanup currently uses generationDir as a
sentinel which overloads its meaning; introduce a dedicated boolean (e.g.,
swapped or renameSucceeded) alongside generationDir/tmpDir and have the defer
check that boolean rather than comparing paths. Leave the temp-dir creation
logic in place (tmpDir, generationDir = tmpDir) and after the successful
os.Rename(tmpDir, absPath) set swapped = true (or renameSucceeded = true);
update the defer to only call os.RemoveAll(tmpDir) when swapped is false. This
preserves behavior but makes intent explicit for generationDir, tmpDir, absPath
and the deferred cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0c292cbe-edff-4cdd-b424-3a4222158dd1

📥 Commits

Reviewing files that changed from the base of the PR and between d1be17b and a12d925.

📒 Files selected for processing (1)
  • v3/internal/commands/bindings.go

Comment thread v3/internal/commands/bindings.go Outdated

@fbbdev fbbdev 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.

LGTM

If os.Rename failed after os.RemoveAll, neither old nor new bindings
existed. Now: move old aside first, rename new into place, then clean
up old. On failure the original bindings are restored.

Co-authored-by: multica-agent <github@multica.ai>
Comment thread v3/internal/commands/bindings.go Outdated
}
if err := os.Rename(generationDir, absPath); err != nil {
if oldDir != "" {
_ = os.Rename(oldDir, absPath) // best-effort restore

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.

This change is wrong IMO.

I had already replied to @coderabbitai 's comment but apparently it got lost...

If renaming the new directory fails, there's a high probability renaming the old one fails as well. Then the system might end up in an even more inconsistent state than before.

The contract of the -clean option has never been to replace bindings atomically, hence I think it's safe and more robust to simply delete the old ones as in the initial implementation.

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.

@fbbdev, your analysis is correct and well-reasoned. The two-phase rename approach can indeed produce a worse failure state: if os.Rename(generationDir, absPath) fails (e.g. due to a cross-device error, permission issue, or filesystem constraint), the best-effort restore os.Rename(oldDir, absPath) will very likely fail for the exact same reason — leaving no bindings at absPath, the old bindings stranded at absPath.old, and the new bindings abandoned in generationDir.

The simpler sequence — os.RemoveAll(absPath) then os.Rename(tmpDir, absPath) — is more predictable:

  • On success: new bindings in place. ✅
  • On rename failure: no bindings at absPath (same failure mode as the original pre-PR behavior). ✅

The core chokidar fix (write into a dot-prefixed temp dir, then swap with a single rename) already achieves the stated goal without needing the .old safeguard. Since the -clean option's contract has never guaranteed atomic replacement, the simpler approach is clearly preferable here.

The absPath + ".old" mechanism should be removed in favour of the straightforward:

if generationDir != absPath {
    if err := os.RemoveAll(absPath); err != nil {
        return fmt.Errorf("failed to remove existing bindings: %w", err)
    }
    if err := os.Rename(generationDir, absPath); err != nil {
        return fmt.Errorf("failed to install new bindings: %w", err)
    }
    // Signal the defer that cleanup is no longer needed.
    generationDir = absPath
}

This matches the spirit of the original implementation while still getting the chokidar benefit from writing into the temp directory first.


🧠 Learnings used
Learnt from: symball
Repo: wailsapp/wails PR: 4853
File: v2/internal/system/system.go:128-152
Timestamp: 2026-01-04T08:01:00.038Z
Learning: In v2/internal/system/system.go, shared functions like checkLibrary are defined without build tags but are only invoked from platform-specific files (system_linux.go, system_windows.go, system_darwin.go) that have build constraints. Reviewers should ensure there are no runtime OS checks in system.go and that platform-specific behavior is controlled via build tags. If runtime switches exist, remove them in favor of compile-time platform constraints to reduce overhead and improve correctness.

Learnt from: leaanthony
Repo: wailsapp/wails PR: 4906
File: v3/internal/setupwizard/wizard_windows.go:93-94
Timestamp: 2026-01-26T01:56:49.314Z
Learning: Ensure the Go toolchain minimum version for Wails v3 is 1.25+. Update go.mod to reflect 1.25+ and validate that setup wizard checks enforce Go 1.25+ at release time. Align any CI/go-version checks, documentation, and release notes with this requirement; this guideline applies to all Go files under the v3 module, not just the wizard.

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.

I would rather spend some time to improve error messages so that the user understands what to expect instead of seeing something like "low level operation failed: ..."

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.

@fbbdev, that's a great point. Here's what the simplified swap block could look like with user-friendly error messages that describe the system state and what the user can expect:

if generationDir != absPath {
    if err := os.RemoveAll(absPath); err != nil {
        // Old bindings are still in place; temp dir will be cleaned up by defer.
        return fmt.Errorf(
            "failed to remove old bindings directory %q: %w\n"+
                "Old bindings have been left in place. Generated output has been discarded.",
            absPath, err,
        )
    }
    if err := os.Rename(generationDir, absPath); err != nil {
        // Old bindings are gone; temp dir will be cleaned up by defer.
        return fmt.Errorf(
            "failed to move generated bindings into place at %q: %w\n"+
                "Old bindings have been removed. Please re-run the command to regenerate them.",
            absPath, err,
        )
    }
    // Signal the defer that cleanup is no longer needed.
    generationDir = absPath
}

The key distinctions:

  • RemoveAll failure → old bindings are still intact, nothing is lost, and the temp dir is discarded silently by the deferred cleanup.
  • Rename failure → old bindings are gone (unavoidable with the simple approach), so the message tells the user they need to re-run. Since the rename failed, the temp dir is also still cleaned up by the defer, so there's no dangling state to explain.

This avoids leaking filesystem paths or low-level syscall jargon in the normal case while still giving actionable context on failure. Does this match what you had in mind?


🧠 Learnings used
Learnt from: symball
Repo: wailsapp/wails PR: 4853
File: v2/internal/system/system.go:128-152
Timestamp: 2026-01-04T08:01:00.038Z
Learning: In v2/internal/system/system.go, shared functions like checkLibrary are defined without build tags but are only invoked from platform-specific files (system_linux.go, system_windows.go, system_darwin.go) that have build constraints. Reviewers should ensure there are no runtime OS checks in system.go and that platform-specific behavior is controlled via build tags. If runtime switches exist, remove them in favor of compile-time platform constraints to reduce overhead and improve correctness.

Learnt from: leaanthony
Repo: wailsapp/wails PR: 4906
File: v3/internal/setupwizard/wizard_windows.go:93-94
Timestamp: 2026-01-26T01:56:49.314Z
Learning: Ensure the Go toolchain minimum version for Wails v3 is 1.25+. Update go.mod to reflect 1.25+ and validate that setup wizard checks enforce Go 1.25+ at release time. Align any CI/go-version checks, documentation, and release notes with this requirement; this guideline applies to all Go files under the v3 module, not just the wizard.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

All addressed in 31d8139. The .old rename-and-restore block is gone — replaced with the simple RemoveAll+Rename you and @leaanthony both preferred. The defer now uses a dedicated swapped bool rather than comparing generationDir != absPath. Error messages describe the actual system state: if RemoveAll fails the user is told the old bindings are untouched and the generated output was discarded; if Rename fails they're told the old bindings are gone and to re-run.

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.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@leaanthony leaanthony added this to the v3.0.0-beta.1 milestone May 20, 2026
@leaanthony

Copy link
Copy Markdown
Member Author

Self-review note: the .old backup strategy in the atomic swap block is more fragile than the simpler RemoveAll → Rename sequence.

If os.Rename(generationDir, absPath) fails (AV hold, file lock, cross-device — all real on Windows), the restore rename will fail for exactly the same reason and the user is left with no bindings at all and an orphaned .old directory. This is strictly worse than the status quo. The temp-dir approach already achieves the chokidar goal; the backup doesn't add reliability.

Suggest simplifying to:

if generationDir != absPath {
    if err := os.RemoveAll(absPath); err != nil {
        return fmt.Errorf("failed to remove old bindings directory %q: %w", absPath, err)
    }
    if err := os.Rename(generationDir, absPath); err != nil {
        return fmt.Errorf("failed to install new bindings at %q: %w", absPath, err)
    }
    generationDir = absPath
}

(This was also flagged by @fbbdev in an earlier review thread.)

…rror messages

The two-phase rename (Rename→.old, Rename, restore on failure) was strictly
worse than RemoveAll+Rename: a restore rename fails for exactly the same
reason the forward rename just failed, leaving an orphaned .old directory
and no bindings at all. The -clean contract has never guaranteed atomic
replacement, so RemoveAll+Rename matches the existing contract.

Also replaces the `generationDir != absPath` sentinel with a dedicated
`swapped` bool for clarity (per coderabbitai nitpick), and adds error
messages that describe the resulting system state so users know whether
their old bindings are still in place or gone.
@leaanthony

Copy link
Copy Markdown
Member Author

Done — pushed as 31d8139. The .old backup block is removed and replaced with your suggested RemoveAll → Rename sequence. Also applied the swapped bool from coderabbitai's nitpick for clarity (the generationDir != absPath sentinel was confusing), and both error paths now tell the user the resulting system state. Tests still pass.

@leaanthony leaanthony merged commit 2f7c250 into master May 20, 2026
12 of 15 checks passed
@leaanthony leaanthony deleted the agent/engineer-windows/5c265c20 branch May 20, 2026 22:05
leaanthony added a commit that referenced this pull request Jun 10, 2026
…hen Vite dev server is running (#5561)

* fix(v3/windows): sync bindings into place instead of renaming over the output directory (#5515)

The atomic directory swap introduced in #5367 (RemoveAll+Rename) fails on
Windows with "Access is denied" when a file watcher such as Vite's dev
server is running: the watcher holds an open handle on the watched
bindings directory, so deleting it leaves the name in delete-pending
state and the subsequent rename over it is rejected.

Keep generating into the dot-prefixed temp directory (still invisible to
chokidar during generation), but install the result by syncing files into
the output directory instead of replacing the directory itself:

- the output directory is never deleted or renamed, so Windows watcher
  handles are never violated (#5515)
- the directory delete+recreate cascade that caused the chokidar
  rename-event loop still never happens (#3976)
- unchanged files are left untouched, so the dev server only reloads
  files that actually changed

File operations retry briefly with backoff on Windows to ride out
transient locks from watchers and antivirus scanners.

Fixes #5515

* fix(v3): don't sleep after the final retry attempt in withRetry

* fix(v3): address review feedback on bindings sync

- Handle the output path existing as a regular file: clear it and move
  the generated tree into place (with test)
- Fall back to remove+rename in replaceFile for destinations that
  os.Rename cannot replace on Windows (read-only attribute)
- Retry the final temp directory removal like other file operations
- Use 0o755 for created directories, consistent with the rest of the
  package
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants