zcbot/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs

439 lines
17 KiB
C#

using System.Net;
using System.Net.WebSockets;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Collections.Concurrent;
namespace Zcbot.WindowsNode;
internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>? statusChanged = null)
{
private readonly SemaphoreSlim sendLock = new(1, 1);
private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory);
private readonly JobInputDownloader inputDownloader = new(
config, new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory));
private readonly OriginWorkerRunner workerRunner = new(
new JobInboxStore(NodePaths.ForCurrentMachine().JobsDirectory));
private readonly JobOutputUploader outputUploader = new(config);
private readonly ConcurrentDictionary<Guid, Task> jobPipelines = new();
private static readonly TimeSpan[] Backoff =
[
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(60),
];
internal async Task RunAsync(CancellationToken cancellationToken)
{
var attempt = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
Report(NodeState.Connecting, "正在连接 zcbot…");
await ConnectOnceAsync(cancellationToken);
attempt = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (WebSocketException exception)
{
Report(NodeState.Offline, "连接中断,等待重连");
Console.Error.WriteLine($"[WARN] WebSocket disconnected: {exception.Message}");
}
catch (IOException exception)
{
Report(NodeState.Offline, "网络不可用,等待重连");
Console.Error.WriteLine($"[WARN] Connection I/O failed: {exception.Message}");
}
catch (JsonException exception)
{
Report(NodeState.Offline, "服务端消息无效,等待重连");
Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}");
}
catch (NodeEndpointException exception)
{
Report(NodeState.Offline, "WebSocket 握手被拒绝,请检查服务端或反向代理");
Console.Error.WriteLine($"[WARN] WebSocket handshake rejected: {exception.Message}");
}
var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)];
attempt++;
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 750));
var delay = baseDelay + jitter;
Console.WriteLine($"[INFO] Reconnecting in {delay.TotalSeconds:F1}s.");
await Task.Delay(delay, cancellationToken);
}
}
private async Task ConnectOnceAsync(CancellationToken cancellationToken)
{
using var socket = new ClientWebSocket();
socket.Options.SetRequestHeader("Authorization", $"Bearer {config.NodeToken}");
socket.Options.SetRequestHeader("X-Node-Id", config.NodeId.ToString());
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(config.HeartbeatSeconds);
socket.Options.CollectHttpResponseDetails = true;
var endpoint = NodeUri.WebSocketEndpoint(config.ServerUrl);
Console.WriteLine($"[INFO] Connecting to {endpoint.GetLeftPart(UriPartial.Path)}.");
try
{
await socket.ConnectAsync(endpoint, cancellationToken);
}
catch (WebSocketException exception) when (
socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
throw new NodeEndpointException(
$"WebSocket handshake returned HTTP {(int?)socket.HttpStatusCode}. "
+ "Verify that the server includes the current Windows Node routes and that "
+ $"the reverse proxy forwards WebSocket Upgrade for {endpoint.AbsolutePath}. "
+ exception.Message);
}
Console.WriteLine("[OK] Node connected.");
Report(NodeState.Online, "已连接");
await SendAsync(socket, "hello", RuntimePayload(), cancellationToken);
await ReportRecoverableJobsAsync(socket, cancellationToken);
using var heartbeatStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var heartbeat = HeartbeatLoopAsync(socket, heartbeatStop.Token);
try
{
await ReceiveLoopAsync(socket, cancellationToken);
}
finally
{
heartbeatStop.Cancel();
try
{
await heartbeat;
}
catch (OperationCanceledException) when (heartbeatStop.IsCancellationRequested)
{
}
}
}
private async Task HeartbeatLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(config.HeartbeatSeconds));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await SendAsync(socket, "heartbeat", RuntimePayload(), cancellationToken);
foreach (var job in jobInbox.ReadRecoverableJobs())
{
if (job.Terminal is JsonElement terminal
&& terminal.GetProperty("status").GetString() == "succeeded"
&& !job.UploadComplete)
{
StartJobPipeline(socket, job);
}
}
}
}
private async Task ReportRecoverableJobsAsync(
ClientWebSocket socket, CancellationToken cancellationToken)
{
foreach (var job in jobInbox.ReadRecoverableJobs())
{
if (job.Terminal is JsonElement terminal)
{
if (terminal.GetProperty("status").GetString() == "succeeded")
{
if (!job.UploadComplete)
{
StartJobPipeline(socket, job);
}
}
else
{
await SendAsync(socket, "job_terminal", terminal, cancellationToken);
}
continue;
}
await SendAsync(socket, "job_state", new
{
job_id = job.JobId,
lease_id = job.LeaseId,
request_digest = job.RequestDigest,
stage = "waiting_input",
progress = 0,
metrics = new { },
}, cancellationToken);
StartJobPipeline(socket, job);
}
}
private async Task ReceiveLoopAsync(
ClientWebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[16 * 1024];
using var message = new MemoryStream();
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
if ((int?)result.CloseStatus == 4003)
{
throw new NodeConfigurationException(
"节点身份已被服务端拒绝。请在管理后台确认该 Node ID 未被禁用或删除;"
+ "若记录不存在或身份已撤销,请清除本机身份并使用新注册码重新注册。"
+ $" 服务端信息:{result.CloseStatusDescription}");
}
if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation)
{
throw new NodeConfigurationException(
$"节点上报被服务端拒绝:{result.CloseStatusDescription}");
}
return;
}
if (result.MessageType != WebSocketMessageType.Text)
{
throw new JsonException("Only text WebSocket messages are supported.");
}
message.Write(buffer, 0, result.Count);
if (!result.EndOfMessage)
{
if (message.Length > 1024 * 1024)
{
throw new JsonException("Server message exceeded 1 MiB.");
}
continue;
}
using var document = JsonDocument.Parse(message.ToArray());
if (document.RootElement.TryGetProperty("type", out var type))
{
Console.WriteLine($"[INFO] Server message: {type.GetString()}.");
if (type.GetString() == "job_offer"
&& document.RootElement.TryGetProperty("payload", out var payload))
{
var offerResult = jobInbox.Accept(payload);
await SendAsync(
socket,
offerResult.Accepted ? "job_accept" : "job_reject",
offerResult.Accepted
? new
{
job_id = offerResult.JobId,
lease_id = offerResult.LeaseId,
request_digest = offerResult.RequestDigest,
}
: new
{
job_id = payload.TryGetProperty("job_id", out var jobId)
? jobId.GetString() : "",
lease_id = payload.TryGetProperty("lease_id", out var leaseId)
? leaseId.GetString() : "",
reason = offerResult.Reason,
},
cancellationToken);
if (offerResult.Accepted)
{
await SendAsync(socket, "job_state", new
{
job_id = offerResult.JobId,
lease_id = offerResult.LeaseId,
request_digest = offerResult.RequestDigest,
stage = "waiting_input",
progress = 0,
metrics = new { },
}, cancellationToken);
var acceptedJob = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == offerResult.JobId);
StartJobPipeline(socket, acceptedJob);
}
}
}
message.SetLength(0);
}
}
private void StartJobPipeline(ClientWebSocket socket, RecoverableJob job)
{
var completion = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
if (jobPipelines.TryAdd(job.JobId, completion.Task))
{
_ = RunJobPipelineAndReleaseAsync(socket, job, completion);
}
}
private async Task RunJobPipelineAndReleaseAsync(
ClientWebSocket socket, RecoverableJob job, TaskCompletionSource completion)
{
try
{
await RunJobPipelineAsync(socket, job);
}
finally
{
completion.TrySetResult();
jobPipelines.TryRemove(job.JobId, out _);
}
}
private async Task RunJobPipelineAsync(ClientWebSocket socket, RecoverableJob job)
{
try
{
if (job.Terminal is null)
{
await inputDownloader.DownloadAsync(job, CancellationToken.None);
await TrySendAsync(socket, "job_state", new
{
job_id = job.JobId,
lease_id = job.LeaseId,
request_digest = job.RequestDigest,
stage = "ready_to_run",
progress = 5,
metrics = new { input_bytes = job.InputTransfer?.GetProperty("size_bytes").GetInt64() },
});
await TrySendAsync(socket, "job_state", new
{
job_id = job.JobId,
lease_id = job.LeaseId,
request_digest = job.RequestDigest,
stage = "origin_running",
progress = 10,
metrics = new { },
});
await workerRunner.RunAsync(job);
}
var refreshed = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId);
if (refreshed.Terminal is not JsonElement terminal)
{
throw new InvalidDataException("Origin worker did not create a terminal record.");
}
if (terminal.GetProperty("status").GetString() != "succeeded")
{
await TrySendAsync(socket, "job_terminal", terminal);
return;
}
await TrySendAsync(socket, "job_state", new
{
job_id = job.JobId,
lease_id = job.LeaseId,
request_digest = job.RequestDigest,
stage = "uploading_outputs",
progress = 90,
metrics = new { },
});
await outputUploader.UploadAsync(refreshed);
}
catch (Exception exception) when (
exception is HttpRequestException
or IOException
or JsonException
or UnauthorizedAccessException
or InvalidDataException)
{
var current = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId);
if (current.Terminal is JsonElement terminal
&& terminal.GetProperty("status").GetString() == "succeeded")
{
Console.Error.WriteLine($"[WARN] Output upload deferred: {exception.Message}");
return;
}
jobInbox.WriteTerminal(
job,
"failed",
"INPUT_DOWNLOAD_FAILED",
exception.Message[..Math.Min(exception.Message.Length, 500)]);
var failedTerminal = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId).Terminal;
if (failedTerminal is JsonElement payload)
{
await TrySendAsync(socket, "job_terminal", payload);
}
}
}
private async Task TrySendTerminalAsync(ClientWebSocket socket, RecoverableJob job)
{
var terminal = jobInbox.ReadRecoverableJobs()
.Single(item => item.JobId == job.JobId).Terminal;
if (terminal is JsonElement terminalPayload)
{
await TrySendAsync(socket, "job_terminal", terminalPayload);
}
}
private async Task TrySendAsync(ClientWebSocket socket, string type, object payload)
{
if (socket.State != WebSocketState.Open)
{
return;
}
try
{
await SendAsync(socket, type, payload, CancellationToken.None);
}
catch (Exception exception) when (
exception is WebSocketException or IOException or ObjectDisposedException)
{
Console.Error.WriteLine($"[WARN] Job report deferred: {exception.Message}");
}
}
private void Report(NodeState state, string message) =>
statusChanged?.Invoke(NodeStatus.Create(state, message));
private async Task SendAsync(
ClientWebSocket socket, string type, object payload, CancellationToken cancellationToken)
{
var envelope = JsonSerializer.SerializeToUtf8Bytes(new
{
protocol_version = 1,
message_id = Guid.NewGuid(),
type,
sent_at = DateTimeOffset.UtcNow,
payload,
});
await sendLock.WaitAsync(cancellationToken);
try
{
await socket.SendAsync(
envelope, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
}
finally
{
sendLock.Release();
}
}
private object RuntimePayload()
{
var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\";
var origin = OriginRuntimeProbe.Detect();
return new
{
install_id = config.InstallId,
node_name = config.NodeName,
node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0",
os_version = RuntimeInformation.OSDescription,
capabilities = config.Capabilities,
available_slots = origin.Health == "ready" && !jobInbox.HasPendingJobs ? 1 : 0,
disk_free_bytes = new DriveInfo(root).AvailableFreeSpace,
desktop_session = Environment.UserInteractive,
origin = new
{
software = origin.Software,
software_version = origin.SoftwareVersion,
adapter_version = origin.AdapterVersion,
health = origin.Health,
detail = origin.Detail,
},
};
}
}