Skip to content

Add the Bloom Freeze Doctor (BL-16719) - #8229

Merged
andrew-polk merged 112 commits into
Version6.5from
BL-16719-doctor-in-repo
Sep 9, 2026
Merged

Add the Bloom Freeze Doctor (BL-16719)#8229
andrew-polk merged 112 commits into
Version6.5from
BL-16719-doctor-in-repo

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Users tell us Bloom froze, and we get almost nothing to work with. Their problem report is written after they killed Bloom, so it describes a healthy new process, and the log holds only what Bloom managed to write before it stopped responding. Three quite different failures arrive looking identical — the UI stops responding; Bloom exits without reporting anything; or Bloom's window is gone while the process lives on, so the user cannot start Bloom again. BL-16697 is the live example.

Cause

Nobody is watching at the moment it happens, and the worst case cannot be watched from outside at all.

The measurement the whole design rests on — check this first if you check one thing: a WinForms UI thread blocked in a managed wait on an STA thread still dispatches sent messages, so the window answers probes, IsHungAppWindow reports it healthy and Process.Responding returns true while Bloom is completely stuck. Measured on a real Bloom: nine minutes frozen, reported responsive throughout. Since Bloom's UI thread awaits WebView2 constantly, that is likely the common shape of freeze rather than an exotic one. WM_TIMER is not dispatched, which is why detection rests on a UI-thread timer heartbeat published through shared memory, and not on either API that looks built for the job.

What this PR does

Adds a companion Windows app — the Freeze Doctor, four projects in this repository — and teaches Bloom to publish enough about itself that the Doctor can tell it has stopped and say something useful about why.

Bloom's side:

  • A UI-thread heartbeat in shared memory, which for the reason above is the only signal that detects a freeze at all; a per-run session file recording which log 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 guessing from outside picks the wrong file in exactly the restart-after-a-freeze case); in-flight API requests and which lock each is waiting on; and long-operation scopes at the half-dozen places Bloom legitimately stops answering for minutes, which raise the freeze threshold rather than letting a publish look like a freeze.
  • It announces itself as it starts — when the Doctor is switched on — early in Main, so a Doctor already running adopts it at once rather than at its next sweep. Everything before that announcement is time in which a hang or a crash cannot be doctored at all, because Bloom only asks for a crash dump when a Doctor is already watching. For the users who have never switched the Doctor on it costs one setting read.
  • A launcher and a debug-menu toggle, off by default, and a simulator that makes Bloom break itself on purpose so the detection and crash paths can be exercised at all.

The Doctor's side: it is already running when the trouble starts, gathers what can only be gathered at that instant, and files a YouTrack card by itself.

  • It watches one Bloom, once a second, reading Bloom's shared page rather than asking Bloom anything, so a wedged Bloom is still readable. It distinguishes three states — frozen, window-less but alive, and gone without proof of a clean shutdown — and applies a longer threshold while Bloom says it is doing something legitimately slow.
  • It gathers at the moment of failure: managed stacks (from a live read, or from a dump Bloom pauses its own death to let us take), the Windows wait chain showing who is blocked on whom, the WebView2 processes and their state, the right log file, and the machine's own account of the exit from the Event Log and Windows Error Reporting. The report leads with what a reader needs first — where the UI thread is stuck, or what was thrown.
  • It files, and does not duplicate. Reports are written to disk first and sent afterwards, so nothing is lost to a machine that is offline or about to be shut down. The same problem recurring becomes one card with a count: a freeze is identified by the UI thread's top frames, a crash by the faulting thread's exception and frames — the UI thread being no use for a crash, since it is sitting in its message loop throughout.
  • It stays quiet when it should. A developer build, a headless job run, a deliberately simulated failure, a process seen under a debugger, and a problem Bloom has already reported itself all produce evidence on disk and no card — with a "Send it anyway" button for the developer who decides that particular report should go after all.
  • It offers to clear the way. A window-less Bloom holds the single-instance token, so nothing can start Bloom again; the Doctor can end that one — never a merely frozen one, which may still be holding unsaved work — after the evidence is safely gathered, and only after re-checking that the process it is about to end is still the one it was watching.

It ships inside Bloom's installer with no installer of its own, and is off by default, switched on from the debug menu on the Collections tab. It is part of the next Beta; whether to offer it to Release users is still open.

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

Devin review


This change is Reviewable

@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds the opt-in Bloom Freeze Doctor companion application, Bloom-side health publication, freeze/crash/zombie detection, evidence gathering, persistent report queuing, and YouTrack submission.

  • Adds a versioned shared-memory protocol with UI and background heartbeats, activity state, shutdown state, and diagnostic signals.
  • Adds the Doctor supervisor, detectors, evidence collectors, outbox, YouTrack integration, tray UI, and automated tests.
  • Integrates Doctor startup, settings, debug controls, build output, signing, and installer packaging with Bloom.

Important Files Changed

Filename Overview
src/BloomFreezeDoctor.Core/Outbox/ReportOutbox.cs Implements persistent report queuing and cross-process drain serialization; the latest lock-timeout fix now exits without entering the drain when another process holds the gate.
src/BloomFreezeDoctor/DoctorSupervisor.cs Coordinates process discovery, evidence gathering, report-now requests, queued submission, and watcher lifecycle without a remaining blocking issue from the reviewed threads.
src/BloomFreezeDoctor.Core/FreezeDetector.cs Implements heartbeat-based freeze, crash, and windowless-process classification with slow-activity and debugger handling.
src/BloomFreezeDoctor.Protocol/DoctorChannel.cs Defines the shared-memory health protocol and compatibility behavior used between Bloom and the Doctor.
src/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs Publishes Bloom health, activity, debugger, shutdown, logging, and diagnostic-signal state to the companion process.
src/BloomFreezeDoctor.Core/Outbox/YouTrackSubmitter.cs Submits queued diagnostic reports and attachments to YouTrack while supporting fingerprint-based report consolidation.
src/BloomFreezeDoctor.Tests/ReportOutboxTests.cs Covers outbox persistence, submission limits, retention, and refusal of a second drain while another process owns the lock.
build/Bloom.proj Builds, signs, and packages the Freeze Doctor alongside Bloom.

Reviews (15): Last reviewed commit: "Stop the comments pointing at the abando..." | Re-trigger Greptile

Comment thread src/BloomFreezeDoctor.Core/Outbox/ReportOutbox.cs Outdated
JohnThomson added a commit that referenced this pull request Aug 24, 2026
Both found by Devin on PR #8229, and both verified against the code before
changing anything.

THE 6.3 WEBVIEW2 FALLBACK COULD NEVER FIRE

FindCdpPort ended with

    return children.Count == 0 ? null : (int?)null;

