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