Skip to content

Don't listen for ApplicationExit when there is no GUI application (BL-16668) - #8178

Merged
andrew-polk merged 2 commits into
masterfrom
BL-16668-harvester-exit-guard
Aug 7, 2026
Merged

Don't listen for ApplicationExit when there is no GUI application (BL-16668)#8178
andrew-polk merged 2 commits into
masterfrom
BL-16668-harvester-exit-guard

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

A command-line verb never calls Application.Run, so the only WinForms message loop in the process
belongs to a worker: the dedicated thread of the off-screen browser PublishHelper uses for its
page checks, created and disposed once per batch. So:

  1. createArtifacts builds the .bloompub first; BloomPubMaker does using (var helper = new PublishHelper()).
  2. Disposing it calls PublishHelper.ReleaseBrowser()OffScreenBrowser.Dispose(), ending that loop.
  3. It was the process's only message loop, 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 fires, because the main thread's loop is still running. It only ever bit a
non-GUI entry point.

d41fa15f9e had already met this mechanism and made ProjectContext.Dispose tolerate the container
being disposed first — but the premature disposal itself remained. It was harmless there only
because it happened after all the requested work.

The fix

Rather than act on a signal we know to be wrong, don't subscribe to it. Program.RunningInConsoleMode
is set by Program.Main for every command-line verb (hydrate, upload, download, getfonts,
changeLayout, createArtifacts, spreadsheetExport/Import, sendFontAnalytics) before it
dispatches to any of them, so gating the subscription on it means the container never hears a claim
of application exit that no GUI is behind.

Two notes on why it is this flag and this location:

  • RunningInConsoleMode, not RunningHarvesterMode. An earlier draft used the harvester flag.
    This one covers every verb — the others never set the harvester flag and would have stayed
    exposed — and it has no ordering hazard: it is true before any container exists, whereas
    CreateArtifactsCommand sets RunningHarvesterMode after constructing the container, so a
    guard on that flag works only because the event happens to fire later.
  • Gating the subscription, not the handler. The question is settled before we would subscribe,
    so there is no reason to sign up and then ignore the answer.

Verified that the console-mode branch of Main is self-contained — it parses, dispatches, pumps
with Application.DoEvents() and returns, never reaching RunBloom's Application.Run — so
RunningInConsoleMode being true really does mean there is no GUI application to listen for, and no
GUI path is affected.

Regression test

Every pre-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 tests call HandleInternal directly and so bypass Main, which sets that flag for real runs, so
they have to stand in for it — hence the new SetUp, mirroring what the fixture already did for
RunningHarvesterMode in TearDown. The repro was re-verified with ApplicationContainer.cs at
exact master state and that SetUp in place, confirming it is the fix passing the test rather than
the harness.

Relationship to #8177

#8177 is the more thorough alternative: it stops OffScreenBrowser using Application.Run at all,
so the false ApplicationExit is never raised and no listener has to know to distrust it. That is
the better end state, but it is a bigger change (a private Win32 message pump) and it turned out to
have a consequence of its own — with no WinForms loop on that thread, Application.MessageLoop goes
false, which sent Bloom's fatal-error handler down a hard-kill path that skips releasing the
single-instance token. Fixable, and fixed there, but it widened the change.

This PR is the deliberate small-scope choice for now. The two should not both land: with the
pump change in place this guard is dead code.

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

Devin review


This change is Reviewable

Comment thread src/BloomExe/ApplicationContainer.cs
Comment thread src/BloomTests/CLI/CreateArtifactsCommandTests.cs
Comment thread src/BloomTests/CLI/CreateArtifactsCommandTests.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, up to commit 878735fcfb1331f987d8d6440925ea7a73fb2f9a. No bugs. Six flags, two of them Investigate.

  • Unit tests outside this fixture could still hit the same mechanism (thread) — raised in both passes. Verified unreachable today: no test invokes DownloadBookCommand, UploadCommand, ChangeLayoutCommand or SendFontAnalyticsCommand, and none builds an ApplicationContainer directly. Left open for Andrew, because also exempting RunningUnitTests would let the regression test pass through a test-only exemption and stop proving the production predicate is wired right — a trade worth his call rather than mine.
  • The regression test does a real epub + bloomdigital build (thread) — assessed as not a new risk and resolved: this fixture already booted a real off-screen browser for its bloomdigital case, and not sharing a browser is the point, since a shared one would skip the teardown that BL-16668 broke.
  • The epub worker's exit code is read across threads (thread) — filed by Devin as informational, promoted to a thread because checking it turned up a real pre-existing bug next door: CreateArtifactsCommand.cs:198 assigns exitCode where every other step uses |=, so an epub failure erases flags such as BookHtmlNotFound. Left open for Andrew; it changes exit codes the harvester consumes, so it is not a cleanup to slip into a narrow fix.

Three further informational items, not mirrored. One — FinishLocalizationHarvesting no longer running for console verbs — was already addressed in 878735f, which documents it next to the guard. One notes that console-mode disposal now rests entirely on the using blocks in each verb; Devin checked all five verbs and confirmed they all have one, and the guard's comment already gives that as the reason it is safe. The last, that the new SetUp sets a process-global for the whole fixture, was assessed as harmless: every production path it touches is also exempted by RunningUnitTests, and this repo configures no test parallelism (the only related attribute anywhere is a [NonParallelizable]), so no other fixture can observe it.

Full C# suite at this commit: 3052 passed, 12 skipped, 0 failed — completely green, no environment exclusions needed. CodeRabbit is switched off for this repo in .coderabbit.yml. Note that nothing server-side builds or tests this branch: the only GitHub check, pr-automation, just opens this Devin review URL, and TeamCity builds default branches only — so that local run is the whole of the test evidence.

