diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b4a7cf2..10bb20697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ This document follows the conventions laid out in [Keep a CHANGELOG][]. ### Fixed +- Fix shutdown serialization of CLR namespace modules on .NET 8 while preserving + namespace name tracking across runtime restarts. ## 3.2.0 - 2026-09-29 diff --git a/src/embed_tests/StateSerialization/ModuleSerialization.cs b/src/embed_tests/StateSerialization/ModuleSerialization.cs new file mode 100644 index 000000000..80e010a45 --- /dev/null +++ b/src/embed_tests/StateSerialization/ModuleSerialization.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using Python.Runtime; + +namespace Python.EmbeddingTest.StateSerialization; + +[NonParallelizable] +public class ModuleSerialization +{ + [TestCase(false)] + [TestCase(true)] + public void NamespaceModuleRoundtrip(bool loadNames) + { + var formatter = RuntimeData.CreateFormatter(); + if (formatter is NoopFormatter) + { + Assert.Inconclusive("NoopFormatter in use, cannot perform serialization test."); + } + + using var gil = Py.GIL(); + using var pyModule = ModuleObject.Create("System").MoveToPyObject(); + var module = (ModuleObject)ManagedType.GetManagedObject(pyModule)!; + using (var int32 = module.GetAttribute("Int32", true)) + { + Assert.That(int32.IsNull(), Is.False); + } + + string[] names = null; + if (loadNames) + { + using var all = pyModule.GetAttr("__all__"); + names = all.As(); + Assert.That(names, Does.Contain("Int32")); + } + + for (int i = 0; i < 2; i++) + { + var context = module.Save(pyModule); + using var buffer = new MemoryStream(); + formatter.Serialize(buffer, new object[] { module, context }); + buffer.Position = 0; + var restored = (object[])formatter.Deserialize(buffer); + ExtensionType.tp_clear(pyModule); + module = (ModuleObject)restored[0]; + module.Load(pyModule, (Dictionary)restored[1]); + + using (var int32 = module.GetAttribute("Int32", true)) + { + Assert.That(int32.IsNull(), Is.False); + } + using (var collections = module.GetAttribute("Collections", true)) + { + Assert.That(collections.IsNull(), Is.False); + } + + using var all = pyModule.GetAttr("__all__"); + var restoredNames = all.As(); + Assert.That(restoredNames, Does.Contain("Int32")); + Assert.That(restoredNames, Is.Unique); + if (names is not null) + { + Assert.That(restoredNames, Is.EquivalentTo(names)); + } + names = restoredNames; + } + } +} diff --git a/src/runtime/Types/ModuleObject.cs b/src/runtime/Types/ModuleObject.cs index c9508aeed..3e020e563 100644 --- a/src/runtime/Types/ModuleObject.cs +++ b/src/runtime/Types/ModuleObject.cs @@ -14,13 +14,15 @@ namespace Python.Runtime [Serializable] internal class ModuleObject : ExtensionType { - private readonly ConcurrentDictionary cache = new(); + [NonSerialized] + private ConcurrentDictionary cache = new(); internal string moduleName; internal PyDict dict; protected string _namespace; private readonly PyList __all__ = new (); - private readonly ConcurrentDictionary allNames = new(); + [NonSerialized] + private ConcurrentDictionary allNames = new(); // Attributes to be set on the module according to PEP302 and 451 // by the import machinery. @@ -378,7 +380,8 @@ public static int tp_traverse(BorrowedReference ob, IntPtr visit, IntPtr arg) protected override Dictionary? OnSave(BorrowedReference ob) { - var context = base.OnSave(ob); + var context = base.OnSave(ob) ?? new(); + context[nameof(allNames)] = allNames.Keys.ToArray(); System.Diagnostics.Debug.Assert(dict == GetObjectDict(ob)); // destroy the cache(s) foreach (var pair in cache) @@ -402,6 +405,9 @@ public static int tp_traverse(BorrowedReference ob, IntPtr visit, IntPtr arg) protected override void OnLoad(BorrowedReference ob, Dictionary? context) { + cache = new(); + allNames = new(((string[])context![nameof(allNames)]!) + .Select(name => new KeyValuePair(name, 0))); base.OnLoad(ob, context); SetObjectDict(ob, new NewReference(dict).Steal()); }