Skip to content

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

Draft
JohnThomson wants to merge 1 commit into
masterfrom
BL-16773-console-message-loop
Draft

Give console mode a real message loop, so command-line Bloom can use a browser (BL-16773)#8251
JohnThomson wants to merge 1 commit into
masterfrom
BL-16773-console-message-loop

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Aug 27, 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

Devin review


This change is Reviewable

@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR gives console commands a real WinForms message loop so asynchronous work remains on the STA thread required by WebView2.

  • Runs command parsing and dispatch from inside the console message loop and preserves command exit and exception behavior.
  • Records WebView2 initialization faults and exposes them to browser readiness waits.
  • Makes OffScreenBrowser fail immediately with the recorded initialization cause, resolving the previously reported timeout behavior.
  • Corrects asynchronous browser creation through Control.Invoke and adds focused message-loop and initialization-failure tests.

Important Files Changed

Filename Overview
src/BloomExe/Program.cs Replaces the outermost DoEvents polling loop with an STA WinForms message loop that starts, monitors, and returns the console command task.
src/BloomExe/Publish/OffScreenBrowser.cs Checks the inner browser’s recorded initialization fault before its timeout, completing the prior review fix.
src/BloomExe/WebView2Browser.cs Records and reports asynchronous initialization faults and makes readiness and navigation paths surface the underlying cause.
src/BloomExe/Spreadsheet/SpreadsheetImporter.cs Awaits the Task<Browser> returned by invoking the asynchronous browser factory on the control thread.
src/BloomTests/ConsoleCommandLoopTests.cs Verifies STA continuation affinity, WebView2 readiness, exit-code propagation, and synchronous and asynchronous command failures.
src/BloomTests/WebView2BrowserInitFailureTests.cs Verifies that initialization failure is recorded, externally observable, and surfaced before navigation timeout.

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

@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from John Thomson's machine during preflight]

Thanks — the P1 was right, and it was the most important place to be right about.

The earlier commits recorded a WebView2 initialization failure and made the waits inside WebView2Browser give up immediately with the real cause. But OffScreenBrowser runs its own readiness loop — it has to, because it owns the dedicated STA thread the browser lives on — and that loop tested only IsReadyToNavigate. So an off-screen browser that failed to initialize still sat out the full 20-second timeout and then reported Timed out initializing the off-screen WebView2 with no cause attached. That is exactly the misdiagnosis this PR exists to remove, and it was left on the path that matters most: OffScreenBrowser is what publishing and the upload font check use.

Fixed in 3c0226191: WebView2Browser now exposes the recorded failure as InitializationError, and OffScreenBrowser's loop bails as soon as it is set, throwing with the real exception as InnerException. WebView2BrowserInitFailureTests now also asserts that the failure is readable from outside the class, since that is the property OffScreenBrowser depends on and the one that was missing.

Also added the doc comments the three remaining test methods were missing, per this project's convention that public methods carry one.

Comment thread src/BloomExe/Program.cs
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from John Thomson's machine during preflight] Consulted Devin on 2026-08-27 up to commit bab4d7f. Its review of 3c02261 found no bugs, one Investigate flag (blocking console commands can now deadlock) and five informational items. The Investigate flag is posted as its own thread, assessed, and acted on: the existing blocking console paths were checked individually and are safe, and RunConsoleCommandLoop now carries a warning about the hazard for future code. One informational item was also acted on -- the loop now fails loudly rather than hanging if something other than the command ends it. Greptile's review covers 3c02261 and its one P1 is fixed; it has not re-run against that fix. CI (pr-automation) passed and the full C# suite is green.

…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>
@JohnThomson
JohnThomson force-pushed the BL-16773-console-message-loop branch from bab4d7f to 5714016 Compare August 27, 2026 20:57
@JohnThomson
JohnThomson marked this pull request as ready for review August 27, 2026 20:58
@JohnThomson
JohnThomson changed the base branch from master to Version6.5 August 31, 2026 16:19
@JohnThomson
JohnThomson changed the base branch from Version6.5 to master August 31, 2026 16:29
@JohnThomson

Copy link
Copy Markdown
Contributor Author

Closing: this PR targets the wrong branch. BL-16773 is a Version6.5 fix, but this PR was opened against master.

Retargeting it in place was not viable: this branch was cut from master, which is 18 commits ahead of Version6.5, so against a Version6.5 base the PR spanned 10 commits — including Promote master to 6.6 and the 20260826 Crowdin translation merge. Merging that would have pulled master's history onto the release branch.

Superseded by #8261, which is the same change cherry-picked onto Version6.5 (verified byte-for-byte identical: same 7 files, +630 / -137, none of master's commits). The review done here — Greptile, Devin up to bab4d7f, and John's own pass — carries over; #8261 records that.

Closing rather than force-pushing a rebase, to preserve the review history on this PR.

@JohnThomson JohnThomson reopened this Aug 31, 2026
@JohnThomson
JohnThomson marked this pull request as draft August 31, 2026 17:40
@JohnThomson

Copy link
Copy Markdown
Contributor Author

Reopened as a draft. Supersedes my earlier "closing: wrong branch" note above — that call has been reversed.

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

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

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

This PR is the 6.6 route. Merge it only if the decision is to wait for 6.6; in that case close #8261. Do not merge both.

Note for whoever picks: this branch was cut from master and cannot simply be retargeted at Version6.5 — against that base it spans 10 commits, including Promote master to 6.6 and the 20260826 Crowdin merge. Choosing 6.5 means merging #8261, not retargeting this one.

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