Skip to content

Recover cleanly when a collection fails to open (BL-16678) - #8220

Open
StephenMcConnel wants to merge 2 commits into
masterfrom
BL-16678-CannotStartBloomAfterBadCollection
Open

Recover cleanly when a collection fails to open (BL-16678)#8220
StephenMcConnel wants to merge 2 commits into
masterfrom
BL-16678-CannotStartBloomAfterBadCollection

Conversation

@StephenMcConnel

@StephenMcConnel StephenMcConnel commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

After one collection fails to open, every later collection in the same run used to die with An item with the same key has already been added. Key: audio/startrecord. ProjectContext registers its ~65 project-level API endpoints on the application-level BloomServer.ApiHandler, so a project that failed part way through construction left them registered, and the next project's identical registrations hit a duplicate key.

#8204 (BL-16679) already fixes the reported symptom

It made the constructor dispose itself on failure, and Dispose already called ClearProjectLevelHandlers(). _scope is assigned well before the first RegisterWithApiHandler, so the clear is reached on the reported path. The log attached to the card confirms the boundary: the empty Bad Collection.bloomCollection failed at _scope.Resolve<CollectionSettings>(), before any registration, and poisoned nothing; the BL-16679 collection failed at _scope.Resolve<TeamCollectionApi>(), after, and poisoned everything.

So this PR is the hardening: recovery no longer depends on Dispose getting far enough, and it closes two other ways a failed open left Bloom running with no window and no way to quit.

1. The application-level reset can no longer be skipped

Dispose did the reset inside a try whose first statement was _scope.Resolve<BloomServer>() and whose catch only tolerated ObjectDisposedException. If that resolve threw anything else, ClearProjectLevelHandlers() was skipped, the exception was swallowed by the constructor's inner catch, and the next collection was poisoned again.

The server is now kept in a field, and the reset — extracted as ReleaseApplicationLevelProjectState — runs from that field, before anything that touches _scope.

We deliberately do not clear the server's own CurrentCollectionSettings. BloomServer reads it unguarded (e.g. to serve writingSystemDisplayForUI.css and files relative to the collection folder), so between one project closing and the next opening — exactly when the Open/Create Collections dialog is up — settings describing the collection we just left are a better answer than null. The next project overwrites them.

2. A clean slate is guaranteed at the recovery point

Program.HandleErrorOpeningProjectWindow is the one place that knows we failed and are about to offer another collection, so it repeats the reset (idempotent), with a Debug.Assert that Dispose should already have done it.

Its reporting half is now separate and guarded. It runs inside OpenProjectWindow's catch, and the callers depend on OpenProjectWindow returning false to put up the chooser — so a failure to report used to take the user's way back into the app with it.

3. One throw no longer wedges startup permanently

StartupScreenManager.DoStartupAction ran _current.Task() unguarded, and returns immediately on every later idle while _current is set. A throw therefore killed the queue for the rest of the run: the splash screen never closed, ChooseACollection() never ran, nothing called ProgramExit.Exit() so not even its 20-second force-quit net was armed, and Main's finally never released the UniqueToken — so the next launch was turned away with "Bloom is already running". That is the shape the card's title describes.

FatalExceptionHandler cannot save this: during collection open UseFallback is true, so it shows a message and returns, letting the process carry on windowless.

4. ReopenProject gets the fallback its sibling already had

ReopenProject called OpenCollection(MruProjects.Latest) with nothing on failure, unlike OpenCollectionChosenInDialog — although it also runs with the Shell already closed (it is reached from a UI language change and from the Collection Settings dialog). New ChooseACollectionOrQuit is used at all three recovery sites, so a chooser that itself fails quits rather than idling invisibly.

5. Two diagnosis traps, and a null hole

  • catch (FileNotFoundException) showed the "Bloom was not able to find all its bits" message, called ProgramExit.Exit(), then fell through into code dereferencing the _scope it had just failed to build — so what the user and Sentry actually saw was a NullReferenceException naming nothing useful.
  • catch (Exception) { return; } inside the BeginLifetimeScope lambda would have abandoned every remaining registration, turning any failure there into an unrelated Autofac ComponentNotRegisteredException.
  • The startup MRU pruning loop called IsInvalidCollectionToEdit(path) after path could have become null.

Testing

