Skip to content

Commit 1ffbcad

Browse files
authored
Add ResetExceptionDispatchState operator and document exception reuse limitations (#2237)
This also updates the UWP hack to use 10.0.26100 SDK since the the Azure DevOps Windows 2025 agents don't have the 10.0.19041 SDK we had previously been relying on installed but they do have 26100. Also fix build issue that seems to have started with .NET SDK 10 in which the Reverse extension method for Span<char> takes precedence over the LINQ operator.
1 parent d6859c5 commit 1ffbcad

File tree

18 files changed

+463
-25
lines changed

18 files changed

+463
-25
lines changed

Rx.NET/Documentation/ReleaseHistory/Rx.v6.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
This release adds:
66

7-
* A `DisposeWith`extension method for `IDisposable` to simplify disposal in conjunction with `CompositeDisposable` (see [#2178](https://github.com/dotnet/reactive/pull/2178) thanks to [Chris Pulman](https://github.com/ChrisPulman)
7+
* A `DisposeWith` extension method for `IDisposable` to simplify disposal in conjunction with `CompositeDisposable` (see [#2178](https://github.com/dotnet/reactive/pull/2178) thanks to [Chris Pulman](https://github.com/ChrisPulman)
88
* A new overload of `TakeUntil` accepting a `CancellationToken` (see [#2181](https://github.com/dotnet/reactive/issues/2181) thanks to [Nils Aufschläger](https://github.com/nilsauf)
9-
9+
* A new `ResetExceptionDispatchState` operator for use where a source that will provide the same `Exception` instance multiple times over (e.g., the `Throw` or `Repeat` operators) will be used in conjunction with a mechanism that turns `OnError` notifications into actual exceptions (e.g., the `await` support) to avoid the problem described in [#2187](https://github.com/dotnet/reactive/issues/2187) in which the exception's `StackTrace` gets longer and longer with each rethrowing of the exception
1010

1111
## v6.0.2
1212

Rx.NET/Documentation/adr/0003-uap-targets.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The following versions are of interest. The comments about the status of the lat
7777
* `10.0.19045`: Windows 10 22H2, the last ever version of Windows 10 (support ends October 2025)
7878
* `10.0.22621`: the oldest Windows 11 version (22H2) still in GA support (enterprise only; support ends October 2025)
7979
* `10.0.22631`: the oldest Windows 11 version (23H2) with GA support for Home, Pro and Education (non-enterprise servicing ends November 2025; enterprise servicing ends November 2026)
80-
* `10.0.26100`: the latest version of Windows (24H2)
80+
* `10.0.26100`: the latest version of Windows (24H2), also the version installed on the `windows-2025` Azure DevOps hosted build images
8181

8282
So as it happens, we don't technically need anything newer than 10.0.17763. So we could specify that as the minimum platform version. However, there's no compelling reason to do this, and since 10.0.18362 is as far back as the current tooling fully understands, and is the version Rx 6.0 has always targetted, it makes sense to continue with that.
8383

@@ -161,12 +161,12 @@ The provides references to the .NET runtime library components. (So this provide
161161
So this enables normal .NET code to compile. However, Rx.NET also includes code that uses some UWP-specific APIs. (After all, a large part of the issue we're dealing with here exists because of features like schedulers that support UWP dispatchers.) And for that to work, the compiler needs access to `.winmd` files with the metadata for these APIs. So we have this:
162162

163163
```xml
164-
<ReferencePath Include="$(TargetPlatformSdkPath)UnionMetadata\10.0.19041.0\Windows.winmd" />
164+
<ReferencePath Include="$(TargetPlatformSdkPath)UnionMetadata\10.0.26100.0\Windows.winmd" />
165165
```
166166

167167
This relies on the `TargetPlatformSdkPath` build variable being set. When building locally (either in Visual Studio, or with `dotnet build` from the command line) this variable is set correctly, but for some reason it doesn't seem to be set on the build agents. So we set this as an environment variable in the `azure-pipelines.rx.yml` build pipeline definition.
168168

169-
You might be wondering about that 19041 in there. Why is that not 18362, consistent with the TFM? This is because, as mentioned earlier, Azure DevOps Windows build agents have only certain Windows SDK versions installed. They don't have 18362. but they do have the 19041 version, and we can use that to target `10.0.18362`.
169+
You might be wondering about that 26100 in there. Why is that not 18362, consistent with the TFM? This is because, as mentioned earlier, Azure DevOps Windows build agents have only certain Windows SDK versions installed. They don't have 18362. but the `windows-2025` image does have the 26100 version, and we can use that to target `10.0.18362`.
170170

171171

172172
#### Prevent Over-Zealous WinRT Interop Code Generation
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# Rules for when OnError notifications become thrown exceptions
2+
3+
Rx uses .NET `Exception` objects in a slightly unusual way: they are typically not thrown. Instead they are passed as the argument to an observer's `OnError` method. There are some situations in which an error reported in this way will end up causing an exception to be thrown. For example, if you `await` an `IObservable<T>` that calls `OnError`, the `await` will throw:
4+
5+
```cs
6+
IObservable<int> ts = Observable.Throw<int>(new Exception("Pow!"));
7+
8+
await ts; // Exception thrown here
9+
```
10+
11+
This can cause problems. For example, as [#2187](https://github.com/dotnet/reactive/issues/2187) describes, if you `await` the observable shown in this example multiple times, the exception's `StackTrace` gets longer each time.
12+
13+
Problems arise because the use of singleton exception objects is slightly tricky even with straightforward use of `throw`, but it becomes a good deal more subtle when you start to 'cross the streams' of normal .NET exception handling and Rx's use of `Exception` in `OnError`.
14+
15+
Rx has never previously offered any guidance that would enable a developer to understand that the code shown above might have problems. The purpose of this ADR is to establish suitable rules.
16+
17+
## Status
18+
19+
Proposed
20+
21+
22+
## Authors
23+
24+
@idg10 ([Ian Griffiths](https://endjin.com/who-we-are/our-people/ian-griffiths/)).
25+
26+
27+
28+
## Context
29+
30+
Exceptions may appear to be ordinary .NET objects, but they get special handling from the runtime. MSIL has a [`throw`](https://learn.microsoft.com/en-us/dotnet/api/system.reflection.emit.opcodes.throw?view=net-9.0) instruction for the purpose of raising exceptions, and there is C++ code inside the CLR that directly manipulates fields defined by the `Exception` class. Certain expectations around the use of exception types are baked deeply into the runtime.
31+
32+
Rx does not generally use exceptions in the way the runtime expects. In particular it does not normally use the MSIL `throw` instruction to raise an exception. Instead, when an Rx `IObservable<T>` wants to report an error, it just passes an exception object as an argument to the `IObserver<T>.OnError` method.
33+
34+
This causes no problems when an application remains entirely within Rx's world. But when we want to move into the more conventional .NET approach of throwing exceptions, it raises an interesting question: where should the exception appear to originate from?
35+
36+
Consider this example:
37+
38+
```cs
39+
IObservable<string> fileLines = Observable.Create<string>(async obs =>
40+
{
41+
using var reader = new StreamReader(@"c:\temp\test.txt");
42+
43+
while ((await reader.ReadLineAsync()) is string line)
44+
{
45+
obs.OnNext(line);
46+
}
47+
});
48+
49+
string firstNonEmptyLine = await fileLines
50+
.FirstAsync(line => line.Length > 0);
51+
Console.WriteLine(firstNonEmptyLine);
52+
```
53+
54+
If the attempt to open the file throws an exception, what do we expect to see? A developer familiar with how exceptions generally work with `async` in .NET might reasonably expect the exception to report two stack traces: one for the point at which the exception was originally thrown, and another for where it was rethrown from the `await`. And that's exactly what we see:
55+
56+
```
57+
Unhandled exception. System.IO.FileNotFoundException: Could not find file 'c:\temp\test.txt'.
58+
File name: 'c:\temp\test.txt'
59+
at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
60+
at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
61+
at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
62+
at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
63+
at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encoding, Int32 bufferSize)
64+
at System.IO.StreamReader..ctor(String path)
65+
at Program.<>c.<<<Main>$>b__0_0>d.MoveNext() in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 5
66+
--- End of stack trace from previous location ---
67+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
68+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
69+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
70+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 13
71+
at Program.<Main>(String[] args)
72+
```
73+
74+
This is straightforward because the exception here is thrown in the conventional .NET manner. It happens to be caught by Rx—this overload of `Observable.Create` wraps the `Task` returned by the callback in an adapter that detects when the `Task` enters a faulted state, in which case it extracts the exception and passes it to the subscribing `IObserver<T>`. And then the awaiter that Rx provides when you `await` an observable rethrows this same exception.
75+
76+
But what about the earlier example in which the exception originated from `Observable.Throw`? In that code, we construct an `Exception` but we never use the `throw` keyword with it, and nor do we invoke any API that might do that for us. What would you expect the call stack to show in that case? In practice we get this:
77+
78+
```
79+
Unhandled exception. System.Exception: Pow!
80+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
81+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
82+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
83+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 20
84+
at Program.<Main>(String[] args)
85+
```
86+
87+
This time, we've got just a single stack trace, effectively showing the `await`. This looks very similar to the 2nd trace from the previous example—the difference here is that we don't have an extra trace showing the original location from which the exception was first thrown. And you could argue that this makes sense: this particular exception wasn't thrown until it emerged from the `await`.
88+
89+
So far so good. But look what happens if we use this same observable source a few times:
90+
91+
```cs
92+
IObservable<int> ts = Observable.Throw<int>(new Exception("Pow!"));
93+
94+
for (int i = 0; i < 3; ++i)
95+
{
96+
Console.WriteLine();
97+
Console.WriteLine();
98+
99+
try
100+
{
101+
await ts; // Exception thrown here
102+
103+
}
104+
catch (Exception x)
105+
{
106+
Console.WriteLine(x);
107+
}
108+
}
109+
```
110+
111+
Since we're doing the same thing three times, you might expect to see the same exception report three times. But that's not what happens:
112+
113+
```
114+
System.Exception: Pow!
115+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
116+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
117+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
118+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 27
119+
120+
121+
System.Exception: Pow!
122+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
123+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
124+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
125+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 27
126+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
127+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
128+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
129+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 27
130+
131+
132+
System.Exception: Pow!
133+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
134+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
135+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
136+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 27
137+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
138+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
139+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
140+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 27
141+
at System.Reactive.PlatformServices.ExceptionServicesImpl.Rethrow(Exception exception)
142+
at System.Reactive.ExceptionHelpers.Throw(Exception exception)
143+
at System.Reactive.Subjects.AsyncSubject`1.GetResult()
144+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\RxThrowExamples\Program.cs:line 27
145+
```
146+
147+
The stack trace gets longer each time!
148+
149+
(This does not accurately reflect the actual runtime behaviour: the call stack does not in fact get deeper. It's just that the `StackTrace` string with which an `Exception` reports this information ends up containing multiple copies of the stack trace.)
150+
151+
This makes no sense.
152+
153+
It occurs as a direct result of the steps Rx takes to produce the stack trace we expect in the earlier example. It uses the .NET runtime library's `ExceptionDispatchInfo.Throw` method to rethrow the exception from the `await`. That method preserves the original context in which the exception was thrown, and appends the context from which it is rethrown: this is how we end up with the multiple stack traces that .NET developers are accustomed to with normal use of `async` and `await`. (In fact, Rx is using exactly the same rethrow mechanism that enables this behaviour in conventional `async` code.)
154+
155+
This behaviour is not peculiar to Rx. It originates from `ExceptionDispatchInfo.Throw` and we can create a `Task`-based version of this behaviour without using Rx:
156+
157+
```
158+
Exception ox = new("Kaboom!");
159+
160+
for (int i = 0; i < 3; ++i)
161+
{
162+
Console.WriteLine();
163+
Console.WriteLine();
164+
165+
try
166+
{
167+
await Task.FromException(ox); // Exception thrown here
168+
169+
}
170+
catch (Exception x)
171+
{
172+
Console.WriteLine(x);
173+
}
174+
}
175+
```
176+
177+
The stack traces are shorter, but we see the same repeating behaviour:
178+
179+
```
180+
System.Exception: Kaboom!
181+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\ThrowStackDupWithoutRx\Program.cs:line 10
182+
183+
184+
System.Exception: Kaboom!
185+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\ThrowStackDupWithoutRx\Program.cs:line 10
186+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\ThrowStackDupWithoutRx\Program.cs:line 10
187+
188+
189+
System.Exception: Kaboom!
190+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\ThrowStackDupWithoutRx\Program.cs:line 10
191+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\ThrowStackDupWithoutRx\Program.cs:line 10
192+
at Program.<Main>$(String[] args) in D:\source\RxThrowExamples\ThrowStackDupWithoutRx\Program.cs:line 10
193+
```
194+
195+
(To be precise, this _doesn't_ happen if you create a single `Task` and `await` that multiple times. It's the combination of `ExceptionDispatchInfo.Capture` and `ExceptionDispatchInfo.Throw` that causes this accumulation, and this `Task` captures exception information at the point when we create it with `Task.FromException`.)
196+
197+
This appending of exception data is by design: `ExceptionDispatchInfo.Throw` is intended to append the current context to whatever was captured in the `ExceptionDispatchInfo`. The .NET runtime assumes that if you want to represent a new exceptional event that you will execute a `throw`. Rx does not do this in `await` (or other mechanisms that can rethrow an exception delivered through `OnError` such as `ToEnumerable`) precisely because it preserves whatever context was present when it received the exception. It does not perform a `throw` (or do anything else to reset the exception context) because this would prevent the full context being preserved in examples such as the `FileNotFoundException` handling shown earlier.
198+
199+
This behaviour makes sense in the context for which it was designed—capturing the context in which an exception was initially thrown and augmenting it with additional information if it is rethrown from a different context. But unless you are aware of that, it's not at all obvious that although there's nothing inherently wrong with using `Observable.Throw<int>()`, it is not compatible with having multiple subscribers that will each rethrow the exception.
200+
201+
202+
## Decision
203+
204+
Rx.NET will explicitly adopt this position: if a developer using Rx chooses to use a mechanism that takes exceptions delivered by an `IObservable<T>` and throws them (e.g. if you `await` an `IObservable<T>`) then it is the developer's responsibility to ensure that either:
205+
206+
* each exception object is used only once
207+
208+
or
209+
210+
* the exception's dispatch state is reset prior to being supplied to the observer that will be rethrowing it (e.g., by executing a `throw`)
211+
212+
Since Rx defines operators that won't conform to the first option (notably `Observable.Throw`, but also `ReplaySubject` and the related `Observable.Replay`) Rx 6.1 introduces a new operator, `ResetExceptionDispatchState`. This passes all notifications through, but effectively performs a `throw` on any `Exception` before forwarding it. It can be used like this:
213+
214+
```cs
215+
var ts = Observable.Throw<int>(new Exception("Aaargh!")).ResetExceptionDispatchState();
216+
```
217+
218+
When an observer subscribes to this, the `Throw` immediately calls `OnError`, and the `ResetExceptionDispatchState` will throw (and immediately catch) that exception before passing it on to the subscriber. (You would _not_ use this in scenarios such as the `Create` example shown earlier, because in that case each exception is freshly thrown, and has useful contextual information so we don't want to reset that. This is for use specifically in cases where the exception would not otherwise be thrown.)
219+
220+
221+
## Consequences
222+
223+
By adopting this position, we make it clear that examples such as the one in [#2187](https://github.com/dotnet/reactive/issues/2187) are not expected to work correctly.
224+
225+
More generally, this clarifies that observable sources that repeatedly produce the same exception object (e.g. `Observable.Throw` or `Observable.Repeat`) are incompatible with multiple calls to `await`.
226+
227+
The addition of the `ResetExceptionDispatchState` operator provides a clear, simple way to fix code that runs into this problem.

Rx.NET/Source/src/System.Reactive.Observable.Aliases/System.Reactive.Observable.Aliases.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
</ItemGroup>
2020

2121
<ItemGroup Condition="'$(TargetFramework)'=='uap10.0.18362'">
22-
<ReferencePath Include="$(TargetPlatformSdkPath)UnionMetadata\10.0.19041.0\Windows.winmd" />
22+
<ReferencePath Include="$(TargetPlatformSdkPath)UnionMetadata\10.0.26100.0\Windows.winmd" />
2323
</ItemGroup>
2424

2525
</Project>

Rx.NET/Source/src/System.Reactive/Linq/IQueryLanguage.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ internal partial interface IQueryLanguage
582582
IObservable<TSource> AsObservable<TSource>(IObservable<TSource> source);
583583
IObservable<IList<TSource>> Buffer<TSource>(IObservable<TSource> source, int count);
584584
IObservable<IList<TSource>> Buffer<TSource>(IObservable<TSource> source, int count, int skip);
585+
IObservable<TSource> ResetExceptionDispatchState<TSource>(IObservable<TSource> source);
585586
IObservable<TSource> Dematerialize<TSource>(IObservable<Notification<TSource>> source);
586587
IObservable<TSource> DistinctUntilChanged<TSource>(IObservable<TSource> source);
587588
IObservable<TSource> DistinctUntilChanged<TSource>(IObservable<TSource> source, IEqualityComparer<TSource> comparer);

0 commit comments

Comments
 (0)