Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package edge

import (
"unsafe"

"golang.org/x/sys/windows"
)

type _ICoreWebView2NavigationCompletedEventArgsVtbl struct {
Expand All @@ -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
}
224 changes: 213 additions & 11 deletions v3/pkg/application/webview_window_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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)
}

}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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()
}
Loading
Loading