#2320

[Windows] WebView2 controller creation fails with ERROR_INVALID_WINDOW_HANDLE (0x80070578) — app starts tray-only, no UI

OpenJotaSXBRopened 7d ago

Summary

On Windows 11 (Insider build 26300.9711), Handy starts but never shows a usable UI — only the tray icon. Both WebView2 controllers fail to be created with ERROR_INVALID_WINDOW_HANDLE (0x80070578):

[tauri_runtime_wry][ERROR] failed to create webview: WebView2 error:
  WindowsError(Error { code: HRESULT(0x80070578), message: "The handle is invalid." })

The error is logged twice per launch (main window Handy + recording_overlay), Tauri continues, the tray and overlay are reported as created, and the Tauri Window / WRY_WEBVIEW windows are destroyed — leaving a tray-only app with no way to open settings or the main window.

This does not look like a missing/broken WebView2 dependency: the same runtime, on the same machine, in the same minute, creates controllers fine from a .NET host that replicates wry's exact sequence (see "Control experiment"). It looks specific to how tao/wry/WebView2 interact inside the Handy process on this Windows build.

Environment

Handy0.9.8 (Handy_0.9.8_x64-setup.exe, NSIS) — also reproduced on 0.9.6
OSWindows 11 Pro, 26H2, build 26300.9711
WebView2 Runtime155.0.4283.62
tauri / tauri-runtime-wry / wry / tao (Cargo.lock)2.11.5 / 2.11.4 / 0.55.1 / 0.35.3 (fork cjpais/tao @ c3bee28c)
GPUhybrid — NVIDIA RTX 3060 Laptop + AMD Radeon iGPU
Displays2 × 1920×1080, 100% scaling (one positioned at y = -9)
WebView2 user data folder%LOCALAPPDATA%\com.pais.handy\EBWebView
Log%LOCALAPPDATA%\com.pais.handy\logs\handy.log

Steps to reproduce

  1. Launch %LOCALAPPDATA%\Handy\handy.exe (normal launch, not elevated).
  2. No window appears; tray icon only.
  3. handy.log contains the two failed to create webview errors.

Hit rate: 0 successes in ~40 launches today. Historically there were sporadic successes in the same log (2026-10-18 13:33, 10-19 13:59, 10-22 11:03 UTC) and failures since 10-19, which suggests a race/timing dependency rather than a static misconfiguration.

Log excerpt (one launch)

[...][handy_app_lib::tray][DEBUG] tray apply: icon=resources/tray_idle.png menu=rebuilt busy=false took=4.3338ms
[...][tauri_runtime_wry][ERROR] failed to create webview: WebView2 error: WindowsError(Error { code: HRESULT(0x80070578), message: "The handle is invalid." })
[...][handy_app_lib::overlay][DEBUG] Recording overlay window created successfully (hidden)
[...][handy_app_lib::managers::transcription][INFO] transcribe-cpp initialized with 3 compute device(s): [Vulkan0 (vulkan), Vulkan1 (vulkan), CPU (cpu)]

(The messages are localized in the log — pt-BR locale prints "O identificador da janela é inválido" with a codepage artifact.)

Note that Recording overlay window created successfully is logged even though its webview creation failed, and the process keeps running normally otherwise (transcription backend, tray, hotkeys all come up).

Window/process lifecycle (Win32 polling, ~30 ms granularity)

 236 ms  + com.pais.handy-sic          (16x16, single-instance window, class <identifier>-sic)
 259 ms  + Tao Thread Event Target
 319 ms  + Tauri Window 'Handy'        (696x609, WS_VISIBLE clear)
 319 ms  +   child WRY_WEBVIEW         (680x570, WS_CHILD)
 319 ms  +   child Chrome_WidgetWin_0
 339 ms     msedgewebview2.exe (browser) starts, --user-data-dir=<Handy UDF>
 749 ms  +   child Chrome_WidgetWin_1  (961x1032)
 749 ms  +   child Chrome_RenderWidgetHostHWND
 838 ms  -   child Chrome_RenderWidgetHostHWND
 876 ms  -   child Chrome_WidgetWin_0 / Chrome_WidgetWin_1
