feat(headless): share immutable process content

This commit is contained in:
Erik 2026-07-27 08:37:24 +02:00
parent fbdb58a962
commit 12b500d383
11 changed files with 617 additions and 18 deletions

View file

@ -117,8 +117,10 @@ public sealed class DatCollectionAdapter : IDatReaderWriter {
public int LanguageIteration => _language.Iteration; public int LanguageIteration => _language.Iteration;
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) { public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) {
// Route to cell db (the only region we expose) // Route through the wrapper's serialized raw-read path. The process
return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead); // 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);
} }
/// <summary> /// <summary>

View file

@ -1,14 +1,14 @@
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using AcDream.Content;
using DatReaderWriter.Lib.IO; using DatReaderWriter.Lib.IO;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
namespace AcDream.App; namespace AcDream.Content;
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// DatReaderWriter's default <see cref="FileCachingStrategy.OnDemand"/> retains /// DatReaderWriter's default <see cref="FileCachingStrategy.OnDemand"/> retains
@ -18,12 +18,12 @@ namespace AcDream.App;
/// B-tree lookup cache, but read file payloads on demand and let the owning /// B-tree lookup cache, but read file payloads on demand and let the owning
/// runtime caches decide their residency. /// runtime caches decide their residency.
/// </remarks> /// </remarks>
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))); new RuntimeDatCollection(new DatCollection(CreateReadOnlyOptions(datDirectory)));
internal static DatCollectionOptions CreateReadOnlyOptions(string datDirectory) public static DatCollectionOptions CreateReadOnlyOptions(string datDirectory)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(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 /// 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. /// object facade. Disposing this owner closes both layers exactly once.
/// </summary> /// </summary>
internal sealed class RuntimeDatCollection : IDatReaderWriter public sealed class RuntimeDatCollection : IDatReaderWriter
{ {
private readonly DatCollection _raw; private readonly DatCollection _raw;
private readonly DatCollectionAdapter _bounded; private readonly DatCollectionAdapter _bounded;
@ -57,7 +57,7 @@ internal sealed class RuntimeDatCollection : IDatReaderWriter
public string SourceDirectory => _bounded.SourceDirectory; public string SourceDirectory => _bounded.SourceDirectory;
/// <summary>Aggregate DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary> /// <summary>Aggregate DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats ObjectCacheStats => _bounded.ObjectCacheStats; public CacheStats ObjectCacheStats => _bounded.ObjectCacheStats;
public IDatDatabase Portal => _bounded.Portal; public IDatDatabase Portal => _bounded.Portal;
public IDatDatabase Cell => _bounded.Cell; public IDatDatabase Cell => _bounded.Cell;

View file

@ -16,6 +16,17 @@ internal sealed class HeadlessConfiguration
internal sealed class HeadlessProcessSettings internal sealed class HeadlessProcessSettings
{ {
public HeadlessPathOverrides Paths { get; init; } = new(); 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 internal sealed class HeadlessSessionDescriptor

View file

@ -52,6 +52,8 @@ internal static class HeadlessConfigurationLoader
"sessions must be an array."); "sessions must be an array.");
} }
ValidateContent(configuration.Process?.Content);
var sessionIds = new HashSet<string>(StringComparer.Ordinal); var sessionIds = new HashSet<string>(StringComparer.Ordinal);
var credentialReferences = new HashSet<string>( var credentialReferences = new HashSet<string>(
StringComparer.Ordinal); StringComparer.Ordinal);
@ -83,6 +85,18 @@ internal static class HeadlessConfigurationLoader
return configuration; 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) private static void ValidateSession(HeadlessSessionDescriptor session)
{ {
if (session.Endpoint is null if (session.Endpoint is null

View file

@ -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<string> diagnostic);
}
internal sealed class ProductionHeadlessProcessContentFactory
: IHeadlessProcessContentFactory
{
public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
HeadlessContentDescriptor descriptor,
Action<string> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
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<string> 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();
}
}
}

View file

