diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02b4a7cf2..714052893 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ This document follows the conventions laid out in [Keep a CHANGELOG][].
### Fixed
+- When `BinaryFormatter` is unavailable, skip publishing `sys.clr_data` during
+ `Shutdown` so the next `Initialize` does not deserialize an empty capsule (#2282)
## 3.2.0 - 2026-09-29
diff --git a/src/embed_tests/Python.EmbeddingTest.csproj b/src/embed_tests/Python.EmbeddingTest.csproj
index 8697e7aaf..eb23a755a 100644
--- a/src/embed_tests/Python.EmbeddingTest.csproj
+++ b/src/embed_tests/Python.EmbeddingTest.csproj
@@ -19,6 +19,11 @@
+
+
+
+
+
$(DefineConstants);$(ConfiguredConstants)
diff --git a/src/embed_tests/StateSerialization/ShutdownStash.cs b/src/embed_tests/StateSerialization/ShutdownStash.cs
new file mode 100644
index 000000000..bd61a951b
--- /dev/null
+++ b/src/embed_tests/StateSerialization/ShutdownStash.cs
@@ -0,0 +1,57 @@
+using System;
+
+using NUnit.Framework;
+
+using Python.Runtime;
+
+namespace Python.EmbeddingTest.StateSerialization;
+
+///
+/// Regression for https://github.com/pythonnet/pythonnet/issues/2282.
+/// When BinaryFormatter cannot run, Shutdown must not throw and must not
+/// leave a sys.clr_data capsule behind.
+///
+public class ShutdownStash
+{
+ [Test]
+ public void ShutdownLeavesNoClrData()
+ {
+ if (!PythonEngine.IsInitialized)
+ {
+ PythonEngine.Initialize();
+ }
+
+ Assert.That(RuntimeData.CreateFormatter(), Is.InstanceOf());
+
+ using (Py.GIL())
+ {
+ using var wrapped = new UriBuilder().ToPython();
+ Assert.That(RuntimeData.HasStashData(), Is.False);
+ }
+
+ try
+ {
+ PythonEngine.Shutdown();
+
+ using (Py.GIL())
+ {
+ Assert.That(RuntimeData.HasStashData(), Is.False);
+ }
+ }
+ finally
+ {
+ // Shutdown leaves the interpreter alive. A second Shutdown in this
+ // process then deallocates types from the previous runtime and
+ // trips MetaType.tp_dealloc. Finish the interpreter first so the
+ // suite, and GlobalTestsSetup, start from a new one.
+ if (Runtime.Runtime.Py_IsInitialized() != 0)
+ {
+ Runtime.Runtime.Py_Finalize();
+ }
+ if (!PythonEngine.IsInitialized)
+ {
+ PythonEngine.Initialize();
+ }
+ }
+ }
+}
diff --git a/src/runtime/StateSerialization/RuntimeData.cs b/src/runtime/StateSerialization/RuntimeData.cs
index c3e5d2287..e6e0a8f9e 100644
--- a/src/runtime/StateSerialization/RuntimeData.cs
+++ b/src/runtime/StateSerialization/RuntimeData.cs
@@ -84,6 +84,19 @@ static void ClearCLRData ()
internal static void Stash()
{
+ IFormatter formatter = CreateFormatter();
+ // NoopFormatter.Serialize writes nothing. An empty sys.clr_data capsule
+ // makes the next Initialize call Deserialize and throw (#2282).
+ if (formatter is NoopFormatter)
+ {
+ if (HasStashData())
+ {
+ ClearCLRData();
+ ClearStash();
+ }
+ return;
+ }
+
var runtimeStorage = new PythonNetState
{
Metatype = MetaType.SaveRuntimeData(),
@@ -93,7 +106,6 @@ internal static void Stash()
SharedObjects = SaveRuntimeDataObjects(),
};
- IFormatter formatter = CreateFormatter();
var ms = new MemoryStream();
formatter.Serialize(ms, runtimeStorage);