Skip to content

Improve StreamingTextNode thread-safety test coverage#226

Merged
Aaronontheweb merged 1 commit into
devfrom
fix/streaming-textnode-test-coverage
May 24, 2026
Merged

Improve StreamingTextNode thread-safety test coverage#226
Aaronontheweb merged 1 commit into
devfrom
fix/streaming-textnode-test-coverage

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Owner

Summary

Follow-up to #225. A self-review of the new race regression suite surfaced two coverage holes — both tests passed, but for the wrong reasons.

1. Replace stress test exercised the wrong race

AnimationInvalidation_ConcurrentWithReplace_DoesNotThrow ordered Interlocked.Exchange after node.Replace(id, next). By the time Exchange ran, Replace had already disposed the old segment, so the ticker's Volatile.Read(ref current) mostly returned a reference to an already-disposed segment. The test was effectively validating "TriggerInvalidation tolerates disposed segment" rather than the advertised live-segment-vs-Replace race.

Fix: Restructure with two tracked elements — a permanently-tracked spinner the ticker fires on (always live), and a separate tracked element that Replace swaps repeatedly. The ticker is always firing on a live subscription, so OnAnimationInvalidated competes with Replace for _contentLock, which is the race the test claims to cover.

2. AfterDispose test passed for the wrong reason

AnimationInvalidation_AfterDispose_DoesNotThrow called node.AppendTracked, then node.Dispose() (which disposes the R3 subscription), then spinner.TriggerInvalidation. Since the subscription was already torn down, OnAnimationInvalidated was never invoked — the _disposed flag's belt-and-suspenders role was not exercised. The test would have passed even if the flag check were absent.

Fix: Keep the original test (renamed AnimationInvalidation_AfterDispose_SubscriptionDisposalUnhooksCallback — it does validate that subscription teardown unhooks the observer, which is real coverage) and add OnAnimationInvalidated_AfterDispose_BailsOutViaDisposedFlag, which uses reflection to invoke OnAnimationInvalidated directly after Dispose(). This simulates an in-flight callback that already passed the Subject's observer-list dispatch and is about to enter the lock when Dispose completes — exactly the scenario the flag protects.

Verification

Confirmed the new reflection-based test correctly catches the regression: temporarily removed the if (_disposed) return; guard and ran the test:

Failed OnAnimationInvalidated_AfterDispose_BailsOutViaDisposedFlag
Expected: null
Actual: System.ObjectDisposedException: Cannot access a disposed object.
   at R3.CompleteState.ThrowObjectDiposedException()
   at R3.Subject\`1.OnNext(T value)
   at Termina.Layout.StreamingTextNode.OnAnimationInvalidated()

Restored the guard — test passes again. Production code is unchanged in this PR.

Test plan

  • dotnet build succeeds, 0 warnings
  • Full suite (1125 tests) passes
  • Thread-safety tests (6) pass
  • Negative test: removing _disposed guard makes the new test fail with the expected ObjectDisposedException from R3
  • CI green on PR

Follow-up to #225. A self-review surfaced two test-quality issues in the
new race regression suite:

1. AnimationInvalidation_ConcurrentWithReplace_DoesNotThrow ordered
   Interlocked.Exchange AFTER node.Replace, so the ticker mostly read a
   reference to an already-disposed segment rather than racing a live one.
   The test mostly validated "TriggerInvalidation tolerates disposed
   segment" rather than the advertised live-vs-Replace race.

   Restructure: a permanently-tracked spinner is the ticker's target;
   Replace operates on a SEPARATE tracked element. The ticker is always
   firing on a live subscription, so OnAnimationInvalidated competes with
   Replace for _contentLock — which is the race the test claims to cover.

2. AnimationInvalidation_AfterDispose_DoesNotThrow passed for the wrong
   reason: node.Dispose() disposes the R3 subscription before the late
   TriggerInvalidation runs, so OnAnimationInvalidated is never invoked
   and the _disposed flag's belt-and-suspenders role is not exercised.

   Keep the original test (rebranded — it does validate subscription
   teardown, a legitimate concern) and add a new reflection-based test
   that invokes OnAnimationInvalidated directly after Dispose. Verified
   the new test FAILS with ObjectDisposedException from R3.Subject.OnNext
   when the `_disposed` guard is removed, and PASSES when the guard is
   present — so it actually catches the regression.
@Aaronontheweb Aaronontheweb merged commit 7b19e72 into dev May 24, 2026
8 checks passed
@Aaronontheweb Aaronontheweb deleted the fix/streaming-textnode-test-coverage branch May 24, 2026 19:57
@Aaronontheweb Aaronontheweb mentioned this pull request May 24, 2026
4 tasks
Aaronontheweb added a commit that referenced this pull request May 24, 2026
- Update VersionPrefix to 0.10.1
- Refresh PackageReleaseNotes with the StreamingTextNode thread-safety
  fixes (PRs #224, #225, #226, #227)
- Prepend 0.10.1 section to RELEASE_NOTES.md
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