Skip to content

feat!: deliver notifications outside the lock, one sink per operator, no runtime-async - #217

Merged
glennawatson merged 39 commits into
mainfrom
feat/delivery-gate
Sep 16, 2026
Merged

glennawatson merged 39 commits into
mainfrom
feat/delivery-gate

Conversation

@glennawatson

@glennawatson glennawatson commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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 OnNext runs 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.Invoke and Control.Invoke do. At the same moment the UI thread sets that same property:

// worker thread: holds the subject's lock, waits for the UI thread
source.Subscribe(value => dispatcher.Invoke(() => label.Text = value));

// UI thread: inside Invoke, sets the property, waits for the same lock
viewModel.Name = "Ada";

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 CombineLatest and Merge still 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

  • Latest-value conflation in CurrentValueDelivery<T> follows Kotlin's MutableStateFlow: 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".
  • The hand-off, where a producer that cannot deliver leaves its work for the thread already delivering, is the emitter-loop from RxJava's SerializedObserver and Rx.NET's AsyncLock. The FIFO queue in SerializedDelivery<T> is that shape too.
  • The bounded wait before handing off is closest to Reactor's busy-looping emit handler, which spins for a fixed time before giving up. We hand the work off rather than failing the emission.
  • The owner yielding to a waiting producer is not borrowed; it is what keeps a producer from being starved once waiting is bounded.

Kotlin's StateFlow never has a producer deliver to a collector at all, because each collector runs its own loop. Pushing to an IObserver<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:

Subject Mean
Signal.Serialized<T>() 5.33 us
SerializeWitness<T> 5.73 us
SynchronizeWitness<T>, lock-based 6.22 us
Subject.Synchronize, System.Reactive 7.28 us
Observable.Synchronize, System.Reactive 9.52 us

Replay, late subscriber over a 16-value buffer:

Subject Mean Allocated
ReplaySignal<T> 251 ns 520 B
ReplaySubject<T>, R3 280 ns 688 B
ReplaySubject<T>, System.Reactive 464 ns 696 B

Latest value, 1024 notifications, against subjects that do not serialize at all:

Subject Mean Allocated
BehaviorSubject<T>, System.Reactive 7.22 us 200 B
BehaviorSubject<T>, R3 8.36 us 192 B
StateSignal<T> 12.22 us 248 B

That last table is the one to read carefully, because it compares different guarantees. BehaviorSubject in both libraries lets two producers call one observer at the same time; StateSignal does 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.Synchronize and Subject.Synchronize do. 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:

  • No overlapping calls and no stale values in 2000 two-writer rounds. Delivering without serializing left 15 stale finals in the same probe.
  • A deep path such as x => x.Child.Name delivered every final change in 5000 rounds. Unserialized delivery silently dropped 27 of them.
  • A UI thread that is already delivering when a background burst starts is held for its own delivery only - 1, 10 or 50 ms for a 1, 10 or 50 ms subscriber - against 2.5 s when the hand-off had no bound.
  • A producer's worst case under contention is the wait budget, 21 ms, against 101 to 116 ms for the lock under the same load.
  • Binding's three property observables ported onto this API pass all 21 behaviour checks, including the ordering ones.

New things you can use

  • Serialize() hands you values one at a time from a source several threads push into.
    IObservable<int> safe = noisySource.Serialize();
    safe.Subscribe(value => Console.WriteLine(value));   // never two at once
  • Signal.Serialized<T>() is a subject with that same promise, for when you own the subject.
    SerializedSignal<int> subject = Signal.Serialized<int>();
    subject.Subscribe(value => Console.WriteLine(value));
    
    Parallel.For(0, 100, i => subject.OnNext(i));        // safe from many threads
  • Signal.Serialized<T>(signal) wraps a subject you already have.
    SerializedSignal<int> guarded = Signal.Serialized(existingSubject);
  • 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.
    var person = new Person { Name = "Ada" };
    
    IObservable<string> names = new CurrentValueSignal<string>(
        () => person.Name,
        onChanged =>
        {
            void Handler(object? sender, PropertyChangedEventArgs e) => onChanged();
            person.PropertyChanged += Handler;
            return new ActionDisposable(() => person.PropertyChanged -= Handler);
        });
    
    names.Subscribe(name => Console.WriteLine(name));    // prints Ada, then every change
    Pass an IEqualityComparer<T> as the third argument and a value equal to the last one is skipped.
  • DisposableSet holds a few disposables inline, without allocating a list for the usual two or three.
    var subscriptions = new DisposableSet(first, second);
    subscriptions.Add(third);
    subscriptions.Dispose();                             // disposes all three
  • SyncLatestSlot and SubscribeToSlotAsync let 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.
    // inside your ISyncLatestCoordinator<int>
    public ValueTask<IAsyncDisposable> SubscribeAtAsync(int index, CancellationToken token) =>
        index == 0
            ? first.SubscribeToSlotAsync(this, index, value => _first = value, token)
            : second.SubscribeToSlotAsync(this, index, value => _second = value, token);

