Add net9 TextWriter.CreateBroadcasting - #602
Merged
Merged
Conversation
One public member, but the writer it returns is the substance. The BCL type is TextWriter's private nested BroadcastingTextWriter, so it is recreated as a class nested inside Polyfill and only the factory is exposed. Every virtual member of TextWriter is forwarded verbatim rather than left to the base class to decompose. Verified against net11 that this is what the BCL does: Write(int) reaches each writer's Write(int), not its Write(string). The difference is invisible in the output but bypasses a writer that overrides a composite overload, which is the normal shape of a logging writer. That is 59 overrides, generated from a table rather than typed, with per target guards taken from the reference assemblies: span and memory from netcoreapp2.1 or netstandard2.1, StringBuilder from netcoreapp3.0, DisposeAsync from netcoreapp3.0 or netstandard2.1, FlushAsync(CancellationToken) from net8.0. Also verified: Encoding and FormatProvider come from the first writer, falling back to Encoding.Unicode and InvariantCulture when there are none; NewLine keeps its own default and the setter propagates to every writer; the array is copied; a null array or element throws ArgumentNullException naming writers; an empty broadcast is legal and drops everything; Close routes through Dispose rather than the children's Close; Dispose is not idempotent and writing after it still forwards; and a writer that throws stops the rest rather than aggregating. API count 1150 -> 1151.
This was referenced Sep 10, 2026
This was referenced Sep 11, 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.
One public member, +1 to the API count — but the writer it returns is the whole job. The BCL type is
TextWriter's private nestedBroadcastingTextWriter, so it is recreated as a class nested insidePolyfillwith only the factory exposed.Every overload is forwarded verbatim, and that is not cosmetic
The lazy implementation overrides only
Write(char),Write(string)andWrite(char[], int, int)and lets the base class decompose everything else. The characters that come out are identical, so a naive test passes.It is still wrong. Verified against net11:
broadcast.Write(42)reaches each underlying writer'sWrite(int), not itsWrite(string). Same forWriteLine(string),Write(ReadOnlySpan<char>),Write(StringBuilder)and the rest. A writer that overrides a composite overload — which is the normal shape of a logging or filtering writer — would simply never see those calls under the lazy version.So all 59 virtual members are overridden and forwarded one-to-one. They are generated from a table rather than hand-typed, with per-target guards taken from the reference assemblies rather than guessed:
ReadOnlySpan<char>/ReadOnlyMemory<char>StringBuilderoverloadsDisposeAsync()FlushAsync(CancellationToken)Write(string, params ReadOnlySpan<object>)is net9-only, so it never falls inside the polyfill's window.The rest of the behaviour, all measured rather than assumed
EncodingandFormatProvidercome from the first writer. With no writers they areEncoding.UnicodeandCultureInfo.InvariantCulture— invariant, notably, notCurrentCulture, which is what a naivebase(null)would have produced.NewLinekeeps its own default and does not adopt a child's; the setter propagates to every writer.ArgumentNullExceptionnamingwriters.Close()routes throughDispose(true)on the children, not theirClose().Dispose()is not idempotent — calling it twice disposes the children twice — and writing after disposal still forwards. So the polyfill keeps no state at all.Tests
The central test drives 40+ distinct overloads through the broadcast and asserts the exact, ordered log of which overload each of two writers received. That is what catches a missing override: the lazy implementation would fail it on the first composite call.
It runs on every target framework, so net9.0 and later check the BCL and everything below checks the polyfill — 8 tests green on net11.0, net10.0, net9.0, net8.0 and netcoreapp3.1, and 7 on net462 where
DisposeAsyncdoes not exist.One incidental finding: net9.0 added
Write(string, params ReadOnlySpan<object>), so a loose argument list likeWrite(fmt, a, b, c, d)binds to the span overload there and to the array overload below. The test pins the array overload explicitly; it is a BCL source-compatibility wrinkle, not a polyfill divergence.Verification
Solution clean in Release, Consume clean across all 22 TFMs, tests green on net11.0 (1726), net10.0 (1726), net9.0 (1726), net8.0 (1723), net462 (1672), plus PublicTests, EmbeddedTests, UnsafeTests, NoRefsTests and NoExtrasTests. netcoreapp3.1, net5.0, net6.0 and net7.0 were exercised via
dotnet exec --roll-forward LatestMajor, since those runtimes are not installed here and each covers a different combination of the guards above.