refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,433 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// 一个会说 Fleet 协议的假 Agent:用加入令牌注册 → 连接 → 签名认证 → hello → 接收期望状态 → 上传原始块并等待 ACK。
|
||||
/// 只用于测试和离线工具,不是真正的节点。
|
||||
/// </summary>
|
||||
public sealed class AgentSimulator : IAsyncDisposable
|
||||
{
|
||||
private readonly NodeIdentity _identity;
|
||||
private readonly ClientWebSocket _socket = new();
|
||||
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<bool>> _acks = new();
|
||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _responses = new();
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private long _nextId;
|
||||
private int _disposed;
|
||||
private Task? _loop;
|
||||
|
||||
private AgentSimulator(NodeIdentity identity, NodeId nodeId, string status)
|
||||
{
|
||||
_identity = identity;
|
||||
ExportedKey = identity.ExportPkcs8();
|
||||
NodeId = nodeId;
|
||||
EnrollmentStatus = status;
|
||||
}
|
||||
|
||||
public NodeId NodeId { get; }
|
||||
|
||||
/// <summary>身份私钥(PKCS#8):释放之后仍可用它以同一个身份重新连接。</summary>
|
||||
public byte[] ExportedKey { get; }
|
||||
|
||||
public string EnrollmentStatus { get; }
|
||||
|
||||
public string AuthStatus { get; private set; } = "";
|
||||
|
||||
public ConcurrentQueue<TaskSet> TaskSets { get; } = new();
|
||||
|
||||
public ConcurrentQueue<TestRunParams> TestRuns { get; } = new();
|
||||
|
||||
/// <summary>面板发来的发布清单(<c>moshiro.update.offer</c>)和更新请求(<c>moshiro.update.apply</c>)。</summary>
|
||||
public ConcurrentQueue<UpdateOfferParams> UpdateOffers { get; } = new();
|
||||
|
||||
private int _updateApplies;
|
||||
|
||||
public int UpdateApplies => Volatile.Read(ref _updateApplies);
|
||||
|
||||
public ConcurrentQueue<TestId> TestCancels { get; } = new();
|
||||
|
||||
public IReadOnlyList<RejectedItem> RejectNextState { get; set; } = [];
|
||||
|
||||
public bool Revoked { get; private set; }
|
||||
|
||||
public string? CloseReason { get; private set; }
|
||||
|
||||
/// <summary>收到 moshiro.test.run 时的应答:默认接受。</summary>
|
||||
public Func<TestRunParams, (bool Accepted, string? ErrorCode)> TestRunHandler { get; set; } = _ => (true, null);
|
||||
|
||||
public byte[] PublicKey => _identity.PublicKeySpki;
|
||||
|
||||
/// <summary>用这个节点的私钥为认证随机数签名(可以冒充别的节点 ID:用来测试服务端的校验)。</summary>
|
||||
public byte[] SignAuth(NodeId claimedNode, string nonce) => _identity.Sign(SignaturePurpose.Auth, claimedNode.Value, nonce);
|
||||
|
||||
public static async Task<AgentSimulator> EnrollAsync(HttpClient http, string joinToken, string name, string[]? kinds = null, string publicHost = "203.0.113.10")
|
||||
{
|
||||
NodeIdentity identity = NodeIdentity.Generate();
|
||||
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||
byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken));
|
||||
string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey));
|
||||
var body = new JsonObject
|
||||
{
|
||||
["join_token"] = joinToken,
|
||||
["name"] = name,
|
||||
["pubkey"] = pubkey,
|
||||
["tls_spki"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
|
||||
["proof"] = proof,
|
||||
["node_version"] = "0.0.1-sim",
|
||||
["meta"] = new JsonObject
|
||||
{
|
||||
["probe_tcp_port"] = 7443,
|
||||
["probe_udp_ports"] = new JsonArray(7443),
|
||||
["public_host"] = publicHost,
|
||||
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
|
||||
},
|
||||
};
|
||||
|
||||
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await http.PostAsync("/api/agent/v1/enroll", content);
|
||||
string text = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
identity.Dispose();
|
||||
throw new InvalidOperationException($"enroll failed: {(int)response.StatusCode} {text}");
|
||||
}
|
||||
|
||||
JsonObject result = JsonNode.Parse(text)!.AsObject();
|
||||
return new AgentSimulator(identity, new NodeId(result["node_id"]!.GetValue<string>()), result["status"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
/// <summary>用已有的身份恢复一个模拟器(例如后端重启之后同一个节点重新连接)。</summary>
|
||||
public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId) => new(NodeIdentity.FromPkcs8(pkcs8), nodeId, "active");
|
||||
|
||||
/// <summary>连接并完成认证与 hello。认证失败时抛出异常。</summary>
|
||||
public async Task ConnectAsync(Uri baseAddress, bool sendHello = true, string[]? kinds = null)
|
||||
{
|
||||
var uri = new UriBuilder(baseAddress) { Scheme = baseAddress.Scheme == "https" ? "wss" : "ws", Path = "/api/agent/v1/rpc" }.Uri;
|
||||
await _socket.ConnectAsync(uri, _stop.Token);
|
||||
|
||||
RpcMessage challenge = await ReceiveMessageAsync(_stop.Token) ?? throw new InvalidOperationException("no challenge");
|
||||
ChallengeParams nonce = challenge.Params!.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||
string signature = Convert.ToBase64String(_identity.Sign(SignaturePurpose.Auth, NodeId.Value, nonce.Nonce));
|
||||
await SendAsync(RpcMessage.Request(NextId(), FleetMethods.Auth, new AuthParams { NodeId = NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams));
|
||||
RpcMessage? authReply = await ReceiveMessageAsync(_stop.Token);
|
||||
if (authReply?.Error is { } error)
|
||||
{
|
||||
throw new InvalidOperationException($"auth failed: {error.Data?.Code} {error.Message}");
|
||||
}
|
||||
|
||||
AuthStatus = authReply!.Result!.Value.Deserialize(FleetRpcContext.Default.AuthResult)!.Status;
|
||||
_loop = Task.Run(ReceiveLoopAsync);
|
||||
if (sendHello)
|
||||
{
|
||||
await HelloAsync(kinds);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HelloAsync(string[]? kinds = null)
|
||||
{
|
||||
var meta = JsonSerializer.SerializeToElement(
|
||||
new AgentHelloMeta
|
||||
{
|
||||
TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
|
||||
ProbeTcpPort = 7443,
|
||||
ProbeUdpPorts = [7443],
|
||||
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
|
||||
},
|
||||
MoShiroJsonContext.Default.AgentHelloMeta);
|
||||
var hello = new HelloParams
|
||||
{
|
||||
NodeVersion = "0.0.1-sim",
|
||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"])],
|
||||
NowUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
Meta = meta,
|
||||
};
|
||||
await RequestAsync(FleetMethods.Hello, JsonSerializer.SerializeToElement(hello, FleetRpcContext.Default.HelloParams));
|
||||
}
|
||||
|
||||
/// <summary>上传一个原始块并等待服务端 ACK(持久化之后才会确认)。</summary>
|
||||
/// <summary>设置之后,这个假 Agent 发出的每个块都归属于这个 Probe(夹具的默认 Probe)。</summary>
|
||||
public ProbeId? ProbeForChunks { get; set; }
|
||||
|
||||
public async Task<bool> SendChunkAsync(RawChunk chunk, TimeSpan? timeout = null)
|
||||
{
|
||||
if (ProbeForChunks is { } probe)
|
||||
{
|
||||
chunk.Header.ProbeId = probe;
|
||||
}
|
||||
|
||||
byte[] bytes = RawChunkCodec.Encode(chunk);
|
||||
Guid id = chunk.Header.ChunkId.Value;
|
||||
var ack = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_acks[id] = ack;
|
||||
await SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.RawChunk, id, bytes));
|
||||
return await ack.Task.WaitAsync(timeout ?? TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>发送一个任意的文本帧(用来测试服务端对非法输入的处理)。</summary>
|
||||
public Task SendTextAsync(string text) => SendFrameAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text);
|
||||
|
||||
/// <summary>发送一个任意的二进制帧(用来测试服务端对非法输入的处理)。</summary>
|
||||
public Task SendBinaryAsync(byte[] frame) => SendFrameAsync(frame, WebSocketMessageType.Binary);
|
||||
|
||||
public Task SendEventAsync(string kind, string? detail = null) =>
|
||||
SendAsync(RpcMessage.Notification(
|
||||
MoShiroMethods.Event,
|
||||
new AgentEventParams { Kind = kind, AtUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail },
|
||||
MoShiroJsonContext.Default.AgentEventParams));
|
||||
|
||||
public Task SendHostAsync(HostSnapshot snapshot) =>
|
||||
SendAsync(RpcMessage.Notification(MoShiroMethods.Host, snapshot, MoShiroJsonContext.Default.HostSnapshot));
|
||||
|
||||
public Task SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null) =>
|
||||
SendAsync(RpcMessage.Notification(
|
||||
MoShiroMethods.TestStatus,
|
||||
new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode },
|
||||
MoShiroJsonContext.Default.TestStatusParams));
|
||||
|
||||
/// <summary>像节点的更新器报告的那样,随心跳上报更新状态。</summary>
|
||||
public Task SendUpdateStatusAsync(UpdateStatus status) =>
|
||||
SendAsync(RpcMessage.Notification(
|
||||
FleetMethods.Heartbeat,
|
||||
new HeartbeatParams { Extra = JsonSerializer.SerializeToElement(new AgentHeartbeatExtra { Update = status }, MoShiroJsonContext.Default.AgentHeartbeatExtra) },
|
||||
FleetRpcContext.Default.HeartbeatParams));
|
||||
|
||||
public async Task<RpcMessage> RequestRawAsync(string method, JsonElement? parameters = null) => await RequestAsync(method, parameters);
|
||||
|
||||
public async Task<TaskSet> WaitForTaskSetAsync(Func<TaskSet, bool> predicate, TimeSpan? timeout = null)
|
||||
{
|
||||
long started = TimeProvider.System.GetTimestamp();
|
||||
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(10);
|
||||
while (TimeProvider.System.GetElapsedTime(started) < limit)
|
||||
{
|
||||
foreach (TaskSet set in TaskSets.ToArray().Reverse())
|
||||
{
|
||||
if (predicate(set))
|
||||
{
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
throw new TimeoutException("the expected task set never arrived");
|
||||
}
|
||||
|
||||
public async Task WaitUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
long started = TimeProvider.System.GetTimestamp();
|
||||
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(10);
|
||||
while (TimeProvider.System.GetElapsedTime(started) < limit)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
throw new TimeoutException("condition was not met in time");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _stop.CancelAsync();
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 已经断开
|
||||
}
|
||||
}
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _loop;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常结束
|
||||
}
|
||||
}
|
||||
|
||||
_socket.Dispose();
|
||||
_identity.Dispose();
|
||||
_stop.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
|
||||
private long NextId() => Interlocked.Increment(ref _nextId);
|
||||
|
||||
private async Task<RpcMessage> RequestAsync(string method, JsonElement? parameters)
|
||||
{
|
||||
long id = NextId();
|
||||
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_responses[id] = waiter;
|
||||
await SendAsync(new RpcMessage { Id = id, Method = method, Params = parameters });
|
||||
return await waiter.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
private Task SendAsync(RpcMessage message) =>
|
||||
SendFrameAsync(JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage), WebSocketMessageType.Text);
|
||||
|
||||
private async Task SendFrameAsync(byte[] bytes, WebSocketMessageType type)
|
||||
{
|
||||
await _sendLock.WaitAsync(_stop.Token);
|
||||
try
|
||||
{
|
||||
await _socket.SendAsync(bytes, type, endOfMessage: true, _stop.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RpcMessage?> ReceiveMessageAsync(CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[16 * 1024];
|
||||
using var message = new MemoryStream();
|
||||
while (true)
|
||||
{
|
||||
WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
CloseReason = result.CloseStatusDescription;
|
||||
if (_socket.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
// 按协议确认对端发起的关闭
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return JsonSerializer.Deserialize(message.ToArray(), FleetRpcContext.Default.RpcMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
RpcMessage? message = await ReceiveMessageAsync(_stop.Token);
|
||||
if (message is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await HandleAsync(message);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常结束
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 连接断开
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(RpcMessage message)
|
||||
{
|
||||
if (message.IsResponse)
|
||||
{
|
||||
if (_responses.TryRemove(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.Method)
|
||||
{
|
||||
case FleetMethods.StateSync:
|
||||
StateSyncParams sync = message.Params!.Value.Deserialize(FleetRpcContext.Default.StateSyncParams)!;
|
||||
if (sync.Type == TaskSet.StateType)
|
||||
{
|
||||
TaskSets.Enqueue(sync.Doc.Deserialize(MoShiroJsonContext.Default.TaskSet)!);
|
||||
}
|
||||
|
||||
var result = new StateSyncResult { AppliedVersion = sync.Version, Rejected = [.. RejectNextState] };
|
||||
await SendAsync(RpcMessage.Success(message.Id!.Value, result, FleetRpcContext.Default.StateSyncResult));
|
||||
break;
|
||||
case FleetMethods.OutboxAck:
|
||||
OutboxAckParams ack = message.Params!.Value.Deserialize(FleetRpcContext.Default.OutboxAckParams)!;
|
||||
foreach (string id in ack.Ids.Concat(ack.Duplicates))
|
||||
{
|
||||
if (_acks.TryRemove(Guid.Parse(id), out TaskCompletionSource<bool>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(ack.Duplicates.Contains(id) ? false : true);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case FleetMethods.Revoked:
|
||||
Revoked = true;
|
||||
break;
|
||||
case MoShiroMethods.UpdateOffer:
|
||||
UpdateOffers.Enqueue(message.Params!.Value.Deserialize(MoShiroJsonContext.Default.UpdateOfferParams)!);
|
||||
break;
|
||||
case MoShiroMethods.UpdateApply:
|
||||
Interlocked.Increment(ref _updateApplies);
|
||||
break;
|
||||
case MoShiroMethods.TestRun:
|
||||
TestRunParams run = message.Params!.Value.Deserialize(MoShiroJsonContext.Default.TestRunParams)!;
|
||||
TestRuns.Enqueue(run);
|
||||
(bool accepted, string? errorCode) = TestRunHandler(run);
|
||||
if (accepted)
|
||||
{
|
||||
await SendAsync(RpcMessage.Success(message.Id!.Value, new TestRunResult { Accepted = true }, MoShiroJsonContext.Default.TestRunResult));
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(new ErrorCode(errorCode ?? "test_busy"), "refused")));
|
||||
}
|
||||
|
||||
break;
|
||||
case MoShiroMethods.TestCancel:
|
||||
TestCancelParams cancel = message.Params!.Value.Deserialize(MoShiroJsonContext.Default.TestCancelParams)!;
|
||||
TestCancels.Enqueue(cancel.TestId);
|
||||
await SendAsync(RpcMessage.Success(message.Id!.Value, new TestRunResult { Accepted = true }, MoShiroJsonContext.Default.TestRunResult));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace MoShiro.TestSupport;
|
||||
|
||||
public sealed record AuthAttempt(bool Ok, string? Status, string? ErrorCode, string? CloseReason);
|
||||
|
||||
/// <summary>只做 Fleet 的挑战/认证握手:用来测试被拒绝的连接(冒名、被禁用的节点等),不保持连接。</summary>
|
||||
public static class AgentWire
|
||||
{
|
||||
public static async Task<AuthAttempt> AuthenticateAsync(Uri baseAddress, NodeId claimedNode, Func<string, byte[]> signNonce)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var uri = new UriBuilder(baseAddress) { Scheme = baseAddress.Scheme == "https" ? "wss" : "ws", Path = "/api/agent/v1/rpc" }.Uri;
|
||||
await socket.ConnectAsync(uri, timeout.Token);
|
||||
|
||||
RpcMessage? challenge = await ReceiveAsync(socket, timeout.Token);
|
||||
ChallengeParams nonce = challenge!.Params!.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||
var auth = new AuthParams { NodeId = claimedNode.Value, Signature = Convert.ToBase64String(signNonce(nonce.Nonce)) };
|
||||
byte[] request = JsonSerializer.SerializeToUtf8Bytes(RpcMessage.Request(1, FleetMethods.Auth, auth, FleetRpcContext.Default.AuthParams), FleetRpcContext.Default.RpcMessage);
|
||||
await socket.SendAsync(request, WebSocketMessageType.Text, endOfMessage: true, timeout.Token);
|
||||
|
||||
string? closeReason = null;
|
||||
while (true)
|
||||
{
|
||||
RpcMessage? reply = await ReceiveAsync(socket, timeout.Token);
|
||||
if (reply is null)
|
||||
{
|
||||
closeReason = socket.CloseStatusDescription;
|
||||
return new AuthAttempt(false, null, null, closeReason);
|
||||
}
|
||||
|
||||
if (reply.IsResponse)
|
||||
{
|
||||
return reply.Error is { } error
|
||||
? new AuthAttempt(false, null, error.Data?.Code, closeReason)
|
||||
: new AuthAttempt(true, reply.Result!.Value.Deserialize(FleetRpcContext.Default.AuthResult)!.Status, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RpcMessage?> ReceiveAsync(ClientWebSocket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[16 * 1024];
|
||||
using var message = new MemoryStream();
|
||||
while (true)
|
||||
{
|
||||
WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return JsonSerializer.Deserialize(message.ToArray(), FleetRpcContext.Default.RpcMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.TestSupport;
|
||||
|
||||
/// <summary>每一个回显包的设定:上行或下行丢失,以及两个方向的单向时延(微秒)。</summary>
|
||||
public readonly record struct EchoPacket(bool LostUp, bool LostDown, long OwdUpUs, long OwdDownUs, long ProcessUs = 50);
|
||||
|
||||
/// <summary>合成原始块:用于测试、模拟器和离线工具。块头里的时钟锚点保证 Probe 与 Agent 时钟相差 <see cref="ProbeOffsetUs"/>。</summary>
|
||||
public static class ChunkFactory
|
||||
{
|
||||
/// <summary>合成块默认属于哪个 Probe(测试夹具在后端创建一个真实的 Probe 之后把它的 id 写在这里)。</summary>
|
||||
public static string DefaultProbe { get; set; } = "prb_test";
|
||||
|
||||
public const long AgentMonoAnchorUs = 1_000_000_000;
|
||||
public const long ProbeOffsetUs = 5_000_000;
|
||||
|
||||
public static RawChunkHeader Header(NodeId agent, string kind, long startUtcMs, int durationMs = 10_000, IReadOnlyDictionary<string, JsonElement>? parameters = null)
|
||||
{
|
||||
KindSpec spec = Kinds.Find(kind)!;
|
||||
ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters);
|
||||
return new RawChunkHeader
|
||||
{
|
||||
ChunkId = new ChunkId(Guid.CreateVersion7()),
|
||||
Kind = kind,
|
||||
AgentId = agent,
|
||||
ProbeId = new ProbeId(DefaultProbe),
|
||||
Params = new Dictionary<string, JsonElement>(resolved.Values!.Values),
|
||||
Port = 7443,
|
||||
StartUtcMs = startUtcMs,
|
||||
DurationMs = durationMs,
|
||||
Clock = new ChunkClock { AgentMonoUs = AgentMonoAnchorUs, UtcMs = startUtcMs, ProbeOffsetUs = ProbeOffsetUs },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Probe 时钟下,距离块起点 <paramref name="offsetMs"/> 毫秒的时刻(微秒)。</summary>
|
||||
public static long ProbeUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs - ProbeOffsetUs;
|
||||
|
||||
public static long AgentUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs;
|
||||
|
||||
public static RawChunk Echo(
|
||||
NodeId agent,
|
||||
string kind,
|
||||
long startUtcMs,
|
||||
int count,
|
||||
int intervalMs,
|
||||
Func<int, EchoPacket> packet,
|
||||
int durationMs = 10_000,
|
||||
IReadOnlyDictionary<string, JsonElement>? parameters = null,
|
||||
IEnumerable<RawStream>? extraStreams = null)
|
||||
{
|
||||
var msg = new StreamBuilder(Kinds.Find(kind)!.FindStream("msg")!);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
EchoPacket p = packet(i);
|
||||
long t1 = ProbeUs((long)i * intervalMs);
|
||||
long t2 = t1 + ProbeOffsetUs + p.OwdUpUs;
|
||||
long t3 = t2 + p.ProcessUs;
|
||||
long t4 = t3 - ProbeOffsetUs + p.OwdDownUs;
|
||||
msg.Add(i, 64, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4);
|
||||
}
|
||||
|
||||
var streams = new List<RawStream> { msg.Build() };
|
||||
if (extraStreams is not null)
|
||||
{
|
||||
streams.AddRange(extraStreams);
|
||||
}
|
||||
|
||||
return new RawChunk(Header(agent, kind, startUtcMs, durationMs, parameters), streams);
|
||||
}
|
||||
|
||||
/// <summary>10 Hz 的 udp_echo,没有丢包,RTT = 2 × owdMs。</summary>
|
||||
public static RawChunk UdpEchoClean(NodeId agent, long startUtcMs, int count = 100, double owdMs = 20) =>
|
||||
Echo(agent, "udp_echo", startUtcMs, count, 100, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)));
|
||||
|
||||
public static RawStream TcpInfo(IEnumerable<(long TsUs, byte Side, uint SrttUs, uint RetransTotal, uint SegsOut)> samples)
|
||||
{
|
||||
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
|
||||
foreach ((long ts, byte side, uint srtt, uint retrans, uint segs) in samples)
|
||||
{
|
||||
builder.Add(ts, side, srtt, srtt / 4, 10, retrans, segs, segs * 100L);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
public static RawChunk Connect(NodeId agent, long startUtcMs, IEnumerable<(long TcpUs, long? TlsUs, ConnectOutcomeCode Outcome)> attempts)
|
||||
{
|
||||
var builder = new StreamBuilder(Kinds.Connect.FindStream("conn")!);
|
||||
int i = 0;
|
||||
foreach ((long tcp, long? tls, ConnectOutcomeCode outcome) in attempts)
|
||||
{
|
||||
builder.Add(i, ProbeUs(i * 5000L), outcome == ConnectOutcomeCode.Timeout ? null : tcp, tls, (long)outcome);
|
||||
i++;
|
||||
}
|
||||
|
||||
return new RawChunk(Header(agent, "connect", startUtcMs), [builder.Build()]);
|
||||
}
|
||||
|
||||
/// <summary>udp_train:Agent 以固定间隔发包,Probe 记录收到的时刻;<paramref name="lossEvery"/> 为 N 时每 N 个包丢一个。</summary>
|
||||
public static RawChunk Train(NodeId agent, long startUtcMs, double mbps, int phase, int seconds = 10, int lossEvery = 0, long owdUs = 20_000)
|
||||
{
|
||||
const int size = 1250;
|
||||
int packets = (int)(mbps * 1_000_000 / 8 / size * seconds);
|
||||
double intervalUs = seconds * 1_000_000.0 / packets;
|
||||
var pkt = new StreamBuilder(Kinds.UdpTrain.FindStream("pkt")!);
|
||||
for (int i = 0; i < packets; i++)
|
||||
{
|
||||
long sendAgent = AgentMonoAnchorUs + (long)(i * intervalUs);
|
||||
bool lost = lossEvery > 0 && i % lossEvery == 0;
|
||||
long recvProbe = sendAgent + owdUs - ProbeOffsetUs;
|
||||
pkt.Add(i, size, sendAgent, lost ? null : recvProbe);
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["mbps"] = JsonSerializer.SerializeToElement(mbps, FactoryJson.Default.Double),
|
||||
["duration_s"] = JsonSerializer.SerializeToElement((double)seconds, FactoryJson.Default.Double),
|
||||
};
|
||||
RawChunkHeader header = Header(agent, "udp_train", startUtcMs, seconds * 1000, parameters);
|
||||
header.Phase = phase;
|
||||
header.TestId = new TestId("tst_test");
|
||||
return new RawChunk(header, [pkt.Build(), new StreamBuilder(Kinds.UdpTrain.FindStream("step")!).Build()]);
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectOutcomeCode
|
||||
{
|
||||
Ok = 0,
|
||||
Timeout = 1,
|
||||
Reset = 2,
|
||||
TlsAlert = 3,
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(double))]
|
||||
internal sealed partial class FactoryJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\shared\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
<ProjectReference Include="..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 <c>line_task_set</c>,
|
||||
/// 在自己的 UDP 套接字上发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
|
||||
/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。
|
||||
/// </summary>
|
||||
public sealed class ProbeSimulator : IAsyncDisposable
|
||||
{
|
||||
private readonly NodeIdentity _identity;
|
||||
private readonly RpcRouter<ProbeSimulator> _router;
|
||||
private readonly ConcurrentDictionary<Guid, TaskCompletionSource> _acks = new();
|
||||
private readonly ConcurrentDictionary<uint, EchoCollector> _echoes = new();
|
||||
private readonly ConcurrentDictionary<uint, TrainCollector> _trains = new();
|
||||
private readonly ConcurrentQueue<LineTaskSet> _taskSets = new();
|
||||
private readonly long _origin = TimeProvider.System.GetTimestamp();
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private NodeId _agentId;
|
||||
private RpcSession<ProbeSimulator>? _rpc;
|
||||
private UdpClient? _udp;
|
||||
private IPEndPoint? _agentUdp;
|
||||
private PeerSessionInfo? _info;
|
||||
private Task? _receiver;
|
||||
private Task? _run;
|
||||
|
||||
public ProbeSimulator(string probeId, NodeIdentity identity)
|
||||
{
|
||||
ProbeId = probeId;
|
||||
_identity = identity;
|
||||
_router = BuildRouter();
|
||||
}
|
||||
|
||||
public string ProbeId { get; }
|
||||
|
||||
/// <summary>同一个身份的另一个假 Probe(模拟 Probe 重启:新的进程、新的会话,序号从 0 重新开始)。</summary>
|
||||
public ProbeSimulator Restarted() => new(ProbeId, _identity);
|
||||
|
||||
/// <summary>true 时拒绝按需测试的阶段(用来测试 Agent 对 Probe 拒绝的处理)。</summary>
|
||||
public bool RefusePhases { get; set; }
|
||||
|
||||
/// <summary>收到 phase_end 之后不交出列块,只报告 0 个(用来测试 Agent 的等待逻辑)。</summary>
|
||||
public bool DropFragments { get; set; }
|
||||
|
||||
public long ClockOffsetUs { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<LineTaskSet> TaskSets => [.. _taskSets];
|
||||
|
||||
public ConcurrentQueue<TestPhaseStartParams> Phases { get; } = new();
|
||||
|
||||
public ConcurrentQueue<TestId> Aborts { get; } = new();
|
||||
|
||||
public bool IsConnected => _rpc is not null && !_stop.IsCancellationRequested;
|
||||
|
||||
public long NowUs() => (TimeProvider.System.GetTimestamp() - _origin) * 1_000_000 / TimeProvider.System.TimestampFrequency;
|
||||
|
||||
public async Task ConnectAsync(string host, int controlPort, byte[] agentSpkiSha256, int udpPort, NodeId agentId, TimeProvider? time = null, CancellationToken ct = default)
|
||||
{
|
||||
_agentId = agentId;
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentSpkiSha256, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
|
||||
_info = info;
|
||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
_agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort);
|
||||
_rpc = new RpcSession<ProbeSimulator>(transport, _router, this, time ?? TimeProvider.System);
|
||||
_rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
_run = _rpc.RunAsync(_stop.Token);
|
||||
_receiver = Task.Run(ReceiveLoopAsync, _stop.Token);
|
||||
await SyncClockAsync();
|
||||
}
|
||||
|
||||
public async Task<LineTaskSet> WaitForTasksAsync(Func<LineTaskSet, bool> predicate, TimeSpan? timeout = null)
|
||||
{
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15);
|
||||
while (Stopwatch.GetElapsedTime(started) < limit)
|
||||
{
|
||||
LineTaskSet? found = _taskSets.ToArray().LastOrDefault(predicate);
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
throw new TimeoutException("the agent did not send the expected line task set");
|
||||
}
|
||||
|
||||
/// <summary>NTP 式的时钟同步:偏移 = Agent 时钟 − (往返中点的) Probe 时钟。取最小往返的一次。</summary>
|
||||
public async Task SyncClockAsync(int rounds = 5)
|
||||
{
|
||||
long bestRoundTrip = long.MaxValue;
|
||||
for (int i = 0; i < rounds; i++)
|
||||
{
|
||||
long t0 = NowUs();
|
||||
Result<ClockResult> reply = await _rpc!.RequestAsync(MoShiroMethods.Clock, new ClockParams { ProbeMonoUs = t0 }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, CancellationToken.None);
|
||||
long t1 = NowUs();
|
||||
long roundTrip = t1 - t0;
|
||||
if (reply.IsSuccess && roundTrip < bestRoundTrip)
|
||||
{
|
||||
bestRoundTrip = roundTrip;
|
||||
ClockOffsetUs = reply.Value.AgentMonoUs - ((t0 + t1) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>对一个 udp_echo 线路任务发 <paramref name="count"/> 个回显包,等回复,然后交出一个列块。返回交给 Agent 的列块的序号范围。</summary>
|
||||
public async Task<(int Sent, int Received)> RunUdpEchoAsync(LineTask task, int count, TimeSpan interval, CancellationToken ct = default)
|
||||
{
|
||||
int size = (int)task.Params["size"].GetInt64();
|
||||
var collector = new EchoCollector();
|
||||
_echoes[task.StreamId] = collector;
|
||||
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
byte[] packet = new byte[size];
|
||||
for (uint seq = 0; seq < count; seq++)
|
||||
{
|
||||
long t1 = NowUs();
|
||||
collector.Sent[seq] = t1;
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
|
||||
await _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
||||
await Task.Delay(interval, ct);
|
||||
}
|
||||
|
||||
await Task.Delay(300, ct); // 等最后的回复
|
||||
_echoes.TryRemove(task.StreamId, out _);
|
||||
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
|
||||
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
|
||||
{
|
||||
msg.Add(seq, size, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
|
||||
}
|
||||
|
||||
RawChunkHeader header = Header("udp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
|
||||
await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct);
|
||||
return (count, collector.Received.Count);
|
||||
}
|
||||
|
||||
/// <summary>对一个 tcp_echo 线路任务建立一条 TCP(可选 TLS)连接,发 <paramref name="count"/> 条回显消息,然后交出一个列块。</summary>
|
||||
public async Task<(int Sent, int Received)> RunTcpEchoAsync(LineTask task, int tcpPort, int count, TimeSpan interval, bool tls, CancellationToken ct = default)
|
||||
{
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, tcpPort, ct);
|
||||
Stream stream = client.GetStream();
|
||||
if (tls)
|
||||
{
|
||||
#pragma warning disable CA5359 // 测试:Agent 的证书是自签名的,这里只验证数据面
|
||||
var ssl = new System.Net.Security.SslStream(stream, false, (_, _, _, _) => true);
|
||||
#pragma warning restore CA5359
|
||||
await ssl.AuthenticateAsClientAsync(new System.Net.Security.SslClientAuthenticationOptions { TargetHost = "agent" }, ct);
|
||||
stream = ssl;
|
||||
}
|
||||
|
||||
byte[] auth = new byte[TcpWire.AuthBytes];
|
||||
TcpWire.WriteAuth(auth, _info!.Credentials.SessionIdValue, task.StreamId, RandomNumberGenerator.GetBytes(TcpWire.NonceBytes), _info.Credentials.Key);
|
||||
await stream.WriteAsync(auth, ct);
|
||||
|
||||
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
|
||||
int received = 0;
|
||||
for (uint seq = 0; seq < count; seq++)
|
||||
{
|
||||
byte[] body = new byte[48];
|
||||
long t1 = NowUs();
|
||||
TcpWire.WriteEchoBody(body, seq, t1);
|
||||
await TcpWire.WriteMessageAsync(stream, body, ct);
|
||||
await stream.FlushAsync(ct);
|
||||
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
|
||||
long? t4 = reply is not null && TcpWire.TryReadEchoBody(reply, out uint echoed, out _) && echoed == seq ? NowUs() : null;
|
||||
received += t4 is null ? 0 : 1;
|
||||
msg.Add(seq, body.Length, t1, t4);
|
||||
await Task.Delay(interval, ct);
|
||||
}
|
||||
|
||||
RawChunkHeader header = Header("tcp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
|
||||
await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct);
|
||||
return (count, received);
|
||||
}
|
||||
|
||||
/// <summary>发送任意字节到 Agent 的 UDP 端口(用来测试 Agent 对垃圾包的处理)。</summary>
|
||||
public Task SendRawUdpAsync(byte[] datagram) => _udp!.SendAsync(datagram, datagram.Length, _agentUdp!);
|
||||
|
||||
/// <summary>用伪造的密钥发一个回显包。</summary>
|
||||
public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null)
|
||||
{
|
||||
byte[] packet = new byte[64];
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
|
||||
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
||||
}
|
||||
|
||||
/// <summary>重放:用同一个序号、同样的密钥再发一次。</summary>
|
||||
public Task SendEchoAsync(LineTask task, uint seq)
|
||||
{
|
||||
byte[] packet = new byte[64];
|
||||
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
|
||||
}
|
||||
|
||||
public int EchoRepliesSeen(uint streamId) => _echoes.TryGetValue(streamId, out EchoCollector? c) ? c.Received.Count : 0;
|
||||
|
||||
/// <summary>交出一个已经构造好的列块并等待 Agent 确认。</summary>
|
||||
public async Task ShipAsync(RawChunk fragment, bool waitForAck = true, CancellationToken ct = default)
|
||||
{
|
||||
Guid id = fragment.Header.ChunkId.Value;
|
||||
var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_acks[id] = ack;
|
||||
await _rpc!.SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.ColumnBlock, id, RawChunkCodec.Encode(fragment)), ct);
|
||||
if (waitForAck)
|
||||
{
|
||||
await ack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stop.CancelAsync();
|
||||
_udp?.Dispose();
|
||||
if (_rpc is not null)
|
||||
{
|
||||
await _rpc.DisposeAsync();
|
||||
}
|
||||
|
||||
foreach (Task? task in new[] { _run, _receiver })
|
||||
{
|
||||
if (task is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or ObjectDisposedException or SocketException or IOException)
|
||||
{
|
||||
// 关闭中
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_stop.Dispose();
|
||||
}
|
||||
|
||||
private RawChunkHeader Header(string kind, TaskId? task, TestId? test, int? phase, long startUtcMs, int durationMs, IReadOnlyDictionary<string, JsonElement>? extra)
|
||||
{
|
||||
var header = new RawChunkHeader
|
||||
{
|
||||
ChunkId = new ChunkId(Guid.CreateVersion7()),
|
||||
Kind = kind,
|
||||
AgentId = _agentId,
|
||||
TaskId = task,
|
||||
TestId = test,
|
||||
Phase = phase,
|
||||
ProbeId = new ProbeId(ProbeId),
|
||||
StartUtcMs = startUtcMs,
|
||||
DurationMs = durationMs,
|
||||
Clock = new ChunkClock { ProbeOffsetUs = ClockOffsetUs },
|
||||
};
|
||||
if (extra is not null)
|
||||
{
|
||||
foreach ((string key, JsonElement value) in extra)
|
||||
{
|
||||
header.Params[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync()
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
UdpReceiveResult result;
|
||||
try
|
||||
{
|
||||
result = await _udp!.ReceiveAsync(_stop.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
|
||||
{
|
||||
if (_stop.IsCancellationRequested || ex is ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
long now = NowUs();
|
||||
byte[] data = result.Buffer;
|
||||
if (!UdpPacket.TryParseHeader(data, out UdpHeader header) || !UdpPacket.VerifyMac(data, _info!.Credentials.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.Type == UdpPacketType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo))
|
||||
{
|
||||
echo.Received.TryAdd(header.Seq, now);
|
||||
}
|
||||
else if (header.Type == UdpPacketType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
|
||||
{
|
||||
train.Received.TryAdd(header.Seq, now);
|
||||
train.Size = data.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RpcRouter<ProbeSimulator> BuildRouter() =>
|
||||
new RpcRouter<ProbeSimulator>()
|
||||
.On(
|
||||
FleetMethods.StateSync,
|
||||
FleetRpcContext.Default.StateSyncParams,
|
||||
FleetRpcContext.Default.StateSyncResult,
|
||||
(probe, sync, _) =>
|
||||
{
|
||||
if (sync.Type == LineTaskSet.StateType)
|
||||
{
|
||||
probe._taskSets.Enqueue(sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet)!);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(Result<StateSyncResult>.Ok(new StateSyncResult { AppliedVersion = sync.Version }));
|
||||
})
|
||||
.On(
|
||||
MoShiroMethods.TestPhase,
|
||||
MoShiroJsonContext.Default.TestPhaseStartParams,
|
||||
MoShiroJsonContext.Default.TestPhaseStartResult,
|
||||
async (probe, start, ct) => Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = await probe.StartPhaseAsync(start, ct) }))
|
||||
.On(
|
||||
MoShiroMethods.TestPhaseEnd,
|
||||
MoShiroJsonContext.Default.TestPhaseEndParams,
|
||||
MoShiroJsonContext.Default.TestPhaseEndResult,
|
||||
async (probe, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await probe.EndPhaseAsync(end, ct) }))
|
||||
.OnNotify(
|
||||
MoShiroMethods.TestAbort,
|
||||
MoShiroJsonContext.Default.TestAbortParams,
|
||||
(probe, abort, _) =>
|
||||
{
|
||||
probe.Aborts.Enqueue(abort.TestId);
|
||||
return ValueTask.CompletedTask;
|
||||
})
|
||||
.OnNotify(
|
||||
FleetMethods.OutboxAck,
|
||||
FleetRpcContext.Default.OutboxAckParams,
|
||||
(probe, ack, _) =>
|
||||
{
|
||||
foreach (string id in ack.Ids.Concat(ack.Duplicates))
|
||||
{
|
||||
if (probe._acks.TryRemove(Guid.Parse(id), out TaskCompletionSource? waiter))
|
||||
{
|
||||
waiter.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
|
||||
private readonly ConcurrentDictionary<uint, TestPhaseStartParams> _activePhases = new();
|
||||
|
||||
private async Task<bool> StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct)
|
||||
{
|
||||
Phases.Enqueue(start);
|
||||
if (RefusePhases)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_activePhases[start.StreamId] = start;
|
||||
if (start.Kind == "udp_train")
|
||||
{
|
||||
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||
// 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted
|
||||
byte[] hello = new byte[64];
|
||||
for (uint seq = 0; seq < 3; seq++)
|
||||
{
|
||||
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
|
||||
await _udp!.SendAsync(hello.AsMemory(), _agentUdp!, ct);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
|
||||
{
|
||||
_activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start);
|
||||
if (start is null || DropFragments)
|
||||
{
|
||||
_trains.TryRemove(end.StreamId, out _);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (start.Kind == "udp_train" && _trains.TryRemove(end.StreamId, out TrainCollector? train))
|
||||
{
|
||||
await Task.Delay(200, ct); // 最后一批包还在路上
|
||||
// 单块的记录数有上限(ProtocolLimits.MaxRecordsPerChunk):按序号范围切成多个列块,Agent 一侧按同样的范围取行
|
||||
const int PerFragment = 40_000;
|
||||
uint total = (uint)end.SentCount;
|
||||
int fragments = 0;
|
||||
for (uint from = 0; from < total || fragments == 0; from += PerFragment)
|
||||
{
|
||||
uint to = (uint)Math.Min((long)from + PerFragment, total);
|
||||
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv", nullable: true)]));
|
||||
foreach ((uint seq, long recv) in train.Received.Where(p => p.Key >= from && p.Key < to).OrderBy(p => p.Key))
|
||||
{
|
||||
pkt.Add(seq, recv);
|
||||
}
|
||||
|
||||
var streams = new List<RawStream> { pkt.Build() };
|
||||
if (fragments == 0)
|
||||
{
|
||||
var step = new StreamBuilder(Kinds.UdpTrain.FindStream("step")!);
|
||||
step.Add(0, (long)start.Params["mbps"].GetDouble(), NowUs() - (start.DurationSeconds * 1_000_000L), NowUs());
|
||||
streams.Add(step.Build());
|
||||
}
|
||||
|
||||
RawChunkHeader header = Header("udp_train", null, start.TestId, start.Phase, train.StartUtcMs, start.DurationSeconds * 1000, start.Params);
|
||||
header.Params[FragmentKeys.SeqFrom] = JsonSerializer.SerializeToElement(from);
|
||||
header.Params[FragmentKeys.SeqTo] = JsonSerializer.SerializeToElement(to);
|
||||
// 不能在这里等确认:确认是 Agent 的通知,由同一个入站队列顺序处理,而当前正处在 phase_end 的处理函数里
|
||||
await ShipAsync(new RawChunk(header, streams), waitForAck: false, ct: ct);
|
||||
fragments++;
|
||||
if (total == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class EchoCollector
|
||||
{
|
||||
public ConcurrentDictionary<uint, long> Sent { get; } = new();
|
||||
|
||||
public ConcurrentDictionary<uint, long> Received { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class TrainCollector
|
||||
{
|
||||
public ConcurrentDictionary<uint, long> Received { get; } = new();
|
||||
|
||||
public int Size { get; set; }
|
||||
|
||||
public long StartUtcMs { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user