fix: atomic directory swap for bindings generation prevents chokidar rename loop (#3976)#5367
Conversation
…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>
|
Caution Review failedPull request was closed or merged during review WalkthroughWhen 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. ChangesAtomic Bindings Output Swap
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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=trueandDryRun=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.
| // 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) | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
v3/internal/commands/bindings.go (1)
39-66: 💤 Low valueClean+!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 setsgenerationDir = absPath) skips removal of the now-renamed directory.One small readability nit: the defer overloads
generationDiras 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 = trueafter the successfulos.Renamebelow.🤖 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
📒 Files selected for processing (1)
v3/internal/commands/bindings.go
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>
| } | ||
| if err := os.Rename(generationDir, absPath); err != nil { | ||
| if oldDir != "" { | ||
| _ = os.Rename(oldDir, absPath) // best-effort restore |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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: ..."
There was a problem hiding this comment.
@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:
RemoveAllfailure → old bindings are still intact, nothing is lost, and the temp dir is discarded silently by the deferred cleanup.Renamefailure → 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
|
Self-review note: the If 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.
|
Done — pushed as 31d8139. The |
…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
Problem
When
wails devis running and Go bindings are regenerated (viawails3 generate bindings -clean=true), the node process gradually consumes 2–6 MB/s of memory. This was root-caused in #3976 by @WolfyBytes86:The trigger is the sequence in
GenerateBindings: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 singleos.Rename: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 cleango test github.com/wailsapp/wails/v3/internal/commands— all tests passwails dev, observe node RAM stays flat after bindings regeneration (requires Windows + Vite dev server — seewindows-vm-fixfor a full repro environment)Notes
Clean=true && DryRun=false(the hot path duringwails dev). Dry-run and non-clean modes are unchanged.os.MkdirAllis called on the parent beforeos.MkdirTempto handle first-run cases where the output directory's parent doesn't yet exist.os.Renamewithin the same volume is atomic; placing the temp dir in the same parent asabsPathguarantees same-volume placement.CC @leaanthony
Summary by CodeRabbit