using System.Collections.Immutable; using System.Diagnostics; using System.Reflection; using System.Runtime.Loader; using System.Text.Json; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; namespace w4c_workflows.Services.Execution; /// /// Executes multi-file C# with the Roslyn compiler: every .cs file under /// the task's working directory is compiled together into an in-memory assembly /// (one syntax tree per file, so per-file using directives and file-scoped /// namespaces work), then the entry method is invoked reflectively. /// /// Each execution uses a so the /// loaded assembly and all its types can be garbage-collected after the call /// returns. The previous implementation loaded into the default (non-collectible) /// ALC, causing unbounded memory growth in long-running workers. /// /// Entry contract (v1): a public static method named after /// entry.function (default Main) that takes a single /// string (the JSON input) or no arguments, returning a value serialized /// to JSON as the task output (sync or Task/Task<T>). /// public class CSharpScriptExecutor : IScriptExecutor { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); // Cached once per process lifetime — the set of runtime assemblies doesn't // change after startup. This avoids re-scanning AppDomain on every compile. private static ImmutableArray? _cachedReferences; private static readonly object _referenceLock = new(); public string Language => "csharp"; /// Roslyn ships with the service image — no external runtime to probe. public bool IsAvailable() => true; public Task ExecuteAsync(TaskInvocation invocation, CancellationToken ct) { var sw = Stopwatch.StartNew(); try { var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation); var sources = EnumerateSources(workingDir); if (sources.Count == 0) return Task.FromResult(Fail("no .cs source files found under the task directory", sw.Elapsed)); // Compilation is CPU-bound and synchronous; keep it off the request thread. var (assemblyLoadContext, assembly, diagnostics) = Compile(sources); if (assembly == null) { assemblyLoadContext?.Unload(); var errors = diagnostics .Where(d => d.Severity == DiagnosticSeverity.Error) .Select(d => d.ToString()); return Task.FromResult(Fail("C# compilation failed:\n" + string.Join('\n', errors), sw.Elapsed)); } string? output; try { output = InvokeEntry(assembly, invocation); } finally { // Unload the collectible ALC so the assembly and all its types // become eligible for GC. The next GC.Collect will reclaim them. assemblyLoadContext.Unload(); } sw.Stop(); return Task.FromResult(new ExecutionResult(true, 0, string.Empty, string.Empty, output, null, sw.Elapsed)); } catch (Exception ex) { sw.Stop(); return Task.FromResult(new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed)); } } private static ExecutionResult Fail(string message, TimeSpan duration) => new(false, -1, string.Empty, string.Empty, null, message, duration); private static IReadOnlyList EnumerateSources(string workingDir) { // One syntax tree per file; compilation is order-independent, so a // deterministic sort is all that matters for reproducibility. return Directory.EnumerateFiles(workingDir, "*.cs", SearchOption.AllDirectories) .Select(full => new SourceFile( Path.GetRelativePath(workingDir, full), File.ReadAllText(full))) .OrderBy(s => s.Path, StringComparer.Ordinal) .ToList(); } private static (AssemblyLoadContext, Assembly?, ImmutableArray) Compile(IReadOnlyList sources) { var syntaxTrees = sources .Select(s => CSharpSyntaxTree.ParseText(s.Source, path: s.Path)) .ToArray(); var compilation = CSharpCompilation.Create( $"wf-cs-{Guid.NewGuid():N}", syntaxTrees, GetCachedReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); using var ms = new MemoryStream(); var emit = compilation.Emit(ms); if (!emit.Success) { var emptyAlc = new AssemblyLoadContext(name: null, isCollectible: true); return (emptyAlc, null, emit.Diagnostics); } ms.Position = 0; // Each execution gets its own collectible ALC. After Unload() + GC, // the assembly and all its types are reclaimed — no unbounded growth. var alc = new AssemblyLoadContext(name: null, isCollectible: true); var assembly = alc.LoadFromStream(ms); return (alc, assembly, emit.Diagnostics); } /// /// Returns the cached set of metadata references for the current runtime. /// The first call scans AppDomain; subsequent calls return the cached /// immutable array. This eliminates the per-compile AppDomain scan that /// compounded the ALC leak. /// private static ImmutableArray GetCachedReferences() { if (_cachedReferences is { } cached) return cached; lock (_referenceLock) { if (_cachedReferences is { } cached2) return cached2; var references = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { if (asm.IsDynamic) continue; var location = asm.Location; if (string.IsNullOrEmpty(location) || !File.Exists(location) || !seen.Add(location)) continue; try { references.Add(MetadataReference.CreateFromFile(location)); } catch { // Some assemblies (e.g. reflection-only) cannot be referenced — skip. } } _cachedReferences = ImmutableArray.CreateRange(references); return _cachedReferences.Value; } } private static string? InvokeEntry(Assembly assembly, TaskInvocation invocation) { var function = string.IsNullOrWhiteSpace(invocation.EntryFunction) ? "Main" : invocation.EntryFunction; MethodInfo? method = null; foreach (var type in assembly.GetTypes()) { foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) { if (!candidate.IsStatic || !string.Equals(candidate.Name, function, StringComparison.Ordinal)) continue; var parameters = candidate.GetParameters(); if (parameters.Length == 1 && parameters[0].ParameterType == typeof(string)) { method = candidate; // best match — one string parameter break; } method ??= parameters.Length == 0 ? candidate : null; } if (method != null && method.GetParameters().Length == 1) break; } if (method == null) throw new InvalidOperationException($"no static entry method '{function}' found in the compiled C# program"); var arguments = method.GetParameters().Length == 1 ? new object?[] { invocation.Input ?? "{}" } : null; var result = method.Invoke(null, arguments); // Await Task / Task returns so async entries produce a concrete value. if (result is Task task) { task.GetAwaiter().GetResult(); result = task.GetType().IsGenericType ? task.GetType().GetProperty("Result")!.GetValue(task) : null; } return result == null ? null : JsonSerializer.Serialize(result, Json); } private sealed record SourceFile(string Path, string Source); }