Files
sbox-public/engine/Tests/Sandbox.Test.Engine/Web/HttpStream.cs
Gamah Gamerson 42cee6807d Http.RequestStreamAsync: stream the response instead of buffering it
RequestStreamAsync went through RequestAsync, which sends with the
default HttpCompletionOption.ResponseContentRead - so the whole body was
downloaded before the task resolved, and the "stream" handed back was
the buffer. A response that never ends (NDJSON, SSE, a chunked long
poll) therefore never resolved at all.

It also did `using var response`, disposing the response and with it the
content stream, before returning that stream to the caller. That second
bug hid the first: you could not read the stream far enough to notice
the buffering.

Send with ResponseHeadersRead and return a stream that owns the
HttpResponseMessage and disposes it with itself. RequestAsync's
signature is untouched, so no Sandbox.Access change is needed, and the
consumer side (System.IO.Stream.*, StreamReader) was already
whitelisted. Validation is unaffected: SboxHttpHandler runs
Http.IsAllowedAsync before every send, redirects included, all before
the headers come back.

WebTests.HttpStreamTest serves paced chunked NDJSON on localhost:8080 (a
port Http.IsAllowed accepts) and asserts the call returns before the
body ends, that the lines arrive spread across it rather than at once,
and that a non-2xx still throws.
2026-07-30 17:55:19 +01:00

129 lines
3.7 KiB
C#

using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
namespace WebTests;
/// <summary>
/// Tests that RequestStreamAsync hands back a live stream rather than a buffer. Without the fix
/// (ResponseHeadersRead) it went through RequestAsync, which downloads the whole body before the
/// task resolves - so a response that never ends never resolves.
/// </summary>
[TestClass]
public class HttpStreamTest
{
// Http.IsAllowed only permits loopback on 80/443/8080/8443, and Http.Client is private, so
// this goes over a real socket on a real allowed port rather than a stubbed handler.
private const int Port = 8080;
private const int Lines = 8;
private const int IntervalMs = 200;
private const int BodyMs = Lines * IntervalMs;
private static readonly string Url = $"http://localhost:{Port}/stream";
private static HttpListener Listener;
[ClassInitialize]
public static void Start( TestContext context )
{
Listener = new HttpListener();
Listener.Prefixes.Add( $"http://localhost:{Port}/" );
Listener.Start();
_ = Task.Run( async () =>
{
while ( Listener.IsListening )
{
HttpListenerContext ctx;
try { ctx = await Listener.GetContextAsync(); }
catch { return; }
_ = Task.Run( () => Serve( ctx ) );
}
} );
}
[ClassCleanup]
public static void Stop() => Listener?.Close();
/// <summary>Paced NDJSON, chunked so there's no Content-Length to buffer against.</summary>
private static async Task Serve( HttpListenerContext ctx )
{
try
{
if ( ctx.Request.Url?.AbsolutePath != "/stream" )
{
ctx.Response.StatusCode = 404;
ctx.Response.Close();
return;
}
ctx.Response.ContentType = "application/x-ndjson";
ctx.Response.SendChunked = true;
for ( int n = 0; n < Lines; n++ )
{
if ( n > 0 ) await Task.Delay( IntervalMs );
await ctx.Response.OutputStream.WriteAsync( Encoding.UTF8.GetBytes( $"{{\"n\":{n}}}\n" ) );
await ctx.Response.OutputStream.FlushAsync();
}
ctx.Response.Close();
}
catch
{
try { ctx.Response.Abort(); } catch { }
}
}
[TestMethod]
public async Task RequestStreamAsync_ReturnsBeforeBodyEnds()
{
using var cts = new CancellationTokenSource( TimeSpan.FromSeconds( 30 ) );
var sw = Stopwatch.StartNew();
using var stream = await Http.RequestStreamAsync( Url, cancellationToken: cts.Token );
Assert.IsTrue( sw.ElapsedMilliseconds < BodyMs / 2,
$"Returned after {sw.ElapsedMilliseconds}ms of a ~{BodyMs}ms body - it buffered." );
}
[TestMethod]
public async Task RequestStreamAsync_DeliversLinesAsTheyArrive()
{
using var cts = new CancellationTokenSource( TimeSpan.FromSeconds( 30 ) );
var sw = Stopwatch.StartNew();
// Reading after the call returned also covers the disposal half: the old code disposed the
// HttpResponseMessage before returning its content stream, which a live socket won't survive.
using var stream = await Http.RequestStreamAsync( Url, cancellationToken: cts.Token );
using var reader = new StreamReader( stream );
long first = -1, last = 0;
var count = 0;
while ( await reader.ReadLineAsync( cts.Token ) is not null )
{
if ( first < 0 ) first = sw.ElapsedMilliseconds;
last = sw.ElapsedMilliseconds;
count++;
}
Assert.AreEqual( Lines, count );
Assert.IsTrue( first < BodyMs / 2, $"First line took {first}ms." );
Assert.IsTrue( last - first >= BodyMs / 2,
$"All {Lines} lines arrived within {last - first}ms - they were buffered, not streamed." );
}
[TestMethod]
public async Task RequestStreamAsync_NonSuccess_Throws()
{
await Assert.ThrowsExceptionAsync<HttpRequestException>(
() => Http.RequestStreamAsync( $"http://localhost:{Port}/nope" ) );
}
}