~1.2 s      ERROR 0x80070578 returned by CreateCoreWebView2ControllerAsync
1175 ms  + Tauri Window 'Recording' + WRY_WEBVIEW      (same sequence, same failure)

So the browser process does start, read the profile, spawn a renderer and create its widget windows — then exits ~0.4 s later, and the controller completion reports the container HWND as invalid.

No crash dump is produced: %LOCALAPPDATA%\CrashDumps has nothing for msedgewebview2.exe and the UDF's Crashpad/reports is empty (i.e. not a hard crash; more like a clean exit/shutdown).

With WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--enable-logging --v=1" (Handy honors it — a chrome_debug.log is generated), the browser log contains only this, and it is not the cause (the working control experiment prints it too):

ERROR:base\win\edge_dpi_util.cc:218] failed to set WebView dpi awareness of 2

Control experiment (same machine / runtime / UDF — succeeds)

A minimal .NET 10 WinForms app replicating wry's create_container_hwnd + create_controller path:

  1. create a top-level window without showing it (matching .visible(false) in src-tauri/src/lib.rs),
  2. create a WS_CHILD | WS_CLIPCHILDREN container child of it (the WRY_WEBVIEW equivalent),
  3. call CreateCoreWebView2EnvironmentWithOptions then CreateCoreWebView2ControllerAsync(container_hwnd),
  4. pump messages while waiting (Application.DoEvents(), equivalent to webview2_com::wait_with_pump).

Result: controller created OK, 100% of runs, including:

  • process set to PerMonitorV2 (SetProcessDpiAwarenessContext(-4), as Tauri does),
  • using Handy's own UDF (%LOCALAPPDATA%\com.pais.handy\EBWebView),
  • same runtime 155.0.4283.62,
  • same extra browser arguments wry passes (--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection).

It produces the identical window tree (container child → Chrome_WidgetWin_0/1 → Chrome_RenderWidgetHostHWND → Intermediate D3D Window), and that tree stays alive. Meanwhile msedgewebview2.exe instances for SearchHost / GoogleDriveFS / WhatsApp work normally at the same time.

Ruled out (each tested here, failure reproduced)

  • Missing dependency / broken runtime — control experiment works; runtime healthy.
  • Corrupt user data folder — same failure with a fresh UDF; control experiment works with the original UDF.
  • Elevated privileges — not elevated; control experiment not elevated either.
  • Compatibility layers — DPIUNAWARE, HIGHDPIAWARE, ~ DPIUNAWARE on handy.exe: no change.
  • GPU path — --disable-gpu, --disable-gpu-compositing, --disable-direct-composition, --in-process-gpu, --single-process, --no-sandbox: no reproducible effect (in one run with --disable-gpu a single webview rendered, then failed again on the next launches).
  • WebView2 policy / env — none configured; WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS is honored, so the browser is launched with wry's arguments.
  • App version — 0.9.6 behaves exactly like 0.9.8.
  • tao fork — cjpais/tao@c3bee28c differs from upstream dev only by DefSubclassProc → DefWindowProcW in thread_event_target_callback (src/platform_impl/windows/event_loop.rs). That handles messages after webview creation, so it should not cause this — but it's the only fork-specific code in the window path.
  • Third-party DLL injection — a banking security agent (Warsaw/Topaz OFD) injects wslbscr64.dll / wslbscrwh64.dll into handy.exe, but also into the working control experiment and into powershell.exe, so it is not a differentiator by itself.
  • Autostart / launch context — start_hidden on or off, launched from Explorer or a shell: same result.

Where it probably hurts (suggestions)

  1. tauri-runtime-wry logs the failure and continues, so the app ends up with a WebviewWindow whose webview doesn't exist, and Recording overlay window created successfully is logged even though it failed. Failing loudly (or at least surfacing it in the UI/log with a distinct marker) would turn a silent tray-only app into a diagnosable error.
  2. Since the failure looks timing-dependent, a single retry with a new container after a short delay may be enough to work around it.
  3. More logging around create_controller would help pin it down: the container HWND value, IsWindow(hwnd), owning thread/process id, and the msedgewebview2.exe browser process exit code.

I'm happy to test a build with extra logging or a retry — but I don't have a Rust toolchain on this machine, so I can only test prebuilt artifacts.

I can provide

