308 lines
10 KiB
C#
308 lines
10 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Pipes;
|
|
using System.Text;
|
|
using YMhut.Box.Core.Api;
|
|
using YMhut.Box.Core.Data;
|
|
using YMhut.Box.Core.Logging;
|
|
using YMhut.Box.Core.Tools;
|
|
|
|
namespace YMhut.Box.WinUI.Services;
|
|
|
|
public sealed class ProcessToolWorkerService : IToolWorkerService, IDisposable
|
|
{
|
|
private readonly IApiManager? _apiManager;
|
|
private readonly IReferenceDataService? _referenceDataService;
|
|
private readonly ILogService? _logService;
|
|
private readonly SemaphoreSlim _requestGate = new(1, 1);
|
|
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
|
|
|
private Process? _process;
|
|
private NamedPipeServerStream? _pipe;
|
|
private StreamReader? _reader;
|
|
private StreamWriter? _writer;
|
|
private bool _disposed;
|
|
|
|
public ProcessToolWorkerService(
|
|
IApiManager? apiManager = null,
|
|
IReferenceDataService? referenceDataService = null,
|
|
ILogService? logService = null)
|
|
{
|
|
_apiManager = apiManager;
|
|
_referenceDataService = referenceDataService;
|
|
_logService = logService;
|
|
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
|
}
|
|
|
|
public Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.Run(() => work(cancellationToken), cancellationToken);
|
|
}
|
|
|
|
public async Task<ToolExecutionResult> ExecuteToolAsync(
|
|
IToolModule module,
|
|
string input,
|
|
IApiManager? apiManager = null,
|
|
IReferenceDataService? referenceDataService = null,
|
|
CancellationToken cancellationToken = default,
|
|
string language = "zh-CN")
|
|
{
|
|
await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
return await ExecuteThroughWorkerAsync(module, input, language, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
await WriteWorkerLogAsync("Warning", "worker", "Tool worker unavailable; using in-process fallback", exception.Message, CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
return await ToolExecutor.ExecuteAsync(
|
|
module,
|
|
input,
|
|
cancellationToken,
|
|
apiManager ?? _apiManager,
|
|
referenceDataService ?? _referenceDataService,
|
|
language).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_requestGate.Release();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
try
|
|
{
|
|
if (_writer is not null)
|
|
{
|
|
_writer.WriteLine(ToolWorkerProtocol.Serialize(new ToolWorkerMessage(ToolWorkerProtocol.Shutdown)));
|
|
_writer.Flush();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
DisposeWorker(killProcess: false);
|
|
_requestGate.Dispose();
|
|
_writeGate.Dispose();
|
|
}
|
|
|
|
private async Task<ToolExecutionResult> ExecuteThroughWorkerAsync(
|
|
IToolModule module,
|
|
string input,
|
|
string language,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await EnsureWorkerAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var requestId = Guid.NewGuid().ToString("N");
|
|
var request = new ToolWorkerMessage(
|
|
ToolWorkerProtocol.ExecuteTool,
|
|
requestId,
|
|
ToolId: module.Id,
|
|
Input: input,
|
|
TimeoutMs: 120_000,
|
|
Language: language);
|
|
|
|
await WriteMessageAsync(request, cancellationToken).ConfigureAwait(false);
|
|
await WriteWorkerLogAsync("Information", "worker", $"Queued tool in worker: {module.Id}", null, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
using var cancellation = cancellationToken.Register(() =>
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await WriteMessageAsync(
|
|
new ToolWorkerMessage(ToolWorkerProtocol.Cancel, requestId),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
});
|
|
});
|
|
|
|
while (true)
|
|
{
|
|
var message = await ReadMessageAsync(cancellationToken).ConfigureAwait(false);
|
|
if (!string.Equals(message.RequestId, requestId, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(message.Type, ToolWorkerProtocol.Result, StringComparison.Ordinal))
|
|
{
|
|
return new ToolExecutionResult(message.Ok, message.Output ?? string.Empty, message.Error, message.Document);
|
|
}
|
|
|
|
if (string.Equals(message.Type, ToolWorkerProtocol.Error, StringComparison.Ordinal))
|
|
{
|
|
return ToolExecutionResult.Fail(message.Error ?? "Worker failed to execute the tool.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task EnsureWorkerAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_process is { HasExited: false } && _pipe?.IsConnected == true && _reader is not null && _writer is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DisposeWorker(killProcess: true);
|
|
|
|
var executable = ResolveWorkerExecutable()
|
|
?? throw new FileNotFoundException("YMhut.Box.Worker.exe was not found in the application output.");
|
|
var pipeName = $"YMhutBoxWorker-{Environment.ProcessId}-{Guid.NewGuid():N}";
|
|
_pipe = new NamedPipeServerStream(
|
|
pipeName,
|
|
PipeDirection.InOut,
|
|
1,
|
|
PipeTransmissionMode.Byte,
|
|
PipeOptions.Asynchronous);
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = executable,
|
|
Arguments = $"--pipe {pipeName}",
|
|
WorkingDirectory = Path.GetDirectoryName(executable) ?? AppContext.BaseDirectory,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
_process = Process.Start(startInfo)
|
|
?? throw new InvalidOperationException("Unable to start the YMhut tool worker process.");
|
|
|
|
await _pipe.WaitForConnectionAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(8), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
_reader = new StreamReader(_pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
|
|
_writer = new StreamWriter(_pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 4096, leaveOpen: true)
|
|
{
|
|
AutoFlush = true
|
|
};
|
|
|
|
var ready = await ReadMessageAsync(cancellationToken).ConfigureAwait(false);
|
|
if (!string.Equals(ready.Type, ToolWorkerProtocol.Ready, StringComparison.Ordinal) ||
|
|
!string.Equals(ready.Version, ToolWorkerProtocol.Version, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("Tool worker did not complete the protocol handshake.");
|
|
}
|
|
|
|
await WriteWorkerLogAsync("Information", "worker", "Tool worker process started", Path.GetFileName(executable), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task WriteMessageAsync(ToolWorkerMessage message, CancellationToken cancellationToken)
|
|
{
|
|
var writer = _writer ?? throw new InvalidOperationException("Tool worker pipe is not connected.");
|
|
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await writer.WriteLineAsync(ToolWorkerProtocol.Serialize(message).AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_writeGate.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<ToolWorkerMessage> ReadMessageAsync(CancellationToken cancellationToken)
|
|
{
|
|
var reader = _reader ?? throw new InvalidOperationException("Tool worker pipe is not connected.");
|
|
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
|
if (line is null)
|
|
{
|
|
DisposeWorker(killProcess: true);
|
|
throw new IOException("Tool worker pipe closed unexpectedly.");
|
|
}
|
|
|
|
return ToolWorkerProtocol.Deserialize(line)
|
|
?? throw new InvalidDataException("Tool worker returned an invalid message.");
|
|
}
|
|
|
|
private void DisposeWorker(bool killProcess)
|
|
{
|
|
try
|
|
{
|
|
_reader?.Dispose();
|
|
_writer?.Dispose();
|
|
_pipe?.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
finally
|
|
{
|
|
_reader = null;
|
|
_writer = null;
|
|
_pipe = null;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_process is not null && !_process.HasExited && killProcess)
|
|
{
|
|
_process.Kill(entireProcessTree: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
finally
|
|
{
|
|
_process?.Dispose();
|
|
_process = null;
|
|
}
|
|
}
|
|
|
|
private async Task WriteWorkerLogAsync(
|
|
string level,
|
|
string category,
|
|
string message,
|
|
string? detail,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (_logService is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await _logService.WriteAsync(level, category, message, detail, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static string? ResolveWorkerExecutable()
|
|
{
|
|
var baseDirectory = AppContext.BaseDirectory;
|
|
var candidates = new List<string>
|
|
{
|
|
Path.Combine(baseDirectory, "worker", "win-x64", "YMhut.Box.Worker.exe"),
|
|
Path.Combine(baseDirectory, "worker", "YMhut.Box.Worker.exe"),
|
|
Path.Combine(baseDirectory, "YMhut.Box.Worker.exe")
|
|
};
|
|
|
|
var directory = new DirectoryInfo(baseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.Worker", "bin", "Debug", "net10.0", "YMhut.Box.Worker.exe"));
|
|
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.Worker", "bin", "Release", "net10.0", "YMhut.Box.Worker.exe"));
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
return candidates.FirstOrDefault(File.Exists);
|
|
}
|
|
}
|