Operator and disposable corrections

  • Switch completes when an inner sequence finishes while it is still subscribing.
  • The async SubscribeAsync sink calls your handler after releasing its queue lock.
  • Buffer(count, skip) opens a window every skip values. 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.
  • HandleCancellation on an observable passes the token into the wait, so cancelling part-way through ends it.
  • 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.
  • Retry(n) counts total runs, matching the System.Reactive operator of that name. Reattempt(n) counts extra tries.
  • Probe and Sample send a value still waiting when the source completes, ahead of the completion, as Calm and the time-based Buffer already do.
  • OnErrorRetry<TException> retries only that exception type. Any other failure terminates the sequence rather than retrying forever.
  • Async SwitchTo ignores a superseded inner sequence's outcome, so an inner still running when the next arrives no longer deadlocks the producer.
  • SignalAsync.Use disposes its resource exactly once.
  • Async Interval counts from zero, matching Every, Pulse, Timer and the synchronous operator of that name.
  • Async TakeUntil(predicate) emits the element that matched before completing, matching the synchronous helper.
  • Async LogErrors reports terminal failures 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.
  • A single-assignment slot runs its action on disposal whether or not a value was ever assigned. A replaceable slot disposes a value assigned after disposal without running the action a second time.

The async library gains Probe and Sample

IObservableAsync<T> carries the same sampling operator as the synchronous side, with an optional TimeProvider.

IObservableAsync<int> sampled = source.Probe(TimeSpan.FromMilliseconds(100));

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 operator
IObservable<int> a = source.Unique();

// the type behind it
IObservable<int> b = new UniqueSignal<int>(source, EqualityComparer<int>.Default);

The signals behind Fold, Reduce, Unique, Zip, CombineLatest, Calm, Shift, Probe, Latch, KeepNotNull, KeepType, Reattempt and absolute-time Expire all live in Advanced and 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, ReattemptCoordinator and CalmCoordinator join 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 TakeUntil overloads that take a cancellation token watch their stop condition and the token together, rather than stacking a second sink on top.

SelectManyThen runs both projection stages against one active count, LastOrDefaultAsync resolves its fallback inside the task terminal, and ScheduledSignal builds 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.0 package 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.0 asset 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. Switch swallows 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.

Removed Use instead
WitnessAsync<T> IWitnessAsync<T> plus a WitnessAsyncState field
SyncLatestCoordinatorBase<TResult> ISyncLatestCoordinator<TResult> plus SyncLatestLifecycle<TResult>
TaskResultWitnessAsyncBase<T, TTaskValue> ITaskSignalJob<T> plus TaskSignalState
ForwardingWitnessAsync<T> IWitnessAsync<T>
CoalescingDispatchScheduler the platform sequencer for your UI framework
IReentrantAsyncDisposable IAsyncDisposable
// Before
public sealed class MyObserver : WitnessAsync<int>
{
    protected override ValueTask OnNextAsyncCore(int value, CancellationToken token) => default;
}

// After
public sealed class MyObserver : IWitnessAsync<int>
{
    private WitnessAsyncState _witness;

    ref WitnessAsyncState IWitnessState.Witness => ref _witness;

    ValueTask IWitnessAsync<int>.OnNextAsyncCore(int value, CancellationToken token) => default;
}

A 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.

Operator Before After
Retry(n) runs the source n times after the first runs the source n times in total; Retry(0) completes without running it
Probe(period), Sample(interval) drops a value still waiting when the source completes sends it ahead of the completion
Buffer(count, skip) emits one window per count values opens a window every skip values, so windows overlap or leave a gap; completion flushes every window still filling
OnErrorRetry<TException> retries every failure retries only TException; any other failure terminates the sequence
Schedule(dueTime, sequencer) on a plain value shared a name with the source operator, so a concrete signal type bound it and emitted the signal object named ScheduleValue; the source operator is what a signal type binds
async Interval(period) counts from 1 counts from 0, matching Every, Pulse, Timer and the synchronous operator
async TakeUntil(predicate) drops the element that matched emits it, then completes, matching the synchronous helper
async LogErrors(logger) logged resumable errors only logs terminal failures too
SelectManyThen completed once per projection stage completes once

A call that relied on Retry(n) making n attempts beyond the first should use Reattempt(n).

The Android targets no longer generate the resource designer, so the generated Resource type leaves the Android public surface.

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

