Skip to content

Take OffScreenBrowser's thread out of WinForms' application lifetime (BL-16668) - #8177

Draft
andrew-polk wants to merge 4 commits into
masterfrom
BL-16668-offscreen-browser-lifetime
Draft

Take OffScreenBrowser's thread out of WinForms' application lifetime (BL-16668)#8177
andrew-polk wants to merge 4 commits into
masterfrom
BL-16668-offscreen-browser-lifetime

Conversation

@andrew-polk

@andrew-polk andrew-polk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Bloom.exe createArtifacts was dying with ObjectDisposedException on the epub step, so the
harvester failed every book with epubs enabled and uploaded no artifacts at all — not even
the .bloompub it had already built successfully. Reported downstream as BH-7836.

What was happening

Application.Run registers the loop it starts as an application message loop. OffScreenBrowser
(introduced in 48d8e2588c for BL-12614 / BL-13120) runs one on its own dedicated STA thread. The
CLI path never calls Application.Run itself — it waits with Thread.Sleep + Application.DoEvents
— so that browser thread's loop is the only application message loop in the process. Therefore:

  1. createArtifacts builds the .bloompub first; BloomPubMaker does using (var helper = new PublishHelper()).
  2. Disposing it calls PublishHelper.ReleaseBrowser()OffScreenBrowser.Dispose()ApplicationContext.ExitThread().
  3. The process's only application message loop ends, so WinForms raises Application.ApplicationExit.
  4. ApplicationContainer.OnApplicationExit disposes the container — the parent of ProjectContext._scope.
  5. The epub step, which runs next, evaluates s_projectContext.BookServerObjectDisposedException.

In the GUI this never fired, because the main thread's loop is still running. It only bites a
non-GUI entry point.

The fix

Rather than teach one more listener to distrust the event, stop telling the lie: pump the browser
thread with a bare GetMessage/TranslateMessage/DispatchMessage loop and end it with
PostQuitMessage from the teardown we already post onto that thread. That dispatches everything
the thread actually needs — the WindowsFormsSynchronizationContext's marshaling control and the
WebView2's windows and async completions — while staying invisible to WinForms'
application-lifetime bookkeeping.

This removes the whole bug class: no non-GUI entry point (harvester, bulk upload, a future CLI
verb) can now be told the application is exiting merely because a worker finished with a browser.
ReleaseBrowser's resource-saving intent is untouched — the browser and its thread are still torn
down per batch of page checks. Since Application.Run also disposed the thread's windows as its
loop ended, the pump now disposes the synchronization context itself, so we don't leave a Control
to be finalized on a dead thread.

Regression test

Every existing CreateArtifactsCommandTests case requested a single artifact, which is exactly why
CI stayed green through this. The harvester always passes --bloomdOutputPath/
--bloomDigitalOutputPath and --epubOutputPath in one run, and only that combination exposes
the bug — with one artifact, nothing runs after the premature disposal. The new test asks for both
and fails on unmodified master with the production stack trace, frame for frame:

Expected: Success   But was: EpubException
System.ObjectDisposedException: Instances cannot be resolved and nested lifetimes cannot be created
from this LifetimeScope as it (or one of its parent scopes) has already been disposed.
   at Bloom.ProjectContext.get_BookServer() ... ProjectContext.cs:840
   at Bloom.CLI.CreateArtifactsCommand.CreateEpubArtifact ... CreateArtifactsCommand.cs:401

The test commit is deliberately byte-identical to the one on BL-16668-harvester-exit-guard, which
carries the minimal alternative fix (guarding OnApplicationExit on Program.RunningHarvesterMode)
for comparison.

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

Devin review


This change is Reviewable

andrew-polk and others added 3 commits August 7, 2026 10:40
…D an epub (BL-16668)

Every existing CreateArtifactsCommandTests case requests a single artifact, which is why
CI stayed green through the bug the harvester hit in production. The harvester always
passes --bloomdOutputPath/--bloomDigitalOutputPath *and* --epubOutputPath in one run, and
only that combination exposes BL-16668: building the bloomdigital creates and then
disposes PublishHelper's off-screen browser, whose dedicated thread is running the only
WinForms message loop a CLI process has. Ending that loop makes WinForms raise
Application.ApplicationExit, ApplicationContainer disposes itself in response, and the
epub step -- which runs after -- then dies with ObjectDisposedException resolving
ProjectContext.BookServer, so createArtifacts returns EpubException.

