using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.Loader;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace w4c_workflows.Services.Execution;
///
/// Executes single-file C# with the Roslyn compiler: the entry .cs file
/// specified by the task's entry.file is compiled into an in-memory
/// assembly, then the entry method is invoked reflectively.
///
/// Only the entry file is compiled — not every .cs file under the
/// working directory. Compiling all files would merge unrelated task sources
/// (e.g. Main.cs + tasks/hello-world.cs) into one assembly,
/// causing duplicate-type errors when both define a Program class.
///
/// Each execution uses a so the loaded assembly
/// and all its types can be garbage-collected after the call returns. The
/// compiled image bytes are cached by source hash, so a repeated task
/// skips Roslyn and only re-loads the image into a fresh collectible ALC.
///
/// The compile + invoke runs off the caller thread under
/// Workflows:CSharpTimeoutSeconds (default TaskTimeoutSeconds) and
/// observes ct: an async entry is awaited with cancellation, and the
/// caller always returns by the deadline. A synchronous entry that ignores the
/// token cannot be force-killed in-process; the caller stops waiting at the
/// deadline and the abandoned thread is left to finish (documented limitation).
///
/// Entry contract (v1): a public static method named after
/// entry.function (default Main) that takes a single
/// string or object/dynamic argument (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);
/// Compiled PE images keyed by source hash, so a hot task never re-runs Roslyn.
private static readonly ConcurrentDictionary AssemblyCache = new(StringComparer.Ordinal);
private static readonly ConcurrentQueue CacheOrder = new();
private const int MaxCachedAssemblies = 64;
// 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();
private readonly TimeSpan _timeout;
public CSharpScriptExecutor(IConfiguration? config = null)
{
var seconds = ParseInt(config?["Workflows:CSharpTimeoutSeconds"], 0);
if (seconds <= 0)
seconds = ParseInt(config?["Workflows:TaskTimeoutSeconds"], 60);
_timeout = TimeSpan.FromSeconds(Math.Max(1, seconds));
}
public string Language => "csharp";
/// Roslyn ships with the service image — no external runtime to probe.
public bool IsAvailable() => true;
public async Task ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
if (!ExecutionHelpers.TryResolveEntryPath(invocation, out var entryPath, out var pathError))
return Fail(pathError!, sw.Elapsed);
if (!File.Exists(entryPath))
return Fail($"entry file not found: {invocation.EntryFile}", sw.Elapsed);
string source;
try
{
source = File.ReadAllText(entryPath);
}
catch (Exception ex)
{
return Fail($"cannot read entry file: {ex.Message}", sw.Elapsed);
}
// Linked token: fires on caller cancellation OR the hard deadline. Used
// inside the work for async entries; WaitAsync below guarantees the caller
// returns at the deadline even if a sync entry ignores the token.
var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_timeout);
var token = linked.Token;
try
{
var work = Task.Run(() => CompileAndInvokeAsync(source, entryPath, invocation, token), token);
var output = await work.WaitAsync(_timeout, ct);
sw.Stop();
return new ExecutionResult(true, 0, string.Empty, string.Empty, output, null, sw.Elapsed);
}
catch (TimeoutException)
{
return Fail($"C# script timed out after {_timeout.TotalSeconds:0}s", sw.Elapsed);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// P1-11: cancellation is not a normal script failure.
throw;
}
catch (OperationCanceledException)
{
return Fail($"C# script timed out after {_timeout.TotalSeconds:0}s", sw.Elapsed);
}
catch (Exception ex)
{
return Fail(ex.Message, sw.Elapsed);
}
}
private static ExecutionResult Fail(string message, TimeSpan duration)
=> new(false, -1, string.Empty, string.Empty, null, message, duration);
private static async Task CompileAndInvokeAsync(
string source, string entryPath, TaskInvocation invocation, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var image = GetOrCompile(source, entryPath, ct);
var alc = new AssemblyLoadContext(name: null, isCollectible: true);
try
{
using var ms = new MemoryStream(image);
var assembly = alc.LoadFromStream(ms);
return await InvokeEntryAsync(assembly, invocation, ct);
}
finally
{
// Unload the collectible ALC so the assembly and all its types become
// eligible for GC. The next GC.Collect will reclaim them.
alc.Unload();
}
}
///
/// Returns the compiled PE image for , compiling on a
/// cache miss. Throws with the Roslyn
/// diagnostics when compilation fails (failures are never cached).
///
private static byte[] GetOrCompile(string source, string path, CancellationToken ct)
{
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
if (AssemblyCache.TryGetValue(key, out var cached))
return cached;
var image = Compile(source, path);
ct.ThrowIfCancellationRequested();
if (AssemblyCache.TryAdd(key, image))
{
CacheOrder.Enqueue(key);
while (CacheOrder.Count > MaxCachedAssemblies && CacheOrder.TryDequeue(out var evicted))
AssemblyCache.TryRemove(evicted, out _);
}
return image;
}
private static byte[] Compile(string source, string path)
{
var syntaxTree = CSharpSyntaxTree.ParseText(source, path: path);
var compilation = CSharpCompilation.Create(
$"wf-cs-{Guid.NewGuid():N}",
[syntaxTree],
GetCachedReferences(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true));
using var ms = new MemoryStream();
var emit = compilation.Emit(ms);
if (!emit.Success)
{
var errors = emit.Diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => d.ToString());
throw new CSharpCompileException("C# compilation failed:\n" + string.Join('\n', errors));
}
return ms.ToArray();
}
///
/// 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.
///
private static ImmutableArray GetCachedReferences()
{
if (_cachedReferences is { } cached)
return cached;
lock (_referenceLock)
{
if (_cachedReferences is { } cached2)
return cached2;
// Assemblies injected by dotnet-watch hot reload (Edit and Continue)
// contain duplicate metadata keys that crash Roslyn's internal cache.
// Skip them — user code never references these types directly.
static bool IsHotReloadAssembly(Assembly asm)
{
var name = asm.GetName().Name;
if (string.IsNullOrEmpty(name))
return false;
return name.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal)
|| name.StartsWith("System.Reflection.Metadata", StringComparison.Ordinal)
|| name.Contains("HotReload", StringComparison.OrdinalIgnoreCase)
|| name.Contains("EditAndContinue", StringComparison.OrdinalIgnoreCase);
}
var references = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
if (asm.IsDynamic || IsHotReloadAssembly(asm))
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 async Task InvokeEntryAsync(
Assembly assembly, TaskInvocation invocation, CancellationToken ct)
{
var method = FindEntryMethod(assembly, invocation);
if (method == null)
{
var function = string.IsNullOrWhiteSpace(invocation.EntryFunction) ? "Main" : invocation.EntryFunction;
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;
ct.ThrowIfCancellationRequested();
var result = method.Invoke(null, arguments);
// Await Task / Task returns so async entries produce a concrete value
// and a cancellation is observed instead of blocking the caller.
if (result is Task task)
{
await task.WaitAsync(ct);
result = task.GetType().IsGenericType
? task.GetType().GetProperty("Result")!.GetValue(task)
: null;
}
ct.ThrowIfCancellationRequested();
return result == null ? null : JsonSerializer.Serialize(result, Json);
}
private static MethodInfo? FindEntryMethod(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();
// Accept a single JSON-input parameter typed as `string` or
// `object`. The frontend/LLM stubs declare `dynamic input`, which
// compiles to `object` at runtime — a `string`-only match would
// reject every UI/AI-generated entry. Both receive the raw JSON
// as the argument; a `dynamic`/`object` param lets the author
// parse it or echo it back.
var singleInput = parameters.Length == 1
&& (parameters[0].ParameterType == typeof(string) || parameters[0].ParameterType == typeof(object));
if (singleInput)
{
method = candidate; // best match — one JSON-input parameter
break;
}
method ??= parameters.Length == 0 ? candidate : null;
}
if (method != null && method.GetParameters().Length == 1)
break;
}
return method;
}
private static int ParseInt(string? raw, int fallback)
=> int.TryParse(raw, out var value) ? value : fallback;
/// Raised on Roslyn emit failure; the message carries the diagnostics.
private sealed class CSharpCompileException(string message) : Exception(message);
}