feat(headless): share immutable process content
This commit is contained in:
parent
fbdb58a962
commit
12b500d383
11 changed files with 617 additions and 18 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
227
src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
Normal file
227
src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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*\(");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using DatReaderWriter.Options;
|
using DatReaderWriter.Options;
|
||||||
|
using AcDream.Content;
|
||||||
|
|
||||||
namespace AcDream.App.Tests;
|
namespace AcDream.App.Tests;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
256
tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
Normal file
256
tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue