A game framework written with osu! in mind.
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}