Skip to content

Teach Bloom to publish its health, and add the Doctor's wire format (BL-16719) - #8259

Closed
JohnThomson wants to merge 2 commits into
masterfrom
BL-16719-bloom-side
Closed

Teach Bloom to publish its health, and add the Doctor's wire format (BL-16719)#8259
JohnThomson wants to merge 2 commits into
masterfrom
BL-16719-bloom-side

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

This is the first of two stacked PRs. It contains everything that goes inside Bloom, plus the wire format both sides share. The Doctor process itself is #8229, which targets this branch.

Problem

When Bloom freezes, we get almost nothing. The user kills it and carries on, and freezes are exactly the failure people don't report — so a bug like BL-16697 can only be guessed at. Worse, Bloom cannot reliably tell us it is frozen, and nor can Windows: a WinForms UI thread blocked in a managed wait still dispatches sent messages, so IsHungAppWindow and Process.Responding report a thoroughly wedged Bloom as healthy. That was measured at nine minutes frozen and reported responsive.

What this PR changes

Bloom starts publishing enough about itself that a separate process can tell it has stopped, and can say something useful about why:

  • A UI-thread heartbeat written to a small shared-memory page by a WinForms timer. Because of the measurement above, this is the only signal that actually detects a freeze — everything else in the report is evidence, this is detection.
  • A per-run session file carrying the facts a watcher cannot work out from outside — above all which log file this run is writing to. Bloom recreates Log.txt each run and falls back to a random name only when another Bloom holds it, so from outside the newest log is the wrong answer exactly in the restart-after-a-freeze case.
  • In-flight API request tracking (ApiActivityTracker) recording which requests are running and which lock each is waiting on, so a server deadlock can be read off a report rather than reproduced.
  • Long-operation scopes at the half-dozen places Bloom legitimately stops answering for minutes — BloomPUB, ePUB, Reading App Builder, video recording, upload, download. These raise the freeze threshold from 1 to 5 minutes so a legitimate slow job is not reported as a freeze.
  • DoctorLauncher and a debug-menu toggle, which start the companion process when the setting is on. Off by default.
  • FreezeSimulator, which makes Bloom break itself on purpose so the detection and crash paths can be exercised at all. It refuses to arm on the release channels.

BloomFreezeDoctor.Protocol is a plain project both sides reference rather than a published package, so one wire format has one definition instead of two hand-maintained copies. Its layout is pinned by value in tests on both sides.

Nothing here does anything on its own. With no Doctor installed — the default — Bloom writes a heartbeat that nobody reads.


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

Why the split: at ~19,600 lines the combined change defeated every automated reviewer we have — Devin's analysis never completed on it across four attempts on four separate commits. It also mixes two very different risk profiles: the Doctor is a separate process, so if it is wrong a card is wrong, whereas this half runs inside the shipping application. This is the half that deserves the scrutiny.

Devin review


This change is Reviewable

…BL-16719)

The first of two PRs. This one is everything that goes INSIDE Bloom, plus the
protocol both sides share; the Doctor process itself follows in a second PR
stacked on this branch.

Split this way for two reasons. The first is review: at ~19,600 lines the
combined change defeated every automated reviewer we have - Devin's analysis
never completed on it across four attempts on four different commits. The second
is that these two halves carry very different risk. The Doctor is a separate
process, and if it is wrong a card is wrong. This half runs inside the shipping
application, so it is the half that deserves the scrutiny.

What Bloom gains:

- FreezeDoctorSupport, which publishes a UI-thread heartbeat and what Bloom
  believes it is doing into a small shared-memory page, and writes a per-run
  session file with the facts a watcher cannot work out from outside - above all
  which log file this run is writing to.
- ApiActivityTracker, which records in-flight API requests and which lock each
  one is waiting on, so a deadlock can be read off a report.
- Long-operation scopes at the half-dozen places Bloom legitimately stops
  answering for minutes (BloomPUB, ePUB, RAB, video, upload, download), which
  raise the freeze threshold rather than letting a legitimate slow job look like
  a freeze.
- DoctorLauncher, which starts the Doctor if the setting is on, and the debug-menu
  toggle for that setting. Off by default.
- FreezeSimulator, which makes Bloom break itself on purpose so the detection and
  crash paths can be exercised at all. Refuses to arm on the release channels.

