Skip to content

fix: repair operator timer arming, close the remaining Rx gaps, and cut dispatch cost - #177

Merged
glennawatson merged 2 commits into
mainfrom
CP_fix-primitives-167-154-compatibility
Sep 1, 2026
Merged

fix: repair operator timer arming, close the remaining Rx gaps, and cut dispatch cost#177
glennawatson merged 2 commits into
mainfrom
CP_fix-primitives-167-154-compatibility

Conversation

@ChrisPulman

@ChrisPulman ChrisPulman commented Aug 31, 2026

Copy link
Copy Markdown
Member

What kind of change does this PR introduce?

Backward-compatible API additions, a scheduling correction across four operators, and two
performance changes, addressing gaps found while investigating #167 and #154. Existing public
APIs remain available.

What is the new behavior?

Rx compatibility APIs

  • CombineLatest returns named tuples for 2 to 16 sources without a result selector, in both the
    lean and Reactive packages. It also combines a source collection or a params array into an
    IList<T>, with an optional selector over that list. The tuple overloads outrank the list one,
    so two to sixteen same-typed sources listed inline keep binding to the tuple that names them.
  • Signal.FromEventPattern accepts a handler conversion, and a sibling keeps the sender's static
    type through a new EventPattern<TSender, TEventArgs>. Signal.FromEvent covers events that
    carry only their argument, with and without a conversion. Every event bridge takes an optional
    trailing sequencer that attaches and detaches the handler as scheduled work and cancels a
    pending attach on disposal. Supplying a conversion also avoids deriving the handler
    reflectively, which keeps those overloads trim- and AOT-safe.
  • Five SubscribePrimitives overloads and one SubscribeSafePrimitives overload explicitly
    select the Primitives implementation when System.Reactive is also in scope. Those are exactly
    the shapes System.Reactive declares in the System namespace, so they are the only ones an
    implicit using System; makes ambiguous.

Scheduling correction

Timer ownership is installed before scheduling, through a shared TimerSlot.Arm. A sequencer that
runs a callback before its own Schedule returns can no longer have the successor timer that
callback armed cancelled when the outer call returns. This covers Calm/Throttle,
Shift/Delay, Every/Interval and Timer; under that interleaving Delay previously
dropped every value and the two timers stopped after a single tick.

Performance

  • Combine-latest holds each source's latest value in a slot of that source's own element type
    rather than a shared object array, so a notification no longer boxes. Each slot observes its
    source directly, so a subscription costs one object per source instead of a closure and a
    delegate per callback. This is the arity 3 to 16 path, shared by CombineLatest and
    SyncLatest and by the tuple and list overloads built on them.
  • Signal<T>.OnNext no longer takes the observer gate. The single observer, the single action and
    the slot array collapse into one field that a dispatch reads once, with terminal state carried
    in that same field, so a stopped signal stays silent and a disposed one still throws. Subscribe,
    remove and the terminal transitions still take the lock and still reuse the slot array in place.

What is the current behavior?

Tuple- and list-returning combination overloads, custom event-handler conversion, typed-sender
event patterns and sequencer-scheduled handler attachment are all missing. Importing
System.Reactive alongside Primitives makes Subscribe and the observer-taking SubscribeSafe
ambiguous. SubscribeSafe(Action<Exception>) handles terminal errors rather than exception
values, so it is not a safe drop-in for ThrownExceptions.

An operator that arms its next timer from inside the current one loses that successor when the
scheduling call returns. Combine-latest boxes every value for three or more sources, and every
signal notification takes a lock.

Checklist

  • I have read the Contribute guide
  • Tests have been added or updated (for bug fixes / features)
  • Docs have been added or updated (for bug fixes / features)
  • Changes target the main branch
  • PR title follows Conventional Commits

Additional information

Related to #167 and #154. This PR does not claim to reproduce or resolve the original Skia crash
or the reported early-emission symptom. Existing ambiguous Subscribe calls still require a
consumer-side choice, such as SubscribePrimitives, an explicit static call, or a
namespace-scoped import: overload priority does not resolve extension methods declared in
different classes.

Measured with BenchmarkDotNet on net10.0, short job. Before and after are separate runs on the
same machine; the System.Reactive column comes from the same run as the after column.

Benchmark Before After System.Reactive
Signal emit, 1024 values 6,438 ns 2,091 ns 1,690 ns
CombineLatest, 4 sources, 1000 notifications 20.7 us / 26,400 B 9.3 us / 1,392 B 11.2 us / 976 B
CombineLatest, 2 sources, 1000 notifications 13.6 us / 1,040 B 8.0 us / 1,024 B 9.9 us / 472 B

R3's subject emits 1024 values in 2,274 ns on the same run, so the signal is now ahead of it and
within 24% of System.Reactive, from 3.8x behind. Subscribe and dispose was not traded away for
that: 8 subscribers 241 ns / 696 B and 64 subscribers 3,492 ns / 4,352 B, both ahead of
System.Reactive on time and on allocation.

Validation:

  • Full solution build in Release with public API tracking on: zero warnings, zero errors.
  • Full test run across every target framework that builds on the host: 12,534 passed, 0 failed,
    0 skipped.
  • Two benchmark classes are new, covering combine-latest across the pairwise and array-backed
    coordinators and the timer slot at one arm per window, which the existing burst cases never
    reach.
  • Apple target framework baselines cannot be generated on Linux. They were rebuilt from the
    matching non-platform baseline plus their platform-only API and verified line for line; the
    macOS build leg is what confirms them.

Compatibility APIs
- Add tuple-returning CombineLatest overloads for 2 through 16 sources in
  the lean and Reactive packages, preserving existing selector calls.
