Skip to content

Harden NotifyChanged against post-Dispose race#227

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

Harden NotifyChanged against post-Dispose race#227
Aaronontheweb merged 1 commit into
devfrom
fix/streaming-textnode-notifychanged-race

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Owner

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 with NotifyChanged()_invalidated.OnNext(Unit.Default). The mutator releases _contentLock before calling NotifyChanged. Dispose() runs _invalidated.OnCompleted() and _invalidated.Dispose() outside the lock (lines 791-792). So:

  1. Thread A: `Append` acquires lock, mutates, releases lock.
  2. Thread B: `Dispose` acquires lock, sets `_disposed=true`, releases lock.
  3. Thread B: `_invalidated.OnCompleted()` + `_invalidated.Dispose()`.
  4. Thread A: `NotifyChanged` → `_invalidated.OnNext(Unit.Default)` → ObjectDisposedException from `R3.Subject.OnNext` (empirically verified).

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

  • Negative test: temporarily replaced the guard with bare `_invalidated.OnNext(Unit.Default)` and ran the 12-case parameterized suite. 11 of 12 cases failed with `ObjectDisposedException` (the 2 that passed are `Remove`/`Replace` with a non-existent SegmentId, which return `false` before reaching NotifyChanged). Restored guard → all 12 pass.
  • Concurrent stress test: `Append` hammered on a background thread for 250ms wall-clock while the main thread disposes mid-flight — no exception.

Tests added

  • `PublicMutator_AfterDispose_DoesNotThrow` (12 parameterized cases, one per public mutator)
  • `Append_ConcurrentWithDispose_DoesNotThrow` (bounded stress test)

Test plan

  • Negative test confirms guard catches the bug (11/12 cases fail without it)
  • Full suite 1138/1138 pass with guard restored
  • Streaming thread-safety tests (19) pass in ~1s — no perf regression
  • CI green on PR

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.

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 Aaronontheweb merged commit 285dea5 into dev May 24, 2026
13 checks passed
@Aaronontheweb Aaronontheweb deleted the fix/streaming-textnode-notifychanged-race branch May 24, 2026 20:37
@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