New BloomApiHandlerTests (5 tests) covers the application-level/project-level split the whole recovery depends on, and nothing covered it before. It includes a sanity check that re-registering without the clear really does throw the card's ArgumentException, so it cannot pass falsely. New StartupScreenManagerTests (3) covers a throwing startup action not wedging the queue. ProjectContextTests gains 3 tests for the new reset.

Full C# suite green (3155 passed). No TypeScript in the diff, so the front-end suite was not run.

Also verified by hand in the running app: with a deliberate failure staged just after audio/startRecord is registered, the card's repro now recovers — error dialog, chooser, and the good collection opens — and no windowless Bloom is left behind. With the self-dispose disabled to simulate the pre-#8204 state, Bloom did not survive at all, consistent with the card's reports.

Deliberately not done

  • The MRU never demotes a collection that failed to open, so every launch retries it, reports, and offers the chooser. Leaving it: MostRecentPathsList.Paths already prunes on serialization and AddNewPath refuses to re-add, so demoting on one failure would drop a user's Team Collection from the Open/Create dialog after a transient network blip. Annoying but always recoverable beats silently losing a collection.
  • Program.cs writes a double-clicked .bloomCollection to the MRU before trying to open it.
  • CollectionSettings.DoDefenderFolderProtectionCheck calls Environment.Exit(-1) from inside the CollectionSettings constructor, skipping ReleaseBloomToken() — a read-only collection folder is currently unrecoverable, and the Defender-specific wording will mislead. Worth its own card.
  • No --no-collection / Shift-at-startup escape hatch for a poisoned MRU.
  • FileSystemWatcher.EnableRaisingEvents is called unguarded in TeamCollection.StartMonitoring and FolderTeamCollection, and nothing subscribes to FileSystemWatcher.Error.

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

Devin review


This change is Reviewable

After one collection failed to open, every later collection in the same run
died with "An item with the same key has already been added.
Key: audio/startrecord". ProjectContext registers its project-level API
endpoints on the application-level BloomServer.ApiHandler, so a project that
failed part way through construction left them registered, and the next
project's identical registrations hit a duplicate key.

BL-16679 made the constructor dispose itself on failure, and Dispose already
cleared those handlers, so the reported symptom is gone. What is left is that
recovery depended on Dispose getting far enough, plus two other ways a failed
open could leave Bloom running with no window and no way to quit.

- ProjectContext: new ReleaseApplicationLevelProjectState, called from Dispose
  via a stored _server field rather than from inside a try whose first
  statement was a _scope.Resolve<BloomServer>() that could throw. If that
  resolve threw anything but ObjectDisposedException, the handler clear was
  skipped and the next collection was poisoned again. We deliberately leave the
  server's own CurrentCollectionSettings alone; BloomServer reads it unguarded
  to serve files relative to the collection folder, so between one project
  closing and the next opening -- exactly when the Open/Create Collections
  dialog is up -- stale settings are a better answer than null.

- Program.HandleErrorOpeningProjectWindow: repeat that reset at the point that
  knows we failed and are about to offer another collection, with a
  Debug.Assert that Dispose should already have done it. Its reporting half is
  now separate and guarded: it runs inside OpenProjectWindow's catch, so a
  failure to report used to swallow the "return false" that puts up the
  chooser.

- StartupScreenManager.DoStartupAction ran _current.Task() unguarded, and
  returns immediately while _current is set. One throw therefore killed the
  startup queue for the rest of the run: the splash screen never closed, the
  chooser never appeared, nothing called ProgramExit.Exit() so not even its
  20-second force-quit net was armed, and Main's finally never released the
  single-instance token -- so the next launch was turned away with "Bloom is
  already running". It now reports and moves on.

- Program.ReopenProject had no failure fallback, unlike
  OpenCollectionChosenInDialog, though it also runs with the Shell already
  closed. New ChooseACollectionOrQuit is used at all three recovery sites, so
  a chooser that fails quits instead of idling invisibly.

- Two diagnosis traps in the open path: the catch (FileNotFoundException) fell
  through into a null _scope, so the user and Sentry saw a
  NullReferenceException instead of the missing file; and a
  catch (Exception) { return; } inside the BeginLifetimeScope lambda would have
  abandoned the remaining registrations, turning any failure there into an
  unrelated Autofac ComponentNotRegisteredException.

- The MRU pruning loop at startup called IsInvalidCollectionToEdit(path) after
  path could have become null.