Both arms are null, so the documented fallback to the port 6.3 hardcodes was
dead code. A Bloom 6.3 with no session file therefore got no CdpPort, and its
WebView2 section was dropped from the report with nothing to say why.

The guard around it was right and is kept: WebView2 children that advertise no
port mean this is NOT the 6.3 arrangement, and guessing 9222 would interrogate
some other program's browser and put the answers on a Bloom card. It is the
no-children case that looks like 6.3, and that now returns
WebView2Processes.LegacyHardcodedPort as the comment always said it did.

FOUR DEDUP SETS MUTATED FROM SEVERAL THREADS AT ONCE

_dumpsRequested, _exitsExamined, _zombiesReported and _zombiesEnded are shared
across watchers, but every watcher raises Observed on ITS OWN timer thread, so
with two Blooms being watched - routine on a developer's machine - they were
touched concurrently with no lock. An unsynchronised HashSet can corrupt itself
or throw, and worst of all can silently lose an entry, which is what these sets
exist to prevent: a second dump of a crashing Bloom while it holds its own death
open for us, or a second report of one exit.

Telling detail: one of the five call sites, the _zombiesReported.Add after a
zombie report is queued, ALREADY held _lock. So locking these was the intention
and the other sites were simply missed.

Each test-and-claim is now inside _lock, and only the test-and-claim - never the
work that follows, which includes gathering and killing processes. The zombie
pair is under a single lock together, because "the evidence is gathered AND
nobody else has taken the one attempt" is one decision; splitting it would let
two threads both conclude they were the one to end the process.

DumpRequested() was pulled out of its short-circuit so it is not called while
holding the lock.

110 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomFreezeDoctor.Core/Gathering/GatherContextBuilder.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/DoctorSupervisor.cs
Comment thread src/BloomFreezeDoctor.Core/Outbox/YouTrackSubmitter.cs
JohnThomson added a commit that referenced this pull request Aug 24, 2026
Found by Greptile on PR #8229, and correct.

Three separate things ask for a drain - startup, the five-minute timer, and the
end of a gather - and all three were fire-and-forget tasks, so two could
overlap. Both would then list the SAME pending bundles and both walk the
search-then-create flow against YouTrack, which is not atomic. The results are
duplicate cards, or duplicate comments on one card, and a combined total that
can exceed the deliberate three-per-day cap. That cap exists so that a machine
in a bad state cannot spam the tracker, so quietly exceeding it is the worst
version of this.

Neither the supervisor nor ReportOutbox had any mutual exclusion, so a
SemaphoreSlim(1,1) now serialises it, and it is disposed with the supervisor.

It WAITS rather than skipping. Skipping would be cheaper and is tempting, since
a second concurrent drain would usually find nothing left to do - but
ReportNowAsync awaits this and then looks for its own bundle in the queue. Had
its drain been skipped because another was already running, it could have
reported failure for a report that was in fact about to be filed perfectly well.

110 tests pass.
Comment thread src/BloomFreezeDoctor/Program.cs
JohnThomson added a commit that referenced this pull request Aug 24, 2026
Found by Devin on PR #8229, and it is the exact failure _workInFlight exists to
prevent - on the one path that never got the guard.

The field's own comment says why it is there: a Bloom would crash, the Doctor
would notice the process was gone, conclude there was nothing left to watch,
and exit, cancelling the examination of the very crash it had just seen. It is
incremented for the report-gathering job and for the exit examination. The crash
DUMP job never incremented it, so ConsiderExiting could see zero work in flight
and shut the Doctor down mid-dump.

This is also the likeliest path for it to happen, not the least: the dump runs
because Bloom is in the act of crashing, so the process is about to disappear -
which is precisely the event that makes the Doctor look around and find nothing
left to watch. And it is the worst one to lose, because Bloom is holding its own
death open for about three seconds waiting for us, so the dump either happens
now or never.

The decrement goes after SignalDumpComplete, so Bloom is released before our
bookkeeping rather than after it.

110 tests pass.
Comment thread src/BloomFreezeDoctor.Core/DoctorSupervisor.cs
Comment thread src/BloomFreezeDoctor.Core/Outbox/ReportOutbox.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/Outbox/ReportOutbox.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/Outbox/ReportOutbox.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/Gathering/WaitChainCollector.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/DoctorSupervisor.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/Outbox/YouTrackSubmitter.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/DoctorSupervisor.cs
Comment thread src/BloomFreezeDoctor.Core/BloomTargetWatcher.cs Outdated
Comment thread src/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs Outdated
Comment thread src/BloomFreezeDoctor.Core/WindowsExitEvidenceCollector.cs
Comment thread src/BloomExe/FatalExceptionHandler.cs
Comment thread src/BloomFreezeDoctor.Protocol/DoctorSession.cs
Comment thread build/check-csharp-robustfile.sh Outdated
Comment thread src/BloomFreezeDoctor.Core/Outbox/YouTrackSubmitter.cs
Comment thread src/BloomExe/Program.cs
Comment thread src/BloomFreezeDoctor.Core/DoctorSupervisor.cs
Comment thread src/BloomFreezeDoctor.Core/WindowsTargetProbe.cs
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 during preflight] Consulted Devin up to 9f3a21f7, and this time it answered.

Devin had timed out on this PR five times over two days — jobs reported completed while the findings pass never finished — so its previous rows were all "timed out". This run it completed: 10 bugs, 45 flags (11 of them Investigate, the rest informational).

Most of it was already dealt with. Devin itself marks 7 of the 10 bugs resolved by earlier commits on this branch. Of what was still live:

  • Fixed in 3b6b959: the discovery sweep could drop a watcher before its exit was examined (roughly one vanished Bloom in ten reported nothing at all); a simulated crash filed a real card, because two of the three places that decide whether filing is allowed used a shorter list of conditions; and a comment stating in bold that the long-operation patience does not exist, which this PR gives it.
  • Fixed earlier this run, before Devin spoke: crash attribution by hex pid substring (d123ec9), and the crash-dump handshake, whose real cause turned out not to be handler ordering at all (c9793f4).
  • Assessed, no change: a clean-exit session file pruned by an unrelated Bloom, the RobustFile carve-outs, and the project-lookup classification that was already fixed.
  • Three left open for the developer: health publishing running in every Bloom whether the Doctor is on or not; "Report now" filing on a developer or simulated run; and the watched process's handle never being released — that last one deliberately, because the handle is what supplies a dead process's exit code and releasing it naively would lose the evidence.

Every current Bug and Investigate flag has its own thread above with its outcome. The informational flags are not mirrored.

Greptile has reviewed this branch 15 times and its threads are all resolved; its most recent review predates today's commits. CI is green.

One thing Devin cannot tell us, and says so itself: the crash-dump path is still unexercised end to end.