The heartbeat is the part worth understanding, because the obvious alternative
does not work: a WinForms UI thread blocked in a managed wait still dispatches
sent messages, so IsHungAppWindow and Process.Responding report a thoroughly
wedged Bloom as healthy - measured at nine minutes frozen and reported
responsive. A timer-driven heartbeat in shared memory is the only thing that
sees it.

BloomFreezeDoctor.Protocol is a plain project both sides reference rather than a
package, so one wire format has one definition. Its layout is pinned by value in
tests on both sides.

Nothing here does anything on its own: with no Doctor installed, Bloom writes a
heartbeat nobody reads.
@greptile-apps

greptile-apps Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Bloom-side health publishing and the shared Freeze Doctor protocol while instrumenting API activity, long-running publishing operations, crash handling, and shutdown state.

  • Publishes UI and watchdog heartbeats plus per-run session metadata.
  • Tracks in-flight API requests and synchronization-lock state for diagnostics.
  • Adds long-operation scopes around publishing, upload, and download workflows.
  • Adds the opt-in launcher setting and debug-menu control for the companion process.
  • Defines and tests the shared-memory, session-file, signal, shutdown, and upload wire formats.

Important Files Changed

Filename Overview
src/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs Adds heartbeat, session persistence, long-operation accounting, crash-dump signaling, and shutdown-state publication with guarded failure handling.
src/BloomExe/FreezeDoctor/ApiActivityTracker.cs Adds lock-free in-flight request diagnostics; the public disposal method is missing repository-required documentation.
src/BloomExe/web/BloomApiHandler.cs Integrates request scopes and lock-state annotations around the existing synchronized API dispatch lifecycle.
src/BloomExe/Program.cs Starts health publishing and the optional Doctor launcher before the message loop and records ordered shutdown phases afterward.
src/BloomExe/FreezeDoctor/DoctorLauncher.cs Launches the companion executable when enabled and safely no-ops when this first stacked PR does not yet provide it.
src/BloomFreezeDoctor.Protocol/DoctorChannel.cs Defines the shared-memory channel and synchronization behavior used by Bloom and the companion process.
src/BloomFreezeDoctor.Protocol/DoctorSession.cs Defines persistent run and exit records plus retention and atomic session-store behavior.
src/BloomBrowserUI/react_components/TopBar/TopBarContextMenu.tsx Adds a persisted debug-menu toggle whose endpoint matches the new AppApi registration.
src/BloomTests/FreezeDoctor/FreezeDoctorProtocolTests.cs Pins protocol constants, layout, signal behavior, session serialization, and long-operation semantics.

Reviews (1): Last reviewed commit: "Teach Bloom to publish its health, and a..." | Re-trigger Greptile

Found by Devin on #8259 — its first completed review of this work, which is the
point of having split the PR.

Long-operation scopes do not always nest. A starts, B starts, A finishes, B
finishes: on the way out, B restored "whatever was showing when I started",
which is A — an operation that had by then been over for some time. A report
gathered afterwards therefore named work that had already completed, which is
the one thing the activity string exists to prevent.

The existing guard handled the other ordering correctly (a scope that no longer
owns the slot leaves it alone), so nesting was safe; only overlap was wrong.

Devin's suggested fix was to track active scopes. That is more machinery than
this needs, and it would have missed the opposite error: falling back to the
empty string when the last scope closes silently discards a standing activity
set OUTSIDE any scope — and there is one, because recording a video says what it
is doing and only then opens a scope to merge the result. So instead the
outermost scope remembers what Bloom was saying before any scope opened, and the
last one out restores that; anyone closing while others still run restores what
they interrupted, as before.

Two tests, one per direction, and the overlap one verified to fail when the fix
is undone. Bloom-side FreezeDoctor tests 27 pass; full C# suite 3321 pass.
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 from Thomson's machine during preflight]

Consulted Devin — up to 73a941252