@ -12,6 +12,7 @@ internal sealed class HeadlessProcessHost : IDisposable
private readonly HeadlessSessionHost[] _sessions; private readonly HeadlessSessionHost[] _sessions;
private readonly HeadlessProcessScheduler _scheduler; private readonly HeadlessProcessScheduler _scheduler;
private readonly HeadlessDiagnosticWriter _diagnostics; private readonly HeadlessDiagnosticWriter _diagnostics;
private readonly HeadlessProcessContentOwner? _content;
private int _disposeIndex; private int _disposeIndex;
private bool _disposed; private bool _disposed;
@ -21,7 +22,8 @@ internal sealed class HeadlessProcessHost : IDisposable
TextReader standardInput, TextReader standardInput,
TextWriter diagnostics, TextWriter diagnostics,
ILiveSessionOperations? sessionOperations = null, ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null) TimeProvider? timeProvider = null,
IHeadlessProcessContentFactory? contentFactory = null)
{ {
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
@ -39,8 +41,19 @@ internal sealed class HeadlessProcessHost : IDisposable
paths.ConfigDirectory); paths.ConfigDirectory);
var sessions = new List<HeadlessSessionHost>( var sessions = new List<HeadlessSessionHost>(
configuration.Sessions.Count); configuration.Sessions.Count);
HeadlessProcessContentOwner? content = null;
try try
{ {
if (configuration.Process?.Content is { } contentDescriptor)
{
content = new HeadlessProcessContentOwner(
contentDescriptor,
message => _diagnostics.Message(
"process-content",
message),
contentFactory);
}
foreach (HeadlessSessionDescriptor? candidate foreach (HeadlessSessionDescriptor? candidate
in configuration.Sessions) in configuration.Sessions)
{ {
@ -51,6 +64,8 @@ internal sealed class HeadlessProcessHost : IDisposable
credentials.Resolve( credentials.Resolve(
descriptor.Id, descriptor.Id,
descriptor.Credential); descriptor.Credential);
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = content?.AcquireLease(descriptor.Id);
try try
{ {
sessions.Add(new HeadlessSessionHost( sessions.Add(new HeadlessSessionHost(
@ -58,10 +73,12 @@ internal sealed class HeadlessProcessHost : IDisposable
secret, secret,
_diagnostics, _diagnostics,
sessionOperations, sessionOperations,
timeProvider)); timeProvider,
contentLease: contentLease));
} }
catch catch
{ {
contentLease?.Dispose();
secret.Dispose(); secret.Dispose();
throw; throw;
} }
@ -71,12 +88,14 @@ internal sealed class HeadlessProcessHost : IDisposable
_scheduler = new HeadlessProcessScheduler( _scheduler = new HeadlessProcessScheduler(
_sessions, _sessions,
timeProvider); timeProvider);
_content = content;
_disposeIndex = _sessions.Length - 1; _disposeIndex = _sessions.Length - 1;
} }
catch catch
{ {
for (int index = sessions.Count - 1; index >= 0; index--) for (int index = sessions.Count - 1; index >= 0; index--)
sessions[index].Dispose(); sessions[index].Dispose();
content?.Dispose();
throw; throw;
} }
} }
@ -88,6 +107,8 @@ internal sealed class HeadlessProcessHost : IDisposable
internal IReadOnlyList<HeadlessSessionHost> Sessions => _sessions; internal IReadOnlyList<HeadlessSessionHost> Sessions => _sessions;
internal HeadlessSchedulerSnapshot Scheduler => internal HeadlessSchedulerSnapshot Scheduler =>
_scheduler.CaptureSnapshot(); _scheduler.CaptureSnapshot();
internal HeadlessProcessContentSnapshot? Content =>
_content?.CaptureSnapshot();
internal async Task<HeadlessExitCode> RunAsync( internal async Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -141,6 +162,7 @@ internal sealed class HeadlessProcessHost : IDisposable
_sessions[_disposeIndex].Dispose(); _sessions[_disposeIndex].Dispose();
_disposeIndex--; _disposeIndex--;
} }
_content?.Dispose();
_disposed = true; _disposed = true;
} }
} }

View file

