feat!: deliver notifications outside the lock, one sink per operator, no runtime-async - #217
Merged
Merged
Conversation
glennawatson
force-pushed
the
feat/delivery-gate
branch
from
September 16, 2026 00:33
a68b410 to
4185448
Compare
glennawatson
force-pushed
the
feat/delivery-gate
branch
from
September 16, 2026 00:59
4185448 to
033fab2
Compare
- Route every synchronous operator, sink and subject through DeliveryGate and SerializedDelivery, so no observer or user callback runs while a lock is held. - Replace the public abstract witness, coordinator and scheduler bases with interfaces, embedded state structs and static helpers. - Add the Serialize operator, SerializeSignal, SerializeWitness, SerializedSignal and Signal.Serialized, which serialize notifications without a lock. - Add CurrentValueSignal, CurrentValueWitness, CurrentValueDelivery and ICurrentValueReader for latest-value sources that are read on subscribe. - Add SyncLatestSlot and the slot subscription extensions, so a custom coordinator wires its sources without computing completion bits. - Add DisposableSet, an inline set that holds a few disposables without allocating a list. - Fix Switch never completing when an inner sequence finishes during its own subscribe. - Run the handler and callbacks of the async subscribe sink outside its queue lock. - Remove the unused Handle, SequencerWorkItem, SequencerWorkItemDisposal and Extensions ConcurrencyRaceHelpers helpers. - Make CoalescingDispatchState, DispatchWorkState and IDispatchHost public API of ReactiveUI.Primitives.Reactive, so a custom UI sequencer can be written outside this repository. Each platform package now references them instead of compiling its own copy. - Move PendingNotifications and PendingDelivery into an internal namespace, since they are storage details of SerializedDelivery rather than something a caller builds against. - Update Microsoft.NET.Test.Sdk to 18.10.1 and TUnit to 1.68.0. - Drop the .NET Framework leg from both benchmark projects, which cannot compile there: the generator harness needs AppContext.GetData and a generic Enum.GetValues, and the comparison benchmarks need GeneratedRegex. - Resolve the .NET Framework polyfill sources from the props file's own directory, so a project nested below src finds them. - Adopt the shared analyzer set in .editorconfig and update the NuGet packages that are not deliberately pinned. - Add benchmarks covering every touched production file, including a diagnostics benchmark for the observable-event generator. - Give the Avalonia and Blazor benchmarks their own assemblies, which target .NET only, so the main benchmark project keeps its .NET Framework leg. - Add dispatchable workflows that run the benchmark projects, one for a suite run and one comparing two commits. BREAKING CHANGE: Six public types are removed. Implement an interface and hold the matching state struct instead of deriving: WitnessAsync<T> becomes IWitnessAsync<T> plus WitnessAsyncState, SyncLatestCoordinatorBase<TResult> becomes ISyncLatestCoordinator<TResult> plus SyncLatestLifecycle<TResult>, TaskResultWitnessAsyncBase<T, TTaskValue> becomes ITaskSignalJob<T> plus TaskSignalState, ForwardingWitnessAsync<T> becomes IWitnessAsync<T>, CoalescingDispatchScheduler is replaced by the platform sequencers, and IReentrantAsyncDisposable is gone.
glennawatson
force-pushed
the
feat/delivery-gate
branch
from
September 16, 2026 01:05
033fab2 to
a871a7a
Compare
- Add WitnessSubscription, which subscribes an operator's witness to its source and links the two teardowns, so every single-source operator shares one subscribe path. - Add TaskResultCompletionSource.CompleteAndDisposeAsync, so a terminal witness publishes its result or its exception in one call and no longer carries its own set-and-dispose wrappers. - Name the CatchSignal advance handoff, so the walker reports teardown from the gate rather than through a local flag.
# Conflicts: # src/Directory.Packages.props
- Rewrite README.md for a reader new to the library: the problem first, a six-step first-signal walkthrough, then the reference material. - List every operator in a table with what it does and its LINQ or System.Reactive name, and add a worked example for the ones in common use. - Add reference sections for the extension helpers, the async operators, the subjects, the sequencers and the disposables. - Move the advanced types to the end, one or two sentences each, for a reader who wants the concrete type instead of the extension method. - Add a Writing Docs section to CLAUDE.md covering who the docs are written for, sentence shape, word choice and structure.
- Move the signal types behind Fold, Reduce, Unique, Zip, CombineLatest, Calm, Shift, Probe, Latch, KeepNotNull, KeepType, Reattempt and absolute-time Expire into Advanced as public types. - Give the Latch, CombineLatest, Reattempt and Calm coordinators their own files as internal types. - Update the public API baselines for the core and System.Reactive shim packages.
- Runtime-async is unsupported on Mono, so a net11.0 package asset built with it fails for Blazor WebAssembly consumers. - The shared framework can enable it because it ships a separate Mono build; a NuGet package resolves one asset for every runtime.
- The readme lists every fused operator type as public, with an example that builds one directly.
- The comparison section explains that System.Reactive and R3 compose a general Synchronize operator while this library builds the behaviour into each sink. - States the trade: more classes for speed and fewer allocations.
- Below 200 tokens the detector reports the interface members every sink must declare, whose bodies already delegate to shared static helpers. - At 300 tokens the detector reports nothing at all for this repository.
- The baseline lookup now keys on the generic constraint, so overloads that differ only by a constraint match their own baseline entry. - Both SubscribeSafe overloads drop their PAS0003 suppressions and are tracked against the baseline again.
- SinkDelivery.Next forwards a value to the downstream observer and disposes the sink when that observer throws. - Sixteen sinks call it instead of repeating the try/catch that tore themselves down on throw.
- OnDisposeWitness holds the synchronous action and the asynchronous callback, so the sync and async dispose overloads share one sink. - FirstTaskWitness carries the default value and a flag for whether an empty sequence yields it or fails, replacing FirstOrDefaultTaskWitness. - Regenerate the public API baselines for every target framework.
- The readme row passes an array, because listing sources individually binds to the tuple overload and returns a tuple rather than a list.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #217 +/- ##
==========================================
+ Coverage 99.77% 99.80% +0.03%
==========================================
Files 714 757 +43
Lines 22221 23650 +1429
Branches 2735 2774 +39
==========================================
+ Hits 22171 23605 +1434
+ Misses 49 45 -4
+ Partials 1 0 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…d rethrow - Buffer(count, skip) opens a window every skip values, so a skip below the count overlaps windows and a skip above it leaves a gap; completion flushes every window still filling. - Timeout(DateTimeOffset) and its sequencer overload arm one window at subscription, so arriving values no longer push the deadline back. - HandleCancellation on an observable passes the token into the wait, so cancelling part-way through ends it instead of waiting forever. - Exception.Throw and Exception.Rethrow go through ExceptionDispatchInfo on every target, keeping the stack trace from the original throw site. - AsObservable returns a read-only view, so a caller cannot cast it back to the subject and push values in. - Correct the Expire, Timeout, Probe and Sample summaries to describe an inactivity window rather than a fixed schedule, and the readme rows for OnErrorResumeNext, Repeat, Reattempt and AsObservable.
- ShareLatest says it shares one live subscription and does not replay to a late subscriber. - ToReadOnlyState describes Changed sending the current value on subscribe and repeating unchanged projections, and qualifies the ToProperty equivalence. - Probe records that a value still waiting when the source completes is dropped, in the readme and in the XML remarks. - Synchronize splits the Lock overload onto its own row, since it exists only on net9.0 and later. - Signal, StateSignal, AsyncSignal, PrioritySemaphoreSignal and CommandSignal list the members their public surface actually carries.
- The row names what Signal.FromTask hands back and the cancellation members it carries.
- Add a table for the extension, option, enum and collection types that sit beside the operators, with the namespace each lives in. - State the naming convention that gives every operator a public type behind it. - The migration step names AsyncSignal rather than a FinalSignal that does not exist. - The blocking-helper note names WaitForValue, WaitForCompletion and WaitForError rather than a WaitFor that does not exist.
- A single-assignment slot runs its action on disposal whether or not a value was ever assigned, matching what its Dispose documents. - A replaceable slot disposes a value assigned after disposal without running the action a second time.
- A table shows which single-value slots run the constructor action before disposing their value and which run it after, since swapping one family for the other reverses the order. - FromEventPattern documents that TEventArgs must derive from EventArgs, and points at FromEvent for an event whose argument type does not.
…arts - A comparison section records that several System.Reactive entry points build delegates or look events up by name at run time, and that reflection does not survive trimming or ahead-of-time compilation. - FromEventPattern states that its EventArgs constraint is what lets it bind the handler at compile time, and points at FromEvent for the argument types it excludes.
- The subject mapping names AsyncSignal, and the disposable mapping names Scope.Create and Scope.Empty, which are the types this library exports. - The reflection section states the constraint and the reason without restating them.
- Retry counts total runs, matching the System.Reactive operator of that name, so Retry(3) runs the source three times and Retry(0) completes without running it. Reattempt keeps counting extra tries. - Probe sends a value still waiting when the source completes, ahead of the completion, as Calm and the time-based Buffer already do. BREAKING CHANGE: Retry(n) now runs the source n times in total rather than n times after the first, so a call that relied on the extra attempt should use Reattempt(n). Probe and Sample now deliver a pending value on completion instead of dropping it.
- Probe forwards the newest element once each sampling period elapses, with an optional TimeProvider. - Sample is the Rx name for the same operator. - An element held when the source completes is forwarded ahead of the completion.
- The async observer row names IWitnessAsync<T> and the WitnessAsyncState field a custom observer holds.
…rity - A replaceable slot that must dispose what it displaces now uses SwapDisposable, so Heartbeat stops stacking a live periodic timer per value and the retry, switch-if-empty and while operators stop leaking a subscription per attempt. - The retry operators hold the pending retry timer in its own slot, so a source that fails during re-subscribe no longer displaces the timer that was about to fire. - OnErrorRetry retries only the exception type it was given; any other failure goes straight downstream instead of being retried forever. - SelectAsync passes a subscription-scoped cancellation token to the selector, and cancels it on disposal. - SelectManyThen delivers through one fused coordinator that counts both projection stages, so completion arrives once rather than once per stage. - SignalAsync.Use disposes its resource exactly once. - Async SwitchTo ignores a superseded inner sequence's outcome, so an inner that is still running when the next arrives no longer deadlocks the producer. - Async Interval counts from zero, matching Every, Pulse, Timer and the synchronous operator of that name. - Async TakeUntil with a predicate emits the element that matched before completing, matching the synchronous helper. - Async LogErrors reports a terminal failure to the logger, not only resumable errors. - AnyAsync takes a predicate without a cancellation token, matching the other terminals. - Awaiting an empty AsyncSignal reports the same message as ToTask, FirstAsync and LastAsync. - Readme rows for async Unique, UniqueBy, Retry, Reattempt, Interval and the R3Async observer mapping describe what the code does. - Comments and editorconfig rule descriptions are ASCII throughout. BREAKING CHANGE: OnErrorRetry<TException> no longer retries exceptions that are not TException; those now terminate the sequence. Async Interval starts at 0 rather than 1. Async TakeUntil(predicate) includes the matching element. SelectManyThen completes once instead of once per projection stage.
- Eleven sinks under Advanced are public, so callers can build them directly the way they already can with BufferSignal and UniqueSignal: CreateSignal, CreateSignal with state, CreateSafeSignal, DeferSignal, WitnessOnSignal, CatchSignal, CallbackSignalAsync, LatchCoordinator, CombineLatestCoordinator, ReattemptCoordinator and CalmCoordinator. - Each coordinator exposes the constructor and Run entry point a caller needs, rather than a public type with no way in. - The multi-source CombineLatestCoordinator exposes Attach and the slot it returns, which is the path the factory passed to CombineLatestSignal has to use. - The readme links the detailed documentation at reactiveui.net, lists the newly constructible sinks, and states that an operator is one sink and never builds its behaviour from other operators.
- The signal behind OnErrorResumeNext takes its sources through a public constructor, matching every other fused operator type.
- IntervalSignal numbers its ticks from zero. - LogErrorsSignal reports terminal failures to the logger as well as resumable errors.
…ic surface - The Android targets no longer generate the resource designer, which was published as a public Resource type in the library's root namespace. BREAKING CHANGE: the generated Resource type is no longer part of the Android public surface.
…dgement it is - The handles are independent: the producer does not wait on one, and one value's handle does not gate the next. - A subscriber that ignores the handle still receives every value and the terminal notification.
- Each window emits its newest value at the end of that window, and the clock starts at subscription, so the first value is held for a full period. - A value arriving after a quiet gap longer than the period goes out at once.
- Scheduling a single value is ScheduleValue, so a concrete signal type can no longer bind the value overload and emit the signal object itself instead of scheduling its values. BREAKING CHANGE: Schedule on a non-observable value is now ScheduleValue.
- The first subscriber is registered before the source is subscribed, so a cold source that runs to completion during subscribe reaches it instead of an empty observer list. - The source is subscribed outside the gate, so it never runs while the gate is held. - The remarks state that both sides share one subscription, so over a cold source the first side to subscribe consumes it.
… behave - ReplayLastOnSubscribe gives each subscriber its own subscription and the initial value, so a late subscriber does not receive the newest source value; the async operator of that name shares one subscription and does replay the newest. - The readme and the Partition example state that both sides share one subscription, so over a cold source the first side to subscribe consumes it.
- The synchronous operator filters nulls without narrowing the element type, so a nullable source stays nullable downstream and a handler taking a non-nullable parameter still warns. - The async operator of that name narrows a T? source to T, and the readme names the difference.
- The iOS, tvOS, macOS and Mac Catalyst baselines carry this branch's public surface: the constructible Advanced sinks, the OnErrorResumeNextSignal constructor and the ScheduleValue rename.
|
ChrisPulman
approved these changes
Sep 16, 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.



