Skip to content

Commit 9d244b4

Browse files
committed
fix(codegen): harden hot reload generation
1 parent e76db92 commit 9d244b4

38 files changed

Lines changed: 544 additions & 549 deletions

File tree

docs/site/src/content/docs/grains/code-generation.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Orleans source generation
33
description: Understand build-time code generation for grains and serialization in Orleans.
4-
ms.date: 08/07/2026
4+
ms.date: 09/01/2026
55
ms.topic: concept-article
66
---
77

@@ -32,6 +32,22 @@ Mark application data crossing grain boundaries or stored by Orleans with <xref:
3232
:::code language="csharp" source="../snippets/compiled/Grains/GeneralSnippets.cs" id="serializable_purchase_order":::
3333
IDs are part of the wire and storage contract. Don't reuse or renumber them after deployment. Use <xref:Orleans.AliasAttribute> when a stable serialized type alias is required independently of the CLR name.
3434

35+
## Hot Reload for serializable members
36+
37+
Enable the Hot Reload generation shape in development builds:
38+
39+
```xml
40+
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
41+
<OrleansHotReload>true</OrleansHotReload>
42+
</PropertyGroup>
43+
```
44+
45+
The generator gives serializer and copier fields stable identities and resolves newly added concrete codec and copier dependencies when an existing generated instance first uses them. A running application can therefore apply supported .NET Hot Reload updates which add strongly typed `[Id]` members to `[GenerateSerializer]` classes and records, including members whose concrete serializable type is added in the same update.
46+
47+
Keep each `[Id]` value stable and unique. Additive member updates preserve the existing wire contract. Changes to existing member types, IDs, type hierarchy, generic shape, grain interfaces, and generated invokable types follow the corresponding .NET Hot Reload and Orleans contract compatibility requirements. A restart rebuilds the serializer manifest after updates which introduce new types through polymorphic declarations such as `object` or interfaces, or through container element types.
48+
49+
When `OrleansHotReload` is unset or `false`, the generator emits readonly fields with eager initialization for normal application builds.
50+
3551
## Generate code for external types
3652

3753
When a project must generate serializers for accessible types declared elsewhere, use <xref:Orleans.GenerateCodeForDeclaringAssemblyAttribute>:

src/Orleans.CodeGenerator/GeneratedSourceOutput.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,8 @@ internal static string CreateHintNameHash(
302302
return CreateStableHash(builder.ToString());
303303
}
304304

305-
internal static string CreateStableHash(string value)
306-
=> HexConverter.ToString(XxHash32.Hash(Encoding.UTF8.GetBytes(value ?? string.Empty)));
305+
internal static string CreateStableHash(string value, int seed = 0)
306+
=> HexConverter.ToString(XxHash32.Hash(Encoding.UTF8.GetBytes(value ?? string.Empty), seed));
307307

308308
internal static void AppendHashComponent(StringBuilder builder, string value)
309309
{
@@ -379,4 +379,3 @@ internal static string SanitizeHintComponent(string value)
379379
return result.Length > 0 ? result : "generated";
380380
}
381381
}
382-

src/Orleans.CodeGenerator/ProxySourceOutputGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ internal static SourceOutputResult CreateProxySourceOutput(
1717
try
1818
{
1919
SourceGeneratorOptionsParser.AttachDebuggerIfRequested(options);
20-
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options, compilation);
20+
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options);
2121
var generatorServices = new GeneratorServices(compilation, codeGeneratorOptions);
2222
var proxyContext = new ProxyGenerationContext(compilation, codeGeneratorOptions);
2323
var model = proxyOutputModel.ProxyInterface;
@@ -187,7 +187,7 @@ internal static ProxyOutputPreparationResult CreateProxyOutputPreparation(
187187
[]);
188188
}
189189

190-
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options, compilation);
190+
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options);
191191
var libraryTypes = LibraryTypes.FromCompilation(compilation, codeGeneratorOptions);
192192
var generatorServices = new GeneratorServices(compilation, codeGeneratorOptions, libraryTypes);
193193
var proxyContext = new ProxyGenerationContext(compilation, codeGeneratorOptions, libraryTypes);