@ -117,6 +117,8 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IDisposable _policySubscription; private readonly IDisposable _policySubscription;
private readonly LiveSessionHost _liveSession; private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
_contentLease;
private int _disposeStage; private int _disposeStage;
private long _reconnectDeadline; private long _reconnectDeadline;
private bool _reconnectPending; private bool _reconnectPending;
@ -129,7 +131,9 @@ internal sealed class HeadlessSessionHost : IDisposable
HeadlessDiagnosticWriter diagnostics, HeadlessDiagnosticWriter diagnostics,
ILiveSessionOperations? sessionOperations = null, ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null, TimeProvider? timeProvider = null,
TimeSpan? reconnectQuiescence = null) TimeSpan? reconnectQuiescence = null,
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = null)
{ {
_descriptor = descriptor _descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor)); ?? throw new ArgumentNullException(nameof(descriptor));
@ -217,6 +221,7 @@ internal sealed class HeadlessSessionHost : IDisposable
liveSession), liveSession),
new HeadlessMovementInputSource( new HeadlessMovementInputSource(
runtime.MovementOwner)); runtime.MovementOwner));
_contentLease = contentLease;
bridge.Bind(this); bridge.Bind(this);
hostLease = runtime.AcquireHostLease( hostLease = runtime.AcquireHostLease(
@ -238,6 +243,7 @@ internal sealed class HeadlessSessionHost : IDisposable
policySubscription?.Dispose(); policySubscription?.Dispose();
policy?.Dispose(); policy?.Dispose();
hostLease?.Dispose(); hostLease?.Dispose();
contentLease?.Dispose();
credential.Dispose(); credential.Dispose();
runtimeRef?.Dispose(); runtimeRef?.Dispose();
throw; throw;
@ -251,6 +257,8 @@ internal sealed class HeadlessSessionHost : IDisposable
string.Empty; string.Empty;
internal bool IsPolicyComplete => _policy.IsComplete; internal bool IsPolicyComplete => _policy.IsComplete;
internal bool IsReconnectPending => _reconnectPending; internal bool IsReconnectPending => _reconnectPending;
internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
Content => _contentLease;
internal long ReconnectDeadline => _reconnectPending internal long ReconnectDeadline => _reconnectPending
? _reconnectDeadline ? _reconnectDeadline
: throw new InvalidOperationException( : throw new InvalidOperationException(
@ -354,6 +362,10 @@ internal sealed class HeadlessSessionHost : IDisposable
_disposeStage++; _disposeStage++;
break; break;
case 7: case 7:
_contentLease?.Dispose();
_disposeStage++;
break;
case 8:
_diagnostics.Message( _diagnostics.Message(
_descriptor.Id, _descriptor.Id,
"disposed", "disposed",

View file

@ -1,4 +1,5 @@
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -7,14 +8,20 @@ namespace AcDream.App.Tests;
public sealed class RuntimeDatAccessArchitectureTests public sealed class RuntimeDatAccessArchitectureTests
{ {
[Fact] [Fact]
public void ProductionAppTypes_DoNotStoreOrAcceptRawDatCollection() public void ProductionTypes_KeepRawDatCollectionInsideContentOwnerSeam()
{ {
Type rawType = typeof(DatReaderWriter.DatCollection); Type rawType = typeof(DatReaderWriter.DatCollection);
Type ownerType = typeof(RuntimeDatCollection); 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<string>(); var violations = new List<string>();
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( foreach (FieldInfo field in type.GetFields(
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Instance | BindingFlags.Static |
@ -54,9 +61,16 @@ public sealed class RuntimeDatAccessArchitectureTests
{ {
string root = FindRepoRoot(); string root = FindRepoRoot();
string appRoot = Path.Combine(root, "src", "AcDream.App"); string appRoot = Path.Combine(root, "src", "AcDream.App");
string contentRoot = Path.Combine(root, "src", "AcDream.Content");
string ownerPath = Path.GetFullPath( string ownerPath = Path.GetFullPath(
Path.Combine(appRoot, "RuntimeDatCollectionFactory.cs")); Path.Combine(contentRoot, "RuntimeDatCollectionFactory.cs"));
string[] sources = Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories); 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[] rawCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollection\s*\(");
string[] adapterCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollectionAdapter\s*\("); string[] adapterCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollectionAdapter\s*\(");

View file

@ -1,4 +1,5 @@
using DatReaderWriter.Options; using DatReaderWriter.Options;
using AcDream.Content;
namespace AcDream.App.Tests; namespace AcDream.App.Tests;

View file

@ -84,6 +84,46 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(string.Empty, error.ToString()); 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] [Theory]
[InlineData("duplicate-id")] [InlineData("duplicate-id")]
[InlineData("duplicate-credential")] [InlineData("duplicate-credential")]

View file

@ -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<ObjectDisposedException>(
() => 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<IOException>(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<HeadlessSessionDescriptor?>()
.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<IDatReaderWriter, TestResourceProxy>();
PreparedResource =
DispatchProxy.Create<ITestPreparedSource, TestResourceProxy>();
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<string> 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>(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);
}
}