Skip to content

Give console mode a real message loop, so command-line Bloom can use a browser (BL-16773) - #8261

Draft
JohnThomson wants to merge 1 commit into
Version6.5from
BL-16773-console-message-loop-6.5
Draft

Give console mode a real message loop, so command-line Bloom can use a browser (BL-16773)#8261
JohnThomson wants to merge 1 commit into
Version6.5from
BL-16773-console-message-loop-6.5

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Problem. Command-line Bloom could not reliably use a browser. Bulk upload was the visible casualty (BL-16767, fixed separately in #8246): every book after the first failed with "The instance of CoreWebView2 is uninitialized." The same trap sat under console spreadsheetImport of a spreadsheet carrying audio, and under anything else a console verb might want a browser for. Worse, when it sprang it said nothing useful — the real error was thrown away, and what surfaced twenty seconds later was a navigation timeout somewhere else.

Cause. Console mode waited for its command by spinning Application.DoEvents(). As the outermost message loop, that uninstalls the WindowsFormsSynchronizationContext and leaves a plain one, whose Post queues to the thread pool — so from the first await that actually yielded, console work moved onto MTA thread-pool threads. A WebView2 cannot even be created there: CoreWebView2Environment.CreateAsync needs an STA thread and throws RPC_E_CHANGED_MODE. Program.Main's own comment said a synchronous Main was kept precisely to stop this happening; the wait loop immediately below it undid it. And because the browser constructors started initialization as _ = InitWebView(), nothing ever observed the resulting exception.

Fix.

  • Console commands now run inside a real message loop (Program.RunConsoleCommandLoop), and are started from inside it, so their awaits resume on the pumping STA main thread. This is what actually fixes the class of bug, console spreadsheet import included.
  • A failed WebView2 initialization is now recorded and reported — log, Sentry, and stderr in console mode — naming the creating thread and its apartment state. Every ready-wait, including OffScreenBrowser's own (which runs its readiness loop on the thread it owns), now gives up at once with the real cause instead of spinning out a timeout and then blaming the timeout.
  • SpreadsheetImporter.GetBrowserAsync no longer casts the Task<Browser> that Control.Invoke hands back to (Browser).
  • Removed about forty lines of dead shared-WebView2-environment machinery, and corrected two comments that had explained themselves in terms of it.

The one new hazard, and what was done about it. Because awaits now come back to the main thread, sync-over-async on that thread can deadlock, where under the old wait it could not. Every blocking wait reachable from a console verb was checked and is safe — each waits on a library task that uses ConfigureAwait(false) internally, on work owned by another thread, or through AsyncUtil.RunSync, which pins to TaskScheduler.Default for exactly this reason. RunConsoleCommandLoop's doc comment records the hazard, which calls are safe and why, and that blocking on one of Bloom's own async methods is the case to avoid.

Not a behavior change for the desktop app: nested inside Application.Run, DoEvents never discarded the context, which is why only console mode was affected.

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

Cherry-picked from #8251, which was mistakenly opened against master.

Devin review


This change is Reviewable

…a browser (BL-16773)

Command-line Bloom could not reliably use a browser. Bulk upload was the visible
casualty (BL-16767, fixed separately in #8246): every book after the first failed
with "The instance of CoreWebView2 is uninitialized." The same trap sat under
console `spreadsheetImport` of a spreadsheet carrying audio, and under anything
else a console verb might want a browser for. Worse, when it sprang it said
nothing useful -- the real error was thrown away, and what surfaced twenty
seconds later was a navigation timeout somewhere else.

The cause was the console wait loop. Console mode waited for its command by
spinning `Application.DoEvents()`, and as the OUTERMOST message loop that
uninstalls the WindowsFormsSynchronizationContext and leaves a plain one, whose
Post queues to the thread pool. So from the first await that actually yielded,
console work moved onto MTA thread-pool threads -- where a WebView2 cannot even
be created, because CoreWebView2Environment.CreateAsync needs an STA thread and
throws RPC_E_CHANGED_MODE. Program.Main's own comment said a synchronous Main
was kept precisely to stop this happening; the wait loop immediately below it
undid it. And because the browser constructors started initialization as
"_ = InitWebView()", nothing ever observed the resulting exception. Nested inside
Application.Run, DoEvents does not discard the context, which is why only console
mode was ever affected.

What this changes:

- Console commands now run inside a real message loop, Program.RunConsoleCommandLoop,
  and are started from INSIDE it, because an await captures whichever context is
  current at the moment it suspends -- starting the command any earlier would
  already be too late for its first await. Their awaits therefore resume on the
  pumping STA main thread. This is what fixes the class of bug, console
  spreadsheet import included. Parsing and dispatch move to
  ParseAndDispatchConsoleCommand, unchanged apart from becoming a method.

- A failed WebView2 initialization is recorded and reported rather than
  discarded: log, Sentry, and stderr in console mode, naming the creating thread
  and its apartment state. Every ready-wait now gives up at once with the real
  cause instead of spinning out its timeout and then blaming the timeout --
  including OffScreenBrowser's own, which runs its readiness loop on the thread
  it owns and so cannot rely on the checks inside WebView2Browser.

- SpreadsheetImporter.GetBrowserAsync no longer casts the Task<Browser> that
  Control.Invoke hands back to (Browser). Control.Invoke returns what the
  delegate returned, and the delegate is async, so the cast was an
  InvalidCastException waiting to happen. It now awaits the Task, as GetMd5Async
  in the same file already did.

- Removed about forty lines of dead shared-WebView2-environment machinery
  (BeginSharedEnvironmentBatch / EndSharedEnvironmentBatch and their statics);
  its only consumer moved to OffScreenBrowser. Two comments that had explained
  themselves in terms of those statics are corrected, including one in
  ExternalApi that claimed process-book needs the UI thread "because it creates
  and pumps an off-screen WebView2" -- no longer the reason.

The one new hazard, and what was done about it: because awaits now come back to
the main thread, sync-over-async on that thread can deadlock, where under the old
wait it could not. Every blocking wait reachable from a console verb was checked
and is safe -- each waits on a library task that uses ConfigureAwait(false)
internally (the AWS SDK, HttpClient), on work owned by another thread
(OffScreenBrowser completes its own), or through AsyncUtil.RunSync, which pins to
TaskScheduler.Default for exactly this reason. The one path that could have
waited on the main thread cannot: ApiRequest marshals only to a form from
Application.OpenForms, which is empty in console mode. RunConsoleCommandLoop's
doc comment records the hazard, which calls are safe and why, and that blocking
on one of Bloom's own async methods is the case to avoid.

Program.MainContext is deliberately not published by the new loop, with a comment
saying why: code keyed off it (RabProjectService, CommonApi, ToastService)
behaves differently when it is set, and RabProjectService has a null branch
precisely for the no-UI case.

Tests: ConsoleCommandLoopTests covers the property that matters -- awaits resume
on the calling STA thread -- and, most to the point, that a WebView2 created by a
console command AFTER an await now becomes ready, which is the exact thing bulk
upload could not do. It also records, as a characterization test, the WinForms
behaviour the whole change is built around, so we find out if DoEvents ever stops
discarding the context. WebView2BrowserInitFailureTests covers the reporting and
the fast failure, including that the recorded error is readable from outside the
class, which is what OffScreenBrowser depends on.

Verified by hand as well as by tests: a three-book bulk upload, and a full
createArtifacts run on a real book producing a valid .bloompub, an ePUB with all
five page files, the complete bloomdigital folder and all three thumbnail sizes.

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

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR replaces console mode’s outermost Application.DoEvents() polling with a real Windows Forms message loop so asynchronous commands remain on the STA thread required by WebView2.

  • Starts command parsing and dispatch only after the console message loop begins pumping.
  • Records WebView2 initialization failures and surfaces their underlying exceptions from readiness waits.
  • Correctly awaits the browser task returned through Control.Invoke during spreadsheet import.
  • Removes obsolete shared-environment state and adds regression coverage for console continuations and initialization failures.

Important Files Changed

Filename Overview
src/BloomExe/Program.cs Introduces the console command message loop and moves command parsing and dispatch inside that loop.
src/BloomExe/WebView2Browser.cs Captures asynchronous WebView2 initialization failures and propagates them through browser readiness and navigation paths.
src/BloomExe/Publish/OffScreenBrowser.cs Stops off-screen browser readiness polling immediately when WebView2 initialization has failed.
src/BloomExe/Spreadsheet/SpreadsheetImporter.cs Awaits the Task returned by the UI-thread invocation when creating a spreadsheet-import browser.
src/BloomTests/ConsoleCommandLoopTests.cs Adds regression coverage for STA continuation affinity, WebView2 readiness, and command exception propagation.
src/BloomTests/WebView2BrowserInitFailureTests.cs Adds coverage ensuring initialization failures are recorded, reported, and surfaced without waiting for downstream timeouts.

Reviews (2): Last reviewed commit: "Give console mode a real message loop, s..." | Re-trigger Greptile

@JohnThomson

Copy link
Copy Markdown
Contributor Author

Closing: #8251 has been retargeted to Version6.5 instead, so this cherry-pick duplicate isn't needed.

@JohnThomson JohnThomson reopened this Aug 31, 2026
@JohnThomson

Copy link
Copy Markdown
Contributor Author

Reopened — this is the PR to review/merge for BL-16773. It replaces #8251, which targeted the wrong branch (master instead of Version6.5) and has been closed.

This change was already reviewed and approved on #8251. The review history lives there: Greptile's pass, Devin consulted up to bab4d7f (no bugs; one Investigate flag about blocking console commands deadlocking, which was assessed and acted on; five informational items), and John's own review with the P1 on OffScreenBrowser's readiness loop addressed.

The code here is identical to what was reviewed — a straight cherry-pick of 5714016 onto Version6.5, verified byte-for-byte: the same 7 files, +630 / -137. It carries none of master's commits (notably not Promote master to 6.6 or the 20260826 Crowdin translation merge), which is why it is a fresh PR rather than a retarget of #8251.

@JohnThomson

Copy link
Copy Markdown
Contributor Author

Converted to draft. Supersedes my earlier "this is the PR to review/merge" note above — that was written when 6.5 looked settled. It is not.

Whether BL-16773 ships in 6.5 or waits for 6.6 is still undecided, so both candidates are open as drafts and exactly one will be merged:

Base Ships in
#8261 (this one) Version6.5 6.5
#8251 master 6.6 — master was promoted to 6.6 in 3905e8a

They are the same change: this PR is a verified byte-for-byte cherry-pick of #8251's branch onto Version6.5 (same 7 files, +630 / -137, none of master's commits). The review history — Greptile, Devin up to bab4d7f, and John's own pass — lives on #8251 and applies to either.

This PR is the 6.5 route. Merge it only if the decision is to ship in 6.5; in that case close #8251. Do not merge both.

One upkeep note: Version6.5 has advanced since this was cherry-picked, so this branch is a couple of commits behind. Harmless (GitHub still shows the correct 7-file diff), but worth a rebase before merging if 6.5 wins.

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