A probe running in a separate process, calling the identical
Process.GetProcessesByName("Bloom") once a second, settles the question the last
five runs could not:

  Bloom 81632 really started at   16:51:48.595
  the same API first saw it at    16:51:48.947   (+0.35s)
  the Doctor adopted it at        16:52:02       (+14s, sweep 82)

So nothing is blind. A new Bloom is visible to that call within 350 milliseconds,
and the whole delay is the sweep's own latency. It also explains the range: the
sweep counter shows sweeps running 5.0 seconds apart while idle and 12.75 seconds
apart while Bloom was starting, so "wait up to one sweep" IS fourteen seconds, and
the 96-second case is several sweeps at that cadence or a few much slower ones
during the build.

That leaves one question - where a sweep spends twelve seconds - and two suspects
with nothing to choose between them by reading:

  - looking for Bloom: one enumeration of every process on the machine;
  - the bookkeeping afterwards: RaiseStatusChanged calls Pending(), which lists the
    outbox directory and deserialises every meta.json in it, on EVERY tick, and has
    nothing to do with discovery.

The second is the obvious suspect and would explain the load-dependence, since it
is disk-bound and a Bloom build saturates the disk. But it is a guess, and this
session has had enough of my guesses stated as findings, so the sweep is now timed
in those two halves and reports when it takes longer than the interval between
sweeps. Durations are bucketed coarsely before the repeat-suppressor sees them, so
repeated slow sweeps read as one shape of slowness rather than a fresh number every
five seconds.

Instrumentation only. One run should now name the half.

242 tests pass; the exe builds.
…-16719)

The instrumentation named the half, and it was not the one I predicted:

  sweep 31 took 18905ms, longer than the 5s between sweeps:
    18903ms looking for Bloom, 2ms on bookkeeping afterwards

My outbox theory was wrong - the bookkeeping is 2 milliseconds. And it is not the
process enumeration either, which measures 8-22ms and which the sweeps that found
nothing all did in under five seconds. The difference on sweep 31 is that it FOUND
Bloom, and finding it meant describing it, and describing it meant a WMI
Win32_Process query for the command line. For a process a few hundred milliseconds
old, on a machine busy launching it, that query took 18.9 seconds.

Caching it, two commits ago, could not help: the FIRST call is the one on the
adoption path.

The command line is wanted for exactly one decision - is this one of Bloom's
console verbs, which legitimately have no window and would otherwise earn a false
zombie report. That decision does not have to be made before adopting. So:

- describing a process no longer reads the command line at all;
- adoption happens on the strength of the process name;
- a background task then reads the command line and LETS GO of the Bloom if it
  turns out to be a headless run;
- gathering reads it for the report, which is the right place to pay: gathering
  already takes seconds and is racing nothing.

Safe because nothing is reported quickly. The freeze rules want twenty seconds of
silence before they even suspect, sixty before they report, and the zombie rule
thirty - so letting go after a few seconds, or even nineteen, lands well inside.
The release checks it is still the same Bloom before dropping it, since by then that
one may have gone and another been adopted.

This also matters beyond the delay itself, and John made the point: adoption
latency is the window in which Bloom's startup problems cannot be doctored at all,
and it is why one run lost a crash dump - Bloom asks to be dumped only if a Doctor
is already watching, and it asked twenty seconds before we had noticed it existed.

Not the whole answer. A poll still costs up to one interval, which is what a Bloom
too old to announce itself will always be stuck with - worth keeping, per John,
because those are the Blooms that most need watching. A signal from Bloom on
startup would make adoption immediate for everything current; that is the next
piece, not this one.

242 tests pass; the exe builds.
…lease" (BL-16719)

**1. Adoption no longer waits for a poll.**

Bloom already starts a Doctor when none is running, but a Doctor that is ALREADY
running never learned a new Bloom existed: the one Bloom starts is a duplicate,
which exits on the singleton mutex without telling the original anything. So
adoption waited for the next sweep - five seconds now that WMI is off that path,
but five seconds in which Bloom's own startup cannot be doctored at all. That is
not hypothetical: on one measured run Bloom crashed and asked to be dumped twenty
seconds before the Doctor had noticed it, and since Bloom only asks when a Doctor
is already watching, the dump was never taken.

So Bloom announces itself on a named event and the Doctor waits on it, using the
same mechanism that already carries the dump request. The signal is the only one
with no process id in its name, and cannot have one: the Doctor is waiting before
it knows which Bloom will appear. It is also the only one Bloom sets without first
checking whether anyone is listening, because there is nobody to check for - hence
Announce, which creates the event if needed, rather than TrySignal, which reports
"nobody there". A manual-reset event set into an empty room is still set when a
Doctor arrives.

Polling stays, and must: a Bloom too old to know about any of this cannot announce
itself, and John's point stands that those are the ones most worth watching. This
is an accelerator on top, not a replacement. If the announcement lands while a
sweep is already running, the re-entrancy guard makes it a no-op - correct rather
than a missed chance, since that sweep is doing the work being asked for, and the
timer covers the sliver where a sweep listed the processes just before ours
appeared.

**2. A path we could not read is no longer called "Release".**

DeriveFromExePath fell through to Release for anything unmatched, including an
empty path - so a Bloom whose executable we could not read was labelled Release on
its card, in the log, and inside the fingerprint, where it merged with genuine
Release reports. Seen in a real log as "watching Bloom 25736 (Release)" for
processes that were nothing of the kind. Only the empty case changes: a path we can
read but do not recognise is still Release, because that is what an ordinary
installation looks like and that is the answer the method exists to give.

244 Doctor tests pass (was 242), the 27 Bloom-side Freeze Doctor tests pass, and
both executables build - BloomExe as well this time, since this is the first change
in a while to touch it.
…L-16719)

Three faults in the announcement I added an hour ago, all found by running it.

**It spun.** The Doctor waits with ThreadPool.RegisterWaitForSingleObject, which
re-arms before the callback runs, so a MANUAL-reset event fires the callback over
and over until something resets it - and my Reset in the callback was racing that
re-arm rather than preventing it. One announcement produced 103 wake-ups and 103
needless sweeps. A pulse wants an auto-reset event, where one Set releases exactly
one wait and the kernel does the reset atomically. Both sides must create it the
same way, since the first creator fixes the mode, so Announce creates it that way
too. Now: one announcement, one wake-up.

**It was gated on the wrong thing.** I put it after the RunFreezeDoctor check, so
it only fired when Bloom was also configured to START a Doctor. That setting
governs starting one, not cooperating with one already watching - nothing else here
consults it either, since the heartbeat and the dying request for a dump both key
off whether a Doctor is listening. The case it excluded is the one that matters
most: support asking a user to start a Doctor by hand, which is also every Doctor
in this session's testing.

**It was too late to be worth anything.** Announced from where the Doctor is
launched, near the end of Program.Main, it was measured arriving 6.2 seconds into
startup - after the five-second sweep had already found Bloom. It is now the first
thing Main does after Logger.Init, which it can be because it needs nothing: one
named event, set, return. Measured at about 1.3 seconds now, that being .NET
startup rather than anything we control, so worst-case adoption goes from 5 seconds
to under 1.5. The point is not the number but what the number covers: everything
before the announcement is time in which a hang or crash cannot be doctored at all,
because Bloom only asks for a dump when a Doctor is already watching.

And a correction to my own comment, which claimed an announcement into an empty
room would still be waiting when a Doctor arrived. It will not: a Windows named
event lives only while a handle is open, so create-set-dispose destroys it on the
way out. Observed - a Bloom announcing 9 seconds before a Doctor started was never
heard. Not a hole, since "no Doctor running" is the case Bloom handles by starting
one, which then finds Bloom by sweeping.

Verified end to end on a real run: adoption, dump requested and taken, exit
examination standing down, one report filed.

244 tests pass; both executables build.
**Stop the Doctor before building.** A running Freeze Doctor loads
BloomFreezeDoctor.Core.dll and .Protocol.dll from output/Debug/AnyCPU and holds
them open, so any build that has to refresh them fails with MSB3027. That is not
confined to building the Doctor: BloomExe's build copies Protocol.dll, so a
running Doctor stops Bloom building at all. It cost John a whole test run - the
build failed, Bloom never started, and the only symptom was a Doctor sitting there
watching for a Bloom that could not come. Killing it is safe: reports are written
to the outbox on disk as they are gathered, an interrupted send is picked up by the
next drain, and Bloom starts a new Doctor moments later if the user has it on.

Two things about that were got wrong first, both worth recording because both were
invisible in the code:

- It ran with stderr PIPED and never read. "close" waits for every stdio stream to
  end, so the promise never settled - and since the launcher awaits it, Bloom was
  never started at all. A convenience that can hang the launcher is worse than the
  problem it solves. It now ignores stderr, resolves on "exit", and has a timeout
  that cannot be missed.
- Then it ran, but timed out. `taskkill /IM` walks the whole process table, and
  called just before the build it was doing that while Vite, LESS and seven file
  watchers were all starting; it needed longer than the five seconds allowed. It is
  now the first thing main does, while the machine is still quiet, where it returns
  at once. It only ever needed to happen before the BUILD, not immediately before.

**--nowatch** runs Bloom under `dotnet run` instead of `dotnet watch run`. C# edits
then do not rebuild by themselves, which is the right trade whenever you are not
editing C#. The front end is untouched - Vite still serves it, so TypeScript and
LESS remain live.

Measured, twice, on a warm build: 35 seconds to a running Bloom with watch, 27
without. A real gain and worth having, but I should say plainly that it is not the
halving we hoped for - most of the wall clock is Vite starting and Bloom itself,
not the watcher. The saving should be larger on a cold or changed build, and it
also stops dotnet watch competing for the machine for the rest of the session.
…e (BL-16719)

Found by running the simulated failures through a Doctor that Bloom launched
itself and reading what came out. For a SPIN the report named the thread burning a
whole core - correct and useful - and then said nothing whatever about where it
was spinning, which is the only thing a developer needs. Two faults behind that:

**The UI thread was not recognised at all.** It was found by looking for a
"RunMessageLoop" frame, and on a thread that is RUNNING rather than waiting the
stack walk yields "(native)" where those frames should be. So the one failure mode
where the UI thread IS the story had no UI-thread section in its report. It is now
also recognised by Bloom.Program.Main at the base, which survives when nothing
above it does, and which no other thread has.

**And recognising it would have made the report worse.** The old description fell
through to the first frame beginning "Bloom.", which on such a stack is
Bloom.Program.Run - the bottom of every UI thread there has ever been. The report
would have announced "The UI thread is blocked in Bloom.Program.Run": wrong twice,
since it is not blocked and that frame means nothing. A stack that cannot be read
is a fact worth reporting as one, so it now says so and names the thread to open
the attached dump at.

The two headlines then corroborate each other instead of one being absent:

  - The UI thread's stack could not be read - which is itself a clue, because a
    stack walk fails on a thread that is RUNNING far more often than on one that is
    waiting. Thread 109948 in the attached dump is the one to open.
  - Thread 109948 is using a whole core, so this looks like a spin rather than a
    deadlock.

The reading is now a pure function with its own tests, built on two REAL stacks
copied from reports - one blocked, one spinning - rather than stacks written to
suit the code. Verified by re-running the simulation.

250 tests pass.
Running the simulated zombie and reading the result. Bloom alive, pumping, window
gone - and the report said:

    Verdict: alive with no visible window for 31s
    No thread is burning CPU, so this is a wait rather than a spin.
    WebView2 answers normally, so the block is in Bloom's .NET UI thread, not the browser.

There is no block. The last line asserts one, and a reader could easily come away
hunting a deadlock instead of asking where the window went - which is the actual
question, and one the report answers a few lines later by listing both of Bloom's
windows as hidden.

The cause is that IsAboutAFreeze counts a zombie as a freeze, and several
collectors use it to decide whether an observation supports a CONCLUSION. For a
frozen UI thread "no CPU, so a wait not a spin" is a real deduction; for a zombie
it is a non sequitur about a thread that is running perfectly well.

So the concept is split. IsAboutTheUiBeingStuck covers only the three states where
the UI thread is actually suspect, and the two deductions use that. IsAboutAFreeze
stays where the distinction being drawn is merely live-versus-dying - notably "the
UI thread is in its message loop", which on a zombie report is not a reassurance
but the key finding: Bloom is fine, its window is not.

The zombie report now reads:

    Verdict: alive with no visible window for 30s
    The UI thread is in its message loop (idle or pumping).
    WebView2 answers normally.

Also, while in here: the UI-thread section heading now carries the thread's OS id.
Every other section that mentions a thread uses it - the wait chains name "thread
83756", the CPU table "thread 109948" - so without it the reader cannot tell
whether the thread in the wait chain IS the UI thread, which is usually the whole
question. Noticed on the mutexchain run, whose wait chain named a thread the report
never connected to anything.

250 tests pass. Verified by re-running the simulation.
…L-16719)

Running the simulated unhandled exception. The Doctor caught it exactly as
designed - Bloom's fatal handler asked to be dumped, the dump was taken, the stack
showed the whole crash path - and the report's headlines were:

    Verdict: Bloom was crashing and asked to be dumped before it died
    The UI thread is blocked in System.Threading.WaitHandle.WaitOneCore.

Not one word about WHAT was thrown, which for a crash is the first question anybody
asks. The answer was in the report all along, 370 lines down inside the tail of
Bloom's log:

    exception = System.ApplicationException: FreezeSimulator was asked to throw

