Files
Radicade/ServerTests/CdnRangeRequestTests.cs
2026-10-16 23:33:13 -07:00

131 lines
5.1 KiB
C#

using System;
using System.IO;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.CDN;
using RadicadeServer.DB;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// The client's Unity asset bundle download is resumable: its string table carries
/// "bytes={0}-", "bytes={0}-{1}", "Accept-Ranges" and "Content-Range", so it asks the CDN for
/// byte ranges rather than a whole file. Every route that serves those bytes must therefore
/// process ranges — a range answer that returns the entire body makes the client re-transfer
/// the whole file once per range, which is how a 73 MB Studio bundle came to spend 228 s in
/// AssetBundle_Download, outlive the room's Photon connection, and fail the load with
/// code 6706-3. Dropping <c>enableRangeProcessing</c> is invisible at runtime (the download
/// still completes, just ~20x slower and past the network timeout), so it is pinned here.
/// </summary>
[Collection(DbFactoryCollection.Name)]
public class CdnRangeRequestTests : IDisposable
{
private const string BlobName = "recdump-0cfd85d85c7b905dfbb83a6f3d3c887b.room";
private readonly string root = Directory.CreateTempSubdirectory("radicade-cdn-range-test-").FullName;
private readonly string? previousStorage;
public CdnRangeRequestTests()
{
previousStorage = DBConfig.CDNStorage;
DBConfig.CDNStorage = root;
File.WriteAllText(Path.Join(root, BlobName), "blob");
}
public void Dispose()
{
DBConfig.CDNStorage = previousStorage!;
try
{
Directory.Delete(root, true);
}
catch (IOException)
{
}
}
private static readonly string ImmutableCache = "public, max-age=31536000, immutable";
private static readonly string ReplaceableCache = "public, max-age=86400";
private const string BundleName = "0f0f0f0f-0f0f-0f0f-0f0f-0f0f0f0f0f0f.assetbundle";
private static T WithHttpContext<T>(T controller) where T : Controller
{
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
return controller;
}
private static PhysicalFileResult RangeServed(IActionResult result)
{
var file = Assert.IsType<PhysicalFileResult>(result);
Assert.True(file.EnableRangeProcessing, $"{file.FileName} is served without range processing");
Assert.Equal("application/octet-stream", file.ContentType);
return file;
}
[Fact]
public void TheDataRouteThatCarriesAssetBundlesProcessesRanges()
{
// /data/{blob} is where the bundle bytes land: /rooms/unity_assets/... hands the
// client a Filename and nothing else, so this is the URL the downloader ranges over.
var file = RangeServed(new DataCDN().GetDataBlob(BlobName));
Assert.Equal(Path.Join(root, BlobName), file.FileName);
}
[Fact]
public void TheRoomAndUnityAssetRoutesProcessRanges()
{
RangeServed(new RoomsCDN().GetDataBlob(BlobName));
RangeServed(new RoomsCDN().GetUnityAssetRaw(BlobName, 0, 1));
RangeServed(new RoomsCDN().GetUnityAssetRaw(BlobName, null, null));
}
[Fact]
public void TheInventionRouteProcessesRanges()
{
RangeServed(new InventionCDN().GetDataBlob(BlobName));
}
[Fact]
public void TheBlobRoutesTellClientsTheyMayCacheWhatTheyDownloaded()
{
// A Studio room's bundle is the payload that decides whether the room loads at all.
// With no cache directive the client's HTTP stack is free to fetch all of it again
// on the next join, at ~100 ms RTT, and the room load is exactly as slow as it was
// the first time.
File.WriteAllText(Path.Join(root, BundleName), "bundle");
var data = WithHttpContext(new DataCDN());
data.GetDataBlob(BundleName);
Assert.Equal(ImmutableCache, data.Response.Headers["Cache-Control"].ToString());
var rooms = WithHttpContext(new RoomsCDN());
rooms.GetUnityAssetRaw(BundleName, 0, 1);
Assert.Equal(ImmutableCache, rooms.Response.Headers["Cache-Control"].ToString());
// Room saves are content-addressed too, but admin repairs replace one under its
// existing name, so they must expire rather than be pinned forever.
data = WithHttpContext(new DataCDN());
data.GetDataBlob(BlobName);
Assert.Equal(ReplaceableCache, data.Response.Headers["Cache-Control"].ToString());
rooms = WithHttpContext(new RoomsCDN());
rooms.GetDataBlob(BlobName);
Assert.Equal(ReplaceableCache, rooms.Response.Headers["Cache-Control"].ToString());
}
[Fact]
public void AnUnknownBlobStillAnswersNotFound()
{
// Range support must not turn a missing file into a body, the way an unhandled
// unsatisfiable range can.
Assert.IsType<NotFoundResult>(new DataCDN().GetDataBlob("not-on-disk.room"));
Assert.IsType<NotFoundResult>(new RoomsCDN().GetDataBlob("not-on-disk.room"));
Assert.IsType<NotFoundResult>(new RoomsCDN().GetUnityAssetRaw("not-on-disk.room", 0, 1));
Assert.IsType<NotFoundResult>(new InventionCDN().GetDataBlob("not-on-disk.room"));
}
}