One process note for whoever reads this later: pr-automation reported success for this commit without a Devin review actually being queued, and it took a manual visit to the review page to start one. A green pr-automation check is not evidence that Devin ran; the jobs API having a job for the current HEAD sha is.

…-16668)

`Bloom.exe createArtifacts` died 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 (BH-7836).

A command-line verb never calls Application.Run, so the only WinForms message loop in the process
belongs to a worker: the dedicated thread of the off-screen browser PublishHelper uses for page
checks, created and disposed once per batch. Building the .bloompub disposes a PublishHelper, which
releases that browser, which ends the loop -- and WinForms, seeing its only message loop end,
raises Application.ApplicationExit. ApplicationContainer believed it and disposed itself, and it is
the parent scope of the ProjectContext still in use, so the epub step (which runs next) got
ObjectDisposedException from ProjectContext.BookServer. The harvester treats any non-FontProblems
exit code as failure, hence losing the whole book. In the GUI this never fired, because the main
thread's loop is still running; it only ever bit a non-GUI entry point.

d41fa15 had already met this mechanism and made ProjectContext.Dispose tolerate the container
being disposed first, but the premature disposal itself remained. It was harmless there only
because it happened after all the requested work.

Rather than act on a signal we know to be wrong, don't subscribe to it. Program.RunningInConsoleMode
is set by Program.Main for every command-line verb (hydrate, upload, download, getfonts,
changeLayout, createArtifacts, spreadsheetExport/Import, sendFontAnalytics) before it dispatches to
any of them.

Two notes on the choice of flag and location:

- RunningInConsoleMode, not RunningHarvesterMode. It covers every verb -- the others never set the
  harvester flag and would have stayed exposed -- and it has no ordering hazard, because it is true
  before any container exists, whereas CreateArtifactsCommand sets RunningHarvesterMode AFTER
  constructing one, so a guard on that flag works only because the event happens to fire later.
- Gating the subscription rather than the handler, because the question is settled before we would
  subscribe, so there is no reason to sign up and then ignore the answer.

Verified that the console-mode branch of Main is self-contained -- it parses, dispatches, pumps with
Application.DoEvents() and returns, never reaching RunBloom's Application.Run -- so
RunningInConsoleMode being true really does mean there is no GUI application to listen for, and no
GUI path is affected.

One knock-on, documented next to the guard: OnApplicationExit was the only caller of
Program.FinishLocalizationHarvesting(), so a CLI verb no longer runs it. That code is #if DEBUG and
inert unless LocalizationManager.IgnoreExistingEnglishTranslationFiles is set, so release runs are
unaffected.

The regression test is the other half of this. Every pre-existing case in
CreateArtifactsCommandTests requested a single artifact, which is exactly why CI stayed green
through this bug: the harvester always passes --bloomdOutputPath/--bloomDigitalOutputPath AND
--epubOutputPath in one run, and only that combination exposes it, because with one artifact
nothing runs after the premature disposal. The test asks for both and fails on unmodified master
with the production stack trace frame for frame. It needs a SetUp that sets RunningInConsoleMode,
because the tests call HandleInternal directly and so bypass Main, which sets it for real runs --
mirroring what the fixture already did for RunningHarvesterMode in TearDown. Re-verified that the
test still fails with ApplicationContainer.cs at exact master state and that SetUp in place, so it
is the fix passing the test rather than the harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andrew-polk
andrew-polk force-pushed the BL-16668-harvester-exit-guard branch from 878735f to aab1ecf Compare August 7, 2026 20:06
…L-16668)

CreateArtifactsExitCode is [Flags], and its own comment says the values exist "so they can be
bit-or'd together". Every step in CreateArtifacts accumulates with |= -- except the epub worker,
which assigned. So an epub failure discarded whatever had already been recorded: most importantly
BookHtmlNotFound from the bloomdigital step immediately above, but also UnhandledException or
LegacyBookCannotHarvest. The process exited with just 4, GetErrorsFromExitCode reported only
"EpubException", and the harvester's record of the book lost the rest. It is most likely to bite in
exactly the configuration this card is about, since the harvester always asks for both artifacts.

Checked whether the assignment might have been deliberate; it was not:

- The commit that introduced it, dd8a087, is titled "Fix incorrect exit codes if ThreadPool
  throws exception" -- its stated purpose was to make the exit code correct.
- At that same commit, `exitCode |= CreateBloomDigitalArtifacts(...)` was already 19 lines above,
  and could already have set BookHtmlNotFound. So it was never lossless; it was inconsistent with
  its own method from the day it was written.
- "Epub failure should dominate" is not the design either: GetErrorsFromExitCode reports every
  flag as a list.

The one real argument for a plain write is atomicity, since |= is a read-modify-write on a variable
captured by a worker thread. It does not apply: the main thread is blocked spinning on
countdownEvent.IsSet for the whole window and does not touch exitCode until after the worker
signals. (And if there were a concurrent writer, the answer would be a lock, not a lossy write.)

Not covered by a test: provoking it needs a book that fails the bloomdigital step AND throws in
epub creation in one run, which no existing fixture arranges. Found by Devin during preflight
review of this PR; Andrew's call to fix it here rather than on its own card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andrew-polk
andrew-polk marked this pull request as ready for review August 7, 2026 23:56
@andrew-polk
andrew-polk merged commit d3603a5 into master Aug 7, 2026
1 check passed
@andrew-polk
andrew-polk deleted the BL-16668-harvester-exit-guard branch August 7, 2026 23:56
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