| 1 | using System.Collections.Concurrent; |
| 2 | using System.Diagnostics; |
| 3 | using System.Text.Json; |
| 4 | using DotNetBuildTest.Core; |
| 5 | |
| 6 | namespace CascadeIDE.Features.Agent.Environment; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Build/test через одноразовый <c>dotnet exec CascadeIDE.BuildVerifyWorker.dll</c> (ADR 0148 out-of-proc MLP). |
| 10 | /// </summary> |
| 11 | public sealed class OutOfProcessBuildVerifyWorkerBackend : IEnvironmentJobBackend |
| 12 | { |
| 13 | private readonly string _workerDllPath; |
| 14 | private readonly ConcurrentDictionary<string, WorkerJobRecord> _jobs = new(); |
| 15 | |
| 16 | public OutOfProcessBuildVerifyWorkerBackend(string workerDllPath) |
| 17 | { |
| 18 | _workerDllPath = Path.GetFullPath(workerDllPath); |
| 19 | } |
| 20 | |
| 21 | public string HostKind => "supervised-worker-process"; |
| 22 | |
| 23 | public BuildTestEnqueueResult TryEnqueue( |
| 24 | BuildTestJobKind kind, |
| 25 | string path, |
| 26 | bool includeRawOutput, |
| 27 | int timeoutSeconds, |
| 28 | DotnetExecutionOptions dotnetOptions) |
| 29 | { |
| 30 | _ = includeRawOutput; |
| 31 | |
| 32 | if (!File.Exists(_workerDllPath)) |
| 33 | return new BuildTestEnqueueResult(false, null, 5); |
| 34 | |
| 35 | var jobId = Guid.NewGuid().ToString("N"); |
| 36 | var record = new WorkerJobRecord(kind, path, timeoutSeconds, dotnetOptions); |
| 37 | if (!_jobs.TryAdd(jobId, record)) |
| 38 | return new BuildTestEnqueueResult(false, null, 1); |
| 39 | |
| 40 | record.RunTask = Task.Run(() => RunWorkerProcessAsync(jobId, record)); |
| 41 | return new BuildTestEnqueueResult(true, jobId, 0); |
| 42 | } |
| 43 | |
| 44 | public async Task<string?> WaitForCompletionAsync(string jobId, CancellationToken cancellationToken) |
| 45 | { |
| 46 | if (!_jobs.TryGetValue(jobId, out var record)) |
| 47 | return null; |
| 48 | |
| 49 | if (record.RunTask is not null) |
| 50 | await record.RunTask.ConfigureAwait(false); |
| 51 | |
| 52 | cancellationToken.ThrowIfCancellationRequested(); |
| 53 | return record.ResultJson; |
| 54 | } |
| 55 | |
| 56 | public object? GetJobStatus(string jobId) |
| 57 | { |
| 58 | if (!_jobs.TryGetValue(jobId, out var record)) |
| 59 | return null; |
| 60 | |
| 61 | lock (record.Sync) |
| 62 | { |
| 63 | if (record.Cancelled) |
| 64 | return new { status = "cancelled", cancelled = true, result = new { success = false } }; |
| 65 | |
| 66 | if (!record.IsComplete) |
| 67 | return new { status = "running" }; |
| 68 | |
| 69 | var success = record.Success ?? false; |
| 70 | return new |
| 71 | { |
| 72 | status = success ? "done" : "failed", |
| 73 | result = new { success }, |
| 74 | success, |
| 75 | }; |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | public object CancelJob(string jobId) |
| 80 | { |
| 81 | if (!_jobs.TryGetValue(jobId, out var record)) |
| 82 | return new { cancelled = false, message = "not_found" }; |
| 83 | |
| 84 | lock (record.Sync) |
| 85 | { |
| 86 | record.Cancelled = true; |
| 87 | try |
| 88 | { |
| 89 | record.Process?.Kill(entireProcessTree: true); |
| 90 | } |
| 91 | catch |
| 92 | { |
| 93 | // |
| 94 | } |
| 95 | |
| 96 | record.IsComplete = true; |
| 97 | record.Success = false; |
| 98 | record.ResultJson ??= JsonSerializer.Serialize(new { success = false, message = "cancelled" }); |
| 99 | } |
| 100 | |
| 101 | return new { cancelled = true }; |
| 102 | } |
| 103 | |
| 104 | private async Task RunWorkerProcessAsync(string jobId, WorkerJobRecord record) |
| 105 | { |
| 106 | var args = BuildWorkerArgs(record); |
| 107 | var psi = new ProcessStartInfo("dotnet", args) |
| 108 | { |
| 109 | RedirectStandardOutput = true, |
| 110 | RedirectStandardError = true, |
| 111 | UseShellExecute = false, |
| 112 | CreateNoWindow = true, |
| 113 | }; |
| 114 | |
| 115 | if (record.DotnetOptions.SupplementalEnvironmentVariables is { Count: > 0 } env) |
| 116 | { |
| 117 | foreach (var pair in env) |
| 118 | psi.Environment[pair.Key] = pair.Value; |
| 119 | } |
| 120 | |
| 121 | using var proc = Process.Start(psi); |
| 122 | if (proc is null) |
| 123 | { |
| 124 | Finish(record, false, JsonSerializer.Serialize(new { success = false, message = "process_start_failed" })); |
| 125 | return; |
| 126 | } |
| 127 | |
| 128 | lock (record.Sync) |
| 129 | record.Process = proc; |
| 130 | |
| 131 | using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(30, record.TimeoutSeconds))); |
| 132 | try |
| 133 | { |
| 134 | var stdoutTask = proc.StandardOutput.ReadToEndAsync(timeoutCts.Token); |
| 135 | var stderrTask = proc.StandardError.ReadToEndAsync(timeoutCts.Token); |
| 136 | await proc.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); |
| 137 | var stdout = (await stdoutTask.ConfigureAwait(false)).Trim(); |
| 138 | var stderr = (await stderrTask.ConfigureAwait(false)).Trim(); |
| 139 | |
| 140 | lock (record.Sync) |
| 141 | { |
| 142 | if (record.Cancelled) |
| 143 | return; |
| 144 | } |
| 145 | |
| 146 | if (proc.ExitCode == 11) |
| 147 | { |
| 148 | Finish(record, false, JsonSerializer.Serialize(new { success = false, message = "busy" })); |
| 149 | return; |
| 150 | } |
| 151 | |
| 152 | if (string.IsNullOrWhiteSpace(stdout)) |
| 153 | { |
| 154 | Finish( |
| 155 | record, |
| 156 | false, |
| 157 | JsonSerializer.Serialize(new { success = false, message = "empty_stdout", stderr })); |
| 158 | return; |
| 159 | } |
| 160 | |
| 161 | var success = proc.ExitCode == 0 && JsonTryGetBool(stdout, "success", out var ok) && ok; |
| 162 | Finish(record, success, stdout); |
| 163 | } |
| 164 | catch (OperationCanceledException) |
| 165 | { |
| 166 | try |
| 167 | { |
| 168 | if (!proc.HasExited) |
| 169 | proc.Kill(entireProcessTree: true); |
| 170 | } |
| 171 | catch |
| 172 | { |
| 173 | // |
| 174 | } |
| 175 | |
| 176 | Finish(record, false, JsonSerializer.Serialize(new { success = false, message = "timed_out" })); |
| 177 | } |
| 178 | catch (Exception ex) |
| 179 | { |
| 180 | Finish(record, false, JsonSerializer.Serialize(new { success = false, message = ex.Message })); |
| 181 | } |
| 182 | finally |
| 183 | { |
| 184 | _jobs.TryRemove(jobId, out _); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | private string BuildWorkerArgs(WorkerJobRecord record) |
| 189 | { |
| 190 | var mode = record.Kind == BuildTestJobKind.RunTests ? "test" : "build"; |
| 191 | var quotedPath = QuoteArg(Path.GetFullPath(record.Path)); |
| 192 | var args = $"exec \"{_workerDllPath}\" {mode} {quotedPath}"; |
| 193 | |
| 194 | if (record.Kind == BuildTestJobKind.RunTests |
| 195 | && !string.IsNullOrWhiteSpace(record.DotnetOptions.Filter)) |
| 196 | { |
| 197 | args += $" --filter {QuoteArg(record.DotnetOptions.Filter.Trim())}"; |
| 198 | } |
| 199 | |
| 200 | return args; |
| 201 | } |
| 202 | |
| 203 | private static void Finish(WorkerJobRecord record, bool success, string resultJson) |
| 204 | { |
| 205 | lock (record.Sync) |
| 206 | { |
| 207 | record.IsComplete = true; |
| 208 | record.Success = success; |
| 209 | record.ResultJson = resultJson; |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | private static bool JsonTryGetBool(string json, string prop, out bool value) |
| 214 | { |
| 215 | value = false; |
| 216 | try |
| 217 | { |
| 218 | using var doc = JsonDocument.Parse(json); |
| 219 | if (doc.RootElement.TryGetProperty(prop, out var el) |
| 220 | && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False)) |
| 221 | { |
| 222 | value = el.GetBoolean(); |
| 223 | return true; |
| 224 | } |
| 225 | } |
| 226 | catch (JsonException) |
| 227 | { |
| 228 | // |
| 229 | } |
| 230 | |
| 231 | return false; |
| 232 | } |
| 233 | |
| 234 | private static string QuoteArg(string arg) => |
| 235 | arg.Contains(' ', StringComparison.Ordinal) || arg.Contains('"', StringComparison.Ordinal) |
| 236 | ? $"\"{arg.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" |
| 237 | : arg; |
| 238 | |
| 239 | private sealed class WorkerJobRecord |
| 240 | { |
| 241 | public WorkerJobRecord( |
| 242 | BuildTestJobKind kind, |
| 243 | string path, |
| 244 | int timeoutSeconds, |
| 245 | DotnetExecutionOptions dotnetOptions) |
| 246 | { |
| 247 | Kind = kind; |
| 248 | Path = path; |
| 249 | TimeoutSeconds = timeoutSeconds; |
| 250 | DotnetOptions = dotnetOptions; |
| 251 | } |
| 252 | |
| 253 | public object Sync { get; } = new(); |
| 254 | public BuildTestJobKind Kind { get; } |
| 255 | public string Path { get; } |
| 256 | public int TimeoutSeconds { get; } |
| 257 | public DotnetExecutionOptions DotnetOptions { get; } |
| 258 | public Process? Process { get; set; } |
| 259 | public Task? RunTask { get; set; } |
| 260 | public bool IsComplete { get; set; } |
| 261 | public bool Cancelled { get; set; } |
| 262 | public bool? Success { get; set; } |
| 263 | public string? ResultJson { get; set; } |
| 264 | } |
| 265 | } |
| 266 | |