This test fails on the current code with exactly the production stack trace, and is the
shared repro for the two candidate fixes on their separate branches.

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

Application.Run registers the loop it starts as an *application* message loop. So when the
off-screen browser's dedicated thread ended its loop, WinForms concluded the application was
exiting -- correct in the GUI, badly wrong in a CLI process where ours is the only such loop --
and raised Application.ApplicationExit. ApplicationContainer acted on that and disposed
itself, taking down the parent scope of the ProjectContext that `Bloom.exe createArtifacts`
was still using. The epub step, which runs right after the bloompub step releases the browser,
then died with ObjectDisposedException, so the harvester failed every book with epubs enabled
and uploaded no artifacts at all (BH-7836).

Rather than teach one more listener to distrust the event, stop telling the lie: pump with a
bare GetMessage/TranslateMessage/DispatchMessage loop and end it with PostQuitMessage from the
teardown we already post onto that thread. That dispatches everything the thread actually needs
-- the WindowsFormsSynchronizationContext's marshaling control and the WebView2's windows and
async completions -- while staying invisible to WinForms' application-lifetime bookkeeping.
This removes the whole bug class: no non-GUI entry point (harvester, bulk upload, a future CLI
verb) can now be told the application is exiting merely because a worker finished with a
browser. ReleaseBrowser's resource-saving intent is untouched -- we still tear the browser and
its thread down per batch of page checks.

Since Application.Run also disposed the thread's windows as its loop ended, the pump now
disposes the synchronization context (and its marshaling control) itself, so we don't leave a
Control to be finalized on a dead thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two documentation fixes from Devin's review of the previous commit, no behavior change.

The note on ctv.Dispose() claimed "Application.Run disposed this thread's windows as its loop
ended". That conflates two things: WinForms' loop teardown destroys the thread's window
HANDLES, but it does not Dispose non-Form controls -- the browser has always been disposed
explicitly from our own Dispose(). Reworded so it no longer implies prior coverage that never
existed. (The previous commit's message carries the same overstatement; this is the correction.)

Also recorded the deliberate other half of the trade-off: because WinForms no longer knows about
this loop, Application.Exit() no longer ends it either, so a GUI shutdown with a browser still
alive leaves this thread pumping until the process goes away. That is harmless -- the thread is
IsBackground so it cannot hold the process open, and the CoreWebView2 process dies with its
host -- but it is a real consequence of leaving the bookkeeping and deserves to be written down
rather than rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomExe/Publish/OffScreenBrowser.cs
Comment thread src/BloomTests/CLI/CreateArtifactsCommandTests.cs
Comment thread src/BloomExe/Publish/OffScreenBrowser.cs
Comment thread src/BloomExe/Publish/OffScreenBrowser.cs
@andrew-polk

Copy link
Copy Markdown
Contributor Author

Claude Opus 5 (1M context) from Andrew Polk's machine during preflight.

Consulted Devin on 2026-08-07, twice: first up to commit 41b404ba6cc02bc8ac0d61ee5072c6e7f9623118, then again up to aa4c570295b00792ab8d7ca50e9aafdeb4d40f48 after the comment fixes.

The second pass raised 1 Bug and 3 Investigate flags, each now its own thread:

  • Bug — a crash on the browser thread now hard-kills Bloom (thread). Real, and worse than the first pass suggested: the hard-kill skips the finally that releases Bloom's single-instance token, so Bloom might not restart. Left open — the clean fix changes Bloom's global fatal-error path, so it is the developer's call.
  • Investigate — Application.MessageLoop is now false on that thread (thread). The same mechanism as the Bug, raised in the first pass; left open with it.
  • Investigate — the WinForms ThreadContext for that thread is never torn down (thread). A small per-instance leak. Left open for a decision.
  • Investigate — the new regression test needs a real WebView2 (thread). Assessed as not a new risk and resolved: this fixture already boots a real browser for its bloomdigital case, and not sharing a browser is the point — a shared one would skip the teardown that BL-16668 broke, and the test would pass on the unfixed code.

Also 3 Informational items, not mirrored. Two of them — a comment that overstated what Application.Run cleaned up, and nothing tearing the thread down on GUI exit — were already fixed in aa4c570, in response to the first pass; the re-review re-emitted the comment one against text that no longer exists, so treat that one as stale. The third (a GetMessage failure after startup would be swallowed) is unreachable today and went to the developer alongside the others.

CI: the only GitHub check is pr-automation, which passed — note it only triggers this Devin review and adds the review link to the description; nothing server-side builds or tests this branch. CodeRabbit is switched off for this repo in .coderabbit.yml. The full C# suite was run locally at this commit: 3049 passed, 12 skipped, 1 failed, and that one failure is an opt-in Reading App Builder test failing its own environment precondition on this machine.

…BL-16668)

