Forge
csharpdeeb25a2
1using System.Collections.Concurrent;
2using System.Diagnostics;
3using System.Text.Json;
4using DotNetBuildTest.Core;
5
6namespace 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>
11public 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
View only · write via MCP/CIDE