8000 Enable pythonnet to survive in the Unity3d editor environment. · pythonnet/pythonnet@784190a · GitHub
[go: up one dir, main page]

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+
F438 + ""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)
0