Generate direct saga property accessors instead of extern UnsafeAccessor methods - #7953
danielmarbach wants to merge 8 commits into
Conversation
…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.
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.
753f665 to
e57d790
Compare
… 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.
| var readReceiver = $"(({mapping.InterfaceReceiverType ?? sagaDataType})sagaData)"; | ||
| var writeReceiver = $"(({(mapping.InterfaceHasSetter ? mapping.InterfaceReceiverType : null) ?? sagaDataType})sagaData)"; | ||
| var read = getterReceiverType is null ? $"{readReceiver}.{member}" : $"AccessFrom_Property(({getterReceiverType})sagaData)"; | ||
| var write = setterReceiverType is null ? $"{writeReceiver}.{member} = ({mapping.PropertyType})value" : $"WriteTo_Property(({setterReceiverType})sagaData, ({mapping.PropertyType})value)"; |
There was a problem hiding this comment.
Just to confirm, this would now be a compilation error for a saga with no writable correlation property, right? (If yes, this error would come from code that we generate. Is that something we care about? )
There was a problem hiding this comment.
Yes. There are a bunch of edge cases that were previously not handled and would lead to a MissingMethodException at runtime. Some of these cases now move to be a compile error, but some of those are also directly handled by the existing saga analyzer and would require you to ignore them in the editor config or suppress it to even run into it.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Receiver selection and accessor identity can break compilation or cause correlation reads and writes to target the wrong property.
Review effort: Balanced
Findings: 3
Open (3)
Resolved since last review (2)
| var directReceiver = mapping.InterfaceReceiverType is { } interfaceType ? $"(({interfaceType})message)" : "message"; | ||
| var read = getterReceiverType is null ? $"{directReceiver}.{MemberName(mapping.MessagePropertyName)}" : "AccessFrom_Property(message)"; |
| var member = MemberName(mapping.PropertyName); | ||
| var getterReceiverType = mapping.ExternGetterReceiverType; | ||
| var setterReceiverType = mapping.ExternSetterReceiverType; | ||
| var readReceiver = $"(({mapping.InterfaceReceiverType ?? sagaDataType})sagaData)"; |
| var readReceiver = $"(({mapping.InterfaceReceiverType ?? sagaDataType})sagaData)"; | ||
| var writeReceiver = $"(({(mapping.InterfaceHasSetter ? mapping.InterfaceReceiverType : null) ?? sagaDataType})sagaData)"; |


The saga generator emits
[UnsafeAccessor] static externmethods 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, everyexternmember has to be markedsafeorunsafe, otherwise the compiler reports CS9389. I verified this on the .NET 11 RC1 SDK withLangVersion=previewand theupdated-memory-safety-rulesfeature 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 keepsUnsafeAccessoronly for accessors that generated code can't call:private set,protected set, or aprivate geton a property that a nested saga maps)For those, the extern targets the type that declares the accessor. It is emitted as
static safe externwhen the compilation uses the updated rules and as plainstatic externotherwise. The detection and thesafemarker follow whatSystem.Text.Jsondoes:UsesUpdatedMemorySafetyRulesreadsIModuleSymbol.MemorySafetyRulesVersionthrough reflection and falls back to theupdated-memory-safety-rulesfeature flag, and the emitter addssafeto its externs when that returns true. It also uses direct access wherever it can and only falls back toUnsafeAccessorotherwise, 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:
externmembers for the common case, and it compiles with and without the new rules.MissingMethodExceptionon the firstWriteTo, because the accessor targeted the derived type. The extern now targets the type that declares the accessor.UnsafeAccessorand wouldn't compile with direct access, so those getters keep the extern.m => ((IHasId)m).Id) is read through that interface. Mapped through an explicit implementation, it used to throwMissingMethodExceptionat runtime, because the old accessor looked forget_Idon 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 aprivate setor init-only setter, the extern targets that setter.@event) are escaped. The old code passed names as strings, so it worked there. Direct access needs the@.[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:
ExpressionBasedCorrelationPropertyAccessorfor 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.UnsafeAccessoreverywhere and only addingsafe. 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:
MemorySafetyRulesVersionisn't on the publicIModuleSymbol, 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.safe externat 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.KeyedServiceCollectionAdapterin Core and a test local accessor inSagaMetadataCreationTestsstill useUnsafeAccessorand would hit CS9389 if the NServiceBus projects themselves opted in. I left those alone since they don't affect consumers.