A fact that is present but unfindable is barely better than a missing one, and
headlines are what a reader reads. BloomsOwnException pulls the last exception out
of the log tail and the report now leads with it.

The other headline was worse than useless. The UI thread WAS blocked in
WaitHandle.WaitOneCore - inside Bloom's fatal handler, waiting for the Doctor to
finish the dump. That is this tool doing its job, reported as though it were the
fault, and it invites a reader to go hunting a deadlock. It now says what it is:
the UI thread is in Bloom's own fatal-error handler waiting for this dump, so the
stack below is the path to the crash rather than a deadlock.

The report now opens:

    Verdict: Bloom was crashing and asked to be dumped before it died
    The UI thread is inside Bloom's own fatal-error handler, waiting for this dump -
      so the stack below is the path to the crash, not a deadlock.
    WebView2 answers normally.
    Bloom's own error handling recorded: System.ApplicationException: FreezeSimulator
      was asked to throw

Tests use a real log tail, and cover the traps: taking the LAST exception when a
session logged several, not swallowing the stack that follows it, cutting a message
long enough to wreck a headline, and refusing to announce an exception when the
marker has nothing after it.

256 tests pass. Verified by re-running the simulation.
…16719)

**FailFast now says why.** It was the one crash kind whose report never stated
what went wrong. It runs no managed handlers by design, so there is no dump and
nothing in Bloom's log - the .NET Runtime event is the sole record, and its
"Message:" line was sitting a hundred lines down in the evidence while the
headlines said only that the process had called FailFast. The report now opens:

    Verdict: Bloom crashed: ... it called FailFast (0x80131623)
    Bloom called FailFast: FreezeSimulator was asked to fail fast

Only when Bloom's own log has not already supplied a headline, which is a closer
account of itself than Windows' record of it.

**And "Send it anyway".** John asked for a way to really send a report from a dev
build. The Doctor declines to file on a developer build, on an automation run, and
when the failure was simulated on purpose; all three are right by default, and all
three are ones a developer sometimes wants to override for the report in front of
them. Until now the only way was "Report now", which gathers a WHOLE NEW report -
impossible once the Bloom in question has died, and about a different moment even
when it has not.

The button appears beside "Show report" whenever a report was saved and not sent,
and it asks first, naming the project. That matters: a Doctor that Bloom started
uses the ordinary project rather than the test one - being a developer build is
precisely why the report was held back - so this is not a rehearsal, and the person
clicking should know before rather than after.

ReportOutbox.SendThisAfterAll lifts only a deliberate refusal. Anything already
pending, uploading or filed is left exactly as it is: this is not a way to re-send,
and a button that quietly re-sent a filed report would make duplicate cards from
one click. Tested, including that it declines a bundle it should not touch and
reports failure for a folder that is not there.

262 tests pass. Both headline changes verified by re-running the simulations.
Found reviewing my own change from an hour ago. The new "Bloom's own error handling
recorded: ..." headline was not gated to crash reports, and the log tail it reads
is a couple of hundred lines of a real session - which routinely contains handled
exceptions that came to nothing.

On a FREEZE report that would put one of them at the top as though it explained the
freeze. A plausible wrong answer in the headlines is worse than no answer, because
the headlines are where the reader starts and a named exception is exactly the kind
of thing they would go and investigate.

Now only on reports that are not about a freeze, which is where it earns its place:
a crash, where what was thrown is the first question anybody asks. On a freeze the
log is still in the section below, unchanged.

262 tests pass.
Two conflicts, both where base and this branch added to the same place.

AppApi.cs and the debug menu: base added the "Use dev.BloomLibrary.org" choice
exactly where this branch added the "Run Freeze Doctor" toggle - the endpoint
registration, the React state, the mount-effect read, the menu item and the
useMemo deps. Independent features throughout; both kept.

One of those needed more than keeping both sides. Base refactored the menu from
`return [ ... ]` into `const items = [ ... ]` followed by a conditional push and
`return items;`, so that it could add its item only on the builds that offer it.
The merge had already taken base's opening line, which left this branch's items
inside an array nothing returned. Resolved to base's structure, with the Freeze
Doctor item where it was in the literal - so the array is built once, base's item
is pushed when that build allows it, and the whole thing is returned.

Typecheck clean, BloomExe builds, 262 Doctor tests pass on the merged tree.
Decision from the preflight report: leave it as it is. John's words - "we want to
catch all the real freezes we can, including older Blooms that happen to be
adopted."

Written down next to the code because it reads like a bug and is not. A Doctor
Bloom launched is not narrowed to the channel that started it, so once that Bloom
has gone it will take up whatever other Bloom it finds - which is what you want
when you restart Bloom after a freeze, and is also how a Doctor started by a
development build came to adopt the BetaInternal install that happened to be
running. That one is not a developer channel, so a genuine freeze in it would have
been filed as a real card about work nobody asked us to watch.

Accepted: a freeze noticed by accident is still a freeze we would otherwise never
have heard about, which is the whole point of the tool.

Also noted in the card's tester notes as expected behaviour, so a card about a
Bloom that was not being deliberately tested is not filed as a mix-up.

262 tests pass.
John took apart the reasoning I had written for leaving it ungated, and he is right.
The claim was that support may have had a user start a Doctor by hand with the
setting off. Both support routes are covered without ungating:

- told to switch the Doctor on and relaunch Bloom, the setting is on by the time
  Main runs;
- told to start a Doctor while Bloom is already running, Main has long since
  finished and it is the Doctor's own sweep that finds Bloom.

What ungating actually bought was at most one poll interval - five seconds - in the
narrow case of a Doctor left running while the setting is off. Against that it ran
a line in the startup of every user who has never switched the feature on. That is
a bad trade, and I made it on a scenario that does not hold.

Also moved from the top of Main to just after CheckForCorruptUserConfig, because it
now reads a setting and that call is what makes reading one safe. The cost is
microseconds and it is still far earlier than the 6.2 seconds measured when the
announcement lived down beside the launcher.

Verified with a Doctor already running: Bloom started at 09:41:10.244, the Doctor
logged "a Bloom has just started and said so" and adopted it within the same
second.