Full handy.log (97 error lines across ~55 launches), chrome_debug.log, the .NET repro source, and Win32 window dumps if useful.

Activity

4
JotaSXBR4d ago

Update — narrowed down, plus a working local workaround

Two more measurements since the initial report:

  1. Repairing/updating the WebView2 runtime does not help. Runtime went 155.0.4283.62 → 156.0.4314.42 (clean reinstall via Evergreen Standalone /silent /install). Same failure, while the minimal .NET host that replicates wry's sequence keeps working on the new runtime.

  2. The browser process does not crash — it exits with code 0 (~0.5 s after starting), and only for Handy. In the working control, the browser process stays alive for the whole session. So 0x80070578 is the root failure; the browser teardown is the consequence:

pid=32376 parent=7484 BROWSER  visto 229ms..730ms     <-- Handy
   crashpad, gpu, utility (no renderer survives)
pid=36984 parent=2592 BROWSER  visto 504ms..7109ms    <-- control (working)
   crashpad, gpu, utility, renderer

Workaround that works: show the window while the webview is created

If the main Tauri Window / Handy HWND is made visible within the first ~100 ms of startup, WebView2 attaches normally:

  • baseline: 0 successes in 40+ launches (failed to create webview ×2, no renderer process, tray-only app)
  • with the window forced visible: 8 consecutive successes, 0 errors, both webviews (main + overlay) load and the UI renders normally

Detail: ShowWindow(SW_SHOW) issued cross-process is a no-op for this window, while SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_SHOWWINDOW) works.

This suggests CreateCoreWebView2Controller fails when the host window has never been shown, in this tao/wry path. The overlay is created hidden after a working environment exists and is fine, so it is specific to the first controller created on a never-shown top-level window.

Sketch of the sequence that fails vs. works:

HWND w = CreateWindowExW(..., WS_OVERLAPPEDWINDOW, ...);        // created, never shown
HWND c = CreateWindowExW(WS_CHILD | WS_CLIPCHILDREN,
                         L"WRY_WEBVIEW", ..., w, ...);          // wry container
env->CreateCoreWebView2Controller(c, handler);                  // -> 0x80070578

// with the window shown before/while creating the controller:
SetWindowPos(w, nullptr, 0, 0, 0, 0,
             SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_SHOWWINDOW);
env->CreateCoreWebView2Controller(c, handler);                  // -> works

Caveat: my standalone .NET replica with a hidden WinForms parent does not reproduce it, so the difference is likely in how tao creates/initializes the window, or something else in the Handy process.

Suggestions

  • Retry: if WebviewWindowBuilder::build() fails, show the window and retry once before giving up — this alone would fix the tray-only state.
  • Or create the window visible and hide it right after the webview is ready when start_hidden is set.

I'm currently running Handy through a small launcher that shows the window during startup and hides it afterwards (when starting to tray). Happy to test a build that handles this natively, with extra logging around create_controller if useful.

JotaSXBR4d ago

Warning for anyone implementing the workaround: showing the window externally desyncs tao's internal VISIBLE flag.

If the HWND is made visible from outside the process (SetWindowPos(..., SWP_SHOWWINDOW)), the webview is created fine, but tao still believes the window is hidden. After that, the app's own window.hide() — the one fired by CloseRequested on the X button (lib.rs:1062-1064) — becomes a no-op, because window_state.rs only calls Win32 when the internal flags change:

fn apply_diff(mut self, window: HWND, mut new: WindowFlags) {
    let mut diff = self ^ new;
    if diff == WindowFlags::empty() { return; }   // <-- hide() does nothing
    ...
}

Result: the X stops sending the window to the tray and it just stays on screen / in the taskbar.

Recovery, for anyone testing this from outside: call show() inside the process once (in my case launching handy.exe again, which hits the single-instance plugin → show_main_window() → window.show()). That realigns the flags and the X works again.

This reinforces the suggestion above: the fix should be create the window visible and hide it once the webview is ready when start_hidden is set, rather than showing a window tao thinks is hidden. Same applies if you add a retry: show() + retry inside the app keeps the flags consistent, an external SetWindowPos does not.

cjpais3d ago

Thank you for continuing to investigate this Do you have a fix that you think makes sense to make a PR for directly?

JotaSXBR3d ago

Yes — and I think I can now point at the actual line, which changes the shape of the fix.

The window is created hidden on every launch

// lib.rs:946-961
let mut win_builder =
    tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("/".into()))
        .title("Handy")
        // ...
        .visible(false);                    // <-- line 953, unconditional

let main_window = win_builder.build()?;     // controller created in here
// ...
show_main_window(&app_handle);              // line 1056 — only shown afterwards

start_hidden doesn't decide whether the window is created hidden — it's always created hidden, and only decides whether show_main_window() runs at the end of setup. So the first CreateCoreWebView2Controller is always issued against a top-level HWND that has never been shown, including the normal "open Handy from the Start Menu" path.

On this machine that fails every time (0/40+ launches); when it fails, the window is torn down, the error is only logged, and the app keeps running with just the tray icon. My earlier guess that it was specific to start_hidden was wrong — start_hidden was just the configuration I happened to be testing.

Proposed fix

Create the window visible (WebView2 needs a shown host window) and hide it right away when we're supposed to start in the tray. Doing it through tao's own API also keeps WindowFlags::VISIBLE in sync, which matters because window.hide() from CloseRequested is a no-op when the internal flag doesn't change.

             // Create main window programmatically so we can set data_directory
             // for portable mode (redirects WebView2 cache to portable Data dir)
+            let mut settings = get_settings(app.handle());
+
+            // wry/WebView2 fails to create the controller on a window that has never
+            // been shown (ERROR_INVALID_WINDOW_HANDLE, #2320), so create it visible
+            // and hide it below when we are supposed to start in the tray.
+            let start_hidden = settings.start_hidden || cli_args.start_hidden;
+            let tray_available = settings.show_tray_icon && !cli_args.no_tray;
+            let show_on_launch = should_force_show_permissions_window(app.handle())
+                || !start_hidden
+                || !tray_available;
+
             let mut win_builder =
                 tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("/".into()))
                     .title("Handy")
                     .inner_size(680.0, 570.0)
                     .min_inner_size(680.0, 570.0)
                     .resizable(true)
                     .maximizable(true)
-                    .visible(false);
+                    .visible(true)
+                    .focused(show_on_launch);
@@
             let main_window = win_builder.build()?;
+
+            if !show_on_launch {
+                if let Err(e) = main_window.hide() {
+                    log::error!("Failed to hide main window on startup: {e}");
+                }
+            }
@@
-            let mut settings = get_settings(app.handle());
@@
-            let should_hide = settings.start_hidden || cli_args.start_hidden;
-            let should_force_show = should_force_show_permissions_window(&app_handle);
-
-            // If start_hidden but tray is disabled, we must show the window
-            // anyway. Without a tray icon, the dock is the only way back in.
-            // Keep in sync with `apply_startup_activation_policy` (macOS).
-            let tray_available = settings.show_tray_icon && !cli_args.no_tray;
-            if should_force_show || !should_hide || !tray_available {
+            if show_on_launch {
                 show_main_window(&app_handle);
             }

Trade-off: with start_hidden the window is now created visible and hidden a moment later, so there can be a brief flash at login. If that's not acceptable, the variant my launcher uses works too: create it visible but positioned off-screen (-20000,-20000), hide it, then restore the position — I measured 0 errors and no visible flash, just uglier code.

If you'd rather not touch the first-paint path at all, the smaller diff is: keep .visible(false) and on build() failure retry once with visible(true) + hide(). I didn't verify that the second CreateCoreWebView2Controller succeeds after a failed attempt, nor that the failed build() rolls the window back cleanly enough to reuse the main label, so I'd only use it as a fallback.

Testing

I can't build here — no Rust/LLVM/CMake/Bun on this box — so I can't hand you a binary. But this is the machine that reproduces it in 100% of launches (Windows 11 26H2 26300.9711, WebView2 156.0.4314.42): 0/40+ baseline, 8/8 with the window visible, plus 2 successful hide/show cycles through CloseRequested afterwards. If you produce a build (or trigger pr-test-build on a PR), I'll test it immediately and report back — start_hidden on and off, X→tray, tray→show, and quit.

I will not open the PR. This was only for helping you with a problem i dont have at 0.9.7 or my windows messes up something in development of my own stuff.