This is the first Devin review that has completed on this work. On the combined ~19,600-line PR (#8229) the analysis never finished, across four attempts on four separate commits. Splitting the change so this half stands alone got a completed review with findings, which is the whole reason for the split.

Also on this PR: Greptile ran and passed.

1 Bug — fixed

Overlapping operations leave stale activitysrc/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs:355

When overlapping operations finish in start order, CompareExchange restores an already-finished operation. Later diagnostic reports keep naming that completed work.

Correct, and traced before fixing: long-operation scopes do not always nest. A starts, B starts, A finishes, B finishes — and B restores "whatever was showing when I started", which is A, over by then. A report gathered afterwards names completed work, which is precisely what the activity string exists to prevent. The existing guard covered the other ordering, so nesting was already safe; only overlap was wrong.

Fixed in f6918c688, though not the way suggested. Tracking active scopes would have missed the opposite error: clearing the activity when the last scope closes silently discards a standing activity set outside any scope — and there is one, because recording a video says what it is doing and only then opens a scope to merge the result. Instead the outermost scope remembers what Bloom was saying before any scope opened, and the last one out restores that. Two tests, one per direction; the overlap one verified to fail when the fix is undone.

2 Investigate flags — both assessed, no change

Future protocol growth needs gated readsDoctorChannel.cs:418

Accurate, and already the documented plan rather than an oversight. The layout note at DoctorChannel.cs:191–212 states the growth rule and the exact condition a reader will need (fieldOffset + fieldSize <= snapshot.PayloadBytes), and records that PayloadBytes is surfaced on the snapshot so that it is there the day it is needed. Nothing is unsafe today: there is one schema version in existence, so every reader and writer agrees on the extent. The gating becomes load-bearing at the first append, which is when it will be written — and the layout tests assert that PayloadBytes really is the end of the last field, so an append that forgets to grow it fails the build.

Doctor toggle remains English-onlyTopBarContextMenu.tsx:161

As Devin notes, this matches the neighbouring debug-menu items, which are deliberately not localized — that menu is a developer and support affordance, not user-facing UI, and adding an XLF entry for it would put a string in front of translators that no user is meant to see. Leaving it consistent with its neighbours.

3 Informational — noted, no action

Credential lookup retains both locations; snapshot sequencing handles concurrent writers; session refresh preserves concurrent markers. Recorded here so the count is visible rather than silently dropped.

@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 from Thomson's machine during preflight]

Correction to my comment above. I claimed the split is what got Devin to complete. That is not supported, and I should not have asserted it.

Checking the job API for #8229 afterwards, lifeguard_status is complete there too — including for c299e1c33, the last commit before the split, on the full ~19,600-line diff. So the earlier failures (four attempts stuck at lifeguard_status: pending) were evidently transient on Devin's side rather than caused by PR size, and the split cannot be credited with fixing them.

The split still stands on its own merits — this half is 4,349 lines against 15,296, it is the half that runs inside Bloom, and it can be reviewed and merged independently. But "it made Devin work" is not one of them.

Everything else in that comment — the bug, the fix, and the two assessments — is unaffected.

JohnThomson added a commit that referenced this pull request Aug 31, 2026
Found by Devin on #8259 — its first completed review of this work, which is the
point of having split the PR.

Long-operation scopes do not always nest. A starts, B starts, A finishes, B
finishes: on the way out, B restored "whatever was showing when I started",
which is A — an operation that had by then been over for some time. A report
gathered afterwards therefore named work that had already completed, which is
the one thing the activity string exists to prevent.

The existing guard handled the other ordering correctly (a scope that no longer
owns the slot leaves it alone), so nesting was safe; only overlap was wrong.

Devin's suggested fix was to track active scopes. That is more machinery than
this needs, and it would have missed the opposite error: falling back to the
empty string when the last scope closes silently discards a standing activity
set OUTSIDE any scope — and there is one, because recording a video says what it
is doing and only then opens a scope to merge the result. So instead the
outermost scope remembers what Bloom was saying before any scope opened, and the
last one out restores that; anyone closing while others still run restores what
they interrupted, as before.

Two tests, one per direction, and the overlap one verified to fail when the fix
is undone. Bloom-side FreezeDoctor tests 27 pass; full C# suite 3321 pass.
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 from John Thomson's machine during preflight]

Closing this. It was a temporary split, made to give the automated reviewers a smaller diff, and it has served its purpose — Devin reviewed it and found one real bug (overlapping long operations leaving finished work on display), which is fixed.

The Freeze Doctor goes back to being a single pull request, #8229, which carries all the machine and human review history. #8229 now targets Version6.5 rather than master, per the transition rule in AGENTS.md.

Nothing is lost by closing this: every commit that was only on this branch is in #8229, and I verified the Doctor's own code is byte-identical after the rebase. The one thing deliberately dropped is master-only work that came along when this branch was cut from masterf8444670d ("Keep automation-launched Bloom windows off the user's active monitor"), which belongs to master and not to this feature.

@JohnThomson
JohnThomson deleted the BL-16719-bloom-side branch August 31, 2026 14:05
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