Skip to content

ISerializationProvider takes TextWriter/TextReader, which blocks genuine async serialization #18

Description

@matt-edmondson

ISerializationProvider is built on TextWriter and TextReader:

public bool TrySerialize(object obj, TextWriter writer);
public T? Deserialize<T>(TextReader reader)
public Task<bool> TrySerializeAsync(object obj, TextWriter writer, CancellationToken cancellationToken = default)
public Task<T?> DeserializeAsync<T>(TextReader reader, CancellationToken cancellationToken = default)

That choice is what stops the serialization providers from ever becoming genuinely async, so it blocks the serialization half of #8 specifically rather than being a general style preference.

Why the parameter type is the blocker

System.Text.Json's asynchronous entry points are JsonSerializer.SerializeAsync(Stream, …) and JsonSerializer.DeserializeAsync<T>(Stream, …). There is no TextWriter or TextReader overload of either, and there is no adapter that recovers one, because the async path exists precisely so the serializer can write UTF-8 bytes straight to a stream without materialising a string.

So a provider handed a TextWriter has two options, and both are the thing #8 is about:

  • serialize synchronously into the writer and wrap it in Task.Run, which is what happens today via ProviderHelpers.RunAsync()
  • serialize to an intermediate buffer, then write that to the writer

JsonSerializationProvider currently declares only TrySerialize(object, TextWriter) and inherits every other member, so all four of its async paths are Task.Run wrappers over synchronous work.

The same reasoning applies to the other three providers to varying degrees. Newtonsoft.Json is TextWriter-native and genuinely has no async story worth reaching for, so it is the one case where the current signature costs nothing.

Why it isn't just "add a Stream overload"

ISerializationProvider uses default interface implementations, so adding Stream overloads alongside the existing ones is source-compatible. The problem is that it leaves two parallel families where the TextWriter one is a trap: it looks equally good at the call site and silently gives you the Task.Run path. The compression conversion in #14 worked because there was one obvious primitive per direction and overriding it converted every derived path. Here that would only be true if Stream became the primitive and the TextWriter members were re-expressed over it.

Encoding matters too. TextWriter carries its own encoding, whereas SerializeAsync writes UTF-8 unconditionally. Layering the existing members over a Stream primitive has to preserve what today's callers get, which is not automatically UTF-8.

Suggested scope

Design work, not a mechanical change, and it probably wants to land with the ValueTask conversion in release 2 of #8 since both change the same signatures and both are breaking. Recording it now so the constraint is written down rather than rediscovered.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Real gap or design debt; scheduled work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions