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 b397a95697d..c281c2cda0d 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -62,6 +62,19 @@ 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 + // 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 @@ -326,6 +339,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) } @@ -382,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 @@ -390,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 { @@ -2429,6 +2456,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) @@ -2601,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 @@ -2615,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) } } @@ -2654,6 +2696,17 @@ func (w *windowsWebviewWindow) navigationCompleted( sender *edge.ICoreWebView2, args *edge.ICoreWebView2NavigationCompletedEventArgs, ) { + // 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. @@ -3030,3 +3083,152 @@ 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. +// 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 + } + + 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() { + // 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) + } + } + + 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. +// 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() { + // 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() +} 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..14c495819aa --- /dev/null +++ b/v3/pkg/application/webview_window_windows_processfailed_test.go @@ -0,0 +1,137 @@ +//go:build windows && !server + +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") + } +}