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

@ -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 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;
}
}

View file

@ -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",