From 942d7ddb3ed56468714291787a52236d5ca4f843 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 11 Nov 2026 15:59:46 +0200 Subject: [PATCH 1/3] Dispatch only WebView2's own messages while one is being created WebView2 is created synchronously: the Windows backend runs the thread's message queue until the environment and the controller report back. It dispatched every message there, the host's among them, while the host was in the middle of the call that waits. A pointer message ran the host's handlers inside that call. Under a Compose scene with a view already attached, that re-entered Compose's dispatcher, which threw, and the window's event loop ended. A wake-up or a request to redraw was lost. Tao sets aside what it receives while its handler is busy and hands it over with the next event, and Nucleus asks for a wake-up or a redraw once and then waits for it. With both taken off the queue during a creation, no next event came: the window stopped drawing and its dispatcher stopped running until something else reached the loop, which on a machine with no one at it is never. The wait now dispatches only what is queued for WebView2's own windows, those of a class whose procedure is in the module its objects come from, and leaves the host's messages queued for the host's loop. A failed creation says on stderr which step failed and with what result. --- .../native/windows/compose_webview_internal.h | 2 +- .../jvmMain/native/windows/view_lifecycle.cpp | 101 ++++++++++++++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h index 705afac..0af5eba 100644 --- a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h +++ b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h @@ -98,7 +98,7 @@ std::wstring compose_webview_utf8_to_wide(const std::string &u); std::string compose_webview_json_escape(const std::string &s); ComposeWebViewState *compose_webview_state_from_handle(jlong handle); -void compose_webview_pump_until_done(const std::atomic &done); +void compose_webview_pump_until_done(const std::vector &before, const std::atomic &done); void compose_webview_apply_bounds(ComposeWebViewState &s); void compose_webview_apply_rounded_clip(ComposeWebViewState &s); diff --git a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp index 62cb7ee..d61edf0 100644 --- a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp +++ b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp @@ -1,5 +1,6 @@ #include "compose_webview_internal.h" +#include #include #include @@ -70,15 +71,90 @@ ComposeWebViewState *compose_webview_unregister(jlong handle) { /* ── Helpers ────────────────────────────────────────────────────────────── */ -void compose_webview_pump_until_done(const std::atomic &done) { +/* The module WebView2's objects come from, known once it has handed one over. */ +static HMODULE g_engineModule = nullptr; + +/* The module holding [address]. */ +static HMODULE moduleAt(const void *address) { + HMODULE module = nullptr; + if (!address) return nullptr; + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(address), &module); + return module; +} + +static BOOL CALLBACK addWindow(HWND window, LPARAM windows) { + reinterpret_cast *>(windows)->push_back(window); + return TRUE; +} + +/* The calling thread's windows: its top-level ones, those inside them, and its message-only ones. */ +static std::vector threadWindows() { + const DWORD thread = GetCurrentThreadId(); + std::vector windows; + EnumThreadWindows(thread, addWindow, reinterpret_cast(&windows)); + const size_t topLevel = windows.size(); + for (size_t i = 0; i < topLevel; i++) { + EnumChildWindows(windows[i], addWindow, reinterpret_cast(&windows)); + } + for (HWND w = nullptr; (w = FindWindowExW(HWND_MESSAGE, w, nullptr, nullptr)) != nullptr;) { + windows.push_back(w); + } + windows.erase( + std::remove_if(windows.begin(), windows.end(), [thread](HWND w) { + return GetWindowThreadProcessId(w, nullptr) != thread; + }), + windows.end()); + return windows; +} + +/* + * Whether [window] is one of WebView2's: of a class whose procedure is in WebView2's module. Until + * its first environment tells which module that is, a window is WebView2's when the call being + * waited on made it, so it is not among [before], the thread's windows from before that call. + */ +static bool isEngineWindow(HWND window, const std::vector &before) { + if (g_engineModule) { + const ULONG_PTR classProcedure = GetClassLongPtrW(window, GCLP_WNDPROC); + return moduleAt(reinterpret_cast(classProcedure)) == g_engineModule; + } + return std::find(before.begin(), before.end(), window) == before.end(); +} + +/* + * Waits until [done]: WebView2 finishes an asynchronous call through the calling thread's message + * queue. Only what is queued for WebView2's own windows is dispatched meanwhile. Everything else on + * the thread is the host's and stays queued for its loop. The host is in the middle of the call + * that waits here, and can only half act on a message handed to it now: its handlers would run + * inside that call, and its event loop sets aside a wake-up or a request to redraw it receives + * while busy, then sleeps waiting for the one that was taken off the queue here. + */ +void compose_webview_pump_until_done(const std::vector &before, const std::atomic &done) { + const HWND threadMessages = reinterpret_cast(static_cast(-1)); while (!done.load(std::memory_order_acquire)) { + bool dispatched = false; MSG msg; - if (GetMessageW(&msg, nullptr, 0, 0) <= 0) break; - TranslateMessage(&msg); - DispatchMessageW(&msg); + /* Takes nothing off the queue: a call into it is what lets through what other threads sent. */ + PeekMessageW(&msg, threadMessages, 0, 0, PM_NOREMOVE); + for (HWND window : threadWindows()) { + if (!isEngineWindow(window, before)) continue; + while (PeekMessageW(&msg, window, 0, 0, PM_REMOVE)) { + DispatchMessageW(&msg); + dispatched = true; + if (done.load(std::memory_order_acquire)) return; + } + } + if (!dispatched) MsgWaitForMultipleObjectsEx(0, nullptr, 5, QS_ALLINPUT, 0); } } +/* Says on stderr which step of creating a view failed, and with what result. */ +static void logCreateFailure(const char *step, HRESULT hr) { + fprintf(stderr, "compose_webview: %s failed: 0x%08lX\n", step, static_cast(hr)); + fflush(stderr); +} + void compose_webview_apply_rounded_clip(ComposeWebViewState &s) { if (!s.dcompDevice || !s.rootVisual) return; if (s.cornerRadiusPx <= 0.0f) { @@ -193,6 +269,7 @@ ComposeWebViewState *compose_webview_create( auto *s = new ComposeWebViewState(); s->parent = parent; + const std::vector before = threadWindows(); std::wstring userDataFolder = opts.dataDirectory; if (userDataFolder.empty() && opts.incognito) { @@ -212,16 +289,23 @@ ComposeWebViewState *compose_webview_create( Callback( [&](HRESULT result, ICoreWebView2Environment *env) -> HRESULT { envResult = result; - if (SUCCEEDED(result) && env) s->env = env; + if (SUCCEEDED(result) && env) { + s->env = env; + /* A COM object starts with the address of its table of methods, which is in + * the module that implements it. */ + g_engineModule = moduleAt(*reinterpret_cast(env)); + } envDone.store(true, std::memory_order_release); return S_OK; }).Get()); if (FAILED(hr)) { + logCreateFailure("starting the WebView2 environment", hr); delete s; return nullptr; } - compose_webview_pump_until_done(envDone); + compose_webview_pump_until_done(before, envDone); if (FAILED(envResult) || !s->env) { + logCreateFailure("creating the WebView2 environment", envResult); delete s; return nullptr; } @@ -230,6 +314,7 @@ ComposeWebViewState *compose_webview_create( HRESULT ccResult = E_FAIL; ComPtr env3; if (FAILED(s->env.As(&env3)) || !env3) { + logCreateFailure("getting ICoreWebView2Environment3", E_NOINTERFACE); delete s; return nullptr; } @@ -243,11 +328,13 @@ ComposeWebViewState *compose_webview_create( return S_OK; }).Get()); if (FAILED(hr)) { + logCreateFailure("starting the composition controller", hr); delete s; return nullptr; } - compose_webview_pump_until_done(ccDone); + compose_webview_pump_until_done(before, ccDone); if (FAILED(ccResult) || !s->compController) { + logCreateFailure("creating the composition controller", ccResult); delete s; return nullptr; } From 94adfd13ccfc3fcd461e4afc058d5df8c2b99595 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 11 Nov 2026 15:59:46 +0200 Subject: [PATCH 2/3] Let the views of one window share its composition target on Windows Each view made a DirectComposition device and a topmost target for the host window. A window takes one topmost target, so only the first view in a window could be created: a second one failed, and with it every screen that shows two views or replaces one with another in the same composition. A window now has one tree, made by its first view and gone with its last, and each view adds its own visual to it. The steps of a creation that can fail after the controller exists say so on stderr too. --- .../native/windows/compose_webview_internal.h | 4 +- .../jvmMain/native/windows/view_lifecycle.cpp | 70 +++++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h index 0af5eba..bf45f5b 100644 --- a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h +++ b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h @@ -37,9 +37,11 @@ struct ComposeWebViewState { ComPtr webview2; ComPtr cookieManager; + /* The device of the host window's composition tree, shared by the views in that window, and this + * view's own visual in the tree. */ ComPtr dcompDevice; - ComPtr dcompTarget; ComPtr rootVisual; + bool inWindowTree = false; int xPx = 0; int yPx = 0; diff --git a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp index d61edf0..947e2db 100644 --- a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp +++ b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp @@ -155,6 +155,61 @@ static void logCreateFailure(const char *step, HRESULT hr) { fflush(stderr); } +/* + * The DirectComposition tree of one host window. A window takes a single topmost composition target, + * so the views in it share this one, each with a visual of its own under [root]. Touched on the + * window's UI thread only, where views are created and released. + */ +struct WindowTree { + ComPtr device; + ComPtr target; + ComPtr root; + int views = 0; +}; +static std::unordered_map g_windowTrees; + +/* Gives [s] its visual in its window's tree, which the window's first view creates. */ +static HRESULT joinWindowTree(ComposeWebViewState *s) { + auto it = g_windowTrees.find(s->parent); + if (it == g_windowTrees.end()) { + WindowTree tree; + HRESULT hr = DCompositionCreateDevice(nullptr, IID_PPV_ARGS(&tree.device)); + if (SUCCEEDED(hr)) hr = tree.device->CreateTargetForHwnd(s->parent, TRUE, &tree.target); + if (SUCCEEDED(hr)) hr = tree.device->CreateVisual(&tree.root); + if (SUCCEEDED(hr)) hr = tree.target->SetRoot(tree.root.Get()); + if (FAILED(hr)) return hr; + it = g_windowTrees.emplace(s->parent, std::move(tree)).first; + } + WindowTree &tree = it->second; + ComPtr visual; + HRESULT hr = tree.device->CreateVisual(&visual); + if (SUCCEEDED(hr)) hr = tree.root->AddVisual(visual.Get(), TRUE, nullptr); + if (FAILED(hr)) { + if (tree.views == 0) g_windowTrees.erase(it); + return hr; + } + tree.views++; + s->dcompDevice = tree.device; + s->rootVisual = visual; + s->inWindowTree = true; + return S_OK; +} + +/* Takes [s]'s visual out of its window's tree; the window's last view takes the tree with it. */ +static void leaveWindowTree(ComposeWebViewState *s) { + if (!s->inWindowTree) return; + s->inWindowTree = false; + auto it = g_windowTrees.find(s->parent); + if (it == g_windowTrees.end()) return; + WindowTree &tree = it->second; + if (s->rootVisual) tree.root->RemoveVisual(s->rootVisual.Get()); + if (--tree.views <= 0) { + g_windowTrees.erase(it); + } else { + tree.device->Commit(); + } +} + void compose_webview_apply_rounded_clip(ComposeWebViewState &s) { if (!s.dcompDevice || !s.rootVisual) return; if (s.cornerRadiusPx <= 0.0f) { @@ -340,10 +395,12 @@ ComposeWebViewState *compose_webview_create( } if (FAILED(s->compController.As(&s->controller)) || !s->controller) { + logCreateFailure("getting the controller", E_NOINTERFACE); delete s; return nullptr; } if (FAILED(s->controller->get_CoreWebView2(&s->webview)) || !s->webview) { + logCreateFailure("getting the web view", E_FAIL); delete s; return nullptr; } @@ -392,14 +449,16 @@ ComposeWebViewState *compose_webview_create( s->controller->put_ZoomFactor(opts.zoomLevel); } - if (FAILED(DCompositionCreateDevice(nullptr, IID_PPV_ARGS(&s->dcompDevice))) || - FAILED(s->dcompDevice->CreateTargetForHwnd(parent, TRUE, &s->dcompTarget)) || - FAILED(s->dcompDevice->CreateVisual(&s->rootVisual))) { + hr = joinWindowTree(s); + if (FAILED(hr)) { + logCreateFailure("joining the window's composition tree", hr); delete s; return nullptr; } - s->dcompTarget->SetRoot(s->rootVisual.Get()); - if (FAILED(s->compController->put_RootVisualTarget(s->rootVisual.Get()))) { + hr = s->compController->put_RootVisualTarget(s->rootVisual.Get()); + if (FAILED(hr)) { + logCreateFailure("setting the view's root visual", hr); + leaveWindowTree(s); delete s; return nullptr; } @@ -458,6 +517,7 @@ void compose_webview_release(ComposeWebViewState *s) { compose_webview_uninstall_parent_subclass(s); compose_webview_unhook_events(s); if (s->controller) s->controller->Close(); + leaveWindowTree(s); delete s; } From fc6cb07bb10075d8a5deb2c4d5a1b0d0082525a1 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 11 Nov 2026 00:51:23 +0200 Subject: [PATCH 3/3] ci: build on macos-15 and install an Android SDK package that still exists GitHub no longer offers the macos-14 runner image, so the macOS native build was cancelled before it started and every job that needs the natives (the JVM tests and the three desktop e2e suites) was skipped. setup-android's default package list still names "tools", which sdkmanager no longer has, so the Android jobs failed in their setup step. --- .github/workflows/build-natives.yaml | 2 +- .github/workflows/pr-build-check.yml | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index 387acda..7889f18 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -86,7 +86,7 @@ jobs: retention-days: 1 macos: - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout Repo uses: actions/checkout@v4 diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index d6850f2..88022ab 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -52,6 +52,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -116,6 +118,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -145,6 +149,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -294,7 +300,7 @@ jobs: # ── Desktop visual e2e (macOS WKWebView) ────────────────────────────────── e2e-macos: needs: build-natives - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 45 steps: - name: Checkout code