fix: repair operator timer arming, close the remaining Rx gaps, and cut dispatch cost - #177
Merged
Merged
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
glennawatson
force-pushed
the
CP_fix-primitives-167-154-compatibility
branch
from
September 1, 2026 08:35
e66b608 to
507f934
Compare
…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
approved these changes
Sep 1, 2026
glennawatson
enabled auto-merge (squash)
September 1, 2026 08:52
glennawatson
disabled auto-merge
September 1, 2026 08:52
glennawatson
enabled auto-merge (squash)
September 1, 2026 08:52
|
2 tasks
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.



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
CombineLatestreturns named tuples for 2 to 16 sources without a result selector, in both thelean and Reactive packages. It also combines a source collection or a
paramsarray into anIList<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.FromEventPatternaccepts a handler conversion, and a sibling keeps the sender's statictype through a new
EventPattern<TSender, TEventArgs>.Signal.FromEventcovers events thatcarry 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.
SubscribePrimitivesoverloads and oneSubscribeSafePrimitivesoverload explicitlyselect the Primitives implementation when System.Reactive is also in scope. Those are exactly
the shapes System.Reactive declares in the
Systemnamespace, so they are the only ones animplicit
using System;makes ambiguous.Scheduling correction
Timer ownership is installed before scheduling, through a shared
TimerSlot.Arm. A sequencer thatruns a callback before its own
Schedulereturns can no longer have the successor timer thatcallback armed cancelled when the outer call returns. This covers
Calm/Throttle,Shift/Delay,Every/IntervalandTimer; under that interleavingDelaypreviouslydropped every value and the two timers stopped after a single tick.
Performance
rather than a shared
objectarray, so a notification no longer boxes. Each slot observes itssource 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
CombineLatestandSyncLatestand by the tuple and list overloads built on them.Signal<T>.OnNextno longer takes the observer gate. The single observer, the single action andthe 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
Subscribeand the observer-takingSubscribeSafeambiguous.
SubscribeSafe(Action<Exception>)handles terminal errors rather than exceptionvalues, 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
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
Subscribecalls still require aconsumer-side choice, such as
SubscribePrimitives, an explicit static call, or anamespace-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.
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:
0 skipped.
coordinators and the timer slot at one arm per window, which the existing burst cases never
reach.
matching non-platform baseline plus their platform-only API and verified line for line; the
macOS build leg is what confirms them.