- Add FromEventPattern handler conversion with per-subscription ownership
  and removal of the exact converted delegate.
- Add five SubscribePrimitives aliases for explicit selection when
  System.Reactive and Primitives subscription extensions are both in scope.
- Update all 44 affected public API baselines with additive changes only.

Scheduling correction
- Install disposable ownership before scheduling a debounce timer so an
  inline, reentrant callback cannot have its successor timer cancelled.

Tests and documentation
- Cover every tuple arity, event conversion and disposal, mixed-Rx consumer
  compilation, subscription callbacks, and deterministic debounce timing.
- Document subscription ambiguity, SubscribeSafe error callback semantics,
  paint-event resource lifetimes, and scheduling requirements.
- Validate 3,806 tests across .NET 8, 9, 10, and 11 with coverage; build both
  library variants for net462 with zero warnings or errors.

Related to #167 and #154. The reported Skia crash remains unverified, and
existing ambiguous Subscribe calls still require a consumer-side choice.
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.73822% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.17%. Comparing base (b0fa198) to head (08e6cb8).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...rc/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs 97.64% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #177      +/-   ##
==========================================
+ Coverage   98.13%   98.17%   +0.04%     
==========================================
  Files         705      711       +6     
  Lines       21761    22080     +319     
  Branches     2680     2704      +24     
==========================================
+ Hits        21356    21678     +322     
+ Misses        199      198       -1     
+ Partials      206      204       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@glennawatson glennawatson changed the title fix: close Rx compatibility gaps and preserve debounce timers fix: close Rx compatibility gaps, repair timer arming, and speed up dispatch Sep 1, 2026
@glennawatson
glennawatson force-pushed the CP_fix-primitives-167-154-compatibility branch from e66b608 to 507f934 Compare September 1, 2026 08:35
@glennawatson glennawatson changed the title fix: close Rx compatibility gaps, repair timer arming, and speed up dispatch fix: close Rx compatibility gaps and preserve debounce timers Sep 1, 2026
…ut dispatch cost

Scheduling correction
- Route Calm, Shift, Every and After through a shared TimerSlot.Arm, which
  publishes slot ownership before scheduling. A sequencer that runs a callback
  before its own Schedule returns could otherwise cancel the successor timer that
  callback had just armed: Shift dropped every value, and the two timers stopped
  after a single tick.

Compatibility APIs
- Add CombineLatest over a source collection, with and without a list selector,
  plus the params form. It ranks below the tuple overloads, so two to sixteen
  same-typed sources listed inline keep binding to the tuple that names them.
- Add FromEventPattern with a typed sender, carried by a new
  EventPattern<TSender, TEventArgs>; FromEvent with and without a handler
  conversion; and a trailing sequencer on every event bridge, which attaches and
  detaches the handler as scheduled work and cancels a pending attach on disposal.
- Move the conversion bridge onto a dedicated signal, so no overload builds its
  handler reflectively.
- Add SubscribeSafePrimitives for the one SubscribeSafe shape System.Reactive also
  declares in the System namespace.

Performance
- Hold each source's latest value in a slot of that source's own element type
  rather than a shared object array, so a combine-latest notification no longer
  boxes, and let each slot observe its source directly so a subscription costs one
  object per source instead of a closure and a delegate per callback. This is the
  arity 3 to 16 path, shared by CombineLatest and SyncLatest.
- Drop the observer gate from Signal<T>.OnNext. The single observer, the single
  action and the slot array collapse into one field a dispatch reads once, with
  terminal state carried in that same field, so a stopped signal stays silent and a
  disposed one still throws. Subscribe, remove and the terminal transitions still
  take the lock and still reuse the slot array in place.

Measured with BenchmarkDotNet on net10.0, short job:

- Signal emit, 1024 values: 6,438 ns becomes 2,091 ns, against System.Reactive at
  1,690 ns and R3 at 2,274 ns.
- CombineLatest over four sources, 1000 notifications: 20.7 us and 26,400 B become
  9.3 us and 1,392 B, against System.Reactive at 11.2 us and 976 B.
- CombineLatest over two sources, 1000 notifications: 13.6 us becomes 8.0 us,
  against System.Reactive at 9.9 us.
- Subscribe and dispose was not traded away: 8 subscribers 241 ns / 696 B and 64
  subscribers 3,492 ns / 4,352 B, both ahead of System.Reactive on time and
  allocation.

Tests and benchmarks
- Cover the reentrant arm for Shift, Every and Timer on a virtual-clock sequencer,
  the collection overloads, the new event bridges, and the overload priority that
  keeps an untyped null selector on the selector overload.
- Make the self-cancelling WasmScheduler test deterministic: it published the
  handle the action cancels through only after Schedule had already armed the
  drain, so the action could find nothing to cancel.
- Benchmark combine-latest across the pairwise and array-backed coordinators, and
  the timer slot at one arm per window, which the existing burst cases never reach.
@glennawatson glennawatson changed the title fix: close Rx compatibility gaps and preserve debounce timers fix: repair operator timer arming, close the remaining Rx gaps, and cut dispatch cost Sep 1, 2026
@glennawatson
glennawatson enabled auto-merge (squash) September 1, 2026 08:52
@glennawatson
glennawatson enabled auto-merge (squash) September 1, 2026 08:52
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

@glennawatson
glennawatson merged commit 2aad34c into main Sep 1, 2026
16 of 17 checks passed
@glennawatson
glennawatson deleted the CP_fix-primitives-167-154-compatibility branch September 1, 2026 11:39
@ChrisPulman ChrisPulman mentioned this pull request Sep 1, 2026
2 tasks
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.

2 participants