Files
RobustToolbox/Robust.Server/ServerStatus/StatusHost.Acz.Sources.cs
2024-12-13 01:25:00 +01:00

327 lines
11 KiB
C#

using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.ObjectPool;
using Robust.Packaging;
using Robust.Packaging.AssetProcessing;
using Robust.Packaging.AssetProcessing.Passes;
using Robust.Shared;
using Robust.Shared.Collections;
using Robust.Shared.ContentPack;
using Robust.Shared.Utility;
using SpaceWizards.Sodium;
namespace Robust.Server.ServerStatus;
// Contains source logic for ACZ (Automatic Client Zip)
// This entails the following:
// * Automatic generation of client zip on development servers. ("Magic ACZ")
// * Loading of pre-built client zip on release servers. ("Hybrid ACZ")
internal sealed partial class StatusHost
{
private IMagicAczProvider? _magicAczProvider;
private IFullHybridAczProvider? _fullHybridAczProvider;
// -- Dictionary<string, OnDemandFile> methods --
private async Task<AczManifestInfo?> PrepareAczInner()
{
var streamCompression = _cfg.GetCVar(CVars.AczStreamCompress);
var blobCompress = _cfg.GetCVar(CVars.AczBlobCompress);
var blobCompressLevel = _cfg.GetCVar(CVars.AczBlobCompressLevel);
var blobCompressSaveThresh = _cfg.GetCVar(CVars.AczBlobCompressSaveThreshold);
var manifestCompress = _cfg.GetCVar(CVars.AczManifestCompress);
var manifestCompressLevel = _cfg.GetCVar(CVars.AczManifestCompressLevel);
// Stream compression disables individual compression.
blobCompress &= !streamCompression;
var manifestResult = await CalcManifestData(
blobCompress,
blobCompressLevel,
blobCompressSaveThresh);
if (manifestResult == null)
return null;
var (manifestData, manifestEntries, manifestBlobData) = manifestResult!.Value;
var manifestHash = CryptoGenericHashBlake2B.Hash(32, manifestData, ReadOnlySpan<byte>.Empty);
var manifestHashString = Convert.ToHexString(manifestHash);
_aczSawmill.Debug("ACZ Manifest hash: {ManifestHash}", manifestHashString);
if (manifestCompress)
{
_aczSawmill.Debug("Compressing ACZ manifest at level {ManifestCompressLevel}", manifestCompressLevel);
var beforeSize = manifestData.Length;
var compressBuffer = ZStd.CompressBound(manifestData.Length);
var compressed = ArrayPool<byte>.Shared.Rent(compressBuffer);
var size = ZStd.Compress(compressed, manifestData, manifestCompressLevel);
manifestData = compressed[..size];
ArrayPool<byte>.Shared.Return(compressed);
_aczSawmill.Debug(
"ACZ manifest compression: {ManifestSize} -> {ManifestSizeCompressed} ({ManifestSizeRatio} ratio)",
beforeSize, manifestData.Length, manifestData.Length / (float) beforeSize);
}
return new AczManifestInfo(
manifestData,
manifestCompress,
manifestHashString,
manifestBlobData,
manifestEntries,
blobCompress);
}
private async Task<(byte[] manifestData, AczManifestEntry[] entries, byte[] blobData)?> CalcManifestData(
bool blobCompress,
int blobCompressLevel,
int blobCompressSaveThresh)
{
var logger = new PackageLoggerSawmill(_aczPackagingSawmill);
using var writerPass = new AssetPassAczWriter(blobCompress, blobCompressLevel, blobCompressSaveThresh);
var result = await SourceAczDictionaryViaFile(writerPass, logger) ||
await SourceAczViaMagic(writerPass, logger);
if (!result)
return null;
await writerPass.FinishedTask;
return (writerPass.ManifestContent!, writerPass.ManifestEntries!, writerPass.BlobData!);
}
private async Task<bool> SourceAczDictionaryViaFile(AssetPass pass, IPackageLogger logger)
{
var path = PathHelpers.ExecutableRelativeFile("Content.Client.zip");
if (!FileHelper.TryOpenFileRead(path, out var fileStream))
return false;
_aczSawmill.Info($"StatusHost found client zip: {path}");
using var zip = new ZipArchive(fileStream, ZipArchiveMode.Read, leaveOpen: false);
await SourceAczDictionaryViaZipStream(zip, pass, logger);
return true;
}
private async Task SourceAczDictionaryViaZipStream(ZipArchive zip, AssetPass outputPass, IPackageLogger logger)
{
var inputPass = new AssetPassPipe { Parallelize = true, Name = "HybridPackageInput" };
if (_fullHybridAczProvider is { } fullProvider)
{
logger.Debug("Using Full Hybrid ACZ with custom provider");
await fullProvider.Package(inputPass, outputPass, logger, CancellationToken.None);
}
else
{
logger.Debug("Using standard Hybrid ACZ without custom provider");
outputPass.AddDependency(inputPass);
AssetGraph.CalculateGraph(new []{inputPass, outputPass}, logger);
}
logger.Verbose("Injecting Hybrid ACZ files");
foreach (var entry in zip.Entries)
{
// Ignore directory entries.
if (entry.Name == "")
continue;
using var stream = entry.Open();
var file = new AssetFileMemory(entry.FullName, stream.CopyToArray());
inputPass.InjectFile(file);
}
inputPass.InjectFinished();
}
private async Task<bool> SourceAczViaMagic(AssetPass pass, IPackageLogger logger)
{
_aczSawmill.Debug("Using Magic ACZ");
var provider = _magicAczProvider;
if (provider == null)
{
_aczSawmill.Verbose("Using default magic ACZ provider");
// Default provider
var (binFolderPath, assemblyNames) = ("Content.Client", new[] { "Content.Client", "Content.Shared" });
var info = new DefaultMagicAczInfo(binFolderPath, assemblyNames);
provider = new DefaultMagicAczProvider(info, _deps);
}
await provider.Package(pass, logger, default);
return true;
}
// -- Information Input --
public void SetMagicAczProvider(IMagicAczProvider provider)
{
_magicAczProvider = provider;
}
public void SetFullHybridAczProvider(IFullHybridAczProvider provider)
{
_fullHybridAczProvider = provider;
}
private sealed class AssetPassAczWriter : AssetPass, IDisposable
{
private readonly object _lock = new();
private readonly SequenceMemoryStream _stream = new();
private readonly bool _blobCompress;
private readonly int _blobCompressLevel;
private readonly int _blobCompressSaveThresh;
private readonly ObjectPool<ZStdCompressionContext> _compressStreamPool =
ObjectPool.Create<ZStdCompressionContext>();
private ValueList<InProgressAczManifestInfo> _infos;
public byte[]? ManifestContent;
public AczManifestEntry[]? ManifestEntries;
public byte[]? BlobData;
public AssetPassAczWriter(
bool blobCompress,
int blobCompressLevel,
int blobCompressSaveThresh)
{
_blobCompress = blobCompress;
_blobCompressLevel = blobCompressLevel;
_blobCompressSaveThresh = blobCompressSaveThresh;
}
protected override AssetFileAcceptResult AcceptFile(AssetFile file)
{
// Logger?.Verbose(file.Path);
var entryHash = new byte[256 / 8];
byte[]? dataPool = null;
Span<byte> data;
if (file is AssetFileMemory mem)
{
data = mem.Memory;
}
else
{
using var fs = file.Open();
dataPool = ArrayPool<byte>.Shared.Rent((int) fs.Length);
data = dataPool.AsSpan(0, (int)fs.Length);
fs.ReadToEnd(data);
}
CryptoGenericHashBlake2B.Hash(entryHash, data, ReadOnlySpan<byte>.Empty);
ReadOnlySpan<byte> toWrite;
byte[]? compressBuffer = null;
if (_blobCompress)
{
var compressCtx = _compressStreamPool.Get();
compressBuffer = ArrayPool<byte>.Shared.Rent(ZStd.CompressBound(data.Length));
var comprLength = compressCtx.Compress(compressBuffer, data, _blobCompressLevel);
_compressStreamPool.Return(compressCtx);
// See if compression was worth it.
if (comprLength + _blobCompressSaveThresh < data.Length)
{
// Worth it
toWrite = compressBuffer.AsSpan(0, comprLength);
}
else
{
// Compression not worth it, just send an uncompressed blob instead.
toWrite = data;
}
}
else
{
toWrite = data;
}
lock (_lock)
{
var streamPos = (int)_stream.Position;
_stream.Write(toWrite);
var info = new AczManifestEntry(
data.Length,
streamPos,
toWrite.Length == data.Length ? 0 : toWrite.Length);
_infos.Add(new InProgressAczManifestInfo(info, file.Path, entryHash));
}
if (compressBuffer != null)
ArrayPool<byte>.Shared.Return(compressBuffer);
if (dataPool != null)
ArrayPool<byte>.Shared.Return(dataPool);
return AssetFileAcceptResult.Consumed;
}
protected override void AcceptFinished()
{
_infos.Sort(OnDemandFilePathComparer.Instance);
var manifestStream = new MemoryStream();
using var manifestWriter = new StreamWriter(manifestStream, EncodingHelpers.UTF8);
manifestWriter.Write("Robust Content Manifest 1\n");
var manifestEntries = new AczManifestEntry[_infos.Count];
for (var i = 0; i < _infos.Count; i++)
{
var info = _infos[i];
manifestWriter.Write($"{Convert.ToHexString(info.Hash)} {info.Path}\n");
manifestEntries[i] = info.Entry;
}
manifestWriter.Flush();
ManifestContent = manifestStream.ToArray();
ManifestEntries = manifestEntries;
BlobData = _stream.AsSequence.ToArray();
}
public void Dispose()
{
// _compressStreamPool is actually a DisposableObjectPool, which is an internal type.
(_compressStreamPool as IDisposable)?.Dispose();
}
}
private sealed record InProgressAczManifestInfo(AczManifestEntry Entry, string Path, byte[] Hash);
private sealed class OnDemandFilePathComparer : IComparer<InProgressAczManifestInfo>
{
public static readonly OnDemandFilePathComparer Instance = new();
public int Compare(InProgressAczManifestInfo? x, InProgressAczManifestInfo? y)
{
return string.Compare(x!.Path, y!.Path, StringComparison.Ordinal);
}
}
}