fix(#503): guard engine-backed ribbon commands against the InboxEngines initialization race - #515
Merged
Merged
Conversation
…ries - Delete ~10 MB baseline and final coverage-*.cobertura.xml reports - Add compact package-level coverage-*.jacoco.xml with identical measured totals - Add coverage-artifact-substitution note documenting the swap and denominator distinction Refs: #503 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F54ScKL18nJNT96WPFFGi4
…RibbonExplorer.xml line count
…emediation cycle 1
…stamp collision findings
- Add policy-audit, code-review, and feature-audit for the post-remediation branch state (PASS, zero blocking findings) - Record feature-review memory on the csharpier XML formatting probe and the cobertura-vs-jacoco coverage artifact distinction Refs: #503 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F54ScKL18nJNT96WPFFGi4
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AppItemEngines.InitAsync()initialization race, so a click during add-in startup no longer throws.InboxEnginesdictionary, implemented in four new host-neutral types that are unit-testable and not coverage-exempt.getEnabledcallback to eight<button>controls and invalidates them once initialization completes, so the buttons report disabled until their backing engine exists.AppItemEngines.csandIAppItemEngines.csare unchanged (zero-line diff), preserving the async engine construction, config loading, and dictionary population order exactly.Why
RibbonController.SBresolves the SpamBayes engine by looking up the"Spam"key inGlobals.Engines.InboxEngines. ThatConcurrentDictionaryis created empty at field-initializer time and is only populated at the very end ofAppItemEngines.InitAsync(), which first awaitsGlobals.AF.Manager.Configurationand then asynchronously constructs each engine.Clicking a ribbon button inside that window — most easily reproduced immediately after an add-in reload — makes
SBreturnnull, andRibbonViewer.TrainSpam_Clickthen executesawait Controller.SB.TrainAsync(...), throwingNullReferenceException. Because the handler isasync void, the exception surfaces on the message-pump synchronization context rather than at the call site.Investigation established the defect surface is broader in one direction and narrower in another than the original report:
TestSpam_Clickfails withKeyNotFoundException, notNullReferenceException, because it indexes the dictionary directly rather than going throughSB.TrainSpam_Click,TrainHam_Click,TestSpam_Click) and Triage x5 (TriageSetA/B/C_Click,ClearTriage_Click,FilterViewer_Click). The original report's claim that the Project, Context, and Actionable classifiers are affected was checked against the call graph and is not correct: no ribbon callback dereferences those engines.A coarse
IsInitializedflag orInitTaskonAppItemEngines/IAppItemEngineswas considered and rejected on two grounds. First, it is wrong on the merits:InitAsyncfilters onconfig.Value.Engine, so an engine that is configured off never enters the dictionary at all, and a global "initialized" flag would report ready for a command that will never work. Second, .NET Framework 4.8.1 has no default interface members, so a newIAppItemEnginesmember could only be bodied insideAppItemEngines, which carries[ExcludeFromCodeCoverage]— the readiness logic would have been untestable by construction.What Changed
Core fix — four new host-neutral decision types
All are
internal sealedunderTaskMaster\Ribbon\, contain zeroMicrosoft.Office.*references, and deliberately carry no[ExcludeFromCodeCoverage]attribute.EngineCommandCatalog.cs— the single source of truth binding the eight ribbon control ids to their engine keys. Centralizing it is what allows one test to assert the ribbon XML and the code agree.EngineReadinessGate.cs— the readiness predicate. Returnsfalsefor a null engines accessor, a nullInboxEngines, a missing key, a null value, or a null/whitespace engine name. Readiness is recomputed on every query, so it tracks both initialization andRestartEngineAsync.EngineGatedCommandRunner.cs— the click guard and thegetEnableddecision. Callers pass a lambda, so the engine is dereferenced only inside that lambda and never evaluated when the gate is closed. This is what converts the exception into a no-op without scattering?.across the viewer.EngineCommandRefreshPlanner.cs— decides which controls to invalidate, keeping that decision covered while theIRibbonUIcall stays in the uncovered shim.Thin Office-typed shims
RibbonController.EngineCommands.cs(new partial) — builds the gate as() => Globals?.Engines, which is null-safe beforeSetGlobalsruns.RibbonViewer.EngineCommands.cs(new partial) — hostsEngineCommand_GetEnabled, the only new Office-typed member introduced by this change, plus the relocated Spam and Triage callbacks rewritten to route through the gated runner.RibbonViewer.cs— becomespartial; the#region Spam Managerand#region Triageblocks move into the new partial. This is required, not cosmetic: the file was at 487 of the 500-line cap, so the new callbacks could not be added in place. Net −99 lines.ThisAddIn.cs— one refresh call afterawait _globals.LoadAsync(false), already on the STA via the idle queue.RibbonExplorer.xml—getEnabled="EngineCommand_GetEnabled"on the eight engine-backed buttons..csprojfiles gain explicit<Compile Include>entries; these are legacy non-SDKpackages.configprojects, so new files are not auto-included.Tests
Four new MSTest classes plus an extension to
RibbonExplorerXmlTests.cs, using MSTest + Moq + FluentAssertions. No temporary files, noThread.Sleep/Task.Delay, noForm,MessageBox, message pump, or live COM.Architecture / How It Fits Together
Office calls
EngineCommand_GetEnabled(IRibbonControl)on the COM-visibleRibbonViewershim. That forwards toRibbonController.IsEngineCommandEnabled(controlId), which asksEngineGatedCommandRunner, which maps the control id to an engine key throughEngineCommandCatalogand asksEngineReadinessGatewhether that key is present and non-null in the liveInboxEnginesdictionary.The same runner backs the click path: each of the eight handlers now calls
RunEngineCommandAsync(controlId, () => <original expression>). The original expression is unchanged and is evaluated only when the gate is open.When
InitAsync()completes,ThisAddIncallsRefreshEngineCommands(), which marshals to the UI dispatcher and invalidates the eight control ids so Office re-queriesgetEnabled.All decision logic is host-neutral and would port unchanged if the ribbon is later replaced by an Office.js command surface. The only Office-typed surface added is one method on the pre-existing
[ComVisible(true)] [ExcludeFromCodeCoverage] RibbonViewer.Verification
Completed
csharpier check .— exit 0, 1498 files, empty unformatted set.msbuild TaskMaster.sln /t:Build /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true— exit 0, 0 errors.msbuild TaskMaster.sln /t:Build /p:Nullable=enable /p:TreatWarningsAsErrors=true— exit 0.TaskMasterpackage coverage counter gained exactly 186 covered lines — the sum of the four new types (48+48+72+18) — withmissedbyte-identical, which independently establishes both the new-code floor and no regression on changed lines.AppItemEngines.cs,IAppItemEngines.cs, andApplicationGlobals.cs.getEnabledassertion was proven non-vacuous by removing one attribute from the embedded resource, confirming the mutation reached the built assembly, observing the test fail, and restoring.Recommended
getEnabledcallback is actually bound, since VSTO silently ignores a signature mismatch.Backward Compatibility / Migration Notes
No breaking changes. No public API is altered, no member is added to
IAppItemEngines, and no engine construction, configuration loading, or dictionary population order is modified. On the ready path the eight handlers evaluate expressions identical to the previous implementation.Risks and Mitigations
getEnabledcallback may not bind at runtime. VSTO ignores a signature mismatch silently, and no local test can prove binding. Mitigated by a reflection test pinning the signature, and more importantly by the click guard, which makes the original defect unreachable even if the enabled-state callback never fires. Rollback is limited to reverting the eight XML attributes.MessageBox, following the existing pattern inRibbonViewer/RibbonController. Tests assert against an injected sink, so no dialog is constructed under test.RibbonExplorer.xmlis 539 lines, over the 500-line cap. The file was already at 519 before this change; the growth is 8 required attributes plus 12 lines that CSharpier mandates, because a collapsed<button>carrying the new attribute is 116 characters against a default print width of 100. Splitting the resource is tracked separately rather than bundled here.Review Guide
Suggested order:
TaskMaster/Ribbon/EngineReadinessGate.csandEngineCommandCatalog.cs— the readiness contract and the id/key binding.TaskMaster/Ribbon/EngineGatedCommandRunner.cs— the lambda-deferral mechanism that is the actual fix.TaskMaster/Ribbon/RibbonViewer.EngineCommands.cs— confirm the eight rewritten handlers preserve their original expressions.TaskMaster/Ribbon/RibbonExplorer.xmlandRibbonExplorerXmlTests.cs— the wiring and the tests that pin it.TaskMaster/ThisAddIn.cs— the single refresh call.TaskMaster/Ribbon/RibbonViewer.csshows 1 insertion and 100 deletions; this is almost entirely the mechanical region move into the new partial plus thepartialkeyword. Reviewing it againstRibbonViewer.EngineCommands.csside by side is the fastest path.Follow-ups
Defects found during this work were promoted to their own issues rather than fixed here:
onActioncallback names resolve to no method, leaving four Quick Filer settings check boxes inert.SpamBayesEnabled_GetPressedandTriageEnabled_GetPressedareasync Task<bool>against the required synchronousgetPressedcontract.SpamBayesEnabled_ClickandTriageEnabled_Clickdiscard the task returned byToggleEngineAsync.RibbonController.Engineslacks the null propagation its sibling properties use.YieldAsync_WithoutDispatcher_RemainsStrictis order-dependent and flakes under parallel execution.UtilitiesCS.Test.csproj, and a flaky WinForms pump-host test./p:values change. A forced rebuild surfaces substantial pre-existing nullable debt, none of it in this change's files.GitHub Auto-close