Skip to content

Never touch the WebView2 from a background thread on a book rename (BL-16749) - #8232

Open
StephenMcConnel wants to merge 3 commits into
masterfrom
BL-16646-ErrorLoadingBloomSource
Open

Never touch the WebView2 from a background thread on a book rename (BL-16749)#8232
StephenMcConnel wants to merge 3 commits into
masterfrom
BL-16646-ErrorLoadingBloomSource

Conversation

@StephenMcConnel

@StephenMcConnel StephenMcConnel commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Importing a .bloomSource as a derivative could fail with CoreWebView2 can only be accessed from the UI thread, leaving the new book on disk but telling the user the import failed.

The import runs off the UI thread (importBloomSource is registered handleOnUiThread: false, and the work runs behind the collection tab's progress dialog). Bringing the new book up to date saves it, which renames its folder to the book's title and raises BookRenamedEvent inline, on that worker thread. EditingView answers by refreshing the page list, which runs javascript in the workspace's WebView2 — and the readiness guard in RunJavascriptFireAndForget reads _webview.CoreWebView2, which the WinForms wrapper cannot always hand to a thread other than the one that created the control. The guard meant to keep that call safe was the crash site.

Why only some machines: whether that read throws depends on the apartment the ICoreWebView2Controller RCW ended up in. On the reporter's machine (a native ARM64 build) the cross-apartment QueryInterface fails E_NOINTERFACE; the same import completes on x64 here. It also takes a selected book, which is what gates the two WebView2 calls in the handler — so a first import into a collection with nothing selected succeeds, and the next one (the import having selected a book) does not.

Two changes, so neither the browser nor its callers depend on that lottery:

  • WebView2Browser.RunJavascriptFireAndForget re-posts itself to the control's own thread when Invoke is required, before anything reads CoreWebView2. The method is fire-and-forget by contract, so marshalling costs the caller nothing, and this also covers its other callers on book-update, import and publish paths.
  • EditingView's BookRenamedEvent handler moves out of the constructor into HandleBookRenamed, marshalled as a whole by HandleBookRenamedOnUiThread. It writes Settings.Default and drives WinForms too, so the whole handler wants the UI thread. With no window open (unit tests, command-line tools) it runs inline, as CollectionModel.SelectBookOnUiThread does.

Verified in the running app: the rename arrives on the worker thread and the handler body then executes on the UI thread, and the import completes. Adds a test that renames a book from a worker thread and checks the rename and its notification both come through.

Note: the branch name carries BL-16646 (an earlier ticket worked in this worktree); this change fixes BL-16749.

Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16749

Devin review


This change is Reviewable

…L-16749)

Importing a .bloomSource as a derivative could fail with "CoreWebView2 can only be
accessed from the UI thread", leaving the new book on disk but the import reported to
the user as failed.

The import runs off the UI thread: the importBloomSource endpoint is registered
handleOnUiThread: false and the work itself runs behind the collection tab's progress
dialog. Bringing the new book up to date saves it, which renames its folder to the
book's title and raises BookRenamedEvent inline, on that worker thread. EditingView
answers that event by refreshing the page list, which runs javascript in the
workspace's WebView2 - and the readiness guard in RunJavascriptFireAndForget reads
_webview.CoreWebView2, which the WinForms wrapper cannot always hand to a thread
other than the one that created the control. The guard meant to keep that call safe
was therefore the crash site.

Whether the read throws depends on the apartment the ICoreWebView2Controller RCW
ended up in, which is why it fails on some machines and not others: on the reporter's
machine (a native ARM64 build) the cross-apartment QueryInterface fails
E_NOINTERFACE, while the same import completes on x64 here. It also takes a selected
book, which is what gates the two WebView2 calls in the handler - so a first import
into a collection with nothing selected succeeds, and the next one, now that the
import has selected a book, does not.

Two changes, so neither the browser nor its callers depend on that lottery:

- RunJavascriptFireAndForget re-posts itself to the control's own thread when Invoke
  is required, before anything reads CoreWebView2. The method is fire-and-forget by
  contract, so marshalling costs the caller nothing, and this also covers its other
  callers on book-update, import and publish paths.

- EditingView's BookRenamedEvent handler moves out of the constructor into
  HandleBookRenamed and is marshalled as a whole by HandleBookRenamedOnUiThread. It
  writes Settings.Default and drives WinForms as well, so the whole handler wants the
  UI thread rather than each callee defending itself. With no window open (unit tests,
  command-line tools) it runs inline, as CollectionModel.SelectBookOnUiThread does.

Verified in the running app: the rename arrives on the worker thread and the handler
body then executes on the UI thread, and the import completes.

Adds a test that renames a book from a worker thread and checks that the rename and
its notification both come through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR marshals book-rename UI work and fire-and-forget WebView2 JavaScript calls onto the owning UI thread to prevent cross-apartment WebView2 access failures.

  • Extracts the rename-event handler and synchronously invokes it on the UI thread.
  • Makes WebView2 readiness checks and script execution occur on the control’s thread.
  • Adds coverage confirming that worker-thread book renames complete and notify subscribers.

Important Files Changed

Filename Overview
src/BloomExe/Edit/EditingView.cs Moves the rename-event response onto the UI thread before updating settings, page-list controls, and WebView2 state.
src/BloomExe/WebView2Browser.cs Marshals fire-and-forget JavaScript calls before accessing CoreWebView2 and handles expected disposal races while posting.
src/BloomTests/Book/BookStorageTests.cs Adds an NUnit test verifying rename and inline notification behavior when SetBookName runs on a worker thread.

Reviews (2): Last reviewed commit: "Say what the worker-thread rename test d..." | Re-trigger Greptile

StephenMcConnel and others added 2 commits August 25, 2026 11:51
Devin flagged the synchronous form.Invoke as worth a look. It is deliberate -- it keeps the handler's old ordering, so the settings write and the page-list refresh still finish before the rename returns -- and it can only deadlock if the UI thread is blocked waiting on the worker, which no current path does. Recording that reasoning where the next reader will ask the question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…749)

Greptile pointed out that the test's comment claimed more than the test does: it said the test showed a subscriber that marshals to the UI thread still runs with no window, but the subscriber here is just a lambda in the test. Describe what it actually checks -- the folder and file are renamed and the subscriber fires once on the worker thread -- and say plainly that the WebView2 half needs the running app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@StephenMcConnel

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from Steve McConnel's machine during preflight] Consulted Devin on 2026-08-25 18:00 UTC up to commit 15876a425.

No bugs, and nothing flagged for investigation. Two informational notes, neither mirrored as a thread: one confirms the new thread guard in RunJavascriptFireAndForget looks safe, and one asks about the blocking Invoke in the rename handler. That second one is deliberate — it keeps the handler's previous ordering, and it could only deadlock if the UI thread were waiting on the import's worker, which no path does — so I recorded the reasoning in a comment (c6dbf9f3d) rather than changing behavior. Devin review.

@StephenMcConnel

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from Steve McConnel's machine during preflight] Answering Greptile's review summary, which said the new test's documentation overstates what it covers.

Fair point, and fixed in 15876a425. The comment had claimed the test showed that a subscriber which marshals to the UI thread still runs when no window is open — but the subscriber in the test is just a lambda, so the test never exercises that. It now says what it actually checks (the folder and the HTML file are renamed, and the subscriber fires exactly once, on the worker thread) and states plainly that the WebView2 half of BL-16749 needs the running app.

@StephenMcConnel
StephenMcConnel marked this pull request as ready for review August 25, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant