Skip to content

Add net9 TextWriter.CreateBroadcasting - #602

Merged
SimonCropp merged 1 commit into
mainfrom
textwriter-createbroadcasting
Sep 10, 2026
Merged

Add net9 TextWriter.CreateBroadcasting#602
SimonCropp merged 1 commit into
mainfrom
textwriter-createbroadcasting

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

One public member, +1 to the API count — but the writer it returns is the whole job. The BCL type is TextWriter's private nested BroadcastingTextWriter, so it is recreated as a class nested inside Polyfill with only the factory exposed.

Every overload is forwarded verbatim, and that is not cosmetic

The lazy implementation overrides only Write(char), Write(string) and Write(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's Write(int), not its Write(string). Same for WriteLine(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:

group available from
ReadOnlySpan<char> / ReadOnlyMemory<char> netcoreapp2.1 or netstandard2.1
StringBuilder overloads netcoreapp3.0
DisposeAsync() netcoreapp3.0 or netstandard2.1
FlushAsync(CancellationToken) net8.0

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

  • Encoding and FormatProvider come from the first writer. With no writers they are Encoding.Unicode and CultureInfo.InvariantCulture — invariant, notably, not CurrentCulture, which is what a naive base(null) would have produced.
  • NewLine keeps its own default and does not adopt a child's; the setter propagates to every writer.
  • The array is copied, so mutating the caller's array afterwards has no effect.
  • A null array, or a null element, throws ArgumentNullException naming writers.
  • An empty broadcast is legal and silently drops everything.
  • Close() routes through Dispose(true) on the children, not their Close().
  • 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.
  • A writer that throws stops the rest; there is no aggregation, and later writers are skipped.

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 DisposeAsync does not exist.

One incidental finding: net9.0 added Write(string, params ReadOnlySpan<object>), so a loose argument list like Write(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.

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.
@SimonCropp SimonCropp added this to the 11.3.0 milestone Sep 10, 2026
@SimonCropp
SimonCropp merged commit 6853521 into main Sep 10, 2026
4 of 6 checks passed
@SimonCropp
SimonCropp deleted the textwriter-createbroadcasting branch September 10, 2026 09:46
This was referenced Sep 10, 2026
This was referenced Sep 11, 2026
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.

1 participant