src/Orleans.CodeGenerator/ReferenceAssemblyDataProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal static ReferenceAssemblyDataResult CreateReferenceAssemblyDataResult(
1414
{
1515
var model = ModelExtractor.ExtractReferenceAssemblyData(
1616
compilation,
17-
SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options, compilation),
17+
SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options),
1818
cancellationToken,
1919
out var diagnostics);
2020

src/Orleans.CodeGenerator/SerializableSourceOutputGenerator.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ internal static SerializableTypeResult CreateSerializableTypeResult(
2929
SourceGeneratorOptionsParser.AttachDebuggerIfRequested(options);
3030

3131
var compilation = context.SemanticModel.Compilation;
32-
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options, compilation);
32+
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options);
3333
var libraryTypes = LibraryTypes.FromCompilation(compilation, codeGeneratorOptions);
3434
var typeDescription = CreateSerializableTypeDescription(compilation, libraryTypes, codeGeneratorOptions, symbol);
3535
if (typeDescription is null)
@@ -66,7 +66,7 @@ internal static ImmutableArray<SourceOutputResult> CreateSerializableSourceOutpu
6666
}
6767

6868
SourceGeneratorOptionsParser.AttachDebuggerIfRequested(options);
69-
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options, compilation);
69+
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options);
7070
var generatorServices = new GeneratorServices(compilation, codeGeneratorOptions);
7171
var resolver = new TypeSymbolResolver(compilation);
7272
var assemblyName = compilation.AssemblyName ?? "assembly";
@@ -162,7 +162,7 @@ internal static ImmutableArray<SourceOutputResult> CreateReferencedSerializableS
162162
}
163163

164164
SourceGeneratorOptionsParser.AttachDebuggerIfRequested(options);
165-
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options, compilation);
165+
var codeGeneratorOptions = SourceGeneratorOptionsParser.CreateCodeGeneratorOptions(options);
166166
var generatorServices = new GeneratorServices(compilation, codeGeneratorOptions);
167167
var resolver = new TypeSymbolResolver(compilation);
168168
var assemblyName = compilation.AssemblyName ?? "assembly";
@@ -470,4 +470,3 @@ internal static bool IsCurrentCompilationAssembly(TypeMetadataIdentity metadataI
470470
}
471471

472472

473-

src/Orleans.CodeGenerator/SourceGeneratorOptionsParser.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Diagnostics;
2-
using Microsoft.CodeAnalysis;
32
using Microsoft.CodeAnalysis.Diagnostics;
43
using Orleans.CodeGenerator.Model;
54