What kind of change does this PR introduce?
A feature, with breaking changes to a few advanced types.
What is the new behavior?
A subject no longer holds a lock while it calls your code. Your
OnNextruns after the lock is released, so a subscriber that blocks can no longer freeze the producer.Here is the deadlock that motivated it, from the ReactiveUI.Binding work. A property changes on a worker thread. The subscriber handles it by marshalling to the UI thread, the way
Dispatcher.InvokeandControl.Invokedo. At the same moment the UI thread sets that same property:The worker holds the lock and waits for the UI thread; the UI thread waits for the lock. Every lock-based variant hung on this, and fixing only the leaf was not enough: chains through
CombineLatestandMergestill deadlocked, because those coordinators also called downstream inside their own lock.How delivery works now
A producer takes the lock only to record the value, then releases it and delivers. One thread delivers at a time. A second producer waits a short, bounded time for that delivery to finish; past the budget it hands its value to the delivering thread and returns, and the delivering thread drains the handover before it leaves. The thread that is delivering yields to a producer that is waiting, so no producer is starved.
Values still arrive one at a time and in the order they were sent. What you give up is the guarantee that a value is always delivered on the thread that raised it: serialized delivery, delivery on the raising thread, and never blocking a producer cannot all hold at once. This keeps the first two, and only gives up the third past the wait budget - which is precisely the case that used to deadlock.
Where the design comes from
CurrentValueDelivery<T>follows Kotlin'sMutableStateFlow: hold one value slot, mark that it changed, read the current value when it is delivered, and skip a value equal to the last one. Kotlin's own reason for resuming collectors outside its lock is the same as ours, "to avoid dead-locks when using unconfined coroutines".SerializedObserverand Rx.NET'sAsyncLock. The FIFO queue inSerializedDelivery<T>is that shape too.Kotlin's
StateFlownever has a producer deliver to a collector at all, because each collector runs its own loop. Pushing to anIObserver<T>has no such loop, so something has to call the observer - which is what the gate decides.What it costs and what it buys
All figures pinned, .NET 10, one subscriber.
Against the types that make the same promise - one notification at a time, from any thread - for 1000 notifications:
Signal.Serialized<T>()SerializeWitness<T>SynchronizeWitness<T>, lock-basedSubject.Synchronize, System.ReactiveObservable.Synchronize, System.ReactiveReplay, late subscriber over a 16-value buffer:
ReplaySignal<T>ReplaySubject<T>, R3ReplaySubject<T>, System.ReactiveLatest value, 1024 notifications, against subjects that do not serialize at all:
BehaviorSubject<T>, System.ReactiveBehaviorSubject<T>, R3StateSignal<T>That last table is the one to read carefully, because it compares different guarantees.
BehaviorSubjectin both libraries lets two producers call one observer at the same time;StateSignaldoes not. The price of that is 6.2 ns per notification and one 80-byte allocation per subscription - two interlocked operations, with nothing allocated per notification. Every library that does offer serialization is slower than this one.Serializing by holding a lock across the observer call is the approach that deadlocks, and it is what
Observable.SynchronizeandSubject.Synchronizedo. This PR serializes without holding anything while your code runs.What it buys, measured on the Binding side against the same probes that used to fail:
x => x.Child.Namedelivered every final change in 5000 rounds. Unserialized delivery silently dropped 27 of them.New things you can use
Serialize()hands you values one at a time from a source several threads push into.Signal.Serialized<T>()is a subject with that same promise, for when you own the subject.Signal.Serialized<T>(signal)wraps a subject you already have.CurrentValueSignal<T>turns anything with a current value into an observable. You give it a way to read the value and a way to hear that it changed, and each subscriber reads the value as it subscribes. Changes raised while a delivery is running conflate to the latest value.IEqualityComparer<T>as the third argument and a value equal to the last one is skipped.DisposableSetholds a few disposables inline, without allocating a list for the usual two or three.SyncLatestSlotandSubscribeToSlotAsynclet you write your own combine-latest style operator. You say which slot a source fills; the library tracks which sources have reported and when the group is done.Operator and disposable corrections
Switchcompletes when an inner sequence finishes while it is still subscribing.SubscribeAsyncsink calls your handler after releasing its queue lock.Buffer(count, skip)opens a window everyskipvalues. A skip below the count overlaps windows, a skip above it leaves a gap, and completion flushes every window still filling.Timeout(DateTimeOffset)and its sequencer overload arm one window at subscription, so arriving values do not push the deadline back.HandleCancellationon an observable passes the token into the wait, so cancelling part-way through ends it.Exception.ThrowandException.Rethrowgo throughExceptionDispatchInfoon every target, keeping the stack trace from the original throw site.AsObservablereturns a read-only view, so a caller cannot cast it back to the subject and push values in.Retry(n)counts total runs, matching the System.Reactive operator of that name.Reattempt(n)counts extra tries.ProbeandSamplesend a value still waiting when the source completes, ahead of the completion, asCalmand the time-basedBufferalready do.OnErrorRetry<TException>retries only that exception type. Any other failure terminates the sequence rather than retrying forever.SwitchToignores a superseded inner sequence's outcome, so an inner still running when the next arrives no longer deadlocks the producer.SignalAsync.Usedisposes its resource exactly once.Intervalcounts from zero, matchingEvery,Pulse,Timerand the synchronous operator of that name.TakeUntil(predicate)emits the element that matched before completing, matching the synchronous helper.LogErrorsreports terminal failures to the logger, not only resumable errors.AnyAsynctakes a predicate without a cancellation token, matching the other terminals.AsyncSignalreports the same message asToTask,FirstAsyncandLastAsync.The async library gains Probe and Sample
IObservableAsync<T>carries the same sampling operator as the synchronous side, with an optionalTimeProvider.The types behind the fused operators are public
Calling the operator stays the normal path. When you want the type itself, it is there to construct.
The signals behind
Fold,Reduce,Unique,Zip,CombineLatest,Calm,Shift,Probe,Latch,KeepNotNull,KeepType,Reattemptand absolute-timeExpireall live inAdvancedand all take their sources through the constructor.CreateSignal<T>,CreateSignal<T, TState>,CreateSafeSignal<T>,DeferSignal<T>,WitnessOnSignal<T>,CatchSignal<T>,CallbackSignalAsync<T>,OnErrorResumeNextSignal<T>,LatchCoordinator,CombineLatestCoordinator,ReattemptCoordinatorandCalmCoordinatorjoin them, each with the constructor and entry point a caller needs.Every operator is one sink
An operator never builds its behaviour by chaining other operators, so a value passes through one layer per operator you wrote and no more. Where an operator has two jobs, one sink does both: the
TakeUntiloverloads that take a cancellation token watch their stop condition and the token together, rather than stacking a second sink on top.SelectManyThenruns both projection stages against one active count,LastOrDefaultAsyncresolves its fallback inside the task terminal, andScheduledSignalbuilds its observed view once at construction.The readme documents the whole surface
Every operator is listed with what it does, and the ones you reach for daily carry a short example. The advanced types sit at the bottom, for readers who want to build a sink rather than call an extension method. Each example compiles against the built assemblies.
The net11.0 targets build without runtime-async
Runtime-async is unsupported on Mono, and Blazor WebAssembly runs on Mono. A
net11.0package asset compiled with it fails for those consumers at their runtime, with nothing to warn them at build time.The .NET shared framework enables the feature because it ships a separate Mono build and the publish step picks one. A NuGet package resolves a single
lib/net11.0asset for every runtime, so it has no equivalent lever. ASP.NET Core draws the same line, enabling runtime-async only for libraries that never ship as standalone packages.What is the current behavior?
Operators, sinks and subjects call your code while holding a lock, so a subscriber that marshals to another thread can deadlock the producer.
Switchswallows completion when an inner sequence completes during its own subscribe. Shared behavior is inherited from public abstract base classes.What might this PR break?
Six public types are removed. Each was a base class you inherited from. Implement the interface instead, and hold the state struct as a field.
WitnessAsync<T>IWitnessAsync<T>plus aWitnessAsyncStatefieldSyncLatestCoordinatorBase<TResult>ISyncLatestCoordinator<TResult>plusSyncLatestLifecycle<TResult>TaskResultWitnessAsyncBase<T, TTaskValue>ITaskSignalJob<T>plusTaskSignalStateForwardingWitnessAsync<T>IWitnessAsync<T>CoalescingDispatchSchedulerIReentrantAsyncDisposableIAsyncDisposableA value is no longer guaranteed to be delivered on the thread that raised it, as described above.
Broadcaster<T>is unchanged.These operators change what they emit.
Retry(n)ntimes after the firstntimes in total;Retry(0)completes without running itProbe(period),Sample(interval)Buffer(count, skip)countvaluesskipvalues, so windows overlap or leave a gap; completion flushes every window still fillingOnErrorRetry<TException>TException; any other failure terminates the sequenceSchedule(dueTime, sequencer)on a plain valueScheduleValue; the source operator is what a signal type bindsInterval(period)Every,Pulse,Timerand the synchronous operatorTakeUntil(predicate)LogErrors(logger)SelectManyThenA call that relied on
Retry(n)makingnattempts beyond the first should useReattempt(n).The Android targets no longer generate the resource designer, so the generated
Resourcetype leaves the Android public surface.Checklist
mainbranchAdditional information
Most of the diff is mechanical: regenerated API baselines under
src/*/PublicAPI/**, and operator conversions that all follow one pattern. The files worth reading areDeliveryGate.cs,DeliveryGateState.cs,SerializedDelivery{T}.cs,SerializedWitness{T}.cs,SerializedBroadcaster{T}.cs,CurrentValueDelivery{T}.cs,SwitchToSignal.cs,SubscribeAsyncObservable.csandSyncLatestSlot.cs.The Avalonia and Blazor benchmarks sit in their own assemblies,
ReactiveUI.Primitives.Avalonia.BenchmarksandReactiveUI.Primitives.Blazor.Benchmarks, because those packages start at net8.0. WPF and WinForms benchmark assemblies follow the same shape. Neither benchmark project builds a .NET Framework leg any more: the generator harness needsAppContext.GetDataand a genericEnum.GetValues, and the comparison benchmarks needGeneratedRegex.Two
workflow_dispatchworkflows run the benchmark projects:Benchmarksfor a suite run, andBenchmarks A/Bto compare two commits.The platform dispatcher sequencers (WPF, WinForms, WinUI, MAUI, Android, Apple) have no benchmarks, because each needs its own host. That is tracked in #216.
For advanced users: building your own sink
Everything the operators are built from is public, so you can build a sink with the same guarantee instead of reaching for an extension method.
DeliveryGateandDeliveryGateStatedecide who delivers. One thread delivers; another waits briefly, then hands its work over.SerializedDelivery<T>is the whole mechanism in one struct: the gate, the queue and the terminal notification. Hold it as a field and call it in place.IDrainTargetis how the gate drains your queue. ItsDrain()callsDrainTowith the same observer.SerializedWitness<T>is that struct already wrapped around one observer.SerializedBroadcaster<T>andSerializedBroadcast<T>fan one notification out to many subscribers. Post under your lock, then flush after releasing it, so every subscriber sees the same order.CurrentValueDelivery<T>andICurrentValueReader<T>deliver a value that is pulled rather than pushed.Startreads and delivers the first value inside the delivery, so a change raised during that read arrives after it, never before.CurrentValueWitness<T>is that struct wrapped around one observer and reader, withStart,Changed,CompleteandFault.WitnessSubscriptionsubscribes an operator's witness to its source and links the two teardowns, so a single-source operator's subscribe is one call.TaskResultCompletionSource<T>.CompleteAndDisposeAsyncpublishes a terminal result, or its exception, and disposes the witness in one call.CoalescingDispatchState,DispatchWorkState<TState>andIDispatchHostare how the UI sequencers batch work onto a dispatcher. ImplementIDispatchHoston a struct that reaches your platform's dispatcher and embedCoalescingDispatchStateto write a sequencer for a toolkit this repository does not ship.IWitnessAsync<T>,IWitnessStateandWitnessAsyncStateare the async equivalents: implement the interface, hold the state struct.