From 12b500d38362e8aae0a23a4f9712b86367006f76 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 08:37:24 +0200 Subject: [PATCH] feat(headless): share immutable process content --- src/AcDream.Content/DatCollectionAdapter.cs | 6 +- .../RuntimeDatCollectionFactory.cs | 16 +- .../Configuration/HeadlessConfiguration.cs | 11 + .../HeadlessConfigurationLoader.cs | 14 + .../Hosting/HeadlessProcessContentOwner.cs | 227 ++++++++++++++++ .../Hosting/HeadlessProcessHost.cs | 26 +- .../Hosting/HeadlessSessionHost.cs | 14 +- .../RuntimeDatAccessArchitectureTests.cs | 24 +- .../RuntimeDatCollectionFactoryTests.cs | 1 + .../HeadlessEntryPointTests.cs | 40 +++ .../HeadlessProcessContentOwnerTests.cs | 256 ++++++++++++++++++ 11 files changed, 617 insertions(+), 18 deletions(-) rename src/{AcDream.App => AcDream.Content}/RuntimeDatCollectionFactory.cs (89%) create mode 100644 src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs create mode 100644 tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs diff --git a/src/AcDream.Content/DatCollectionAdapter.cs b/src/AcDream.Content/DatCollectionAdapter.cs index e77bc1a3..cad4551d 100644 --- a/src/AcDream.Content/DatCollectionAdapter.cs +++ b/src/AcDream.Content/DatCollectionAdapter.cs @@ -117,8 +117,10 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { public int LanguageIteration => _language.Iteration; public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) { - // Route to cell db (the only region we expose) - return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead); + // Route through the wrapper's serialized raw-read path. The process + // content owner is shared by headless sessions, while DatDatabase's + // seek/read cursor is not a per-caller resource. + return _cell.TryGetFileBytes(fileId, ref bytes, out bytesRead); } /// diff --git a/src/AcDream.App/RuntimeDatCollectionFactory.cs b/src/AcDream.Content/RuntimeDatCollectionFactory.cs similarity index 89% rename from src/AcDream.App/RuntimeDatCollectionFactory.cs rename to src/AcDream.Content/RuntimeDatCollectionFactory.cs index 5890b9e2..dd3a7c7b 100644 --- a/src/AcDream.App/RuntimeDatCollectionFactory.cs +++ b/src/AcDream.Content/RuntimeDatCollectionFactory.cs @@ -1,14 +1,14 @@ using DatReaderWriter; using DatReaderWriter.Options; -using AcDream.Content; using DatReaderWriter.Lib.IO; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -namespace AcDream.App; +namespace AcDream.Content; /// -/// Opens the read-only DAT collection owned for the lifetime of an App process. +/// Opens the read-only DAT collection owned for the lifetime of one client +/// process. /// /// /// DatReaderWriter's default retains @@ -18,12 +18,12 @@ namespace AcDream.App; /// B-tree lookup cache, but read file payloads on demand and let the owning /// runtime caches decide their residency. /// -internal static class RuntimeDatCollectionFactory +public static class RuntimeDatCollectionFactory { - internal static IDatReaderWriter OpenReadOnly(string datDirectory) => + public static IDatReaderWriter OpenReadOnly(string datDirectory) => new RuntimeDatCollection(new DatCollection(CreateReadOnlyOptions(datDirectory))); - internal static DatCollectionOptions CreateReadOnlyOptions(string datDirectory) + public static DatCollectionOptions CreateReadOnlyOptions(string datDirectory) { ArgumentException.ThrowIfNullOrWhiteSpace(datDirectory); @@ -41,7 +41,7 @@ internal static class RuntimeDatCollectionFactory /// Owns the runtime's one raw DAT handle set and its one shared bounded typed /// object facade. Disposing this owner closes both layers exactly once. /// -internal sealed class RuntimeDatCollection : IDatReaderWriter +public sealed class RuntimeDatCollection : IDatReaderWriter { private readonly DatCollection _raw; private readonly DatCollectionAdapter _bounded; @@ -57,7 +57,7 @@ internal sealed class RuntimeDatCollection : IDatReaderWriter public string SourceDirectory => _bounded.SourceDirectory; /// Aggregate DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review). - internal CacheStats ObjectCacheStats => _bounded.ObjectCacheStats; + public CacheStats ObjectCacheStats => _bounded.ObjectCacheStats; public IDatDatabase Portal => _bounded.Portal; public IDatDatabase Cell => _bounded.Cell; diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 752177f7..e05cb864 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -16,6 +16,17 @@ internal sealed class HeadlessConfiguration internal sealed class HeadlessProcessSettings { public HeadlessPathOverrides Paths { get; init; } = new(); + + public HeadlessContentDescriptor? Content { get; init; } +} + +internal sealed class HeadlessContentDescriptor +{ + [JsonRequired] + public string DatDirectory { get; init; } = string.Empty; + + [JsonRequired] + public string PreparedAssetPath { get; init; } = string.Empty; } internal sealed class HeadlessSessionDescriptor diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 20582f13..92b1e978 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -52,6 +52,8 @@ internal static class HeadlessConfigurationLoader "sessions must be an array."); } + ValidateContent(configuration.Process?.Content); + var sessionIds = new HashSet(StringComparer.Ordinal); var credentialReferences = new HashSet( StringComparer.Ordinal); @@ -83,6 +85,18 @@ internal static class HeadlessConfigurationLoader return configuration; } + private static void ValidateContent(HeadlessContentDescriptor? content) + { + if (content is null) + return; + if (string.IsNullOrWhiteSpace(content.DatDirectory) + || string.IsNullOrWhiteSpace(content.PreparedAssetPath)) + { + throw new HeadlessConfigurationException( + "process.content requires non-empty datDirectory and preparedAssetPath."); + } + } + private static void ValidateSession(HeadlessSessionDescriptor session) { if (session.Endpoint is null diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs new file mode 100644 index 00000000..5c839845 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs @@ -0,0 +1,227 @@ +using AcDream.Content; +using AcDream.Headless.Configuration; + +namespace AcDream.Headless.Hosting; + +internal readonly record struct HeadlessProcessContentSnapshot( + int LeaseCount, + bool IsDisposeRequested, + bool IsDisposed, + long MappedVirtualBytes) +{ + internal bool IsConverged => + IsDisposed + && LeaseCount == 0 + && MappedVirtualBytes == 0L; +} + +internal interface IHeadlessProcessContentFactory +{ + (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open( + HeadlessContentDescriptor descriptor, + Action diagnostic); +} + +internal sealed class ProductionHeadlessProcessContentFactory + : IHeadlessProcessContentFactory +{ + public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open( + HeadlessContentDescriptor descriptor, + Action diagnostic) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(diagnostic); + + string datDirectory = Path.GetFullPath(descriptor.DatDirectory); + string preparedAssetPath = + Path.GetFullPath(descriptor.PreparedAssetPath); + IDatReaderWriter? dats = null; + try + { + dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory); + var prepared = new PakPreparedAssetSource( + preparedAssetPath, + dats, + diagnostic); + return (dats, prepared); + } + catch + { + dats?.Dispose(); + throw; + } + } +} + +/// +/// Process lifetime root for immutable/read-only content shared by all +/// headless sessions. A lease carries no mutable session state. Disposal is +/// requested once and closes the package/DAT handles only after the final +/// session releases its lease. +/// +internal sealed class HeadlessProcessContentOwner : IDisposable +{ + private readonly object _gate = new(); + private IDatReaderWriter? _dats; + private IPreparedAssetSource? _prepared; + private int _leaseCount; + private bool _disposeRequested; + private bool _disposed; + + internal HeadlessProcessContentOwner( + HeadlessContentDescriptor descriptor, + Action diagnostic, + IHeadlessProcessContentFactory? factory = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(diagnostic); + (IDatReaderWriter dats, IPreparedAssetSource prepared) = + (factory ?? new ProductionHeadlessProcessContentFactory()) + .Open(descriptor, diagnostic); + _dats = dats + ?? throw new InvalidOperationException( + "The headless content factory returned no DAT owner."); + _prepared = prepared + ?? throw new InvalidOperationException( + "The headless content factory returned no prepared source."); + if (prepared is not IPreparedCollisionSource) + { + try + { + prepared.Dispose(); + } + finally + { + dats.Dispose(); + } + _prepared = null; + _dats = null; + throw new NotSupportedException( + "Headless production content must expose prepared collision."); + } + } + + internal HeadlessProcessContentLease AcquireLease(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_gate) + { + ObjectDisposedException.ThrowIf( + _disposeRequested || _disposed, + this); + checked + { + _leaseCount++; + } + return new HeadlessProcessContentLease( + this, + sessionId, + _dats!, + _prepared!); + } + } + + internal HeadlessProcessContentSnapshot CaptureSnapshot() + { + lock (_gate) + { + return new HeadlessProcessContentSnapshot( + _leaseCount, + _disposeRequested, + _disposed, + _prepared?.MappedVirtualBytes ?? 0L); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposeRequested = true; + if (_leaseCount != 0) + return; + DrainResources(); + } + } + + private void Release() + { + lock (_gate) + { + if (_leaseCount <= 0) + { + throw new InvalidOperationException( + "Headless content lease count underflow."); + } + _leaseCount--; + if (_leaseCount == 0 && _disposeRequested) + DrainResources(); + } + } + + private void DrainResources() + { + if (_prepared is not null) + { + _prepared.Dispose(); + _prepared = null; + } + if (_dats is not null) + { + _dats.Dispose(); + _dats = null; + } + _disposed = true; + } + + internal sealed class HeadlessProcessContentLease : IDisposable + { + private HeadlessProcessContentOwner? _owner; + private readonly IDatReaderWriter _dats; + private readonly IPreparedAssetSource _prepared; + + internal HeadlessProcessContentLease( + HeadlessProcessContentOwner owner, + string sessionId, + IDatReaderWriter dats, + IPreparedAssetSource prepared) + { + _owner = owner; + SessionId = sessionId; + _dats = dats; + _prepared = prepared; + } + + internal string SessionId { get; } + + internal IDatReaderWriter Dats + { + get + { + ObjectDisposedException.ThrowIf(_owner is null, this); + return _dats; + } + } + + internal IPreparedAssetSource PreparedAssets + { + get + { + ObjectDisposedException.ThrowIf(_owner is null, this); + return _prepared; + } + } + + internal IPreparedCollisionSource PreparedCollision => + (IPreparedCollisionSource)PreparedAssets; + + public void Dispose() + { + HeadlessProcessContentOwner? owner = + Interlocked.Exchange(ref _owner, null); + owner?.Release(); + } + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 4edc4910..926ba949 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -12,6 +12,7 @@ internal sealed class HeadlessProcessHost : IDisposable private readonly HeadlessSessionHost[] _sessions; private readonly HeadlessProcessScheduler _scheduler; private readonly HeadlessDiagnosticWriter _diagnostics; + private readonly HeadlessProcessContentOwner? _content; private int _disposeIndex; private bool _disposed; @@ -21,7 +22,8 @@ internal sealed class HeadlessProcessHost : IDisposable TextReader standardInput, TextWriter diagnostics, ILiveSessionOperations? sessionOperations = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IHeadlessProcessContentFactory? contentFactory = null) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(paths); @@ -39,8 +41,19 @@ internal sealed class HeadlessProcessHost : IDisposable paths.ConfigDirectory); var sessions = new List( configuration.Sessions.Count); + HeadlessProcessContentOwner? content = null; try { + if (configuration.Process?.Content is { } contentDescriptor) + { + content = new HeadlessProcessContentOwner( + contentDescriptor, + message => _diagnostics.Message( + "process-content", + message), + contentFactory); + } + foreach (HeadlessSessionDescriptor? candidate in configuration.Sessions) { @@ -51,6 +64,8 @@ internal sealed class HeadlessProcessHost : IDisposable credentials.Resolve( descriptor.Id, descriptor.Credential); + HeadlessProcessContentOwner.HeadlessProcessContentLease? + contentLease = content?.AcquireLease(descriptor.Id); try { sessions.Add(new HeadlessSessionHost( @@ -58,10 +73,12 @@ internal sealed class HeadlessProcessHost : IDisposable secret, _diagnostics, sessionOperations, - timeProvider)); + timeProvider, + contentLease: contentLease)); } catch { + contentLease?.Dispose(); secret.Dispose(); throw; } @@ -71,12 +88,14 @@ internal sealed class HeadlessProcessHost : IDisposable _scheduler = new HeadlessProcessScheduler( _sessions, timeProvider); + _content = content; _disposeIndex = _sessions.Length - 1; } catch { for (int index = sessions.Count - 1; index >= 0; index--) sessions[index].Dispose(); + content?.Dispose(); throw; } } @@ -88,6 +107,8 @@ internal sealed class HeadlessProcessHost : IDisposable internal IReadOnlyList Sessions => _sessions; internal HeadlessSchedulerSnapshot Scheduler => _scheduler.CaptureSnapshot(); + internal HeadlessProcessContentSnapshot? Content => + _content?.CaptureSnapshot(); internal async Task RunAsync( CancellationToken cancellationToken) @@ -141,6 +162,7 @@ internal sealed class HeadlessProcessHost : IDisposable _sessions[_disposeIndex].Dispose(); _disposeIndex--; } + _content?.Dispose(); _disposed = true; } } diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 72ba48a1..d6bf13bb 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -117,6 +117,8 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly IDisposable _policySubscription; private readonly LiveSessionHost _liveSession; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; + private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? + _contentLease; private int _disposeStage; private long _reconnectDeadline; private bool _reconnectPending; @@ -129,7 +131,9 @@ internal sealed class HeadlessSessionHost : IDisposable HeadlessDiagnosticWriter diagnostics, ILiveSessionOperations? sessionOperations = null, TimeProvider? timeProvider = null, - TimeSpan? reconnectQuiescence = null) + TimeSpan? reconnectQuiescence = null, + HeadlessProcessContentOwner.HeadlessProcessContentLease? + contentLease = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -217,6 +221,7 @@ internal sealed class HeadlessSessionHost : IDisposable liveSession), new HeadlessMovementInputSource( runtime.MovementOwner)); + _contentLease = contentLease; bridge.Bind(this); hostLease = runtime.AcquireHostLease( @@ -238,6 +243,7 @@ internal sealed class HeadlessSessionHost : IDisposable policySubscription?.Dispose(); policy?.Dispose(); hostLease?.Dispose(); + contentLease?.Dispose(); credential.Dispose(); runtimeRef?.Dispose(); throw; @@ -251,6 +257,8 @@ internal sealed class HeadlessSessionHost : IDisposable string.Empty; internal bool IsPolicyComplete => _policy.IsComplete; internal bool IsReconnectPending => _reconnectPending; + internal HeadlessProcessContentOwner.HeadlessProcessContentLease? + Content => _contentLease; internal long ReconnectDeadline => _reconnectPending ? _reconnectDeadline : throw new InvalidOperationException( @@ -354,6 +362,10 @@ internal sealed class HeadlessSessionHost : IDisposable _disposeStage++; break; case 7: + _contentLease?.Dispose(); + _disposeStage++; + break; + case 8: _diagnostics.Message( _descriptor.Id, "disposed", diff --git a/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs b/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs index 4b9b2d42..22da595f 100644 --- a/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs +++ b/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs @@ -1,4 +1,5 @@ using DatReaderWriter; +using AcDream.Content; using System.Reflection; using System.Text.RegularExpressions; @@ -7,14 +8,20 @@ namespace AcDream.App.Tests; public sealed class RuntimeDatAccessArchitectureTests { [Fact] - public void ProductionAppTypes_DoNotStoreOrAcceptRawDatCollection() + public void ProductionTypes_KeepRawDatCollectionInsideContentOwnerSeam() { Type rawType = typeof(DatReaderWriter.DatCollection); Type ownerType = typeof(RuntimeDatCollection); - Type[] appTypes = typeof(RuntimeDatCollectionFactory).Assembly.GetTypes(); + Type adapterType = typeof(DatCollectionAdapter); + Type[] productionTypes = + typeof(AcDream.App.Rendering.GameWindow).Assembly.GetTypes() + .Concat(typeof(RuntimeDatCollectionFactory).Assembly.GetTypes()) + .Distinct() + .ToArray(); var violations = new List(); - foreach (Type type in appTypes.Where(type => type != ownerType)) + foreach (Type type in productionTypes.Where( + type => type != ownerType && type != adapterType)) { foreach (FieldInfo field in type.GetFields( BindingFlags.Instance | BindingFlags.Static | @@ -54,9 +61,16 @@ public sealed class RuntimeDatAccessArchitectureTests { string root = FindRepoRoot(); string appRoot = Path.Combine(root, "src", "AcDream.App"); + string contentRoot = Path.Combine(root, "src", "AcDream.Content"); string ownerPath = Path.GetFullPath( - Path.Combine(appRoot, "RuntimeDatCollectionFactory.cs")); - string[] sources = Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories); + Path.Combine(contentRoot, "RuntimeDatCollectionFactory.cs")); + string[] sources = Directory + .GetFiles(appRoot, "*.cs", SearchOption.AllDirectories) + .Concat(Directory.GetFiles( + contentRoot, + "*.cs", + SearchOption.AllDirectories)) + .ToArray(); string[] rawCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollection\s*\("); string[] adapterCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollectionAdapter\s*\("); diff --git a/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs b/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs index 9b75b529..f20d2120 100644 --- a/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs +++ b/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs @@ -1,4 +1,5 @@ using DatReaderWriter.Options; +using AcDream.Content; namespace AcDream.App.Tests; diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs index 10ada977..7667e25c 100644 --- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs @@ -84,6 +84,46 @@ public sealed class HeadlessEntryPointTests Assert.Equal(string.Empty, error.ToString()); } + [Fact] + public void ValidateAcceptsCompleteProcessContentDescriptor() + { + using var file = TemporaryConfiguration.Create( + """{"version":1,"process":{"content":{"datDirectory":"C:\\AC","preparedAssetPath":"C:\\AC\\acdream.pak"}},"sessions":[]}"""); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal(0, exitCode); + Assert.Contains("0 session(s)", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Theory] + [InlineData("""{"version":1,"process":{"content":{"datDirectory":"","preparedAssetPath":"package.pak"}},"sessions":[]}""")] + [InlineData("""{"version":1,"process":{"content":{"datDirectory":"dats","preparedAssetPath":""}},"sessions":[]}""")] + public void ValidateRejectsIncompleteProcessContentDescriptor(string json) + { + using var file = TemporaryConfiguration.Create(json); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal(3, exitCode); + Assert.Contains( + "process.content", + error.ToString(), + StringComparison.Ordinal); + Assert.Equal(string.Empty, output.ToString()); + } + [Theory] [InlineData("duplicate-id")] [InlineData("duplicate-credential")] diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs new file mode 100644 index 00000000..d776787a --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs @@ -0,0 +1,256 @@ +using System.Reflection; +using AcDream.Content; +using AcDream.Headless.Configuration; +using AcDream.Headless.Hosting; +using AcDream.Headless.Platform; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessProcessContentOwnerTests +{ + [Fact] + public void DisposeRequestRetiresResourcesAfterExactFinalLease() + { + var factory = new FixtureContentFactory(); + var owner = new HeadlessProcessContentOwner( + ContentDescriptor(), + _ => { }, + factory); + using var first = owner.AcquireLease("first"); + using var second = owner.AcquireLease("second"); + + Assert.Equal(1, factory.OpenCount); + Assert.Equal( + new HeadlessProcessContentSnapshot( + LeaseCount: 2, + IsDisposeRequested: false, + IsDisposed: false, + MappedVirtualBytes: 4096L), + owner.CaptureSnapshot()); + + owner.Dispose(); + + Assert.True(owner.CaptureSnapshot().IsDisposeRequested); + Assert.False(owner.CaptureSnapshot().IsDisposed); + Assert.Throws( + () => owner.AcquireLease("late")); + + first.Dispose(); + Assert.Equal(1, owner.CaptureSnapshot().LeaseCount); + Assert.Equal(0, factory.Dats.DisposeSuccessCount); + Assert.Equal(0, factory.Prepared.DisposeSuccessCount); + + second.Dispose(); + + Assert.True(owner.CaptureSnapshot().IsConverged); + Assert.Equal(1, factory.Dats.DisposeSuccessCount); + Assert.Equal(1, factory.Prepared.DisposeSuccessCount); + + owner.Dispose(); + first.Dispose(); + second.Dispose(); + Assert.Equal(1, factory.Dats.DisposeSuccessCount); + Assert.Equal(1, factory.Prepared.DisposeSuccessCount); + } + + [Fact] + public void FailedFinalDrainRetainsRetryableOwnerSuffix() + { + var factory = new FixtureContentFactory(); + var owner = new HeadlessProcessContentOwner( + ContentDescriptor(), + _ => { }, + factory); + using var lease = owner.AcquireLease("retry"); + owner.Dispose(); + factory.Prepared.FailNextDispose = true; + + Assert.Throws(lease.Dispose); + + HeadlessProcessContentSnapshot failed = owner.CaptureSnapshot(); + Assert.Equal(0, failed.LeaseCount); + Assert.True(failed.IsDisposeRequested); + Assert.False(failed.IsDisposed); + Assert.Equal(0, factory.Dats.DisposeSuccessCount); + + owner.Dispose(); + + Assert.True(owner.CaptureSnapshot().IsConverged); + Assert.Equal(1, factory.Prepared.DisposeSuccessCount); + Assert.Equal(1, factory.Dats.DisposeSuccessCount); + } + + [Fact] + public void FiveSessionsShareContentButNotMutableRuntimeOwners() + { + const int sessionCount = 5; + var factory = new FixtureContentFactory(); + var configuration = new HeadlessConfiguration + { + Version = 1, + Process = new HeadlessProcessSettings + { + Content = ContentDescriptor(), + }, + Sessions = Enumerable.Range(0, sessionCount) + .Select(index => Session(index)) + .Cast() + .ToList(), + }; + using var input = new StringReader(string.Concat( + Enumerable.Repeat( + "fixture-password" + Environment.NewLine, + sessionCount))); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + TextWriter.Null, + contentFactory: factory); + + Assert.Equal(1, factory.OpenCount); + Assert.Equal(sessionCount, host.Sessions.Count); + Assert.Equal(sessionCount, host.Content?.LeaseCount); + Assert.All( + host.Sessions, + session => + { + Assert.Same( + factory.DatsResource, + session.Content?.Dats); + Assert.Same( + factory.PreparedResource, + session.Content?.PreparedAssets); + }); + Assert.Equal( + sessionCount, + host.Sessions + .Select(static session => session.Runtime) + .Distinct(ReferenceEqualityComparer.Instance) + .Count()); + Assert.Equal( + sessionCount, + host.Sessions + .Select(static session => + session.Runtime.EntityObjects.Physics.Engine) + .Distinct(ReferenceEqualityComparer.Instance) + .Count()); + Assert.Equal( + sessionCount, + host.Sessions + .Select(static session => + session.Runtime.EntityObjects.Physics.DataCache) + .Distinct(ReferenceEqualityComparer.Instance) + .Count()); + + HeadlessSessionHost[] sessions = host.Sessions.ToArray(); + host.Dispose(); + + Assert.True(host.Content?.IsConverged); + Assert.All( + sessions, + static session => + Assert.True( + session.Runtime.CaptureOwnership().IsConverged)); + Assert.Equal(1, factory.Prepared.DisposeSuccessCount); + Assert.Equal(1, factory.Dats.DisposeSuccessCount); + } + + private static HeadlessContentDescriptor ContentDescriptor() => new() + { + DatDirectory = "fixture-dats", + PreparedAssetPath = "fixture.pak", + }; + + private static HeadlessSessionDescriptor Session(int index) => new() + { + Id = $"bot-{index}", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = $"account-{index}", + Character = new HeadlessCharacterSelector + { + Index = 0, + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.StandardInput, + Reference = $"stdin-{index}", + }, + }; + + private sealed class FixtureContentFactory + : IHeadlessProcessContentFactory + { + internal FixtureContentFactory() + { + DatsResource = + DispatchProxy.Create(); + PreparedResource = + DispatchProxy.Create(); + Dats = TestResourceProxy.For(DatsResource); + Prepared = TestResourceProxy.For(PreparedResource); + Prepared.MappedVirtualBytes = 4096L; + } + + internal int OpenCount { get; private set; } + internal IDatReaderWriter DatsResource { get; } + internal ITestPreparedSource PreparedResource { get; } + internal TestResourceProxy Dats { get; } + internal TestResourceProxy Prepared { get; } + + public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open( + HeadlessContentDescriptor descriptor, + Action diagnostic) + { + OpenCount++; + return (DatsResource, PreparedResource); + } + } +} + +public interface ITestPreparedSource + : IPreparedAssetSource, IPreparedCollisionSource; + +public class TestResourceProxy : DispatchProxy +{ + public int DisposeSuccessCount { get; private set; } + public bool FailNextDispose { get; set; } + public long MappedVirtualBytes { get; set; } + + public static TestResourceProxy For(T resource) + where T : class => + (TestResourceProxy)(object)resource; + + protected override object? Invoke( + MethodInfo? targetMethod, + object?[]? args) + { + ArgumentNullException.ThrowIfNull(targetMethod); + if (targetMethod.Name == nameof(IDisposable.Dispose)) + { + if (FailNextDispose) + { + FailNextDispose = false; + throw new IOException("fixture disposal failure"); + } + DisposeSuccessCount++; + MappedVirtualBytes = 0L; + return null; + } + if (targetMethod.Name == "get_MappedVirtualBytes") + return MappedVirtualBytes; + + Type returnType = targetMethod.ReturnType; + return returnType == typeof(void) || !returnType.IsValueType + ? null + : Activator.CreateInstance(returnType); + } +}