102 lines
3.8 KiB
C#
102 lines
3.8 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Sockets;
|
|
using NucleicProtocol.Interop;
|
|
|
|
namespace NucleicApp.Services;
|
|
|
|
public sealed record HostdLaunchOptions(
|
|
string ExecutablePath,
|
|
string RendezvousPath,
|
|
string? WorkingDirectory = null,
|
|
TimeSpan? StartupTimeout = null);
|
|
|
|
/// Starts the channel-matched full-trust host and waits for a live owner-only rendezvous.
|
|
public sealed class HostdLauncher
|
|
{
|
|
private readonly HostdLaunchOptions options;
|
|
|
|
public HostdLauncher(HostdLaunchOptions options) => this.options = options;
|
|
|
|
public async Task<HostdRendezvous> EnsureRunningAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (await TryHealthyRendezvousAsync(cancellationToken).ConfigureAwait(false) is { } healthy)
|
|
return healthy;
|
|
|
|
Process? launched = null;
|
|
if (!HostMutexExists())
|
|
{
|
|
if (!File.Exists(options.ExecutablePath))
|
|
throw new FileNotFoundException("nucleic-hostd was not found", options.ExecutablePath);
|
|
launched = Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = options.ExecutablePath,
|
|
WorkingDirectory = options.WorkingDirectory
|
|
?? Path.GetDirectoryName(options.ExecutablePath) ?? Environment.CurrentDirectory,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
}) ?? throw new InvalidOperationException("Windows refused to start nucleic-hostd");
|
|
}
|
|
|
|
var timeout = options.StartupTimeout ?? TimeSpan.FromSeconds(30);
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
deadline.CancelAfter(timeout);
|
|
while (true)
|
|
{
|
|
deadline.Token.ThrowIfCancellationRequested();
|
|
if (launched is { HasExited: true })
|
|
throw new InvalidOperationException($"nucleic-hostd exited during startup ({launched.ExitCode})");
|
|
if (await TryHealthyRendezvousAsync(deadline.Token).ConfigureAwait(false) is { } ready)
|
|
return ready;
|
|
await Task.Delay(100, deadline.Token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task<HostdRendezvous?> TryHealthyRendezvousAsync(CancellationToken cancellationToken)
|
|
{
|
|
HostdRendezvous record;
|
|
try { record = await RendezvousFile.ReadAsync(options.RendezvousPath, cancellationToken); }
|
|
catch (Exception error) when (error is IOException or UnauthorizedAccessException or System.Text.Json.JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!ProcessIsAlive(record.ProcessId)) return null;
|
|
try
|
|
{
|
|
using var client = new TcpClient();
|
|
using var probe = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
probe.CancelAfter(TimeSpan.FromSeconds(1));
|
|
await client.ConnectAsync(record.Host, record.Port, probe.Token).ConfigureAwait(false);
|
|
return record;
|
|
}
|
|
catch (Exception error) when (error is SocketException or OperationCanceledException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static bool ProcessIsAlive(int processId)
|
|
{
|
|
try { return !Process.GetProcessById(processId).HasExited; }
|
|
catch (ArgumentException) { return false; }
|
|
}
|
|
|
|
private static bool HostMutexExists()
|
|
{
|
|
if (!OperatingSystem.IsWindows()) return false;
|
|
try
|
|
{
|
|
if (!Mutex.TryOpenExisting("Local\\nucleic-hostd" + BuildChannel.Suffix, out var mutex))
|
|
return false;
|
|
mutex.Dispose();
|
|
return true;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
// Existence without access is still existence; hostd's own guard remains definitive.
|
|
return true;
|
|
}
|
|
}
|
|
}
|