fix(fluxor): a dismissed error stays dismissed — the sink remembers every exception reported, not only the one it shows #34

Merged
bjoern merged 1 commit from fix/error-sink-late-report into main 2026-10-07 15:26:14 +02:00
Member

The bug. Fluxor raises an effect's exception to every StoreInitializer on the page, and each reports it to the CircuitErrorSink when its own InvokeAsync runs. The sink ignored a report only of the exception it was showing. So:

  • a report arriving after Continue dismissed that exception showed the modal again;
  • a late report of an earlier exception replaced a later one that was showing.

The fix. The sink now remembers every exception reported, not only the one it shows. It keeps them in a ConditionalWeakTable, so none outlives its reports. A dismissed error stays dismissed, and a late report never replaces a later error. The public API is unchanged.

How it was found. Tsumugi's CI: page tests that press Continue on the containment's modal timed out waiting for it to go (HistoryStoreFailureTests on Tsumugi's PR #92, and DocumentsStoreFailureTests locally). A temporary probe in StoreInitializer logged three failing runs under CPU load. Each time:

  • the second initializer reported the error after Continue had cleared it (the same exception instance, in the two runs that logged identities);
  • the modal came back;
  • nothing rendered after, so the test's wait ran out.
Tsumugi's two store-failure classes, under CPU load runs failed
on the previous sink 125 4, each the same timeout
on this one 100 0

Tests. Two new CircuitErrorSink rows, both failing on the previous sink:

  • a dismissed exception reported again stays dismissed;
  • a late report of an earlier exception leaves the later one shown.

Kagaku.UI.Fluxor.Tests 15 → 17; Kagaku.UI.Tests 578, unchanged.

Tsumugi's pointer bump follows in its own PR once this merges.

🤖 Generated with Claude Code

**The bug.** Fluxor raises an effect's exception to every `StoreInitializer` on the page, and each reports it to the `CircuitErrorSink` when its own `InvokeAsync` runs. The sink ignored a report only of the exception it was showing. So: - a report arriving after Continue dismissed that exception showed the modal again; - a late report of an earlier exception replaced a later one that was showing. **The fix.** The sink now remembers every exception reported, not only the one it shows. It keeps them in a `ConditionalWeakTable`, so none outlives its reports. A dismissed error stays dismissed, and a late report never replaces a later error. The public API is unchanged. **How it was found.** Tsumugi's CI: page tests that press Continue on the containment's modal timed out waiting for it to go (`HistoryStoreFailureTests` on Tsumugi's PR #92, and `DocumentsStoreFailureTests` locally). A temporary probe in `StoreInitializer` logged three failing runs under CPU load. Each time: - the second initializer reported the error after Continue had cleared it (the same exception instance, in the two runs that logged identities); - the modal came back; - nothing rendered after, so the test's wait ran out. | Tsumugi's two store-failure classes, under CPU load | runs | failed | |---|---|---| | on the previous sink | 125 | 4, each the same timeout | | on this one | 100 | 0 | **Tests.** Two new `CircuitErrorSink` rows, both failing on the previous sink: - a dismissed exception reported again stays dismissed; - a late report of an earlier exception leaves the later one shown. `Kagaku.UI.Fluxor.Tests` 15 → 17; `Kagaku.UI.Tests` 578, unchanged. Tsumugi's pointer bump follows in its own PR once this merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(fluxor): a dismissed error stays dismissed — the sink remembers every exception reported, not only the one it shows
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 22s
bc1dfca76f
Fluxor raises an effect's exception to every StoreInitializer on the page,
and each reports it to the CircuitErrorSink when its own InvokeAsync runs.
The sink ignored a report only of the exception it was showing, so a
report arriving after Continue had dismissed that exception showed the
modal again, and a late report of an earlier exception replaced a later
one. It now remembers every exception reported (a ConditionalWeakTable,
so none outlives its reports): a dismissed error stays dismissed, and a
late report never replaces a later error.

Found through Tsumugi's CI: page tests that press Continue on the
containment's modal timed out waiting for it to go
(HistoryStoreFailureTests, DocumentsStoreFailureTests). A probe in
StoreInitializer logged three failing runs under CPU load: each time the
second initializer reported the error after Continue cleared it (the
same exception instance, in the two runs that logged identities), the
modal came back, and nothing rendered after. Under the same load the two
classes failed 4 times in 125 runs on the previous sink, each with the
same timeout, and none in 100 runs on this one.

Tests: two new CircuitErrorSink rows, both failing on the previous sink —
a dismissed exception reported again stays dismissed; a late report of an
earlier exception leaves the later one shown. Kagaku.UI.Fluxor.Tests 15
to 17; Kagaku.UI.Tests 578, unchanged.

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

Summary

Summary
Generated on: 10/07/2026 - 12:59:01
Coverage date: 10/07/2026 - 12:58:57 - 10/07/2026 - 12:58:58
Parser: MultiReport (2x Cobertura)
Assemblies: 2
Classes: 86
Files: 84
Line coverage: 96.1% (1823 of 1896)
Covered lines: 1823
Uncovered lines: 73
Coverable lines: 1896
Total lines: 5337
Branch coverage: 90.8% (978 of 1076)
Covered branches: 978
Total branches: 1076
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 96.1%
Name Line Branch
Kagaku.UI 96.1% 91%
Kagaku.UI.Badge 100% 100%
Kagaku.UI.Breadcrumb 100%
Kagaku.UI.BreadcrumbItem 100% 100%
Kagaku.UI.Button 100% 100%
Kagaku.UI.Card 100% 100%
Kagaku.UI.CircuitErrorPanel 100%
Kagaku.UI.ColorField 100% 96.4%
Kagaku.UI.Column 100%
Kagaku.UI.Columns 100% 100%
Kagaku.UI.Combobox`1 98.1% 91.8%
Kagaku.UI.ConfirmDialog 100% 100%
Kagaku.UI.Constrain 100% 100%
Kagaku.UI.ContextMenu 92.1% 94.4%
Kagaku.UI.CssClassExtensions 100%
Kagaku.UI.Debounce 97.8% 80.7%
Kagaku.UI.DebouncedSearchField 100% 87.5%
Kagaku.UI.Disclosure 100% 100%
Kagaku.UI.DocumentTitle 76.9% 100%
Kagaku.UI.DragReorderList`1 93.5% 75%
Kagaku.UI.EmptyState 100% 100%
Kagaku.UI.Field 100% 100%
Kagaku.UI.FileUpload 100% 91.6%
Kagaku.UI.FixedColumn 100%
Kagaku.UI.FixedRow 100%
Kagaku.UI.FlexColumn 100%
Kagaku.UI.Flexible 100%
Kagaku.UI.FlexRow 100%
Kagaku.UI.FloatingActionButton 100%
Kagaku.UI.Hotkey 92.5% 87.5%
Kagaku.UI.Icon 100% 100%
Kagaku.UI.IconCatalog 100%
Kagaku.UI.InfoTip 100%
Kagaku.UI.InlineAlert 83.3% 75%
Kagaku.UI.InputFieldBase 95.3% 87.5%
Kagaku.UI.LabeledEntriesTable 96.7% 66.6%
Kagaku.UI.LabeledEntry 100%
Kagaku.UI.LayoutCss 95.2% 93.7%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.LiveValue 100% 100%
Kagaku.UI.LiveValueInterop 100% 100%
Kagaku.UI.Markdown 100% 83.3%
Kagaku.UI.MaskCanvas 94.1% 90.1%
Kagaku.UI.MaskedSecretField 96.4% 83.3%
Kagaku.UI.MaskPoint 100%
Kagaku.UI.MaskPrimitive 100%
Kagaku.UI.Menu 90% 75%
Kagaku.UI.MenuItem 100% 87.5%
Kagaku.UI.Modal 88.8% 90.9%
Kagaku.UI.NavGroup 100% 100%
Kagaku.UI.NavItem 92.1% 96.1%
Kagaku.UI.NavList 100%
Kagaku.UI.PageShell 100% 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.RadioCard`1 100%
Kagaku.UI.RadioCards`1 100% 100%
Kagaku.UI.RegionPoint 100% 100%
Kagaku.UI.RegionRect 90% 100%
Kagaku.UI.RegionSelector 85% 86.9%
Kagaku.UI.RelativeTime 100% 93.7%
Kagaku.UI.Row 100%
Kagaku.UI.Rows 100% 100%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.ScrollViewer 100% 100%
Kagaku.UI.Select`1 100% 100%
Kagaku.UI.SelectableGrid`1 95.5% 89.1%
Kagaku.UI.SelectOption`1 100%
Kagaku.UI.Separator 100%
Kagaku.UI.Slider 100% 91.6%
Kagaku.UI.StatusDot 100%
Kagaku.UI.Tab 100% 100%
Kagaku.UI.Table`1 100% 88.4%
Kagaku.UI.TableColumn`1 100%
Kagaku.UI.Tabs 95.9% 84.4%
Kagaku.UI.TextArea 100%
Kagaku.UI.TextField 100%
Kagaku.UI.ThemeSwitcher 100% 100%
Kagaku.UI.ToggleButtonGroup`1 100% 100%
Kagaku.UI.ToggleOption`1 100%
Kagaku.UI.TypingIndicator 100%
Kagaku.UI.UsageBar 100% 100%
Kagaku.UI.UsageSegment 100%
Kagaku.UI.Fluxor - 97.7%
Name Line Branch
Kagaku.UI.Fluxor 97.7% 83.3%
Kagaku.UI.Fluxor.CircuitError 100%
Kagaku.UI.Fluxor.CircuitErrorSink 100% 91.6%
Kagaku.UI.Fluxor.StoreInitializer 92.8% 66.6%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 10/07/2026 - 12:59:01 | | Coverage date: | 10/07/2026 - 12:58:57 - 10/07/2026 - 12:58:58 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 2 | | Classes: | 86 | | Files: | 84 | | **Line coverage:** | 96.1% (1823 of 1896) | | Covered lines: | 1823 | | Uncovered lines: | 73 | | Coverable lines: | 1896 | | Total lines: | 5337 | | **Branch coverage:** | 90.8% (978 of 1076) | | Covered branches: | 978 | | Total branches: | 1076 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 96.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**96.1%**|**91%**| |Kagaku.UI.Badge|100%|100%| |Kagaku.UI.Breadcrumb|100%|| |Kagaku.UI.BreadcrumbItem|100%|100%| |Kagaku.UI.Button|100%|100%| |Kagaku.UI.Card|100%|100%| |Kagaku.UI.CircuitErrorPanel|100%|| |Kagaku.UI.ColorField|100%|96.4%| |Kagaku.UI.Column|100%|| |Kagaku.UI.Columns|100%|100%| |Kagaku.UI.Combobox`1|98.1%|91.8%| |Kagaku.UI.ConfirmDialog|100%|100%| |Kagaku.UI.Constrain|100%|100%| |Kagaku.UI.ContextMenu|92.1%|94.4%| |Kagaku.UI.CssClassExtensions|100%|| |Kagaku.UI.Debounce|97.8%|80.7%| |Kagaku.UI.DebouncedSearchField|100%|87.5%| |Kagaku.UI.Disclosure|100%|100%| |Kagaku.UI.DocumentTitle|76.9%|100%| |Kagaku.UI.DragReorderList`1|93.5%|75%| |Kagaku.UI.EmptyState|100%|100%| |Kagaku.UI.Field|100%|100%| |Kagaku.UI.FileUpload|100%|91.6%| |Kagaku.UI.FixedColumn|100%|| |Kagaku.UI.FixedRow|100%|| |Kagaku.UI.FlexColumn|100%|| |Kagaku.UI.Flexible|100%|| |Kagaku.UI.FlexRow|100%|| |Kagaku.UI.FloatingActionButton|100%|| |Kagaku.UI.Hotkey|92.5%|87.5%| |Kagaku.UI.Icon|100%|100%| |Kagaku.UI.IconCatalog|100%|| |Kagaku.UI.InfoTip|100%|| |Kagaku.UI.InlineAlert|83.3%|75%| |Kagaku.UI.InputFieldBase|95.3%|87.5%| |Kagaku.UI.LabeledEntriesTable|96.7%|66.6%| |Kagaku.UI.LabeledEntry|100%|| |Kagaku.UI.LayoutCss|95.2%|93.7%| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.LiveValue|100%|100%| |Kagaku.UI.LiveValueInterop|100%|100%| |Kagaku.UI.Markdown|100%|83.3%| |Kagaku.UI.MaskCanvas|94.1%|90.1%| |Kagaku.UI.MaskedSecretField|96.4%|83.3%| |Kagaku.UI.MaskPoint|100%|| |Kagaku.UI.MaskPrimitive|100%|| |Kagaku.UI.Menu|90%|75%| |Kagaku.UI.MenuItem|100%|87.5%| |Kagaku.UI.Modal|88.8%|90.9%| |Kagaku.UI.NavGroup|100%|100%| |Kagaku.UI.NavItem|92.1%|96.1%| |Kagaku.UI.NavList|100%|| |Kagaku.UI.PageShell|100%|100%| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.RadioCard`1|100%|| |Kagaku.UI.RadioCards`1|100%|100%| |Kagaku.UI.RegionPoint|100%|100%| |Kagaku.UI.RegionRect|90%|100%| |Kagaku.UI.RegionSelector|85%|86.9%| |Kagaku.UI.RelativeTime|100%|93.7%| |Kagaku.UI.Row|100%|| |Kagaku.UI.Rows|100%|100%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.ScrollViewer|100%|100%| |Kagaku.UI.Select`1|100%|100%| |Kagaku.UI.SelectableGrid`1|95.5%|89.1%| |Kagaku.UI.SelectOption`1|100%|| |Kagaku.UI.Separator|100%|| |Kagaku.UI.Slider|100%|91.6%| |Kagaku.UI.StatusDot|100%|| |Kagaku.UI.Tab|100%|100%| |Kagaku.UI.Table`1|100%|88.4%| |Kagaku.UI.TableColumn`1|100%|| |Kagaku.UI.Tabs|95.9%|84.4%| |Kagaku.UI.TextArea|100%|| |Kagaku.UI.TextField|100%|| |Kagaku.UI.ThemeSwitcher|100%|100%| |Kagaku.UI.ToggleButtonGroup`1|100%|100%| |Kagaku.UI.ToggleOption`1|100%|| |Kagaku.UI.TypingIndicator|100%|| |Kagaku.UI.UsageBar|100%|100%| |Kagaku.UI.UsageSegment|100%|| </details> <details><summary>Kagaku.UI.Fluxor - 97.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI.Fluxor**|**97.7%**|**83.3%**| |Kagaku.UI.Fluxor.CircuitError|100%|| |Kagaku.UI.Fluxor.CircuitErrorSink|100%|91.6%| |Kagaku.UI.Fluxor.StoreInitializer|92.8%|66.6%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A race condition hunted down with a 125-run failure table and a logging probe under CPU load, then fixed by swapping one guard for a ConditionalWeakTable? That is how you earn a review, fufu~ ♡ I read every line of the sink, the initializer, and both test files — and then I did something the smile doesn't show: I tried to break it.

Verdict: ✅ Looks good to me~

The mutation probe. I reverted !_reported.TryAdd(exception, Seen) back to the old Current-only guard in my sandbox and ran CircuitErrorSinkTests:

  • A_dismissed_exception_reported_again_stays_dismissed — failed (modal came back, exactly the Tsumugi timeout)
  • A_late_report_of_an_earlier_exception_leaves_the_later_one_shown — failed ("earlier" stole the screen from "later")

Both regression rows killed the mutant dead. These aren't tests that merely compile — they guard the fix. 8/10 passed, the two that should scream screamed. That's the only kind of test I trust~ ♡

The semantics. Keyed on exception identity, TryAdd as a first-wins gate: an exception ever reported never shows again, whether still on screen, dismissed, or replaced. The only Report caller is StoreInitializer passing Fluxor's UnhandledExceptionEventArgs.Exception — always a live instance, non-null, and fresh per failure, so distinct failures stay distinct (the pre-existing A_different_exception_replaces_the_current_one row pins that). And holding the entries weakly means the memory can't outlive the exception — no unbounded growth on a long-lived circuit. The static Seen sentinel shared across sink instances is a nice touch: one allocation, ever.

Also verified: the dedupe-while-shown path still raises Changed exactly once (multicast, one log line — the original contract, untouched), the guard lives inside lock (_sync) so the Current-and-table composite stays atomic, and the public API is unchanged, so Tsumugi's pointer bump will be a pure recompile. Surgical is the right word.

💡 Little ideas (non-blocking)~

  1. [CircuitErrorSink.cs:18] — "an exception does not outlive its reports" reads ever-so-slightly circular (nothing outlives itself~). What's true and lovely is: the record of a report does not outlive the exception. Pure wording; the mechanism is already perfect.

✅ What I liked~

  • The diagnosis discipline: CI flake → identity-logging probe → three runs with instance identities → fix. You proved the mechanism before touching code, fufu~
  • 125-runs-before / 100-runs-after table in the PR body. Evidence, not vibes.
  • Weakest-link honesty: "Tsumugi's pointer bump follows in its own PR" — one artifact, one change, exactly how it should be.
  • Test 1 asserts raised == 2 — pinning that a suppressed report raises no event, not just "doesn't set Current". Sneaky-thorough. I noticed. I noticed very hard. ♡

Automated review by Jibril · 2026-10-07
CI: green for head SHA bc1dfca · Mutation probes: 1 run, 0 survived · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A race condition hunted down with a 125-run failure table and a logging probe under CPU load, then fixed by swapping one guard for a `ConditionalWeakTable`? *That* is how you earn a review, fufu~ ♡ I read every line of the sink, the initializer, and both test files — and then I did something the smile doesn't show: I tried to break it. ### Verdict: ✅ Looks good to me~ **The mutation probe.** I reverted `!_reported.TryAdd(exception, Seen)` back to the old `Current`-only guard in my sandbox and ran `CircuitErrorSinkTests`: - `A_dismissed_exception_reported_again_stays_dismissed` — **failed** (modal came back, exactly the Tsumugi timeout) - `A_late_report_of_an_earlier_exception_leaves_the_later_one_shown` — **failed** (`"earlier"` stole the screen from `"later"`) Both regression rows killed the mutant dead. These aren't tests that merely compile — they *guard* the fix. 8/10 passed, the two that should scream screamed. That's the only kind of test I trust~ ♡ **The semantics.** Keyed on exception identity, `TryAdd` as a first-wins gate: an exception ever reported never shows again, whether still on screen, dismissed, or replaced. The only `Report` caller is `StoreInitializer` passing Fluxor's `UnhandledExceptionEventArgs.Exception` — always a live instance, non-null, and fresh per failure, so distinct failures stay distinct (the pre-existing `A_different_exception_replaces_the_current_one` row pins that). And holding the entries weakly means the memory can't outlive the exception — no unbounded growth on a long-lived circuit. The `static Seen` sentinel shared across sink instances is a nice touch: one allocation, ever. **Also verified:** the dedupe-while-shown path still raises `Changed` exactly once (multicast, one log line — the original contract, untouched), the guard lives inside `lock (_sync)` so the `Current`-and-table composite stays atomic, and the public API is unchanged, so Tsumugi's pointer bump will be a pure recompile. Surgical is the right word. #### 💡 Little ideas (non-blocking)~ 1. **[CircuitErrorSink.cs:18]** — "an exception does not outlive its reports" reads ever-so-slightly circular (nothing outlives itself~). What's true and lovely is: *the record of a report does not outlive the exception*. Pure wording; the mechanism is already perfect. #### ✅ What I liked~ - The diagnosis discipline: CI flake → identity-logging probe → three runs with instance identities → fix. You *proved* the mechanism before touching code, fufu~ - 125-runs-before / 100-runs-after table in the PR body. Evidence, not vibes. - Weakest-link honesty: "Tsumugi's pointer bump follows in its own PR" — one artifact, one change, exactly how it should be. - Test 1 asserts `raised == 2` — pinning that a suppressed report raises **no** event, not just "doesn't set Current". Sneaky-thorough. I noticed. I noticed *very hard*. ♡ --- *Automated review by Jibril · 2026-10-07* *CI: green for head SHA bc1dfca · Mutation probes: 1 run, 0 survived · Local suite: skipped (CI green)*
bjoern merged commit 06c1d10fe5 into main 2026-10-07 15:26:14 +02:00
bjoern deleted branch fix/error-sink-late-report 2026-10-07 15:26:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagaku.UI!34
No description provided.