FatalExceptionHandler asked Application.MessageLoop to choose between a graceful
ProgramExit.Exit() and a bare Environment.Exit(1). But that flag is per-THREAD: it answers
"does the thread this exception arrived on have a WinForms message loop", not "is there a
running GUI to shut down". For anything surfacing on a worker thread the answer was no even
with Bloom running normally, so we took the hard-kill path -- which does not run `finally`
blocks, and therefore skips the one in Main that releases Bloom's single-instance token. The
user's Bloom could then refuse to start again, which is the exact failure ProgramExit exists
to prevent.

Previously the off-screen browser's thread hid this, by accident: it called Application.Run,
so MessageLoop was true there. Moving it to a private pump exposed the pre-existing flaw
rather than creating it -- any other worker thread was always at risk.

So ask the question directly: Program.MainMessageLoopIsRunning is true only while the
Application.Run in RunBloom is pumping. This is correct for every thread, not just the
browser's.

Found by Devin during preflight review of the previous commits; the choice to fix it here
rather than defer was Andrew's.

Also recorded, at Andrew's request, the decision to accept the WinForms ThreadContext that
each off-screen browser now leaves behind: self-limiting (the table is keyed by reused native
thread ids) and only truly removable by keeping one browser thread alive for the process,
which would undo ReleaseBrowser's intent.

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

Copy link
Copy Markdown
Contributor Author

Claude Opus 5 (1M context) from Andrew Polk's machine during preflight.

Consulted Devin a third time on 2026-08-07, up to commit 645bbdf2795e831477f9b741c234542f93c0970b.

Re-review clean — no new findings, and the bug is confirmed fixed. Devin marks its earlier bug resolved, citing the switch to Program.MainMessageLoopIsRunning. Every Investigate flag in this pass is one we had already mirrored and closed, so nothing new was posted.

Thread status — all four now resolved with a documented outcome:

  • Bug, a crash on the browser thread hard-kills Bloom — fixed in 645bbdf, and Devin agrees (thread). That thread also carries a correction to a claim I had made about the bug being pre-existing; it was not, and the record now says so.
  • Application.MessageLoop false on the browser thread — same fix (thread).
  • Retained WinForms ThreadContext — Andrew decided to accept it; reasoning recorded both on the thread and as a code comment beside ctx.Dispose().
  • Regression test needs a real WebView2 — assessed as not a new risk and resolved earlier.

Five Informational items this pass, none mirrored. One is new — a note that the practical effect of the Application.MessageLoop swap is narrow, which is fair in that the branch is GUI-only (unreachable while UseFallback is true, i.e. on every CLI path); the part of its reasoning I disagree with is answered on the bug thread. Two describe things already fixed in earlier commits of this branch, and one of those quotes text that no longer exists, so treat it as stale. The remaining two — a post-dispose call hanging rather than throwing, and a GetMessage failure after startup being swallowed — went to Andrew as a decision and he chose to leave them: neither is reachable from any current caller.

Full C# suite at this commit, run locally: 3049 passed, 12 skipped, 1 failed — the one failure is an opt-in Reading App Builder test failing its own environment precondition on this machine, unrelated to this branch. Note that nothing server-side builds or tests this branch (the pr-automation check only triggers this Devin review), and CodeRabbit is switched off for this repo, so that local run is the only test evidence. The fatal-error path changed in 645bbdf has no automated coverage — there is no harness for a path that deliberately terminates the process — so its evidence is the compile plus the absence of regression elsewhere.

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