262 Doctor tests pass; BloomExe builds.
@JohnThomson
JohnThomson marked this pull request as ready for review September 1, 2026 14:54
@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (110 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@andrew-polk andrew-polk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrew-polk partially reviewed 110 files and all commit messages, made 13 comments, and resolved 4 discussions.
Reviewable status: all files reviewed, 35 unresolved discussions (waiting on JohnThomson).


build/Bloom.proj line 517 at r19 (raw file):

no longer has one

Never had one, so this seems worth cleaning up or just removing


src/BloomExe/FreezeDoctor/DoctorLauncher.cs line 102 at r19 (raw file):

                    // notification area": a window appearing every time you started Bloom would get the
                    // Doctor uninstalled inside a week.

Not sure what this means. Is the AI just being cheeky? The user can't even uninstall it, can they (without uninstalling Bloom)?


src/BloomFreezeDoctor.Core/BloomChannel.cs line 76 at r19 (raw file):

whether the Doctor will ship enabled for them
/// is not settled

Is that true?

I'm unsure what this means for one-off channels like BloomFAL. Maybe we don't care today.


Bloom.sln line 33 at r19 (raw file):

Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebView2PdfMaker", "src\WebView2PdfMaker\WebView2PdfMaker.csproj", "{F697DD7A-2D74-4850-8381-7E4ADB1E4431}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"

Adding "src" as a project can't be right, can it?


Bloom.sln line 40 at r19 (raw file):

EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BloomFreezeDoctor", "src\BloomFreezeDoctor\BloomFreezeDoctor.csproj", "{DB569C6F-CD1A-4F6C-AD1D-5768749F2B68}"
EndProject

Do we really want/need 3 new production projects for this?


build/check-csharp-robustfile.sh line 42 at r19 (raw file):

      # excused, because a diagnostic tool writing to a user's disk has no more business losing a file to
      # a passing virus scanner than Bloom does - and the first thing the conversion found was a real
      # one, an outbox rename that failed about one run in three and threw away a gathered report.

Not sure the comment is helpful. I would just drop it.


src/BloomExe/BloomExe.csproj line 239 at r19 (raw file):

		<!--
			The protocol Bloom and the Bloom Freeze Doctor share: the shared-memory health page, the
			session file, and the named events. This was three files copied into this repo by hand, 750

Again, the comment only makes sense in light of development churn. Worth cleaning up.


src/BloomExe/FreezeDoctor/DoctorLauncher.cs line 49 at r19 (raw file):

        ///
        /// Gated on <see cref="Settings.RunFreezeDoctor"/>, so it costs nothing at all for the users who
        /// have never switched the Doctor on. It briefly was not, on the argument that support may have had

No reason to keep the historical comment, imo.
Unless you want to restate it more simply as, we decided not to x because y.


src/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs line 622 at r19 (raw file):

        /// are not the ones we care about. It does also refresh the session file, which is cheap and keeps
        /// that work off the UI thread.
        /// </summary>

I think this summary (above) is vestigial.


src/BloomExe/WebLibraryIntegration/AccessKeys.cs line 46 at r19 (raw file):

                    if (BookUpload.IsDryRun)
                        return new AccessKeys(null, null);
                    return UploaderDev(lines);

I think we better discuss this.
If I understand what is happening, it is using credentials we are about to shut down.


src/BloomFreezeDoctor/BloomFreezeDoctor.csproj line 1 at r19 (raw file):

<Project Sdk="Microsoft.NET.Sdk">

For all the new files in new projects, I pretty much just skimmed. And that may be too strong a word for it...


src/BloomFreezeDoctor.Tests/AlreadyFrozenTests.cs line 4 at r19 (raw file):

using NUnit.Framework;

namespace BloomFreezeDoctor.Tests;

Just noting that I didn't really review any of these tests. Let me know if there is something I should look at.


src/BloomFreezeDoctor.Tests/WhereTheFileStillIsTests.cs line 34 at r19 (raw file):

    [Test]
    public void It_says_where_the_file_is()

These test names don't seem to fit our normal C# unit test naming.

Seems like we do something to prevent all these FreezeDoctor tests from running every time. Mark as Nightly or make a new category?

JohnThomson and others added 2 commits September 9, 2026 13:05
…convention (BL-16719)

Comments across the Freeze Doctor projects, their project files, Bloom.proj and
the RobustFile check script now describe the code as it is rather than how it
got here: plan-section and spike references, measurement logs, "used to" and
"no longer" narratives, and name-and-date attributions are gone, with the
rationale for each design choice kept in present tense. About a dozen doc
comments that had drifted away from their members are back on the methods they
describe. The "uninstalled inside a week" figure of speech now just says a
window on every start would be annoying, and the channel comment in
BloomChannel.cs records that the Doctor is part of the next Beta with Release
still open.

Every test in BloomFreezeDoctor.Tests and BloomTests/FreezeDoctor is renamed to
Method_Scenario_Result, and ten theme-named fixtures are renamed after the class
they exercise (e.g. OneBloomAtATimeTests -> DoctorSupervisorTests). Test bodies
are unchanged apart from two stale assertion messages.

Bloom.sln no longer nests the Doctor projects under a "src" solution folder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JohnThomson
JohnThomson marked this pull request as draft September 9, 2026 18:15

@JohnThomson JohnThomson left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson+AGNT made 9 comments.
Reviewable status: 24 of 110 files reviewed, 35 unresolved discussions (waiting on andrew-polk and JohnThomson).


Bloom.sln line 33 at r19 (raw file):

Previously, andrew-polk wrote…

Adding "src" as a project can't be right, can it?

[Claude Fable 5.1] You're right. It was an artifact of dotnet sln add, which mirrors the on-disk directory as a solution folder unless told otherwise. Removed in 34f5ea1; the four Doctor projects now sit at the solution root beside BloomExe, BloomTests and WebView2PdfMaker.


build/Bloom.proj line 517 at r19 (raw file):

Previously, andrew-polk wrote…

no longer has one

Never had one, so this seems worth cleaning up or just removing

[Claude Fable 5.1] Reworded in 34f5ea1. The comment now just says the Doctor has no installer of its own and ships inside Bloom's, so this is the only place it gets signed.


build/check-csharp-robustfile.sh line 42 at r19 (raw file):

Previously, andrew-polk wrote…

Not sure the comment is helpful. I would just drop it.

[Claude Fable 5.1] Dropped in 34f5ea1. The exemption line now stands bare, matching the BloomTests one above it.


src/BloomExe/BloomExe.csproj line 239 at r19 (raw file):

Previously, andrew-polk wrote…

Again, the comment only makes sense in light of development churn. Worth cleaning up.

[Claude Fable 5.1] Trimmed in 34f5ea1 to what the reference is and why it is a ProjectReference rather than a package. The same commit removed development-history narrative across all the Doctor's source, project files and tests, so the other instances of this pattern should be gone as well.


src/BloomExe/FreezeDoctor/DoctorLauncher.cs line 49 at r19 (raw file):

Previously, andrew-polk wrote…

No reason to keep the historical comment, imo.
Unless you want to restate it more simply as, we decided not to x because y.

[Claude Fable 5.1] Removed in 34f5ea1. The comment now states only the current rule: the announcement is gated on the setting so it costs nothing for users who never switched the Doctor on, and a Doctor started by hand still finds Bloom by its own sweep.


src/BloomExe/FreezeDoctor/DoctorLauncher.cs line 102 at r19 (raw file):

Previously, andrew-polk wrote…
                    // notification area": a window appearing every time you started Bloom would get the
                    // Doctor uninstalled inside a week.

Not sure what this means. Is the AI just being cheeky? The user can't even uninstall it, can they (without uninstalling Bloom)?

[Claude Fable 5.1] Agreed, it was a figure of speech and a confusing one. Both places that used it (here and in StatusForm) now just say that a window appearing every time you start Bloom would quickly become annoying. Fixed in 34f5ea1.


src/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs line 622 at r19 (raw file):

Previously, andrew-polk wrote…

I think this summary (above) is vestigial.

[Claude Fable 5.1] It was the heartbeat method's summary, which had drifted away from WatchdogLoop. It is back on that method now, and about a dozen similar strays elsewhere in the Doctor's files were reattached in the same commit (34f5ea1).


src/BloomFreezeDoctor.Core/BloomChannel.cs line 76 at r19 (raw file):

Previously, andrew-polk wrote…

whether the Doctor will ship enabled for them
/// is not settled

Is that true?

I'm unsure what this means for one-off channels like BloomFAL. Maybe we don't care today.

[Claude Fable 5.1] Updated in 34f5ea1: the Doctor is part of the next Beta, and whether to offer it to Release users is still open. The comment also now says explicitly that the filing guards do not distinguish Beta or Release from any other installed channel.

On one-off channels like BloomFAL: the Doctor derives the channel name from the install folder, so a BloomFAL install reads as channel "FAL". BloomFAL is not in the list of process names the Doctor sweeps for, but Bloom launches the Doctor with its own process id, and adopting a process adds that process name to the sweep set, so a BloomFAL user who switches the Doctor on is watched like anyone else and their reports carry FAL as the channel.


src/BloomFreezeDoctor.Tests/WhereTheFileStillIsTests.cs line 34 at r19 (raw file):

Previously, andrew-polk wrote…

These test names don't seem to fit our normal C# unit test naming.

Seems like we do something to prevent all these FreezeDoctor tests from running every time. Mark as Nightly or make a new category?

[Claude Fable 5.1] The names now follow Method_Scenario_Result throughout both test projects, and ten theme-named fixtures (this one included, now YouTrackSubmitterTests) are renamed after the class they exercise. Fixed in 34f5ea1.

On the category question, I measured the run rather than guess. The 262 Doctor tests take 5.4 s in total (about 6 s wall clock). One test takes 1.9 s, RobustFileCopy_LogHeldOpenForWriting_Throws, which waits on a real file lock; everything else is under a second, and only one other is over half a second. The 27 Bloom-side FreezeDoctor tests take 0.1 s together. John will decide whether that warrants a Nightly or separate category.

JohnThomson and others added 2 commits September 9, 2026 13:20
…(BL-16719)

The announcement at the top of Main reads RunFreezeDoctor before Bloom
migrates settings from the previous version, so on the first run after an
upgrade it saw the default (off) and a running Doctor had to wait for its
next sweep. Announce once more after the migration; a repeat is harmless.
Devin raised this on the previous commit.

Also drops a development-history measurement from the announcement comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…outbox cap (BL-16719)

"Restart Bloom" asked the Doctor to end the Bloom holding the single-instance
token and then started a new one regardless of whether that worked. A Bloom
that could not be ended still holds the token, so the new one would exit a few
seconds later: the very "Bloom will not start" the user came about. The window
now says the old Bloom could not be ended and leaves it there.

The outbox's prune kept the newest MaxBundles bundles and skipped any that was
uploading, so a queue with an upload in flight could hold one more than the cap.
It now takes the surplus off the deletable bundles, as its comment already
claimed, and a test pins that.

Both were raised by Devin on the previous commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/BloomFreezeDoctor/StatusForm.cs
Comment thread src/BloomFreezeDoctor.Core/Outbox/ReportOutbox.cs
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Fable 5.1 during preflight] Consulted Devin up to 5ea5937c (re-reviewed after each of this run's three pushes).

It raised three things across the run, each now fixed with its own resolved thread:

  • On the previous commit: after upgrading Bloom, the startup announcement read the "Run Freeze Doctor" setting before settings had migrated, so a running Doctor missed the first run of a new version by one sweep. Fixed in 6adc0921.
  • On the merge commit: "Restart Bloom" started a new Bloom even when the old one could not be ended, so the new one would exit against the held single-instance token. Fixed in 5ea5937c (thread).
  • The outbox's twenty-report cap could be exceeded by one while an upload was in flight. Fixed in 5ea5937c, with a test (thread).

On 5ea5937c Devin reports no bugs. Its 38 Investigate flags are all carried forward from earlier rounds and already have threads; 85 informational notes were read and need nothing. CI is green; Greptile declines the PR for exceeding its 100-file limit.

@JohnThomson JohnThomson left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson partially reviewed 33 files and made 13 comments.
Reviewable status: 24 of 110 files reviewed, 37 unresolved discussions (waiting on andrew-polk).


Bloom.sln line 33 at r19 (raw file):

Previously, andrew-polk wrote…

Adding "src" as a project can't be right, can it?

Fixed, I believe.


Bloom.sln line 40 at r19 (raw file):

Previously, andrew-polk wrote…

Do we really want/need 3 new production projects for this?

I questioned this pretty hard, too. It seems fairly natural that we're adding a new exe, so we need a new project for it. Then, the doctor shares some code with Bloom itself, so the idea of the Protocol project is to contain that shared code. We could just make the doctor reference Bloom.dll, but then you can't rebuild Bloom while a doctor is running. I'm not sure how much that matters. Claude doesn't think it would increase the doctor's memory footprint, which was one concern I had.
Apparently there's a way we could just make the doctor build include some source files from the EXE project. That would be an alternative way to share code without a separate project. I can do that if you think it's worth it.
Claude also suggested that it's a good discipline that most of the doctor's code doesn't reference WinForms and other things like AutoFac that Bloom uses, and can't start using more pieces of Bloom without a conscious decision to move them into the protocol project. This automatically guards against dependencies creeping into the doctor by accident. This is a secondary argument for the Protocol project, and the main argument for the Core project. In particular, everything the freeze doctor needs WinForms for is in the very small EXE project. The doctor's test code does not reference WinForms.
(Claude originally told me that it needed the Core project because test code couldn't reference an EXE. That, of course, is simply false.)
Maybe we should talk about this. I don't feel that I have a strong opinion, so if you do, maybe we should go with that.


build/Bloom.proj line 517 at r19 (raw file):

Previously, andrew-polk wrote…

no longer has one

Never had one, so this seems worth cleaning up or just removing

Done.


build/check-csharp-robustfile.sh line 42 at r19 (raw file):

Previously, andrew-polk wrote…

Not sure the comment is helpful. I would just drop it.

Done.


src/BloomExe/BloomExe.csproj line 239 at r19 (raw file):

Previously, andrew-polk wrote…

Again, the comment only makes sense in light of development churn. Worth cleaning up.

I had Claude do a sweep for unhelpful historical comments. Hopefully it got most of them.


src/BloomExe/FreezeDoctor/DoctorLauncher.cs line 49 at r19 (raw file):

Previously, andrew-polk wrote…

No reason to keep the historical comment, imo.
Unless you want to restate it more simply as, we decided not to x because y.

Hope it got cleaned up in the historical sweep. I want to get this back to you, so I'm not checking specifically.


src/BloomExe/FreezeDoctor/DoctorLauncher.cs line 102 at r19 (raw file):

Previously, andrew-polk wrote…
                    // notification area": a window appearing every time you started Bloom would get the
                    // Doctor uninstalled inside a week.

Not sure what this means. Is the AI just being cheeky? The user can't even uninstall it, can they (without uninstalling Bloom)?

I'm not sure why it put it that way. The user can, at least, turn it off. I think the point is that if every launch of Bloom adds this doctor window as well, taking up screen space, it will be too annoying to use.


src/BloomExe/FreezeDoctor/FreezeDoctorSupport.cs line 622 at r19 (raw file):

Previously, andrew-polk wrote…

I think this summary (above) is vestigial.

Moved to its proper place


src/BloomExe/WebLibraryIntegration/AccessKeys.cs line 46 at r19 (raw file):

Previously, andrew-polk wrote…

I think we better discuss this.
If I understand what is happening, it is using credentials we are about to shut down.

I think we're just moving the code related to submitting YouTrack cards into the shared Protocol library. It shouldn't be any harder to fix there than where it is now, if we change the approach.


src/BloomFreezeDoctor/BloomFreezeDoctor.csproj line 1 at r19 (raw file):

Previously, andrew-polk wrote…

For all the new files in new projects, I pretty much just skimmed. And that may be too strong a word for it...

Me too. I'm worried in both directions about the Freeze Doctor:

  • it feels like it's taken way too much time and attention already. (JohnH thought it would take half a day)
  • it feels like it's had way too little review for something that runs on end user machines and is allowed to do dangerous things like starting and stopping Bloom and submitting YouTrack issues.
    I don't know what to do about it. It would take days of work to thoroughly review all the doctor code.

src/BloomFreezeDoctor.Core/BloomChannel.cs line 76 at r19 (raw file):

Previously, andrew-polk wrote…

whether the Doctor will ship enabled for them
/// is not settled

Is that true?

I'm unsure what this means for one-off channels like BloomFAL. Maybe we don't care today.

At this point we're getting set to ship it with Beta, so I guess that's settled. We'll have a few months to decide whether it's worth any problems it causes and want it in the release build.
If we make new releases of on-off channels and want them to have Freeze Doctor, at this point we'll have to add them here.


src/BloomFreezeDoctor.Tests/AlreadyFrozenTests.cs line 4 at r19 (raw file):

Previously, andrew-polk wrote…

Just noting that I didn't really review any of these tests. Let me know if there is something I should look at.

I've been assuming for weeks that reviewing vibe-coded tests is not doable if we want AI to speed up our work.


src/BloomFreezeDoctor.Tests/WhereTheFileStillIsTests.cs line 34 at r19 (raw file):

Previously, JohnThomson+AGNT (Agent AGNT for John Thomson) wrote…

[Claude Fable 5.1] The names now follow Method_Scenario_Result throughout both test projects, and ten theme-named fixtures (this one included, now YouTrackSubmitterTests) are renamed after the class they exercise. Fixed in 34f5ea1.

On the category question, I measured the run rather than guess. The 262 Doctor tests take 5.4 s in total (about 6 s wall clock). One test takes 1.9 s, RobustFileCopy_LogHeldOpenForWriting_Throws, which waits on a real file lock; everything else is under a second, and only one other is over half a second. The 27 Bloom-side FreezeDoctor tests take 0.1 s together. John will decide whether that warrants a Nightly or separate category.

Probably will take a bit longer in CI, but I'm inclined to leave them as regular unit tests for now. We could reconsider moving them when the doctor has been stable for a while.

@andrew-polk andrew-polk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrew-polk partially reviewed 86 files and all commit messages, made 3 comments, and resolved 37 discussions.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on JohnThomson).


Bloom.sln line 40 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

I questioned this pretty hard, too. It seems fairly natural that we're adding a new exe, so we need a new project for it. Then, the doctor shares some code with Bloom itself, so the idea of the Protocol project is to contain that shared code. We could just make the doctor reference Bloom.dll, but then you can't rebuild Bloom while a doctor is running. I'm not sure how much that matters. Claude doesn't think it would increase the doctor's memory footprint, which was one concern I had.
Apparently there's a way we could just make the doctor build include some source files from the EXE project. That would be an alternative way to share code without a separate project. I can do that if you think it's worth it.
Claude also suggested that it's a good discipline that most of the doctor's code doesn't reference WinForms and other things like AutoFac that Bloom uses, and can't start using more pieces of Bloom without a conscious decision to move them into the protocol project. This automatically guards against dependencies creeping into the doctor by accident. This is a secondary argument for the Protocol project, and the main argument for the Core project. In particular, everything the freeze doctor needs WinForms for is in the very small EXE project. The doctor's test code does not reference WinForms.
(Claude originally told me that it needed the Core project because test code couldn't reference an EXE. That, of course, is simply false.)
Maybe we should talk about this. I don't feel that I have a strong opinion, so if you do, maybe we should go with that.

No, I'm ok with leaving as is. Thanks for thinking through it.


src/BloomExe/WebLibraryIntegration/AccessKeys.cs line 46 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

I think we're just moving the code related to submitting YouTrack cards into the shared Protocol library. It shouldn't be any harder to fix there than where it is now, if we change the approach.

ok; I was thinking it was trying to share the credentials for uploading books. But if the YouTrack submission is already using the same credentials, we already have to modify those rather than remove them. So this should be fine, then.


src/BloomFreezeDoctor.Tests/WhereTheFileStillIsTests.cs line 34 at r19 (raw file):

Previously, JohnThomson (John Thomson) wrote…

Probably will take a bit longer in CI, but I'm inclined to leave them as regular unit tests for now. We could reconsider moving them when the doctor has been stable for a while.

Ok, let's leave them for now.

@andrew-polk
andrew-polk marked this pull request as ready for review September 9, 2026 21:31
@andrew-polk
andrew-polk merged commit 535e5e5 into Version6.5 Sep 9, 2026
2 checks passed
@andrew-polk
andrew-polk deleted the BL-16719-doctor-in-repo branch September 9, 2026 21:31
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.

2 participants