using System; using System.IO; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using RadicadeServer.CDN; using RadicadeServer.DB; using Xunit; namespace RadicadeServer.Tests; /// /// 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 enableRangeProcessing is invisible at runtime (the download /// still completes, just ~20x slower and past the network timeout), so it is pinned here. /// [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 controller) where T : Controller { controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; return controller; } private static PhysicalFileResult RangeServed(IActionResult result) { var file = Assert.IsType(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(new DataCDN().GetDataBlob("not-on-disk.room")); Assert.IsType(new RoomsCDN().GetDataBlob("not-on-disk.room")); Assert.IsType(new RoomsCDN().GetUnityAssetRaw("not-on-disk.room", 0, 1)); Assert.IsType(new InventionCDN().GetDataBlob("not-on-disk.room")); } }