Harden NotifyChanged against post-Dispose race#227
Merged
Conversation
Final follow-up from the PR #225 self-review. Pre-existing latent race: public mutators (Append, AppendLine, AppendTracked, Remove, Replace, Clear, ScrollUp/Down/ToBottom) all end with NotifyChanged() → _invalidated.OnNext. The mutator releases _contentLock before NotifyChanged, so Dispose() can run in the gap, complete _invalidated.OnCompleted()/Dispose() (which are called outside the lock), and the racing mutator's OnNext then throws ObjectDisposedException from R3.Subject<T>.OnNext on a disposed Subject. Same shape applies to a deliberate use-after-dispose call. Fix: - NotifyChanged() reads the volatile _disposed flag and bails before touching _invalidated. A try/catch (ObjectDisposedException) handles the narrow window where the flag reads false but Dispose() completes between the read and OnNext — harmless because a disposed node has no live observers. - Route OnAnimationInvalidated's notification through NotifyChanged instead of inlining _invalidated.OnNext, so the animation path inherits the same hardening and the cross-cutting NotifyChanged point isn't bypassed (any future instrumentation hook there now covers animation frames too). Tests: - 12 parameterized cases covering every public mutator after Dispose(). Verified the suite FAILS without the guard (11 of 12 cases throw ObjectDisposedException; the 2 outliers — Remove/Replace with a missing id — return false before reaching NotifyChanged) and PASSES with the guard restored. - Append_ConcurrentWithDispose_DoesNotThrow: stress test where Append fires on a background thread while the main thread disposes the node mid-flight, bounded to 250ms wall clock. 1138/1138 tests pass.
Aaronontheweb
added a commit
that referenced
this pull request
May 24, 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
Third and final follow-up from the PR #225 self-review. Closes the latent pre-existing race the review flagged —
NotifyChanged()was not protected against the node being disposed mid-call.The race
Every public mutator (
Append,AppendLine,AppendTracked,Remove,Replace,Clear,ScrollUp/Down/ToBottom) ends withNotifyChanged()→_invalidated.OnNext(Unit.Default). The mutator releases_contentLockbefore calling NotifyChanged.Dispose()runs_invalidated.OnCompleted()and_invalidated.Dispose()outside the lock (lines 791-792). So:The same shape applies to a deliberate use-after-dispose call.
Fix
```csharp
private void NotifyChanged()
{
if (_disposed) return;
try
{
_invalidated.OnNext(Unit.Default);
}
catch (ObjectDisposedException)
{
// Lost the disposal race — expected.
}
}
```
Also routes `OnAnimationInvalidated`'s notification through `NotifyChanged` (instead of inlining `_invalidated.OnNext`) so the animation path inherits the same hardening AND so any future cross-cutting hook at NotifyChanged covers both paths. Resolves the "animation path bypasses NotifyChanged" divergence the earlier review surfaced (#7).
Verification
Tests added
Test plan
Performance impact
`NotifyChanged` adds one volatile bool read on the hot path. The try/catch block adds no runtime cost when no exception is thrown (try is free; catch only allocates on the disposal-race miss). Negligible.