Files
ParkingRobot/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/ServerChecks.cs
T

263 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TrajectoryPlanningVisualization;
namespace TrajectoryPlanningVisualizationVerificationHost;
internal static class ServerChecks
{
public static void Run()
{
RejectsUnauthorizedAndMalformedRequests();
LimitsSseClientsAndKeepsPublishNonBlocking();
CollectsHistoryWithoutClientsAndStopsCleanly();
}
private static void RejectsUnauthorizedAndMalformedRequests()
{
int port;
using (var session = new PlanningVisualizationSession(new PlanningVisualizationOptions { Port = 0, RefreshRateHz = 20 }))
{
session.Publish(DynamicSnapshot(0));
PlanningVisualizationSessionInfo info = session.Start(StaticSnapshot());
Verification.Equal(info.Token, session.Start(StaticSnapshot()).Token, "repeated start is idempotent");
port = info.Uri.Port;
Verification.Equal("127.0.0.1", info.Uri.Host, "server binds loopback");
Verification.Equal(64, info.Token.Length, "session token is 256 bits of hex");
Verification.Equal(403, SendStatus(port, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"),
"missing token is forbidden");
Verification.Equal(404, SendStatus(port, Get("/missing?token=" + info.Token)),
"unknown authorized route is not found");
Verification.Equal(405, SendStatus(port, "POST /api/bootstrap?token=" + info.Token + " HTTP/1.1\r\nHost: localhost\r\n\r\n"),
"non-GET request is rejected");
Verification.Equal(400, SendStatus(port, "GET /api/bootstrap HTTP/1.0\r\nHost: localhost\r\n\r\n"),
"non-HTTP/1.1 request is malformed");
Verification.Equal(400, SendStatus(port, "GET /api/bootstrap?token=" + info.Token + " HTTP/1.1\r\nHost: localhost\r\nX-Large: " + new string('a', 16 * 1024) + "\r\n\r\n"),
"oversized request header is rejected");
HttpResponse bootstrap = SendResponse(port, Get("/api/bootstrap?token=" + info.Token));
Verification.Equal(200, bootstrap.Status, "authorized bootstrap succeeds");
Verification.Equal(bootstrap.ContentLength, Encoding.UTF8.GetByteCount(bootstrap.Body),
"normal response has byte-accurate content length");
Verification.True(bootstrap.Body.Contains("sessionNameChinese"), "bootstrap contains static snapshot");
session.Stop();
session.Stop();
session.Publish(DynamicSnapshot(99));
}
Verification.True(CanBindReleasedPort(port), "stop releases loopback port");
}
private static void LimitsSseClientsAndKeepsPublishNonBlocking()
{
using var session = new PlanningVisualizationSession(new PlanningVisualizationOptions { Port = 0, RefreshRateHz = 100 });
PlanningVisualizationSessionInfo info = session.Start(StaticSnapshot());
using TcpClient first = OpenSseClient(info);
using TcpClient second = OpenSseClient(info);
Verification.Equal(503, SendStatus(info.Uri.Port, Get("/api/events?token=" + info.Token)),
"third SSE client is rejected");
Task publisher = Task.Run(() =>
{
for (int i = 1; i <= 10000; i++)
{
session.Publish(DynamicSnapshot(i));
}
});
Verification.True(publisher.Wait(TimeSpan.FromSeconds(1)), "slow SSE client does not block Publish");
session.Stop();
}
private static void CollectsHistoryWithoutClientsAndStopsCleanly()
{
int port;
using (var session = new PlanningVisualizationSession(new PlanningVisualizationOptions { Port = 0, RefreshRateHz = 20 }))
{
PlanningVisualizationSessionInfo info = session.Start(StaticSnapshot());
port = info.Uri.Port;
for (int i = 1; i <= 3; i++)
{
session.Publish(DynamicSnapshot(i));
Thread.Sleep(120);
}
using TcpClient client = OpenSseClient(info);
string frame = ReadUntil(client.GetStream(), "\n\n", TimeSpan.FromSeconds(2));
Verification.True(frame.Contains("cycleVersion\":1") && frame.Contains("cycleVersion\":2") &&
frame.Contains("cycleVersion\":3"), "dispatcher collects bounded history without browsers");
Stopwatch stopWatch = Stopwatch.StartNew();
session.Stop();
Verification.True(stopWatch.Elapsed <= TimeSpan.FromSeconds(1), "normal stop is bounded");
string terminal = ReadUntil(client.GetStream(), "\n\n", TimeSpan.FromSeconds(1));
Verification.True(terminal.Contains("event: end") && terminal.Contains("会话已结束"),
"normal stop offers a terminal SSE event");
}
Verification.True(CanBindReleasedPort(port), "port can be rebound after normal stop");
}
private static TcpClient OpenSseClient(PlanningVisualizationSessionInfo info)
{
var client = new TcpClient();
client.Connect(IPAddress.Loopback, info.Uri.Port);
byte[] request = Encoding.ASCII.GetBytes(Get("/api/events?token=" + info.Token));
NetworkStream stream = client.GetStream();
stream.Write(request, 0, request.Length);
string headers = ReadUntil(stream, "\r\n\r\n", TimeSpan.FromSeconds(2));
Verification.True(headers.StartsWith("HTTP/1.1 200", StringComparison.Ordinal), "authorized SSE succeeds");
return client;
}
private static int SendStatus(int port, string request)
{
return SendResponse(port, request).Status;
}
private static HttpResponse SendResponse(int port, string request)
{
using var client = new TcpClient();
client.Connect(IPAddress.Loopback, port);
NetworkStream stream = client.GetStream();
byte[] bytes = Encoding.ASCII.GetBytes(request);
stream.Write(bytes, 0, bytes.Length);
string headers = ReadUntil(stream, "\r\n\r\n", TimeSpan.FromSeconds(2));
string[] lines = headers.Split(new[] { "\r\n" }, StringSplitOptions.None);
string[] statusParts = lines[0].Split(' ');
int status = int.Parse(statusParts[1]);
int contentLength = 0;
foreach (string line in lines)
{
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
{
contentLength = int.Parse(line.Substring("Content-Length:".Length).Trim());
}
}
byte[] body = ReadExactly(stream, contentLength, TimeSpan.FromSeconds(2));
return new HttpResponse(status, contentLength, Encoding.UTF8.GetString(body));
}
private static string Get(string target)
{
return "GET " + target + " HTTP/1.1\r\nHost: localhost\r\n\r\n";
}
private static string ReadUntil(NetworkStream stream, string delimiter, TimeSpan timeout)
{
var bytes = new List<byte>();
byte[] delimiterBytes = Encoding.ASCII.GetBytes(delimiter);
stream.ReadTimeout = (int)timeout.TotalMilliseconds;
while (true)
{
int next = stream.ReadByte();
if (next < 0)
{
throw new InvalidOperationException("connection ended before expected response delimiter");
}
bytes.Add((byte)next);
if (bytes.Count >= delimiterBytes.Length)
{
bool found = true;
for (int index = 0; index < delimiterBytes.Length; index++)
{
if (bytes[bytes.Count - delimiterBytes.Length + index] != delimiterBytes[index])
{
found = false;
break;
}
}
if (found)
{
return Encoding.UTF8.GetString(bytes.ToArray());
}
}
}
}
private static byte[] ReadExactly(NetworkStream stream, int count, TimeSpan timeout)
{
byte[] result = new byte[count];
stream.ReadTimeout = (int)timeout.TotalMilliseconds;
int offset = 0;
while (offset < count)
{
int read = stream.Read(result, offset, count - offset);
if (read == 0)
{
throw new InvalidOperationException("connection ended before response body");
}
offset += read;
}
return result;
}
private static bool CanBindReleasedPort(int port)
{
try
{
using var listener = new TcpListener(IPAddress.Loopback, port);
listener.Start();
return true;
}
catch (SocketException)
{
return false;
}
}
private static PlanningVisualizationStaticSnapshot StaticSnapshot()
{
return new PlanningVisualizationStaticSnapshot(
"验证会话",
new VisualizationBounds(0d, 1d, 0d, 1d),
null,
Array.Empty<VisualizationPolyline>(),
Array.Empty<VisualizationMarker>(),
Array.Empty<VisualizationDirectionSegment>(),
Array.Empty<VisualizationConfigurationGroup>());
}
private static PlanningVisualizationDynamicSnapshot DynamicSnapshot(long sequence)
{
return new PlanningVisualizationDynamicSnapshot(
sequence,
DateTimeOffset.UtcNow,
"运行中",
0,
"forward",
new VisualizationPose(0d, 0d, 0d),
Array.Empty<VisualizationPolyline>(),
Array.Empty<VisualizationMarker>(),
Array.Empty<VisualizationChart>(),
Array.Empty<VisualizationValue>(),
new VisualizationCycleSummary(sequence, DateTimeOffset.UtcNow, "成功", true, 1d, 0, "forward",
"rolling", "none", null, null, ""));
}
private sealed class HttpResponse
{
public HttpResponse(int status, int contentLength, string body)
{
Status = status;
ContentLength = contentLength;
Body = body;
}
public int Status { get; }
public int ContentLength { get; }
public string Body { get; }
}
}