Skip to content

MessagePack for C# v4: Round3 - #2288

Closed
neuecc wants to merge 4 commits into
masterfrom
v4
Closed

MessagePack for C# v4: Round3#2288
neuecc wants to merge 4 commits into
masterfrom
v4

Conversation

@neuecc

@neuecc neuecc commented Aug 3, 2026

Copy link
Copy Markdown
Member

This Round covers the target framework settings and the adjustments made for them.
As before, anything outside the items described below is off topic, so please save those discussions for another occasion.

We adopt netstandard2.0, netstandard2.1, and net10.0 as the target frameworks.
net10.0 is the main target, and netstandard2.1 is for current Unity(Game Engine).
Unity is netstandard2.1 / C# 9. Considering the LTS cycle, it will still be required for at least another two years.
netstandard2.0 will mainly be for .NET Framework 4.8.
.NET 9 is an STS release that will reach End of Life around the time this library ships, so we start from .NET 10.
The library as a whole is built around allows ref struct, in other words it assumes .NET 9 / C# 13.
allows ref struct involves not only the language but also the runtime, so it does not work on anything below .NET 9.

The architectural focus is how to get through the transition period until everything is on .NET 9 / C# 13 or later.
This proposal is based on the premise that providing full compatibility is impossible, and that we make the best effort we can within that constraint.
Ideally, targeting only net10.0 would be the quickest path, but I want to avoid that, and I would ask you to understand that intent first.

For allows ref struct on each Formatter, we branch with #if....

public interface IMessagePackFormatter<TWriteBuffer, TReadBuffer, T>
    where TWriteBuffer : struct, IWriteBuffer
#if NET9_0_OR_GREATER
    , allows ref struct
#endif
    where TReadBuffer : struct, IReadBuffer
#if NET9_0_OR_GREATER
    , allows ref struct
#endif
{
    ...
}

public interface IMessagePackFormatterFactory
{
#if NET9_0_OR_GREATER

    // use default interface method to cover compatibility
    object? CreateFormatter<TWriteBuffer, TReadBuffer>(Type type)
        where TWriteBuffer : struct, IWriteBuffer, allows ref struct
        where TReadBuffer : struct, IReadBuffer, allows ref struct
    {
        return CreateFormatter(typeof(TWriteBuffer), typeof(TReadBuffer), type);
    }

#endif

    // fallback for netstandard2.0, 2.1
    object? CreateFormatter(Type writeBufferType, Type readBufferType, Type valueType);
}

For ease of writing, a Source Generator is shipped alongside, and in most cases you can write it like this.

// source generator generates where constraint
public sealed partial class FooFormatter<TWriteBuffer, TReadBuffer>
    : IMessagePackFormatter<TWriteBuffer, TReadBuffer, Foo>
{
}

This Source Generator is used both for distribution and internally, referenced by the core library itself.

What needs careful attention is the compatibility problem.
We clarify the behavior when a 3rd party library built against netstandard2.1 is loaded in a net10 environment.

Regarding IMessagePackFormatterFactory.CreateFormatter,
a Generic Virtual Method causes a load-time error due to compatibility issues,
so for netstandard2.0 and 2.1 we define only the non-generic CreateFormatter.
On net10 the generic version is what will mainly be called, but for libraries built against netstandard2.1
we call the non-generic version through a default interface method.

As for formatter types, formatters defined against netstandard2.0 or 2.1
cannot be instantiated on the normal code path of net10.0 (the ref struct IWrite/ReadBuffer).
In this case, the Resolver now throws a dedicated exception message saying the formatter does not support net10.0.

This compatibility problem comes from the existence of libraries defined only for netstandard2.0/2.1,
so as a countermeasure we prepared an Analyzer with the rule "if the TFMs include one that does not resolve to net10.0 (such as netstandard), then net10.0 must also be included".
Since v3, MessagePack ships with a Source Generator and Analyzer by default, so we want to make the checking mechanisms solid.
By taking countermeasures including the Analyzer from the start, we prevent net10.0 from being left out.

//

