A game framework written with osu! in mind.
at master 11 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. 2// See the LICENCE file in the repository root for full licence text. 3 4using System; 5using System.IO; 6using System.Linq; 7using System.Reflection; 8using System.Threading; 9using osu.Framework.Logging; 10using System.Collections.Generic; 11using System.Diagnostics; 12using System.Threading.Tasks; 13using Microsoft.CodeAnalysis; 14using Microsoft.CodeAnalysis.CSharp; 15using System.Text; 16 17namespace osu.Framework.Testing 18{ 19 internal class DynamicClassCompiler<T> : IDisposable 20 where T : IDynamicallyCompile 21 { 22 public event Action CompilationStarted; 23 24 public event Action<Type> CompilationFinished; 25 26 public event Action<Exception> CompilationFailed; 27 28 private readonly List<FileSystemWatcher> watchers = new List<FileSystemWatcher>(); 29 private readonly HashSet<string> requiredFiles = new HashSet<string>(); 30 31 private T target; 32 33 public void SetRecompilationTarget(T target) 34 { 35 if (this.target?.GetType().Name != target?.GetType().Name) 36 { 37 requiredFiles.Clear(); 38 referenceBuilder.Reset(); 39 } 40 41 this.target = target; 42 } 43 44 private ITypeReferenceBuilder referenceBuilder; 45 46 public void Start() 47 { 48 if (Debugger.IsAttached) 49 { 50 referenceBuilder = new EmptyTypeReferenceBuilder(); 51 52 Logger.Log("Dynamic compilation disabled (debugger attached)."); 53 return; 54 } 55 56 var di = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); 57 var basePath = getSolutionPath(di); 58 59 if (!Directory.Exists(basePath)) 60 { 61 referenceBuilder = new EmptyTypeReferenceBuilder(); 62 63 Logger.Log("Dynamic compilation disabled (no solution file found)."); 64 return; 65 } 66 67#if NET5_0 68 referenceBuilder = new RoslynTypeReferenceBuilder(); 69#else 70 referenceBuilder = new EmptyTypeReferenceBuilder(); 71#endif 72 73 Task.Run(async () => 74 { 75 Logger.Log("Initialising dynamic compilation..."); 76 77 await referenceBuilder.Initialise(Directory.GetFiles(basePath, "*.sln").First()).ConfigureAwait(false); 78 79 foreach (var dir in Directory.GetDirectories(basePath)) 80 { 81 // only watch directories which house a csproj. this avoids submodules and directories like .git which can contain many files. 82 if (!Directory.GetFiles(dir, "*.csproj").Any()) 83 continue; 84 85 var fsw = new FileSystemWatcher(dir, @"*.cs") 86 { 87 EnableRaisingEvents = true, 88 IncludeSubdirectories = true, 89 NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.FileName, 90 }; 91 92 fsw.Renamed += onChange; 93 fsw.Changed += onChange; 94 fsw.Created += onChange; 95 96 watchers.Add(fsw); 97 } 98 99 Logger.Log("Dynamic compilation is now available."); 100 }); 101 } 102 103 private static string getSolutionPath(DirectoryInfo d) 104 { 105 if (d == null) 106 return null; 107 108 return d.GetFiles().Any(f => f.Extension == ".sln") ? d.FullName : getSolutionPath(d.Parent); 109 } 110 111 private void onChange(object sender, FileSystemEventArgs args) => Task.Run(async () => await recompileAsync(target?.GetType(), args.FullPath).ConfigureAwait(false)); 112 113 private int currentVersion; 114 private bool isCompiling; 115 116 private async Task recompileAsync(Type targetType, string changedFile) 117 { 118 if (targetType == null || isCompiling || referenceBuilder is EmptyTypeReferenceBuilder) 119 return; 120 121 isCompiling = true; 122 123 try 124 { 125 while (!checkFileReady(changedFile)) 126 Thread.Sleep(10); 127 128 Logger.Log($@"Recompiling {Path.GetFileName(targetType.Name)}...", LoggingTarget.Runtime, LogLevel.Important); 129 130 CompilationStarted?.Invoke(); 131 132 foreach (var f in await referenceBuilder.GetReferencedFiles(targetType, changedFile).ConfigureAwait(false)) 133 requiredFiles.Add(f); 134 135 var assemblies = await referenceBuilder.GetReferencedAssemblies(targetType, changedFile).ConfigureAwait(false); 136 137 using (var pdbStream = new MemoryStream()) 138 using (var peStream = new MemoryStream()) 139 { 140 var compilationResult = createCompilation(targetType, requiredFiles, assemblies).Emit(peStream, pdbStream); 141 142 if (compilationResult.Success) 143 { 144 peStream.Seek(0, SeekOrigin.Begin); 145 pdbStream.Seek(0, SeekOrigin.Begin); 146 147 CompilationFinished?.Invoke( 148 Assembly.Load(peStream.ToArray(), pdbStream.ToArray()).GetModules()[0].GetTypes().LastOrDefault(t => t.FullName == targetType.FullName) 149 ); 150 } 151 else 152 { 153 var exceptions = new List<Exception>(); 154 155 foreach (var diagnostic in compilationResult.Diagnostics) 156 { 157 if (diagnostic.Severity < DiagnosticSeverity.Error) 158 continue; 159 160 exceptions.Add(new InvalidOperationException(diagnostic.ToString())); 161 } 162 163 throw new AggregateException(exceptions.ToArray()); 164 } 165 } 166 } 167 catch (Exception ex) 168 { 169 CompilationFailed?.Invoke(ex); 170 } 171 finally 172 { 173 isCompiling = false; 174 } 175 } 176 177 private CSharpCompilationOptions createCompilationOptions() 178 { 179 var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) 180 .WithMetadataImportOptions(MetadataImportOptions.Internal); 181 182 // This is an internal property which allows the compiler to ignore accessibility checks. 183 // https://www.strathweb.com/2018/10/no-internalvisibleto-no-problem-bypassing-c-visibility-rules-with-roslyn/ 184 var topLevelBinderFlagsProperty = typeof(CSharpCompilationOptions).GetProperty("TopLevelBinderFlags", BindingFlags.Instance | BindingFlags.NonPublic); 185 Debug.Assert(topLevelBinderFlagsProperty != null); 186 topLevelBinderFlagsProperty.SetValue(options, (uint)1 << 22); 187 188 return options; 189 } 190 191 private CSharpCompilation createCompilation(Type targetType, IEnumerable<string> files, IEnumerable<AssemblyReference> assemblies) 192 { 193 // ReSharper disable once RedundantExplicitArrayCreation this doesn't compile when the array is empty 194 var parseOptions = new CSharpParseOptions(preprocessorSymbols: new string[] 195 { 196#if DEBUG 197 "DEBUG", 198#endif 199#if TRACE 200 "TRACE", 201#endif 202#if RELEASE 203 "RELEASE", 204#endif 205 }, languageVersion: LanguageVersion.Latest); 206 207 // Add the syntax trees for all referenced files. 208 var syntaxTrees = new List<SyntaxTree>(); 209 foreach (var f in files) 210 syntaxTrees.Add(CSharpSyntaxTree.ParseText(File.ReadAllText(f, Encoding.UTF8), parseOptions, f, encoding: Encoding.UTF8)); 211 212 // Add the new assembly version, such that it replaces any existing dynamic assembly. 213 string assemblyVersion = $"{++currentVersion}.0.*"; 214 syntaxTrees.Add(CSharpSyntaxTree.ParseText($"using System.Reflection; [assembly: AssemblyVersion(\"{assemblyVersion}\")]", parseOptions)); 215 216 // Add a custom compiler attribute to allow ignoring access checks. 217 syntaxTrees.Add(CSharpSyntaxTree.ParseText(ignores_access_checks_to_attribute_syntax, parseOptions)); 218 219 // Ignore access checks for assemblies that have had their internal types referenced. 220 var ignoreAccessChecksText = new StringBuilder(); 221 ignoreAccessChecksText.AppendLine("using System.Runtime.CompilerServices;"); 222 foreach (var asm in assemblies.Where(asm => asm.IgnoreAccessChecks)) 223 ignoreAccessChecksText.AppendLine($"[assembly: IgnoresAccessChecksTo(\"{asm.Assembly.GetName().Name}\")]"); 224 syntaxTrees.Add(CSharpSyntaxTree.ParseText(ignoreAccessChecksText.ToString(), parseOptions)); 225 226 // Determine the new assembly name, ensuring that the dynamic suffix is not duplicated. 227 string assemblyNamespace = targetType.Assembly.GetName().Name?.Replace(".Dynamic", ""); 228 string dynamicNamespace = $"{assemblyNamespace}.Dynamic"; 229 230 return CSharpCompilation.Create( 231 dynamicNamespace, 232 syntaxTrees, 233 assemblies.Select(asm => asm.GetReference()), 234 createCompilationOptions() 235 ); 236 } 237 238 /// <summary> 239 /// Check whether a file has finished being written to. 240 /// </summary> 241 private static bool checkFileReady(string filename) 242 { 243 try 244 { 245 using (FileStream inputStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.None)) 246 return inputStream.Length > 0; 247 } 248 catch (Exception) 249 { 250 return false; 251 } 252 } 253 254 #region IDisposable Support 255 256 private bool isDisposed; 257 258 protected virtual void Dispose(bool disposing) 259 { 260 if (!isDisposed) 261 { 262 isDisposed = true; 263 watchers.ForEach(w => w.Dispose()); 264 } 265 } 266 267 public void Dispose() 268 { 269 Dispose(true); 270 GC.SuppressFinalize(this); 271 } 272 273 #endregion 274 275 private const string ignores_access_checks_to_attribute_syntax = 276 @"namespace System.Runtime.CompilerServices 277 { 278 [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] 279 public class IgnoresAccessChecksToAttribute : Attribute 280 { 281 public IgnoresAccessChecksToAttribute(string assemblyName) 282 { 283 AssemblyName = assemblyName; 284 } 285 public string AssemblyName { get; } 286 } 287 }"; 288 } 289}