165 lines
6.6 KiB
C#
165 lines
6.6 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace Zcbot.WindowsNode;
|
|
|
|
internal sealed class OriginWorkerRunner(JobInboxStore inbox)
|
|
{
|
|
private static readonly TimeSpan WorkerTimeout = TimeSpan.FromMinutes(30);
|
|
private readonly ConcurrentDictionary<Guid, Task> active = new();
|
|
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> cancellations = new();
|
|
|
|
internal Task RunAsync(RecoverableJob job) =>
|
|
active.GetOrAdd(job.JobId, _ => RunOnceAsync(job, CancellationFor(job.JobId).Token));
|
|
|
|
internal void Cancel(Guid jobId)
|
|
{
|
|
CancellationFor(jobId).Cancel();
|
|
}
|
|
|
|
private CancellationTokenSource CancellationFor(Guid jobId) =>
|
|
cancellations.GetOrAdd(jobId, _ => new CancellationTokenSource());
|
|
|
|
private async Task RunOnceAsync(RecoverableJob job, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var paths = NodePaths.ForCurrentMachine();
|
|
var jobDirectory = Path.Combine(paths.JobsDirectory, job.JobId.ToString("D"));
|
|
var terminalPath = Path.Combine(jobDirectory, "terminal.json");
|
|
if (File.Exists(terminalPath)) return;
|
|
|
|
var markerPath = Path.Combine(jobDirectory, "worker-started.json");
|
|
if (File.Exists(markerPath))
|
|
{
|
|
inbox.WriteTerminal(
|
|
job,
|
|
"failed",
|
|
"NODE_RESTARTED_DURING_JOB",
|
|
"The node restarted after Origin execution began and cannot prove the prior worker state.");
|
|
return;
|
|
}
|
|
|
|
var interpreter = OriginWorkerRuntime.ResolveInterpreter()
|
|
?? throw new InvalidOperationException("The fixed Origin Python interpreter is unavailable.");
|
|
var workerScript = Path.GetFullPath(
|
|
Path.Combine(AppContext.BaseDirectory, "origin-worker", "worker.py"));
|
|
if (!File.Exists(workerScript))
|
|
{
|
|
throw new FileNotFoundException("The fixed Origin worker script is missing.", workerScript);
|
|
}
|
|
WriteMarker(markerPath, interpreter, workerScript);
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = interpreter,
|
|
WorkingDirectory = jobDirectory,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8,
|
|
};
|
|
startInfo.ArgumentList.Add(workerScript);
|
|
startInfo.ArgumentList.Add(jobDirectory);
|
|
using var process = Process.Start(startInfo)
|
|
?? throw new InvalidOperationException("The fixed Origin worker did not start.");
|
|
var stdout = process.StandardOutput.ReadToEndAsync();
|
|
var stderr = process.StandardError.ReadToEndAsync();
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(WorkerTimeout);
|
|
try
|
|
{
|
|
await process.WaitForExitAsync(timeout.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
process.Kill(entireProcessTree: true);
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
inbox.WriteTerminal(job, "cancelled", "USER_CANCELLED", "Cancelled by user.");
|
|
}
|
|
else
|
|
{
|
|
inbox.WriteTerminal(job, "failed", "ORIGIN_WORKER_TIMEOUT", "Origin worker exceeded 30 minutes.");
|
|
}
|
|
return;
|
|
}
|
|
var output = await stdout;
|
|
var error = await stderr;
|
|
WriteDiagnostic(jobDirectory, output, error, process.ExitCode);
|
|
if (!File.Exists(terminalPath))
|
|
{
|
|
inbox.WriteTerminal(
|
|
job,
|
|
"failed",
|
|
"ORIGIN_WORKER_NO_TERMINAL",
|
|
$"Origin worker exited with code {process.ExitCode} without terminal.json.");
|
|
}
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is IOException
|
|
or JsonException
|
|
or UnauthorizedAccessException
|
|
or InvalidOperationException)
|
|
{
|
|
inbox.WriteTerminal(job, "failed", "ORIGIN_WORKER_START_FAILED", exception.Message[..Math.Min(500, exception.Message.Length)]);
|
|
}
|
|
finally
|
|
{
|
|
active.TryRemove(job.JobId, out _);
|
|
if (cancellations.TryRemove(job.JobId, out var cancellation)) cancellation.Dispose();
|
|
}
|
|
}
|
|
|
|
private static void WriteMarker(string path, string interpreter, string workerScript)
|
|
{
|
|
var value = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
started_at = DateTimeOffset.UtcNow,
|
|
node_pid = Environment.ProcessId,
|
|
interpreter,
|
|
worker_script = workerScript,
|
|
});
|
|
using var stream = new FileStream(
|
|
path, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
|
bufferSize: 4096, FileOptions.WriteThrough);
|
|
stream.Write(value);
|
|
stream.Flush(flushToDisk: true);
|
|
}
|
|
|
|
private static void WriteDiagnostic(string jobDirectory, string output, string error, int exitCode)
|
|
{
|
|
var logs = Path.Combine(jobDirectory, "logs");
|
|
Directory.CreateDirectory(logs);
|
|
var value = JsonSerializer.Serialize(new
|
|
{
|
|
exit_code = exitCode,
|
|
stdout = output[..Math.Min(output.Length, 16 * 1024)],
|
|
stderr = error[..Math.Min(error.Length, 16 * 1024)],
|
|
});
|
|
File.WriteAllText(Path.Combine(logs, "worker-process.json"), value, Encoding.UTF8);
|
|
}
|
|
}
|
|
|
|
internal static class OriginWorkerRuntime
|
|
{
|
|
internal static string? ResolveInterpreter()
|
|
{
|
|
var paths = NodePaths.ForCurrentMachine();
|
|
var configured = Environment.GetEnvironmentVariable("ZCBOT_ORIGIN_PYTHON");
|
|
var candidate = string.IsNullOrWhiteSpace(configured)
|
|
? Path.Combine(paths.RootDirectory, "runtimes", "origin", "Scripts", "python.exe")
|
|
: configured;
|
|
if (!Path.IsPathFullyQualified(candidate)) return null;
|
|
var resolved = Path.GetFullPath(candidate);
|
|
return File.Exists(resolved)
|
|
&& Path.GetFileName(resolved).Equals("python.exe", StringComparison.OrdinalIgnoreCase)
|
|
? resolved
|
|
: null;
|
|
}
|
|
}
|