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

@ -84,6 +84,46 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public void ValidateAcceptsCompleteProcessContentDescriptor()
{
using var file = TemporaryConfiguration.Create(
"""{"version":1,"process":{"content":{"datDirectory":"C:\\AC","preparedAssetPath":"C:\\AC\\acdream.pak"}},"sessions":[]}""");
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal(0, exitCode);
Assert.Contains("0 session(s)", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Theory]
[InlineData("""{"version":1,"process":{"content":{"datDirectory":"","preparedAssetPath":"package.pak"}},"sessions":[]}""")]
[InlineData("""{"version":1,"process":{"content":{"datDirectory":"dats","preparedAssetPath":""}},"sessions":[]}""")]
public void ValidateRejectsIncompleteProcessContentDescriptor(string json)
{
using var file = TemporaryConfiguration.Create(json);
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal(3, exitCode);
Assert.Contains(
"process.content",
error.ToString(),
StringComparison.Ordinal);
Assert.Equal(string.Empty, output.ToString());
}
[Theory]
[InlineData("duplicate-id")]
[InlineData("duplicate-credential")]

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