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 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// runtime caches decide their residency.
|
||||
/// </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)));
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <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 Cell => _bounded.Cell;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ internal static class HeadlessConfigurationLoader
|
|||
"sessions must be an array.");
|
||||
}
|
||||
|
||||
ValidateContent(configuration.Process?.Content);
|
||||
|
||||
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var credentialReferences = new HashSet<string>(
|
||||
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
|
||||
|
|
|
|||
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 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<HeadlessSessionHost>(
|
||||
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<HeadlessSessionHost> Sessions => _sessions;
|
||||
internal HeadlessSchedulerSnapshot Scheduler =>
|
||||
_scheduler.CaptureSnapshot();
|
||||
internal HeadlessProcessContentSnapshot? Content =>
|
||||
_content?.CaptureSnapshot();
|
||||
|
||||
internal async Task<HeadlessExitCode> RunAsync(
|
||||
CancellationToken cancellationToken)
|
||||
|
|
@ -141,6 +162,7 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
_sessions[_disposeIndex].Dispose();
|
||||
_disposeIndex--;
|
||||
}
|
||||
_content?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue