feat: serve planning snapshots on loopback
This commit is contained in:
@@ -10,6 +10,7 @@ internal static class Program
|
||||
{
|
||||
ContractChecks.Run();
|
||||
RuntimeChecks.Run();
|
||||
ServerChecks.Run();
|
||||
Console.WriteLine("PASS trajectory-planning-visualization");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user