In addition, IWriteBuffer / IReadBuffer and their implementations were adjusted.
These implementation types come in pairs, a ref struct version and a struct version for environments where allows ref struct is not available.
For the struct version, we removed the internal use of Pin and adjusted it to avoid unsafe definitions as much as possible.
Also, TWriteBuffer and TReadBuffer must not be copied, but there is no language mechanism to enforce this,
so as an initial defensive measure, the Analyzer detects copies of types that implement IWriteBuffer / IReadBuffer.
In addition, GetSpan/Advance now each independently detect out-of-range access.

Comment thread v4/UltraMessagePack.slnx Outdated
Co-authored-by: krishkat <krishna.kater@gmail.com>
Comment on lines +11 to +15
<UltraMessagePackTargetFrameworks>$(TargetFrameworks.Replace(';', ','))</UltraMessagePackTargetFrameworks>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="UltraMessagePackTargetFrameworks" />
</ItemGroup>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These won't help users of your analyzer package as they're repo-local.
But you can define these inside your package's buildTransitive folder so that all consumers of your package get them too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, of course it will be included when packaging. There are other projects that make things visible this way, so I don't think this itself is a problem.

@AArnott

AArnott commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

From your PR description and a quick check of the code changes, it looks like you're declaring your IMessagePackFormatter interface different based on target framework. I think you already know all this, but as it is significant I want to state it clearly to verify you know what you're getting into:

  1. The differences in interface declaration by target framework represent binary breaking changes.
  2. A library that compiled against .NET Standard 2.0 and referenced your package will get the 'lesser' interface declaration.
  3. When that library runs in an app that targets .NET 10, the runtime interface will be different from the one the library implemented. I expect this to cause a runtime exception.
  4. You are addressing this with an analyzer that calls out the library when the library compiles, urging the user to add .NET 10 as a target framework explicitly. That way, when the library runs in a .NET 10 app, the app will ship the .NET 10 targeted library, avoiding the binary compatibility issue.

Is that correct?

Reactions to this plan (if I'm right on above):

  1. It's generally assumed to be safe to load a library from a lesser target framework on a newer runtime. This isn't always the case. Sometimes 'ref assemblies' are in the netstandard2.0 folder and throw at runtime because you must use a runtime-specific build of the library. What you're doing here might be considered along the same lines, although the thrown exception won't be as clear.
  2. Analyzers don't always run. But as long as they are in a nuget package that is reliably referenced (maybe in the same package as the library itself), your approach may be reliable.
  3. I'm a bit uneasy with the plan, as it departs from the normal binary compatibility rules. So while I can't think of any holes to poke in it right now, I would be willing to believe that there are holes in it that you may hit later. And it may be hard or impossible to recover from that state if/when we stumble on those holes.

As we have agreed previously, I don't have to be comfortable with the plan. I just wanted you to be aware of the above for your consideration.

@neuecc

neuecc commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Yes, this is a fairly unusual technique, and I completely understand the concern.
That said, this is where I landed after thinking it through carefully.

You are correct on 1, 2, and 4.
On 3, the most important one, I think we need just a bit more alignment.
The type itself loads fine. Splitting allows ref struct with #if NET is something the BCL does as well (e.g. ImmutableArray.CreateRange):

public static ImmutableArray<TResult> CreateRange<TSource, TArg, TResult>(ImmutableArray<TSource> items, Func<TSource, TArg, TResult> selector, TArg arg)
#if NET
    where TArg : allows ref struct
#endif

Of course, in this proposal the constraint sits on a public type, so the problems that can arise are different.
A class compiled against the downlevel build can still be instantiated; however, it cannot be given the ref struct buffers that the normal code path on net10 uses, so what actually happens is:
the Factory returns null → nothing gets registered in the Resolver → Serialize is not possible → runtime exception (formatter missing).
As for the exception message, we can recognize that a formatter failed to be created in exactly this situation, so the message is able to explain it clearly.

On your reaction 1, it is safe in the sense that it's safe; whether it counts as expected behavior, hard to say.
On 3, as for recoverability, the answer is indeed to add net10.0 to TargetFrameworks. Also, supplying non-ref struct IWriteBuffer/IReadBuffer implementations is another way to make it work. Since this method requires no modifications whatsoever, it may be a safer and more reliable option in case any issues arise.

@neuecc

neuecc commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

The compatibility issue has been resolved.
netstandard2.0 DLLs now work fine on net10.
Because of this, I will remove the Analyzer.
Details will come in the next Round.

@neuecc neuecc closed this Aug 7, 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.

3 participants