Skip to content

Generate direct saga property accessors instead of extern UnsafeAccessor methods - #7953

Open
danielmarbach wants to merge 8 commits into
masterfrom
saga-accessors-avoid-extern
Open

danielmarbach wants to merge 8 commits into
masterfrom
saga-accessors-avoid-extern

Conversation

@danielmarbach

@danielmarbach danielmarbach commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

The saga generator emits [UnsafeAccessor] static extern methods for every message property getter and every correlation property getter and setter. That is what we ship in 10.2. With the C# 15 / .NET 11 updated memory safety rules, every extern member has to be marked safe or unsafe, otherwise the compiler reports CS9389. I verified this on the .NET 11 RC1 SDK with LangVersion=preview and the updated-memory-safety-rules feature flag: the generated code from 10.2 doesn't compile. The opt-in is preview today, and I'd rather not wait for it to become the default before we fix the generated code.

The accessors live in the consumer's assembly, so most properties can be reached with plain C#. This PR emits direct access (message.Prop, ((TSagaData)sagaData).Prop, and an assignment for the setter) and keeps UnsafeAccessor only for accessors that generated code can't call:

  • init-only setters
  • getters and setters that aren't accessible from the assembly (private set, protected set, or a private get on a property that a nested saga maps)

For those, the extern targets the type that declares the accessor. It is emitted as static safe extern when the compilation uses the updated rules and as plain static extern otherwise. The detection and the safe marker follow what System.Text.Json does: UsesUpdatedMemorySafetyRules reads IModuleSymbol.MemorySafetyRulesVersion through reflection and falls back to the updated-memory-safety-rules feature flag, and the emitter adds safe to its externs when that returns true. It also uses direct access wherever it can and only falls back to UnsafeAccessor otherwise, which is the same split I'm making here. The flag is only evaluated for the sagas that need an extern accessor, so everyone else keeps their cached generator output when it changes.

Observable effects:

  • Generated saga code has no extern members for the common case, and it compiles with and without the new rules.
  • An init-only or private-set correlation property declared on a base class used to throw MissingMethodException on the first WriteTo, because the accessor targeted the derived type. The extern now targets the type that declares the accessor.
  • A nested saga can map a property whose getter is private to the containing type. That worked with UnsafeAccessor and wouldn't compile with direct access, so those getters keep the extern.
  • A property mapped through an interface (m => ((IHasId)m).Id) is read through that interface. Mapped through an explicit implementation, it used to throw MissingMethodException at runtime, because the old accessor looked for get_Id on the concrete type. The same applies to correlation properties on saga data. If the interface only declares a getter and the saga data class implements a private set or init-only setter, the extern targets that setter.
  • Properties named like keywords (@event) are escaped. The old code passed names as strings, so it worked there. Direct access needs the @.
  • Direct access to an [Obsolete] property is covered by the CS0612/CS0618 suppression the generated files already have. Nullable warnings are already disabled in generated files, so the new casts can't surface nullability warnings.

Alternatives I considered:

  • Falling back to the runtime ExpressionBasedCorrelationPropertyAccessor for init-only setters. That would have worked and is simpler, but those sagas would silently lose the generated, reflection free accessor they get in 10.2. I think that's the wrong thing to change in a minor, even if the fallback still works under AOT.
  • Keeping UnsafeAccessor everywhere and only adding safe. This is the smallest change. I'm not convinced it's worth it, because every accessor then depends on the detection and on a keyword that may still change before it ships.

What I couldn't verify:

  • In RC1, MemorySafetyRulesVersion isn't on the public IModuleSymbol, so only the feature flag path is active today. The reflection path is guarded by a return type check so a future shape change can't throw, but nothing exercises it yet.
  • I verified safe extern at compile time on RC1 only. I didn't run the resulting program there. The runtime round trips are covered by the analyzer tests on .NET 10.
  • Nothing is benchmarked. The added parser work only runs for interface-declared properties or non-public accessors, so I expect no measurable difference in generator time, but that is from reading the code.
  • KeyedServiceCollectionAdapter in Core and a test local accessor in SagaMetadataCreationTests still use UnsafeAccessor and would hit CS9389 if the NServiceBus projects themselves opted in. I left those alone since they don't affect consumers.

…sor methods

Under the C# 15 / .NET 11 updated memory safety rules every extern member must be
marked safe or unsafe (CS9389), which broke generated saga accessors for consumers
that opt in.

Message and correlation property accessors now read and write the properties
directly. A correlation setter that generated code cannot assign (init-only or not
accessible from the assembly) keeps an UnsafeAccessor setter on the type that
declares it, emitted as safe extern when the compilation uses the updated rules.
Properties named like keywords are now escaped.

This comment was marked as resolved.

A nested saga can map a property whose getter is private to the containing
type, which the generated file-level accessor cannot call directly. Message
and correlation getters now use the same extern fallback as setters, targeting
the type that declares the accessor.

Also drops a stray approval snapshot and shares the reflection helpers in the
accessor execution tests.
@danielmarbach
danielmarbach marked this pull request as ready for review October 1, 2026 14:53
@danielmarbach
danielmarbach requested a balanced review from Copilot October 1, 2026 14:53

This comment was marked as resolved.

@danielmarbach
danielmarbach force-pushed the saga-accessors-avoid-extern branch from 753f665 to e57d790 Compare October 1, 2026 16:17
… setter

When a correlation property is mapped through an interface that only declares a
getter, the setter belongs to the implicit implementation on the saga data type.
Its accessibility and init-only checks now apply to that setter, so a private or
init-only implementation keeps the extern accessor instead of failing to compile.
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.

2 participants