Don't listen for ApplicationExit when there is no GUI application (BL-16668) - #8178
Conversation
|
Claude Opus 5 (1M context) from Andrew Polk's machine during preflight. Consulted Devin on 2026-08-07, up to commit
Three further informational items, not mirrored. One — 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 One process note for whoever reads this later: |
…-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>
878735f to
aab1ecf
Compare
…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>
Bloom.exe createArtifactswas dying withObjectDisposedExceptionon the epub step, so theharvester 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 processbelongs to a worker: the dedicated thread of the off-screen browser
PublishHelperuses for itspage checks, created and disposed once per batch. So:
createArtifactsbuilds the .bloompub first;BloomPubMakerdoesusing (var helper = new PublishHelper()).PublishHelper.ReleaseBrowser()→OffScreenBrowser.Dispose(), ending that loop.Application.ApplicationExit.ApplicationContainer.OnApplicationExitdisposes the container — the parent ofProjectContext._scope.s_projectContext.BookServer→ObjectDisposedException.In the GUI this never fires, because the main thread's loop is still running. It only ever bit a
non-GUI entry point.
d41fa15f9ehad already met this mechanism and madeProjectContext.Disposetolerate the containerbeing 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.RunningInConsoleModeis set by
Program.Mainfor every command-line verb (hydrate,upload,download,getfonts,changeLayout,createArtifacts,spreadsheetExport/Import,sendFontAnalytics) before itdispatches 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, notRunningHarvesterMode. 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
CreateArtifactsCommandsetsRunningHarvesterModeafter constructing the container, so aguard on that flag works only because the event happens to fire later.
so there is no reason to sign up and then ignore the answer.
Verified that the console-mode branch of
Mainis self-contained — it parses, dispatches, pumpswith
Application.DoEvents()and returns, never reachingRunBloom'sApplication.Run— soRunningInConsoleModebeing true really does mean there is no GUI application to listen for, and noGUI path is affected.
Regression test
Every pre-existing
CreateArtifactsCommandTestscase requested a single artifact, which is exactlywhy CI stayed green through this. The harvester always passes
--bloomdOutputPath/--bloomDigitalOutputPathand--epubOutputPathin one run, and only that combination exposesthe bug — with one artifact, nothing runs after the premature disposal. The new test asks for both
and fails on unmodified
masterwith the production stack trace, frame for frame:The tests call
HandleInternaldirectly and so bypassMain, which sets that flag for real runs, sothey have to stand in for it — hence the new
SetUp, mirroring what the fixture already did forRunningHarvesterModeinTearDown. The repro was re-verified withApplicationContainer.csatexact
masterstate and thatSetUpin place, confirming it is the fix passing the test rather thanthe harness.
Relationship to #8177
#8177 is the more thorough alternative: it stops
OffScreenBrowserusingApplication.Runat all,so the false
ApplicationExitis never raised and no listener has to know to distrust it. That isthe 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.MessageLoopgoesfalse, 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