@@ -9,13 +8,13 @@ internal static class SourceGeneratorOptionsParser
98
{
109
private static int _debuggerLaunchState;
1110

12-
internal static CodeGeneratorOptions CreateCodeGeneratorOptions(SourceGeneratorOptions options, Compilation compilation)
11+
internal static CodeGeneratorOptions CreateCodeGeneratorOptions(SourceGeneratorOptions options)
1312
{
1413
return new CodeGeneratorOptions
1514
{
1615
GenerateFieldIds = options.GenerateFieldIds,
1716
GenerateCompatibilityInvokers = options.GenerateCompatibilityInvokers,
18-
HotReloadSafe = options.HotReload ?? compilation.Options.OptimizationLevel == OptimizationLevel.Debug,
17+
HotReloadSafe = options.HotReload ?? false,
1918
};
2019
}
2120

@@ -54,7 +53,7 @@ internal static SourceGeneratorOptions ParseOptions(AnalyzerConfigOptions global
5453
result.GenerateCompatibilityInvokers = genCompatInvokers;
5554
}
5655

57-
if (globalOptions.TryGetValue("build_property.orleans_hotreload", out var hotReloadValue)
56+
if (globalOptions.TryGetValue("build_property.orleanshotreload", out var hotReloadValue)
5857
&& bool.TryParse(hotReloadValue, out var hotReload))
5958
{
6059
result.HotReload = hotReload;
@@ -72,7 +71,7 @@ internal struct SourceGeneratorOptions : IEquatable<SourceGeneratorOptions>
7271
public bool AttachDebugger { get; set; }
7372

7473
/// <summary>
75-
/// Forces hot-reload-safe code generation on or off; when unset, it follows the compilation's optimization level.
74+
/// Enables hot-reload-safe code generation.
7675
/// </summary>
7776
public bool? HotReload { get; set; }
7877

src/Orleans.CodeGenerator/SyntaxGeneration/GeneratedFieldNames.cs

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,26 @@
33
namespace Orleans.CodeGenerator.SyntaxGeneration;
44

55
/// <summary>
6-
/// Produces names for generated private fields which are stable under member reordering and most member additions,
7-
/// so that .NET Hot Reload sees additions rather than retyped or renamed fields. Adding a member whose type causes
8-
/// a previously unseen name collision may still force existing names to switch to a hash-suffixed form.
6+
/// Produces names for generated private fields which are stable under member reordering and additions,
7+
/// so that .NET Hot Reload sees additions rather than retyped or renamed fields.
98
/// </summary>
109
internal static class GeneratedFieldNames
1110
{
11+
private const int SecondaryHashSeed = unchecked((int)0x9E3779B9);
12+
1213
public static string Accessor(string prefix, IMemberDescription member)
1314
=> member.IsPrimaryConstructorParameter ? $"{prefix}_{member.FieldId}_ctor" : $"{prefix}_{member.FieldId}";
1415

1516
public static string[] ForTypes(string prefix, IReadOnlyList<IMemberDescription> members)
1617
{
17-
if (members.Count == 0)
18-
{
19-
return [];
20-
}
21-
22-
// phase 0: optimistic pass: try to get a readable name for each type, and see if any collide
2318
var result = new string[members.Count];
2419
for (var i = 0; i < members.Count; i++)
2520
{
26-
result[i] = TryGetTypeKey(members[i].Type) is { } key ? $"{prefix}_{key}" : null!;
27-
}
28-
29-
// phase 1: detect collisions and mark them for hashing
30-
Span<bool> contested = members.Count <= 64 ? stackalloc bool[members.Count] : new bool[members.Count];
31-
contested.Clear();
32-
for (var i = 0; i < members.Count; i++)
33-
{
34-
// we couldn't get a readable name for this type, so we need to hash it
35-
if (result[i] is null)
36-
{
37-
contested[i] = true;
38-
continue;
39-
}
40-
41-
for (var j = i + 1; j < members.Count; j++)
42-
{
43-
if (string.Equals(result[i], result[j], StringComparison.Ordinal))
44-
{
45-
contested[i] = true;
46-
contested[j] = true;
47-
}
48-
}
49-
}
50-
51-
// phase 2: for any contested names, replace them with a hash-based name
52-
for (var i = 0; i < members.Count; i++)
53-
{
54-
if (contested[i])
55-
{
56-
result[i] = $"{result[i] ?? $"{prefix}_Type"}_{GeneratedSourceOutput.CreateStableHash($"{members[i].TypeName}|{members[i].AssemblyName}")}";
57-
}
21+
var member = members[i];
22+
var key = TryGetTypeKey(member.Type) ?? "Type";
23+
var identity = $"{member.TypeName.Length}:{member.TypeName}{member.AssemblyName.Length}:{member.AssemblyName}";
24+
var hash = $"{GeneratedSourceOutput.CreateStableHash(identity)}{GeneratedSourceOutput.CreateStableHash(identity, SecondaryHashSeed)}";
25+
result[i] = $"{prefix}_{key}_{hash}";
5826
}
5927

6028
return result;

src/Orleans.CodeGenerator/build/Microsoft.Orleans.CodeGenerator.props

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
<CompilerVisibleProperty Include="Orleans_GenerateFieldIds" />
77
<CompilerVisibleProperty Include="Orleans_ConstructorAttributes" />
88
<CompilerVisibleProperty Include="OrleansGenerateCompatibilityInvokers" />
9-
<CompilerVisibleProperty Include="Orleans_HotReload" />
9+
<CompilerVisibleProperty Include="OrleansHotReload" />
1010
</ItemGroup>
1111

1212
<PropertyGroup>
1313
<Orleans_DesignTimeBuild>$(DesignTimeBuild)</Orleans_DesignTimeBuild>
1414
<Orleans_GenerateFieldIds>$(OrleansGenerateFieldIds)</Orleans_GenerateFieldIds>
1515
<Orleans_ConstructorAttributes>$(OrleansConstructorAttributes)</Orleans_ConstructorAttributes>
16-
<Orleans_HotReload>$(OrleansHotReload)</Orleans_HotReload>
1716
</PropertyGroup>
1817

1918
</Project>

test/Orleans.CodeGenerator.Tests/GeneratedFieldNamesTests.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@ public async Task TypeKeyedNamesAreReadableForCommonShapes()
3434
new FakeMember(compilation.GetTypeByMetadataName("System.Collections.Generic.Dictionary`2")!.Construct(compilation.GetSpecialType(SpecialType.System_String), intType), 3),
3535
};
3636

37-
Assert.Equal(["_codec_Int32", "_codec_List_Int32", "_codec_Int32_1", "_codec_Dictionary_String_Int32"], GeneratedFieldNames.ForTypes("_codec", members));
37+
var names = GeneratedFieldNames.ForTypes("_codec", members);
38+
Assert.Collection(
39+
names,
40+
name => Assert.Matches("^_codec_Int32_[0-9A-F]{16}$", name),
41+
name => Assert.Matches("^_codec_List_Int32_[0-9A-F]{16}$", name),
42+
name => Assert.Matches("^_codec_Int32_1_[0-9A-F]{16}$", name),
43+
name => Assert.Matches("^_codec_Dictionary_String_Int32_[0-9A-F]{16}$", name));
44+
Assert.Equal(names.Length, names.Distinct(StringComparer.Ordinal).Count());
3845
}
3946

4047
[Fact]
@@ -50,12 +57,29 @@ namespace Second { public class Item { } }
5057
var names = GeneratedFieldNames.ForTypes("_copier", [first, second]);
5158
var reversed = GeneratedFieldNames.ForTypes("_copier", [second, first]);
5259

53-
Assert.All(names, name => Assert.Matches("^_copier_Item_[0-9A-F]{8}$", name));
60+
Assert.All(names, name => Assert.Matches("^_copier_Item_[0-9A-F]{16}$", name));
5461
Assert.NotEqual(names[0], names[1]);
5562
Assert.Equal(names[0], reversed[1]);
5663
Assert.Equal(names[1], reversed[0]);
5764
}
5865

66+
[Fact]
67+
public async Task AddingCollidingSimpleNameDoesNotRenameExistingField()
68+
{
69+
var compilation = await Compile("""
70+
namespace First { public class Item { } }
71+
namespace Second { public class Item { } }
72+
""");
73+
var first = new FakeMember(compilation.GetTypeByMetadataName("First.Item")!, 0);
74+
var second = new FakeMember(compilation.GetTypeByMetadataName("Second.Item")!, 1);
75+
76+
var originalName = Assert.Single(GeneratedFieldNames.ForTypes("_copier", [first]));
77+
var namesWithCollision = GeneratedFieldNames.ForTypes("_copier", [first, second]);
78+
79+
Assert.Equal(originalName, namesWithCollision[0]);
80+
Assert.NotEqual(namesWithCollision[0], namesWithCollision[1]);
81+
}
82+
5983
[Fact]
6084
public async Task UnspeakableTypesFallBackToHashOnlyNames()
6185
{
@@ -64,7 +88,7 @@ public async Task UnspeakableTypesFallBackToHashOnlyNames()
6488
var members = new List<IMemberDescription> { new FakeMember(pointer, 0) };
6589

6690
var name = Assert.Single(GeneratedFieldNames.ForTypes("_codec", members));
67-
Assert.Matches("^_codec_Type_[0-9A-F]{8}$", name);
91+
Assert.Matches("^_codec_Type_[0-9A-F]{16}$", name);
6892

6993
Assert.Equal(name, Assert.Single(GeneratedFieldNames.ForTypes("_codec", members)));
7094
}

0 commit comments

Comments
 (0)