ReviewOS

also looking at this

craft-native/craft

fix(windows): initialise COM before creating the WebView2 environment

#342
Closed glennmichael123 wants to merge fix/windows-com-init into main
1 file +39 -0
9 days ago

Refs #335.

What is certain

On Windows, CreateCoreWebView2EnvironmentWithOptions returns CO_E_NOTINITIALIZED (0x800401f0) for the desktop bundle I am building, and the process exits before a window exists:

⚡ Loading URL in native window: http://127.0.0.1:63854/app
[WebView2] Environment creation failed: 0x800401f0
[WebView2] Initialization failed
[2026-10-12T13:04:02Z] ERROR WebView2InitFailed

That error means exactly one thing: the calling thread was in no COM apartment. And craft never creates one — ole32 is linked for CoTaskMemFree alone, with no CoInitializeEx, CoInitialize or OleInitialize anywhere in the tree, in v0.0.100, v0.0.140 or today's main.

So craft currently depends on something else in the process having put the thread in an apartment. This PR removes that dependency.

What I got wrong in the first version of this description

I wrote that the window therefore never opens on Windows. That is too strong, and your own CI disproves it: windows-multi-window passes on main, and scripts/verify-desktop-multi-window.ts is a real test of this path — it spawns craft.exe --url …, fails if craft exits early, and only passes when JS running inside the webview reports its steps back. So in that job the environment is created successfully by an unpatched build.

I checked the obvious explanation and it is not the loader: the WebView2Loader.dll your CI pins from NuGet 1.0.4191.47 and the one shipped in craft-windows-x64.zip are the same file, sha256 c66e4a92fdc7a216118e43b7a5024ea2200e8c43f9310bf20d96a0084f82c5bc (and that NuGet download matches the digest pinned in ci.yml, so I have the same bytes you do). Both import only KERNEL32 and reference ole32.dll by name for dynamic resolution. --core in build:core only changes the working directory, so your CI build flags match the release build's.

Since writing that, I measured it. It is neither candidate: it is CREATE_NO_WINDOW. Six argument lists, including my bundle's exact one, all produce a webview against the unpatched shipped binary on windows-latest. Holding the arguments fixed and varying only the spawn:

piped stdio, no windowsHide     loaded in 490ms
piped stdio + windowsHide       no webview   0x800401f0
inherit stdio + windowsHide     no webview   0x800401f0
inherit stdio, no windowsHide   loaded in 467ms
GUI-subsystem parent, windowsHide                        no webview   0x800401f0

windowsHide is Bun's name for CREATE_NO_WINDOW, which my launcher sets so Windows does not put a console window beside the app. It is sufficient on its own; stdio and the parent's subsystem make no difference.

So the apartment craft relies on today is created by something tied to console attachment. Your windows-multi-window job spawns craft with a console and gets one; my launcher takes the console away and nothing does, because craft never does it itself. The comments below carry the full tables.

The change, which stands either way

Window.create calls CoInitializeEx(null, COINIT_APARTMENTTHREADED) before anything touches WebView2. It is the single funnel every window goes through — api.zig dispatches to it and loadWebView2 is reached only from inside it — and a thread-local guard keeps later windows from repeating the call.

An STA, because WebView2 hosts UI on the thread that pumps messages. Two non-S_OK results are accepted rather than treated as failures:

  • S_FALSE — the thread already had an apartment. This is the case in your CI today if something there is already initialising one, and it stays working.
  • RPC_E_CHANGED_MODE — an embedder got there first and chose an MTA. That is theirs to choose and WebView2 works in it, so craft neither overrides it nor complains.

There is deliberately no matching CoUninitialize: the apartment has to outlive every webview created on the thread, and the only point at which that holds is process exit, which tears it down anyway. No new link dependency — build.zig already links ole32.

A WebView2 host establishing its own apartment is the documented expectation rather than a workaround, so this is worth doing even where the dependency happens to be satisfied today.

Verification

Cross-compiled x86_64-windows from macOS before and after, and parsed the PE import directory of each craft.exe:

before   ole32.dll: CoTaskMemFree
after    ole32.dll: CoInitializeEx, CoTaskMemFree

Every other imported DLL is identical in count, so the patch adds exactly one symbol and moves nothing else. zig fmt --check is clean. Built with -Djs-runtime=false, since the zig-js path dependency is a sibling checkout I do not have.

I have not run it: I am on macOS with no Windows machine. The offer from #335 stands — I have a Windows CI job that reproduces the failure against the shipped zip, and I am glad to run the discriminating experiment there (shipped binary, --url alone versus the flags above) and post the result, which would also tell us which difference above is the one that matters.

Found while building a Windows desktop bundle for stacksjs/system-cleaner (stacksjs/system-cleaner#23), where everything up to the webview works: the loopback agent binds, its health endpoint answers, and craft.exe receives the URL, title and size.

🤖 Generated with Claude Code

1 changed file on the files tab, with 0 review threads.

Sign in to comment on this pull request.