Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 784190a

Browse files
Benoit Hudsonfilmor
authored andcommitted
Enable pythonnet to survive in the Unity3d editor environment.
The Unity editor unloads the C# domain and creates a new domain every time it recompiles C# sources. This caused two crashes. 1. After a domain unload, python is now pointing to a bunch of objects whose C# side is now garbage. Solution: Shutdown() the engine on domain reload, which calls Py_Finalize. 2. After a domain unload, Py_Finalize, and a new Py_Intialize, python still keeps pointers in gc for any python objects that were leaked. And pythonnet leaks. This means that python's gc will be calling tp_traverse, tp_clear, and tp_is_gc on types that are implemented in C#. This crashes because the implementation was freed. Solution: implement those calls in code that is *not* released on domain unload. Side effect: we leak a page on every domain unload. I suspect but didn't test that python gc performance will be slightly faster. Changes required to implement and test: 3. Use python's platform package to determine what we're running on, so we can use the right mmap/mprotect or VirtualAlloc/VirtualProtect routines, the right flags for mmap, and (theoretically) the right native code. Side effect: to port to a new platform requires updating some additional code now. 4. New unit tests. (a) A new test for domain reload. It doesn't run under .NET Core because you can't create and unload a domain in .NET Core. (b) New unit tests to help us find where to add support for new platforms.
1 parent 05a1451 commit 784190a

13 files changed

+725
-53
lines changed