Most of the diff is mechanical: regenerated API baselines under src/*/PublicAPI/**, and operator conversions that all follow one pattern. The files worth reading are DeliveryGate.cs, DeliveryGateState.cs, SerializedDelivery{T}.cs, SerializedWitness{T}.cs, SerializedBroadcaster{T}.cs, CurrentValueDelivery{T}.cs, SwitchToSignal.cs, SubscribeAsyncObservable.cs and SyncLatestSlot.cs.

The Avalonia and Blazor benchmarks sit in their own assemblies, ReactiveUI.Primitives.Avalonia.Benchmarks and ReactiveUI.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 needs AppContext.GetData and a generic Enum.GetValues, and the comparison benchmarks need GeneratedRegex.

Two workflow_dispatch workflows run the benchmark projects: Benchmarks for a suite run, and Benchmarks A/B to 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.

  • DeliveryGate and DeliveryGateState decide who delivers. One thread delivers; another waits briefly, then hands its work over.
    private DeliveryGateState _gate;
    
    if (DeliveryGate.TryEnter(ref _gate))
    {
        observer.OnNext(value);                 // no lock is held here
        DeliveryGate.Exit(ref _gate, drain);    // delivers anything handed over meanwhile
    }
  • 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.
    private SerializedDelivery<int> _delivery;
    
    public void OnNext(int value) => _delivery.OnNext(_observer, value, new Drain(this));
  • IDrainTarget is how the gate drains your queue. Its Drain() calls DrainTo with the same observer.
  • SerializedWitness<T> is that struct already wrapped around one observer.
    var witness = new SerializedWitness<int>(observer);
    witness.OnNext(1);                          // safe from any thread
  • SerializedBroadcaster<T> and SerializedBroadcast<T> fan one notification out to many subscribers. Post under your lock, then flush after releasing it, so every subscriber sees the same order.
    SerializedBroadcast<int> batch;
    lock (_gate)
    {
        _lastValue = value;
        batch = _broadcaster.PostNext(value);
    }
    
    batch.Flush();                              // observers run here, with no lock held
  • CurrentValueDelivery<T> and ICurrentValueReader<T> deliver a value that is pulled rather than pushed. Start reads 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, with Start, Changed, Complete and Fault.
  • WitnessSubscription subscribes an operator's witness to its source and links the two teardowns, so a single-source operator's subscribe is one call.
    ValueTask<IAsyncDisposable> IObservableAsync<T>.SubscribeAsync(
        IObserverAsync<T> observer,
        CancellationToken token) =>
        WitnessSubscription.SubscribeAsync(source, new MyWitness(observer, token), observer, token);
  • TaskResultCompletionSource<T>.CompleteAndDisposeAsync publishes a terminal result, or its exception, and disposes the witness in one call.
  • CoalescingDispatchState, DispatchWorkState<TState> and IDispatchHost are how the UI sequencers batch work onto a dispatcher. Implement IDispatchHost on a struct that reaches your platform's dispatcher and embed CoalescingDispatchState to write a sequencer for a toolkit this repository does not ship.
  • IWitnessAsync<T>, IWitnessState and WitnessAsyncState are the async equivalents: implement the interface, hold the state struct.

@glennawatson glennawatson changed the title feat(delivery)!: deliver notifications outside the lock feat(scheduling)!: deliver notifications outside the lock Sep 16, 2026
- 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.
- 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.
@glennawatson glennawatson changed the title feat(scheduling)!: deliver notifications outside the lock feat(scheduling)!: deliver notifications outside the lock and drop net11 runtime-async Sep 16, 2026
- 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

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.71158% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.80%. Comparing base (8aba4af) to head (fe53444).

Files with missing lines Patch % Lines
...Primitives.Shared/Advanced/ExpireCoordinator{T}.cs 96.15% 2 Missing ⚠️
...ons.Shared/Operators/RetryWithBackoffObservable.cs 95.65% 1 Missing ⚠️
...sions.Shared/Operators/RetryWithDelayObservable.cs 95.23% 1 Missing ⚠️
...rimitives.Shared/Advanced/AsObservableSignal{T}.cs 85.71% 1 Missing ⚠️
...rc/Primitives.Shared/Signals/Signal{GetAwaiter}.cs 93.33% 1 Missing ⚠️
...mitives.Async.Core/Advanced/LogErrorsWitness{T}.cs 87.50% 1 Missing ⚠️
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.
📢 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.

…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.
@glennawatson glennawatson changed the title feat(scheduling)!: deliver notifications outside the lock and drop net11 runtime-async feat!: deliver notifications outside the lock, one sink per operator, no runtime-async Sep 16, 2026
- 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.
@sonarqubecloud

Copy link
Copy Markdown

@glennawatson
glennawatson merged commit 49e2515 into main Sep 16, 2026
15 of 16 checks passed
@glennawatson
glennawatson deleted the feat/delivery-gate branch September 16, 2026 23:11
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