From b585775e74c15e6f5ecc96c163778fd345fee118 Mon Sep 17 00:00:00 2001 From: rmurphy Date: Fri, 3 Jul 2026 13:55:43 -0400 Subject: [PATCH 1/4] [windows] Recover from WebView2 process failures instead of leaving a blank window Register CoreWebView2's ProcessFailed event (previously unhandled). Renderer exited/unresponsive -> re-navigate to the last host-set URL. Browser process exited -> rebuild the controller on a fresh edge.Chromium instance and restore the last URL; the old instance cannot be re-embedded because Embed's init-wait loop keys on a per-instance flag a used instance has already set. Without this, any browser-process death (crash, GPU-kill exhaustion, external kill of msedgewebview2) leaves every controller COM call failing with ERROR_INVALID_STATE (0x8007139F) and the window permanently blank until the host app is restarted. Co-Authored-By: Claude Fable 5 --- v3/pkg/application/webview_window_windows.go | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go index b397a95697d..b19a11cb57a 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -62,6 +62,12 @@ type windowsWebviewWindow struct { // the scale during a mixed-DPI monitor cross is exactly the transient // the re-enable exists to avoid. Main-thread only. monitorScaleDetectionOn bool + // lastNavigatedURL is the most recent URL passed to setURL. It is what + // processFailed restores after a WebView2 process failure: the live + // webview cannot be queried at that point (its COM objects are dead), + // so the last host-requested navigation is the only reliable record. + // Main-thread only, like the rest of the webview state. + lastNavigatedURL string // Window visibility management - robust fallback for issue #2861 showRequested bool // Track if show() was called before navigation completed @@ -326,6 +332,7 @@ func (w *windowsWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) { func (w *windowsWebviewWindow) setURL(url string) { // Navigate to the given URL in the webview w.webviewNavigationCompleted = false + w.lastNavigatedURL = url w.chromium.Navigate(url) } @@ -2429,6 +2436,7 @@ func (w *windowsWebviewWindow) setupChromium() { chromium.NavigationStartingCallback = w.navigationStarting chromium.NavigationCompletedCallback = w.navigationCompleted chromium.AcceleratorKeyCallback = w.processKeyBinding + chromium.ProcessFailedCallback = w.processFailed chromium.Embed(w.hwnd) @@ -3030,3 +3038,51 @@ func (w *windowsWebviewWindow) applyDisplayAffinity(affinity uint32) bool { } return true } + +// processFailed handles WebView2 process-failure notifications. Without a +// handler, a dead browser process leaves the controller in a permanent +// invalid state: every subsequent COM call fails with ERROR_INVALID_STATE +// (0x8007139F), the window renders blank, and only an app restart recovers. +// Renderer-level failures recover with a re-navigation; a browser-process +// exit requires a full controller rebuild, deferred out of the callback per +// WebView2 guidance. GPU and utility process failures are deliberately left +// alone: Chromium restarts those processes itself, and if it gives up it +// exits the browser process, which arrives here as BROWSER_PROCESS_EXITED. +func (w *windowsWebviewWindow) processFailed(_ *edge.ICoreWebView2, args *edge.ICoreWebView2ProcessFailedEventArgs) { + kind, err := args.GetProcessFailedKind() + if err != nil { + globalApplication.error("webview2: process failed and failure kind unavailable: %v", err) + return + } + globalApplication.error("webview2: process failed: kind=%d", kind) + switch kind { + case edge.COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED: + InvokeAsync(w.rebuildWebView) + case edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED, + edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE: + if url := w.lastNavigatedURL; url != "" { + InvokeAsync(func() { + w.chromium.Navigate(url) + }) + } + } +} + +// rebuildWebView replaces a dead WebView2 controller with a fresh one and +// restores the last navigated URL. The old Chromium instance is abandoned +// rather than re-embedded: after a browser-process exit every COM reference +// it holds is dangling, and edge.Chromium.Embed's init-wait loop keys on a +// per-instance flag that a used instance has already set, so re-embedding +// the same instance would return before the new controller exists. The +// construction mirrors run(). +func (w *windowsWebviewWindow) rebuildWebView() { + globalApplication.info("webview2: rebuilding controller after browser process exit") + w.chromium = edge.NewChromium() + if globalApplication.options.ErrorHandler != nil { + w.chromium.SetErrorCallback(globalApplication.options.ErrorHandler) + } + w.setupChromium() + if url := w.lastNavigatedURL; url != "" { + w.setURL(url) + } +} From 74ca1860503e556036c1c62052c03f2d72c139e8 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Wed, 19 Aug 2026 12:49:53 +0000 Subject: [PATCH 2/4] fix(v3/windows): bound WebView2 process-failure recovery and stop it drifting from startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts the process-failure recovery from #5733 to current master and closes the gaps that showed up rebasing it off v3.0.0-alpha2.112. Share one Chromium construction path. The original rebuild carried its own copy of run()'s construction, which was accurate at alpha2.112 when that was just NewChromium plus SetErrorCallback. run() has since grown three more pre-Embed settings, so a rebuilt window silently lost non-client region support, dropped from composition hosting to windowed while options.WebView2CompositionHosting stayed true, and lost cursor handling. newChromium is now the only place that construction lives, so the two paths cannot drift again. Restore the last navigation from setupChromium rather than after it. setupChromium already navigates on its way out, so navigating again from the rebuild loaded the start URL and immediately threw it away for the real one. Folding the restore into that existing branch also fixes an options.HTML window recovering into a window that is never shown: the NavigateToString branch left webviewNavigationCompleted set from the dead controller's last navigation, and navigationCompleted uses that flag to skip the Hide/Show visibility hack — so recovery completed onto exactly the blank window it exists to prevent. Bound the attempts. A rebuilt controller that dies again re-enters the same handler, so an unrecoverable runtime turned recovery into a hot loop spawning WebView2 processes. Recovery now gets maxWebviewRecoveryAttempts consecutive tries, reset by any completed navigation, so a working recovery costs nothing and a broken one degrades to the pre-existing blank window instead of looping. RENDER_PROCESS_UNRESPONSIVE re-fires for as long as the renderer stays hung, so the bound covers re-navigation too. Guard the rebuild against teardown, since shutting the app down kills the WebView2 processes and a failure racing destroy would otherwise embed into a window that is going away. The failure-kind policy and the attempt budget are split into webviewRecoveryActionFor and beginWebviewRecovery, which touch no COM and are covered by unit tests. The rest of the path needs a live WebView2 runtime; the manual matrix is in the pull request. Refs #5733, #5705 Co-authored-by: rmurphy Co-authored-by: taliesin-ai Signed-off-by: taliesin-ai --- v3/pkg/application/webview_window_windows.go | 214 ++++++++++++++---- ...bview_window_windows_processfailed_test.go | 137 +++++++++++ 2 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 v3/pkg/application/webview_window_windows_processfailed_test.go diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go index b19a11cb57a..845d22b1043 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -63,11 +63,18 @@ type windowsWebviewWindow struct { // the re-enable exists to avoid. Main-thread only. monitorScaleDetectionOn bool // lastNavigatedURL is the most recent URL passed to setURL. It is what - // processFailed restores after a WebView2 process failure: the live + // setupChromium restores after a WebView2 process failure: the live // webview cannot be queried at that point (its COM objects are dead), // so the last host-requested navigation is the only reliable record. // Main-thread only, like the rest of the webview state. lastNavigatedURL string + // webviewRecoveryAttempts counts consecutive process-failure recoveries + // that have not yet produced a completed navigation. navigationCompleted + // resets it, so a recovery that works costs nothing; a controller that + // dies again on every rebuild gives up after maxWebviewRecoveryAttempts + // rather than respawning WebView2 processes forever. + // Main-thread only, like the rest of the webview state. + webviewRecoveryAttempts int // Window visibility management - robust fallback for issue #2861 showRequested bool // Track if show() was called before navigation completed @@ -389,6 +396,25 @@ func (w *windowsWebviewWindow) extendFrameIntoClientArea(extend bool) error { }) } +// newChromium creates a Chromium instance configured from this window's +// options. Everything set here has to be set *before* Embed, so it cannot live +// in setupChromium. Both start-up and the post-process-failure rebuild go +// through this one function: when the rebuild carried its own copy of this +// construction the two drifted, and a rebuilt window silently lost non-client +// region support, composition hosting and cursor handling. +func (w *windowsWebviewWindow) newChromium() *edge.Chromium { + options := w.parent.options + + chromium := edge.NewChromium() + chromium.NonClientRegionSupportEnabled = options.Windows.NonClientRegionSupport + chromium.CompositionControllerEnabled = options.Windows.WebView2CompositionHosting + chromium.SetCursorChangedCallback(w.applyCompositionCursor) + if globalApplication.options.ErrorHandler != nil { + chromium.SetErrorCallback(globalApplication.options.ErrorHandler) + } + return chromium +} + func (w *windowsWebviewWindow) run() { options := w.parent.options @@ -397,13 +423,7 @@ func (w *windowsWebviewWindow) run() { // Non-hidden windows should be shown by default w.showRequested = !options.Hidden - w.chromium = edge.NewChromium() - w.chromium.NonClientRegionSupportEnabled = options.Windows.NonClientRegionSupport - w.chromium.CompositionControllerEnabled = options.Windows.WebView2CompositionHosting - w.chromium.SetCursorChangedCallback(w.applyCompositionCursor) - if globalApplication.options.ErrorHandler != nil { - w.chromium.SetErrorCallback(globalApplication.options.ErrorHandler) - } + w.chromium = w.newChromium() exStyle := w32.WS_EX_CONTROLPARENT if options.BackgroundType != BackgroundTypeSolid { @@ -2609,7 +2629,21 @@ func (w *windowsWebviewWindow) setupChromium() { } chromium.AddWebResourceRequestedFilter("*", edge.COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL) - if w.parent.options.HTML != "" { + // Land the fresh controller on the right page. lastNavigatedURL is empty on + // first start-up and set once the host has navigated anywhere, so on a + // rebuild after a process failure this restores where the window actually + // was — and it restores it directly, instead of loading the start URL and + // immediately throwing it away for the real one. + // + // Every branch must leave webviewNavigationCompleted false. That flag gates + // the Hide/Show visibility hack in navigationCompleted, and on a rebuild it + // is still true from the dead controller's last navigation; leaving it set + // would recover the webview into a window that is never made visible, which + // is the blank window this whole path exists to fix. + switch { + case w.lastNavigatedURL != "": + w.setURL(w.lastNavigatedURL) + case w.parent.options.HTML != "": var script string if w.parent.options.JS != "" { script = w.parent.options.JS @@ -2623,14 +2657,14 @@ func (w *windowsWebviewWindow) setupChromium() { if script != "" { chromium.Init(script) } + w.webviewNavigationCompleted = false chromium.NavigateToString(w.parent.options.HTML) - } else { + default: startURL, err := assetserver.GetStartURL(w.parent.options.URL) if err != nil { globalApplication.handleFatalError(err) } - w.webviewNavigationCompleted = false - chromium.Navigate(startURL) + w.setURL(startURL) } } @@ -2662,6 +2696,9 @@ func (w *windowsWebviewWindow) navigationCompleted( sender *edge.ICoreWebView2, args *edge.ICoreWebView2NavigationCompletedEventArgs, ) { + // The webview loaded something, so any process-failure recovery that led + // here worked. + w.resetWebviewRecoveryBudget() // Inject runtime core and window-specific flags together so side-effect // runtime modules see a consistent _wails configuration at startup. @@ -3039,50 +3076,145 @@ func (w *windowsWebviewWindow) applyDisplayAffinity(affinity uint32) bool { return true } +// webviewRecoveryAction is what a WebView2 process failure calls for. +type webviewRecoveryAction int + +const ( + // webviewRecoveryNone leaves the failure to the browser process. + webviewRecoveryNone webviewRecoveryAction = iota + // webviewRecoveryRenavigate re-navigates the existing controller. + webviewRecoveryRenavigate + // webviewRecoveryRebuild replaces the controller entirely. + webviewRecoveryRebuild +) + +// maxWebviewRecoveryAttempts bounds consecutive recovery attempts that never +// reach a completed navigation. Without a bound, a controller that dies again +// as soon as it is rebuilt turns recovery into a hot loop spawning WebView2 +// processes; with one, a permanently broken runtime costs a few attempts and +// then leaves the window as it would have been without any recovery at all. +const maxWebviewRecoveryAttempts = 3 + +// webviewRecoveryActionFor maps a WebView2 process-failure kind to the recovery +// it needs. +// +// A browser-process exit invalidates the whole controller — every subsequent +// COM call returns ERROR_INVALID_STATE (0x8007139F) — so nothing short of a new +// controller recovers it. Renderer failures leave the controller usable and a +// re-navigation is enough. +// +// Everything else is deliberately left alone. GPU, utility and sandbox-helper +// processes are restarted by Chromium itself, and if it gives up on them it +// exits the browser process, which arrives back here as +// BROWSER_PROCESS_EXITED. An out-of-process iframe (FRAME_RENDER_PROCESS_EXITED) +// is likewise the browser's to re-create, and reloading the whole window over +// one dead subframe would be far more destructive than the failure. +func webviewRecoveryActionFor(kind edge.COREWEBVIEW2_PROCESS_FAILED_KIND) webviewRecoveryAction { + switch kind { + case edge.COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED: + return webviewRecoveryRebuild + case edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED, + edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE: + return webviewRecoveryRenavigate + default: + return webviewRecoveryNone + } +} + +// beginWebviewRecovery reports whether another recovery attempt is allowed, +// counting it when it is. The counter is cleared by navigationCompleted, so it +// only ever accumulates across failures that never managed to load anything — +// which is exactly the runaway case maxWebviewRecoveryAttempts exists to stop. +// RENDER_PROCESS_UNRESPONSIVE in particular re-fires for as long as the +// renderer stays hung, so the bound applies to re-navigation as well as to +// rebuilds. +func (w *windowsWebviewWindow) beginWebviewRecovery() bool { + if w.webviewRecoveryAttempts >= maxWebviewRecoveryAttempts { + return false + } + w.webviewRecoveryAttempts++ + return true +} + +// resetWebviewRecoveryBudget restores the full attempt budget. Called from +// navigationCompleted: the webview loaded something, so whatever recovery led +// there worked, and a later unrelated failure should get a full set of attempts +// of its own rather than inheriting a spent counter. +func (w *windowsWebviewWindow) resetWebviewRecoveryBudget() { + w.webviewRecoveryAttempts = 0 +} + // processFailed handles WebView2 process-failure notifications. Without a // handler, a dead browser process leaves the controller in a permanent // invalid state: every subsequent COM call fails with ERROR_INVALID_STATE // (0x8007139F), the window renders blank, and only an app restart recovers. -// Renderer-level failures recover with a re-navigation; a browser-process -// exit requires a full controller rebuild, deferred out of the callback per -// WebView2 guidance. GPU and utility process failures are deliberately left -// alone: Chromium restarts those processes itself, and if it gives up it -// exits the browser process, which arrives here as BROWSER_PROCESS_EXITED. +// Work is deferred out of the callback via InvokeAsync, per WebView2 guidance +// about not calling controller methods reentrantly from an event handler. func (w *windowsWebviewWindow) processFailed(_ *edge.ICoreWebView2, args *edge.ICoreWebView2ProcessFailedEventArgs) { kind, err := args.GetProcessFailedKind() if err != nil { globalApplication.error("webview2: process failed and failure kind unavailable: %v", err) return } - globalApplication.error("webview2: process failed: kind=%d", kind) - switch kind { - case edge.COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED: - InvokeAsync(w.rebuildWebView) - case edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED, - edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE: - if url := w.lastNavigatedURL; url != "" { - InvokeAsync(func() { - w.chromium.Navigate(url) - }) + + action := webviewRecoveryActionFor(kind) + if action == webviewRecoveryNone { + globalApplication.info("webview2: process failed (kind=%d); leaving recovery to the browser process", kind) + return + } + + // Work out what recovery would actually do before spending an attempt from + // the budget, so a no-op does not eat into it. + var restore func() + switch action { + case webviewRecoveryRebuild: + restore = w.rebuildWebView + case webviewRecoveryRenavigate: + url := w.lastNavigatedURL + if url == "" { + // A window still showing its options.HTML content has no URL to go + // back to — that content came from NavigateToString, and re-rendering + // it means the full rebuild path, which a renderer failure does not + // warrant on its own. + globalApplication.error( + "webview2: process failed (kind=%d) but there is no host navigation to restore; leaving the page as-is", kind) + return } + restore = func() { + w.chromium.Navigate(url) + } + } + + if !w.beginWebviewRecovery() { + globalApplication.error( + "webview2: process failed (kind=%d) and %d consecutive recovery attempts have not restored the webview; giving up, the window will stay blank until the application restarts", + kind, maxWebviewRecoveryAttempts, + ) + return } + globalApplication.error("webview2: process failed (kind=%d); recovery attempt %d of %d", + kind, w.webviewRecoveryAttempts, maxWebviewRecoveryAttempts) + + InvokeAsync(restore) } -// rebuildWebView replaces a dead WebView2 controller with a fresh one and -// restores the last navigated URL. The old Chromium instance is abandoned -// rather than re-embedded: after a browser-process exit every COM reference -// it holds is dangling, and edge.Chromium.Embed's init-wait loop keys on a -// per-instance flag that a used instance has already set, so re-embedding -// the same instance would return before the new controller exists. The -// construction mirrors run(). +// rebuildWebView replaces a dead WebView2 controller with a fresh one. +// setupChromium re-applies every setting and restores lastNavigatedURL, so +// this only has to supply the controller. +// +// The old Chromium instance is abandoned rather than re-embedded: after a +// browser-process exit every COM reference it holds is dangling, and +// edge.Chromium.Embed's init-wait loop keys on a per-instance flag that a used +// instance has already set, so re-embedding the same instance would return +// before the new controller exists. func (w *windowsWebviewWindow) rebuildWebView() { - globalApplication.info("webview2: rebuilding controller after browser process exit") - w.chromium = edge.NewChromium() - if globalApplication.options.ErrorHandler != nil { - w.chromium.SetErrorCallback(globalApplication.options.ErrorHandler) + // A window destroyed between the failure and this callback has no HWND to + // embed into — shutting the app down kills the WebView2 processes, so a + // process failure racing teardown is expected rather than exceptional. + if w.parent.isDestroyed() || w.hwnd == 0 { + return } + globalApplication.info("webview2: rebuilding controller after browser process exit") + w.chromium = w.newChromium() w.setupChromium() - if url := w.lastNavigatedURL; url != "" { - w.setURL(url) - } } diff --git a/v3/pkg/application/webview_window_windows_processfailed_test.go b/v3/pkg/application/webview_window_windows_processfailed_test.go new file mode 100644 index 00000000000..ad01d9f1705 --- /dev/null +++ b/v3/pkg/application/webview_window_windows_processfailed_test.go @@ -0,0 +1,137 @@ +//go:build windows + +package application + +import ( + "testing" + + "github.com/wailsapp/wails/v3/internal/webview2/pkg/edge" +) + +// The recovery decision and the attempt budget are the two pieces of the +// process-failure path that can be exercised without a live WebView2 runtime: +// neither touches COM. The rest (rebuildWebView, the re-navigation itself) needs +// a real controller and is covered by the manual matrix in issue #5733. + +func TestWebviewRecoveryActionFor(t *testing.T) { + tests := []struct { + name string + kind edge.COREWEBVIEW2_PROCESS_FAILED_KIND + want webviewRecoveryAction + }{ + { + // A dead browser process invalidates the controller permanently, + // so only a new controller recovers it. + name: "browser process exit rebuilds", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED, + want: webviewRecoveryRebuild, + }, + { + name: "render process exit re-navigates", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED, + want: webviewRecoveryRenavigate, + }, + { + name: "unresponsive render process re-navigates", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE, + want: webviewRecoveryRenavigate, + }, + { + // Chromium re-creates a dead out-of-process iframe itself; reloading + // the whole window over one subframe would be worse than the failure. + name: "frame render process exit is left alone", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_FRAME_RENDER_PROCESS_EXITED, + want: webviewRecoveryNone, + }, + { + // Chromium restarts these itself, and when it gives up it exits the + // browser process, which comes back as BROWSER_PROCESS_EXITED. + name: "gpu process exit is left alone", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_GPU_PROCESS_EXITED, + want: webviewRecoveryNone, + }, + { + name: "utility process exit is left alone", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_UTILITY_PROCESS_EXITED, + want: webviewRecoveryNone, + }, + { + name: "sandbox helper process exit is left alone", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND_SANDBOX_HELPER_PROCESS_EXITED, + want: webviewRecoveryNone, + }, + { + // GetProcessFailedKind seeds its out-param with 0xffffffff and a + // newer runtime may report a kind this build has no constant for. + // Anything unrecognised must fall through to "leave it alone" + // rather than trigger a rebuild. + name: "unknown kind is left alone", + kind: edge.COREWEBVIEW2_PROCESS_FAILED_KIND(0xffffffff), + want: webviewRecoveryNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := webviewRecoveryActionFor(tt.kind); got != tt.want { + t.Errorf("webviewRecoveryActionFor(%d) = %d, want %d", tt.kind, got, tt.want) + } + }) + } +} + +func TestBeginWebviewRecoveryStopsAtTheLimit(t *testing.T) { + w := &windowsWebviewWindow{} + + for i := 1; i <= maxWebviewRecoveryAttempts; i++ { + if !w.beginWebviewRecovery() { + t.Fatalf("attempt %d refused, want allowed within the budget of %d", + i, maxWebviewRecoveryAttempts) + } + if w.webviewRecoveryAttempts != i { + t.Fatalf("after attempt %d: webviewRecoveryAttempts = %d, want %d", + i, w.webviewRecoveryAttempts, i) + } + } + + // Past the budget it must keep refusing rather than letting the count (and + // the WebView2 process spawning behind it) run away. + for i := 0; i < 3; i++ { + if w.beginWebviewRecovery() { + t.Fatalf("attempt %d past the budget of %d was allowed", + maxWebviewRecoveryAttempts+i+1, maxWebviewRecoveryAttempts) + } + } + if w.webviewRecoveryAttempts != maxWebviewRecoveryAttempts { + t.Errorf("webviewRecoveryAttempts = %d after refused attempts, want %d", + w.webviewRecoveryAttempts, maxWebviewRecoveryAttempts) + } +} + +// A completed navigation means recovery worked, so the budget must come back — +// otherwise a long-running app that recovered once would have fewer attempts +// available for an unrelated failure hours later, and eventually none. +// +// This covers resetWebviewRecoveryBudget, not its call site: navigationCompleted +// needs a live controller to invoke. +func TestResetWebviewRecoveryBudget(t *testing.T) { + w := &windowsWebviewWindow{} + + for i := 0; i < maxWebviewRecoveryAttempts; i++ { + if !w.beginWebviewRecovery() { + t.Fatalf("attempt %d refused while filling the budget", i+1) + } + } + if w.beginWebviewRecovery() { + t.Fatal("budget not exhausted before the reset; test cannot prove the reset works") + } + + w.resetWebviewRecoveryBudget() + + if w.webviewRecoveryAttempts != 0 { + t.Errorf("webviewRecoveryAttempts = %d after reset, want 0", w.webviewRecoveryAttempts) + } + if !w.beginWebviewRecovery() { + t.Error("recovery still refused after the budget was reset") + } +} From 220811fac5b64511b714dac690d0fd241154c157 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Wed, 19 Aug 2026 12:53:09 +0000 Subject: [PATCH 3/4] test(v3/windows): match the build tag of the file under test webview_window_windows.go is `windows && !server`, so tagging its test plain `windows` broke `go test -tags server` for the package: the test file compiled without any of the declarations it references. Note that dialogs_windows_internal_test.go has the same mismatch and already breaks that build on master; left alone here as unrelated. Co-authored-by: taliesin-ai Signed-off-by: taliesin-ai --- v3/pkg/application/webview_window_windows_processfailed_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3/pkg/application/webview_window_windows_processfailed_test.go b/v3/pkg/application/webview_window_windows_processfailed_test.go index ad01d9f1705..14c495819aa 100644 --- a/v3/pkg/application/webview_window_windows_processfailed_test.go +++ b/v3/pkg/application/webview_window_windows_processfailed_test.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build windows && !server package application From 0d471ac9dffdce1d965417db3846902f23fd3260 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Wed, 19 Aug 2026 13:31:02 +0000 Subject: [PATCH 4/4] fix(v3/windows): only reset the recovery budget on a successful navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead render process leaves WebView2 on an error page, and that error page fires NavigationCompleted like any other load. Resetting the attempt budget there handed a crash-looping renderer a fresh budget every cycle, so the bound never tripped — reintroducing exactly the runaway it was added to stop. Gate the reset on ICoreWebView2NavigationCompletedEventArgs::IsSuccess, whose vtbl slot was already declared but had no accessor; add one following the existing BOOL out-param pattern. An unreadable IsSuccess counts as unsuccessful, since assuming success is the failure mode that loops. Also guard the renderer re-navigation against teardown. rebuildWebView already bails when the window is being destroyed; the deferred Navigate had the same race and no guard. Both found by CodeRabbit on the pull request. Co-authored-by: taliesin-ai Signed-off-by: taliesin-ai --- ...oreWebView2NavigationCompletedEventArgs.go | 16 +++++++++++++++ v3/pkg/application/webview_window_windows.go | 20 ++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/v3/internal/webview2/pkg/edge/ICoreWebView2NavigationCompletedEventArgs.go b/v3/internal/webview2/pkg/edge/ICoreWebView2NavigationCompletedEventArgs.go index e04354c6778..5dc41eae494 100644 --- a/v3/internal/webview2/pkg/edge/ICoreWebView2NavigationCompletedEventArgs.go +++ b/v3/internal/webview2/pkg/edge/ICoreWebView2NavigationCompletedEventArgs.go @@ -4,6 +4,8 @@ package edge import ( "unsafe" + + "golang.org/x/sys/windows" ) type _ICoreWebView2NavigationCompletedEventArgsVtbl struct { @@ -28,3 +30,17 @@ func (i *ICoreWebView2NavigationCompletedEventArgs) Release() uint32 { return uint32(ret) } + +func (i *ICoreWebView2NavigationCompletedEventArgs) GetIsSuccess() (bool, error) { + // BOOL out-params are 4 bytes; receiving into a 1-byte Go bool lets the + // callee write 3 bytes out of bounds on the stack. + var _resultInt int32 + hr, _, _ := i.vtbl.GetIsSuccess.Call( + uintptr(unsafe.Pointer(i)), + uintptr(unsafe.Pointer(&_resultInt)), + ) + if windows.Handle(hr) != windows.S_OK { + return false, windows.Errno(hr) + } + return _resultInt != 0, nil +} diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go index 845d22b1043..c281c2cda0d 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -2696,9 +2696,17 @@ func (w *windowsWebviewWindow) navigationCompleted( sender *edge.ICoreWebView2, args *edge.ICoreWebView2NavigationCompletedEventArgs, ) { - // The webview loaded something, so any process-failure recovery that led - // here worked. - w.resetWebviewRecoveryBudget() + // Only a *successful* load means recovery worked. A dead render process + // leaves WebView2 on an error page, and that error page fires this callback + // too — resetting on it would hand a crash-looping renderer a fresh budget + // on every cycle and the attempt bound would never trip, which is the + // runaway it exists to stop. Treat an unreadable IsSuccess as unsuccessful + // for the same reason. + if ok, err := args.GetIsSuccess(); err != nil { + globalApplication.error("webview2: reading navigation success: %v", err) + } else if ok { + w.resetWebviewRecoveryBudget() + } // Inject runtime core and window-specific flags together so side-effect // runtime modules see a consistent _wails configuration at startup. @@ -3181,6 +3189,12 @@ func (w *windowsWebviewWindow) processFailed(_ *edge.ICoreWebView2, args *edge.I return } restore = func() { + // Same teardown race as rebuildWebView: shutting the app down kills + // the WebView2 processes, so this can land on a window that is + // already going away. + if w.parent.isDestroyed() || w.hwnd == 0 { + return + } w.chromium.Navigate(url) } }