AUTHORS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
- Alexandre Catarino([@AlexCatarino](https://github.com/AlexCatarino))
1616
- Arvid JB ([@ArvidJB](https://github.com/ArvidJB))
17+
- Benoît Hudson ([@benoithudson](https://github.com/benoithudson))
1718
- Bradley Friedman ([@leith-bartrich](https://github.com/leith-bartrich))
1819
- Callum Noble ([@callumnoble](https://github.com/callumnoble))
1920
- Christian Heimes ([@tiran](https://github.com/tiran))
@@ -22,6 +23,7 @@
2223
- Daniel Fernandez ([@fdanny](https://github.com/fdanny))
2324
- Daniel Santana ([@dgsantana](https://github.com/dgsantana))
2425
- Dave Hirschfeld ([@dhirschfeld](https://github.com/dhirschfeld))
26+
- David Lassonde ([@lassond](https://github.com/lassond))
2527
- David Lechner ([@dlech](https://github.com/dlech))
2628
- Dmitriy Se ([@dmitriyse](https://github.com/dmitriyse))
2729
- He-chien Tsai ([@t3476](https://github.com/t3476))
@@ -40,6 +42,7 @@
4042
- Sam Winstanley ([@swinstanley](https://github.com/swinstanley))
4143
- Sean Freitag ([@cowboygneox](https://github.com/cowboygneox))
4244
- Serge Weinstock ([@sweinst](https://github.com/sweinst))
45+
- Viktoria Kovescses ([@vkovec](https://github.com/vkovec))
4346
- Ville M. Vainio ([@vivainio](https://github.com/vivainio))
4447
- Virgil Dupras ([@hsoft](https://github.com/hsoft))
4548
- Wenguang Yang ([@yagweb](https://github.com/yagweb))

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ This document follows the conventions laid out in [Keep a CHANGELOG][].
2626
### Fixed
2727

2828
- Fixed Visual Studio 2017 compat ([#434][i434]) for setup.py
29+
- Fixed crashes when integrating pythonnet in Unity3d ([#714][i714]),
30+
related to unloading the Application Domain
2931
- Fixed crash on exit of the Python interpreter if a python class
3032
derived from a .NET class has a `__namespace__` or `__assembly__`
3133
attribute ([#481][i481])

src/embed_tests/Python.EmbeddingTest.15.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
<BaseDefineConstants>XPLAT</BaseDefineConstants>
3030
<DefineConstants>$(DefineConstants);$(CustomDefineConstants);$(BaseDefineConstants);</DefineConstants>
3131
<DefineConstants Condition="'$(TargetFramework)'=='netcoreapp2.0'">$(DefineConstants);NETCOREAPP</DefineConstants>
32+
<DefineConstants Condition="'$(TargetFramework)'=='netstandard2.0'">$(DefineConstants);NETSTANDARD</DefineConstants>
3233
<DefineConstants Condition="'$(BuildingInsideVisualStudio)' == 'true' AND '$(CustomDefineConstants)' != '' AND $(Configuration.Contains('Debug'))">$(DefineConstants);TRACE;DEBUG</DefineConstants>
3334
<FrameworkPathOverride Condition="'$(TargetFramework)'=='net40' AND $(Configuration.Contains('Mono'))">$(NuGetPackageRoot)\microsoft.targetingpack.netframework.v4.5\1.0.1\lib\net45\</FrameworkPathOverride>
3435
</PropertyGroup>

src/embed_tests/Python.EmbeddingTest.csproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
<Compile Include="pyrunstring.cs" />
8787
<Compile Include="TestConverter.cs" />
8888
<Compile Include="TestCustomMarshal.cs" />
89+
<Compile Include="TestDomainReload.cs" />
8990
<Compile Include="TestExample.cs" />
9091
<Compile Include="TestPyAnsiString.cs" />
9192
<Compile Include="TestPyFloat.cs" />
@@ -103,6 +104,7 @@
103104
<Compile Include="TestPyWith.cs" />
104105
<Compile Include="TestRuntime.cs" />
105106
<Compile Include="TestPyScope.cs" />
107+
<Compile Include="TestTypeManager.cs" />
106108
</ItemGroup>
107109
<ItemGroup>
108110
<ProjectReference Include="..\runtime\Python.Runtime.csproj">
@@ -122,4 +124,4 @@
122124
<Copy SourceFiles="$(TargetAssembly)" DestinationFolder="$(PythonBuildDir)" />
123125
<!--Copy SourceFiles="$(TargetAssemblyPdb)" Condition="Exists('$(TargetAssemblyPdb)')" DestinationFolder="$(PythonBuildDir)" /-->
124126
</Target>
125-
</Project>
127+
</Project>

src/embed_tests/TestDomainReload.cs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
using System;
2+
using System.CodeDom.Compiler;
3+
using System.Reflection;
4+
using NUnit.Framework;
5+
using Python.Runtime;
6+
7+
//
8+
// This test case is disabled on .NET Standard because it doesn't have all the
9+
// APIs we use. We could work around that, but .NET Core doesn't implement
10+
// domain creation, so it's not worth it.
11+
//
12+
// Unfortunately this means no continuous integration testing for this case.
13+
//
14+
#if !NETSTANDARD && !NETCOREAPP
15+
namespace Python.EmbeddingTest
16+
{
17+
class TestDomainReload
18+
{
19+
/// <summary>
20+
/// Test that the python runtime can survive a C# domain reload without crashing.
21+
///
22+
/// At the time this test was written, there was a very annoying
23+
/// seemingly random crash bug when integrating pythonnet into Unity.
24+
///
25+
/// The repro steps that David Lassonde, Viktoria Kovecses and
26+
/// Benoit Hudson eventually worked out:
27+
/// 1. Write a HelloWorld.cs script that uses Python.Runtime to access
28+
/// some C# data from python: C# calls python, which calls C#.
29+
/// 2. Execute the script (e.g. make it a MenuItem and click it).
30+
/// 3. Touch HelloWorld.cs on disk, forcing Unity to recompile scripts.
31+
/// 4. Wait several seconds for Unity to be done recompiling and
32+
/// reloading the C# domain.
33+
/// 5. Make python run the gc (e.g. by calling gc.collect()).
34+
///
35+
/// The reason:
36+
/// A. In step 2, Python.Runtime registers a bunch of new types with
37+
/// their tp_traverse slot pointing to managed code, and allocates
38+
/// some objects of those types.
39+
/// B. In step 4, Unity unloads the C# domain. That frees the managed
40+
/// code. But at the time of the crash investigation, pythonnet
41+
/// leaked the python side of the objects allocated in step 1.
42+
/// C. In step 5, python sees some pythonnet objects in its gc list of
43+
/// potentially-leaked objects. It calls tp_traverse on those objects.
44+
/// But tp_traverse was freed in step 3 => CRASH.
45+
///
46+
/// This test distills what's going on without needing Unity around (we'd see
47+
/// similar behaviour if we were using pythonnet on a .NET web server that did
48+
/// a hot reload).
49+
/// </summary>
50+
[Test]
51+
public static void DomainReloadAndGC()
52+
{
53+
// We're set up to run in the directory that includes the bin directory.
54+
System.IO.Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
55+
56+
Assembly pythonRunner1 = BuildAssembly("test1");
57+
RunAssemblyAndUnload(pythonRunner1, "test1");
58+
59+
// Verify that python is not initialized even though we ran it.
60+
Assert.That(Runtime.Runtime.Py_IsInitialized(), Is.Zero);
61+
62+
// This caused a crash because objects allocated in pythonRunner1
63+
// still existed in memory, but the code to do python GC on those
64+
// objects is gone.
65+
Assembly pythonRunner2 = BuildAssembly("test2");
66+
RunAssemblyAndUnload(pythonRunner2, "test2");
67+
}
68+
69+
//
70+
// The code we'll test. All that really matters is
71+
// using GIL { Python.Exec(pyScript); }
72+
// but the rest is useful for debugging.
73+
//
74+
// What matters in the python code is gc.collect and clr.AddReference.
75+
//
76+
// Note that the language version is 2.0, so no $"foo{bar}" syntax.
77+
//
78+
const string TestCode = @"
79+
using Python.Runtime;
80+
using System;
81+
class PythonRunner {
82+
public static void RunPython() {
83+
AppDomain.CurrentDomain.DomainUnload += OnDomainUnload;
84+
string name = AppDomain.CurrentDomain.FriendlyName;
85+
Console.WriteLine(string.Format(""[{0} in .NET] In PythonRunner.RunPython"", name));
86+
using (Py.GIL()) {
87+
try {
88+
var pyScript = string.Format(""import clr\n""
89+
+ ""print('[{0} in python] imported clr')\n""
90+
+ ""clr.AddReference('System')\n""
91+
+ ""print('[{0} in python] allocated a clr object')\n""
92+
+ ""import gc\n""
93+
+ ""gc.collect()\n""
94+
+ ""print('[{0} in python] collected garbage')\n"",
95+
name);
96+
PythonEngine.Exec(pyScript);
97+
} catch(Exception e) {
98+
Console.WriteLine(string.Format(""[{0} in .NET] Caught exception: {1}"", name, e));
99+
}
100+
}
101+
}
102+
static void OnDomainUnload(object sender, EventArgs e) {
103+
System.Console.WriteLine(string.Format(""[{0} in .NET] unloading"", AppDomain.CurrentDomain.FriendlyName));
104+
}
105+
}";
106+
107+
108+
/// <summary>
109+
/// Build an assembly out of the source code above.
110+
///
111+
/// This creates a file <paramref name="assemblyName"/>.dll in order
112+
/// to support the statement "proxy.theAssembly = assembly" below.
113+
/// That statement needs a file, can't run via memory.
114+
/// </summary>
115+
static Assembly BuildAssembly(string assemblyName)
116+
{
117+
var provider = CodeDomProvider.CreateProvider("CSharp");
118+
119+
var compilerparams = new CompilerParameters();
120+
compilerparams.ReferencedAssemblies.Add("Python.Runtime.dll");
121+
compilerparams.GenerateExecutable = false;
122+
compilerparams.GenerateInMemory = false;
123+
compilerparams.IncludeDebugInformation = false;
124+
compilerparams.OutputAssembly = assemblyName;
125+
126+
var results = provider.CompileAssemblyFromSource(compilerparams, TestCode);
127+
if (results.Errors.HasErrors)
128+
{
129+
var errors = new System.Text.StringBuilder("Compiler Errors:\n");
130+
foreach (CompilerError error in results.Errors)
131+
{
132+
errors.AppendFormat("Line {0},{1}\t: {2}\n",
133+
error.Line, error.Column, error.ErrorText);
134+
}
135+
throw new Exception(errors.ToString());
136+
}
137+
else
138+
{
139+
return results.CompiledAssembly;
140+
}
141+
}
142+
143+
/// <summary>
144+
/// This is a magic incantation required to run code in an application
145+
/// domain other than the current one.
146+
/// </summary>
147+
class Proxy : MarshalByRefObject
148+
{
149+
Assembly theAssembly = null;
150+
151+
public void InitAssembly(string assemblyPath)
152+
{
153+
theAssembly = Assembly.LoadFile(System.IO.Path.GetFullPath(assemblyPath));
154+
}
155+
156+
public void RunPython()
157+
{
158+
Console.WriteLine("[Proxy] Entering RunPython");
159+
160+
// Call into the new assembly. Will execute Python code
161+
var pythonrunner = theAssembly.GetType("PythonRunner");
162+
var runPythonMethod = pythonrunner.GetMethod("RunPython");
163+
runPythonMethod.Invoke(null, new object[] { });
164+
165+
Console.WriteLine("[Proxy] Leaving RunPython");
166+
}
167+
}
168+
169+
/// <summary>
170+
/// Create a domain, run the assembly in it (the RunPython function),
171+
/// and unload the domain.
172+
/// </summary>
173+
static void RunAssemblyAndUnload(Assembly assembly, string assemblyName)
174+
{
175+
Console.WriteLine($"[Program.Main] === creating domain for assembly {assembly.FullName}");
176+
177+
// Create the domain. Make sure to set PrivateBinPath to a relative
178+
// path from the CWD (namely, 'bin').
179+
// See https://stackoverflow.com/questions/24760543/createinstanceandunwrap-in-another-domain
180+
var currentDomain = AppDomain.CurrentDomain;
181+
var domainsetup = new AppDomainSetup()
182+
{
183+
ApplicationBase = currentDomain.SetupInformation.ApplicationBase,
184+
ConfigurationFile = currentDomain.SetupInformation.ConfigurationFile,
185+
LoaderOptimization = LoaderOptimization.SingleDomain,
186+
PrivateBinPath = "."
187+
};
188+
var domain = AppDomain.CreateDomain(
189+
$"My Domain {assemblyName}",
190+
currentDomain.Evidence,
191+
domainsetup);
192+
193+
// Create a Proxy object in the new domain, where we want the
194+
// assembly (and Python .NET) to reside
195+
Type type = typeof(Proxy);
196+
System.IO.Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);
197+
var theProxy = (Proxy)domain.CreateInstanceAndUnwrap(
198+
type.Assembly.FullName,
199+
type.FullName);
200+
201+
// From now on use the Proxy to call into the new assembly
202+
theProxy.InitAssembly(assemblyName);
203+
theProxy.RunPython();
204+
205+
Console.WriteLine($"[Program.Main] Before Domain Unload on {assembly.FullName}");
206+
AppDomain.Unload(domain);
207+
Console.WriteLine($"[Program.Main] After Domain Unload on {assembly.FullName}");
208+
209+
// Validate that the assembly does not exist anymore
210+
try
211+
{
212+
Console.WriteLine($"[Program.Main] The Proxy object is valid ({theProxy}). Unexpected domain unload behavior");
213+
}
214+
catch (Exception)
215+
{
216+
Console.WriteLine("[Program.Main] The Proxy object is not valid anymore, domain unload complete.");
217+
}
218+
}
219+
220+
/// <summary>
221+
/// Resolves the assembly. Why doesn't this just work normally?
222+
/// </summary>
223+
static Assembly ResolveAssembly(object sender, ResolveEventArgs args)
224+
{
225+
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
226+
227+
foreach (var assembly in loadedAssemblies)
228+
{
229+
if (assembly.FullName == args.Name)
230+
{
231+
return assembly;
232+
}
233+
}
234+
235+
return null;
236+
}
237+
}
238+
}
239+
#endif

src/embed_tests/TestRuntime.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ namespace Python.EmbeddingTest
66
{
77
public class TestRuntime
88
{
9+
/// <summary>
10+
/// Test the cache of the information from the platform module.
11+
///
12+
/// Test fails on platforms we haven't implemented yet.
13+
/// </summary>
14+
[Test]
15+
public static void PlatformCache()
16+
{
17+
Runtime.Runtime.Initialize();
18+
19+
Assert.That(Runtime.Runtime.Machine, Is.Not.EqualTo(Runtime.Runtime.MachineType.Other));
20+
Assert.That(!string.IsNullOrEmpty(Runtime.Runtime.MachineName));
21+
22+
Assert.That(Runtime.Runtime.OperatingSystem, Is.Not.EqualTo(Runtime.Runtime.OperatingSystemType.Other));
23+
Assert.That(!string.IsNullOrEmpty(Runtime.Runtime.OperatingSystemName));
24+
25+
// Don't shut down the runtime: if the python engine was initialized
26+
// but not shut down by another test, we'd end up in a bad state.
27+
}
28+
929
[Test]
1030
public static void Py_IsInitializedValue()
1131
{

src/embed_tests/TestTypeManager.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using NUnit.Framework;
2+
using Python.Runtime;
3+
using System.Runtime.InteropServices;
4+
5+
namespace Python.EmbeddingTest
6+
{
7+
class TestTypeManager
8+
{
9+
[SetUp]
10+
public static void Init()
11+
{
12+
Runtime.Runtime.Initialize();
13+
}
14+
15+
[TearDown]
16+
public static void Fini()
17+
{
18+
// Don't shut down the runtime: if the python engine was initialized
19+
// but not shut down by another test, we'd end up in a bad state.
20+
}
21+
22+
[Test]
23+
public static void TestNativeCode()
24+
{
25+
Assert.That(() => { var _ = TypeManager.NativeCode.Active; }, Throws.Nothing);
26+
Assert.That(TypeManager.NativeCode.Active.Code.Length, Is.GreaterThan(0));
27+
}
28+
29+
[Test]
30+
public static void TestMemoryMapping()
31+
{
32+
Assert.That(() => { var _ = TypeManager.CreateMemoryMapper(); }, Throws.Nothing);
33+
var mapper = TypeManager.CreateMemoryMapper();
34+
35+
// Allocate a read-write page.
36+
int len = 12;
37+
var page = mapper.MapWriteable(len);
38+
Assert.That(() => { Marshal.WriteInt64(page, 17); }, Throws.Nothing);
39+
Assert.That(Marshal.ReadInt64(page), Is.EqualTo(17));
40+
41+
// Mark it read-execute. We can still read, haven't changed any values.
42+
mapper.SetReadExec(page, len);
43+
Assert.That(Marshal.ReadInt64(page), Is.EqualTo(17));
44+
45+
// Test that we can't write to the protected page.
46+
//
47+
// We can't actually test access protection under Microsoft
48+
// versions of .NET, because AccessViolationException is assumed to
49+
// mean we're in a corrupted state:
50+
// https://stackoverflow.com/questions/3469368/how-to-handle-accessviolationexception
51+
//
52+
// We can test under Mono but it throws NRE instead of AccessViolationException.
53+
//
54+
// We can't use compiler flags because we compile with MONO_LINUX
55+
// while running on the Microsoft .NET Core during continuous
56+
// integration tests.
57+
if (System.Type.GetType ("Mono.Runtime") != null)
58+
{
59+
// Mono throws NRE instead of AccessViolationException for some reason.
60+
Assert.That(() => { Marshal.WriteInt64(page, 73); }, Throws.TypeOf<System.NullReferenceException>());
61+
Assert.That(Marshal.ReadInt64(page), Is.EqualTo(17));
62+
}
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)