Tests: new BloomApiHandlerTests covers the application-level/project-level
split the whole recovery depends on, including a sanity check that
re-registering without the clear really does throw. New
StartupScreenManagerTests covers a throwing startup action not wedging the
queue. ProjectContextTests gains coverage of the new reset.

Also verified by hand in the running app: with a deliberate failure staged
just after audio/startRecord is registered, opening a good collection after
the failing one now works, and no windowless Bloom is left behind.

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

@greptile-apps greptile-apps Bot 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

Seven findings from the review pass on the first commit, all in the new code:

- ChooseACollectionOrQuit rethrew Utils.PathTooLongException so the top-level
  handler could render its tailored "path is too long" report. It cannot: that
  report lives in ProblemReportApi, which is not in play on these paths (no
  project means we are still on the WinForms fallback reporter), and an
  exception let out of here reaches FatalExceptionHandler, which in fallback
  mode shows a message and lets Bloom carry on windowless -- the very thing the
  method exists to prevent. It now calls LongPathAware.ReportLongPath itself
  and then quits.

- HandleErrorOpeningProjectWindow cleared the application-level state after an
  unguarded teardown. That block only runs when the failure came after the
  ProjectContext was built, and closing a half-working window or disposing a
  half-working project can throw -- which skipped both the clear and the
  _projectContext reset, so the next collection hit the duplicate-key failure
  and tripped OpenProjectWindow's Debug.Assert on the way. Teardown is now
  guarded and both happen in a finally.

- The startup-action guard began after the splash-hiding block, but
  _splashForm.Hide() and the "do this when the splash closes" action can throw
  too (both touch forms that may already be disposed), and a throw there left
  _current set -- the same permanent wedge. The whole body is now guarded, and
  we leave the idle queue before closing the splash screen so a failure there
  cannot repeat on every subsequent idle event.

- ReleaseApplicationLevelProjectState cleared CommonApi.CurrentCollectionSettings
  before bailing out on a null server, which made a second Dispose stop being a
  no-op: it would forget the collection belonging to whichever project was live
  by then. With no server there is nothing of ours to take back anyway, so it
  now bails first.

- Dispose unlocked the collection file ahead of that reset, and
  CollectionLock.Unlock rethrows in a DEBUG build with nothing catching it on
  the ordinary collection-switch path -- so a failing unlock could leave the
  handlers registered. The unlock now happens after.

- The called-twice test was close to vacuous (no application-level snapshot, so
  the first call cleared everything and the second had nothing to do). It now
  asserts what actually matters: the second call leaves a later project's
  collection and the application-level handlers alone.

- StartupScreenManagerTests drained the static queue by ticking it, which
  *runs* whatever another fixture left queued and closes the splash screen. New
  ClearQueueForTests clears it instead, in both SetUp and TearDown.

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

@greptile-apps greptile-apps Bot 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

Comment thread src/BloomExe/ProjectContext.cs
@StephenMcConnel

StephenMcConnel commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context) from Steve McConnel's machine during preflight] Consulted Devin on 2026-08-20 20:49 UTC, up to commit e5f662dcb161917bbc99241b3d3a4434dd85125d.

It raised one Bug and six Flags.

The Bug — the "path too long" signal being swallowed on the startup recovery path, leaving Bloom invisible — Devin itself now marks resolved. The local review had found the same thing independently, and it was fixed in e5f662dcb before Devin's pass finished, so there was never a thread to close.

Of the Flags, one needed investigating (the broken-install path now showing two dialogs) and is posted above as a review thread. It is deliberately left open: it is a user-facing judgement call that has gone to the developer as a decision rather than something to settle here.

The other five were informational and are not mirrored. All five are choices this PR makes deliberately and documents in code comments: a repeating startup action that throws is dropped rather than retried; the server's own copy of the collection settings is deliberately left alone; the collection lock is skipped when the settings file is absent; startup-task exceptions are reported rather than left fatal; and one ObjectDisposedException catch may now be unreachable, which is harmless.

CI is green. Greptile posted only a "trial has ended" notice, and CodeRabbit is switched off for this repo in .coderabbit.yml.

@StephenMcConnel
StephenMcConnel marked this pull request as ready for review August 20, 2026 22:43

@greptile-apps greptile-apps Bot 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.

Your trial has ended. Reactivate Greptile to resume code reviews.

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