263 lines
10 KiB
C#
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; }
|
|
}
|
|
}
|