Recover cleanly when a collection fails to open (BL-16678) - #8220
Recover cleanly when a collection fails to open (BL-16678)#8220StephenMcConnel wants to merge 2 commits into
Conversation
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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
[Claude Opus 5 (1M context) from Steve McConnel's machine during preflight] Consulted Devin on 2026-08-20 20:49 UTC, up to commit 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 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 CI is green. Greptile posted only a "trial has ended" notice, and CodeRabbit is switched off for this repo in |
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
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.ProjectContextregisters its ~65 project-level API endpoints on the application-levelBloomServer.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
Disposealready calledClearProjectLevelHandlers()._scopeis assigned well before the firstRegisterWithApiHandler, so the clear is reached on the reported path. The log attached to the card confirms the boundary: the emptyBad Collection.bloomCollectionfailed 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
Disposegetting 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
Disposedid the reset inside atrywhose first statement was_scope.Resolve<BloomServer>()and whosecatchonly toleratedObjectDisposedException. 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.BloomServerreads it unguarded (e.g. to servewritingSystemDisplayForUI.cssand 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.HandleErrorOpeningProjectWindowis the one place that knows we failed and are about to offer another collection, so it repeats the reset (idempotent), with aDebug.AssertthatDisposeshould already have done it.Its reporting half is now separate and guarded. It runs inside
OpenProjectWindow's catch, and the callers depend onOpenProjectWindowreturningfalseto 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.DoStartupActionran_current.Task()unguarded, and returns immediately on every later idle while_currentis set. A throw therefore killed the queue for the rest of the run: the splash screen never closed,ChooseACollection()never ran, nothing calledProgramExit.Exit()so not even its 20-second force-quit net was armed, andMain'sfinallynever released theUniqueToken— so the next launch was turned away with "Bloom is already running". That is the shape the card's title describes.FatalExceptionHandlercannot save this: during collection openUseFallbackis true, so it shows a message and returns, letting the process carry on windowless.4.
ReopenProjectgets the fallback its sibling already hadReopenProjectcalledOpenCollection(MruProjects.Latest)with nothing on failure, unlikeOpenCollectionChosenInDialog— although it also runs with the Shell already closed (it is reached from a UI language change and from the Collection Settings dialog). NewChooseACollectionOrQuitis 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, calledProgramExit.Exit(), then fell through into code dereferencing the_scopeit had just failed to build — so what the user and Sentry actually saw was aNullReferenceExceptionnaming nothing useful.catch (Exception) { return; }inside theBeginLifetimeScopelambda would have abandoned every remaining registration, turning any failure there into an unrelated AutofacComponentNotRegisteredException.IsInvalidCollectionToEdit(path)afterpathcould 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'sArgumentException, so it cannot pass falsely. NewStartupScreenManagerTests(3) covers a throwing startup action not wedging the queue.ProjectContextTestsgains 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/startRecordis 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
MostRecentPathsList.Pathsalready prunes on serialization andAddNewPathrefuses 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.cswrites a double-clicked.bloomCollectionto the MRU before trying to open it.CollectionSettings.DoDefenderFolderProtectionCheckcallsEnvironment.Exit(-1)from inside theCollectionSettingsconstructor, skippingReleaseBloomToken()— a read-only collection folder is currently unrecoverable, and the Defender-specific wording will mislead. Worth its own card.--no-collection/ Shift-at-startup escape hatch for a poisoned MRU.FileSystemWatcher.EnableRaisingEventsis called unguarded inTeamCollection.StartMonitoringandFolderTeamCollection, and nothing subscribes toFileSystemWatcher.Error.Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16678
Devin review
This change is