fix(plugins): close LA5 host lifecycle review
This commit is contained in:
parent
95f4be94db
commit
fbe9c8a288
25 changed files with 1043 additions and 120 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System.Text.Json;
|
||||
using System.Net;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Headless.Configuration;
|
||||
|
|
@ -7,6 +8,9 @@ using AcDream.Headless.Diagnostics;
|
|||
using AcDream.Headless.Hosting;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Tests.Fixtures.CampaignLa;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
|
|
@ -14,6 +18,7 @@ public sealed class HeadlessPluginSessionTests
|
|||
{
|
||||
private const string FixtureId = "acdream.test.host-fixture";
|
||||
private const string BrokenId = "acdream.test.broken";
|
||||
private const string ThrowingId = "acdream.test.throwing-fixture";
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
|
||||
|
|
@ -29,8 +34,10 @@ public sealed class HeadlessPluginSessionTests
|
|||
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
|
||||
credential,
|
||||
diagnostics,
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
HeadlessPluginSession plugins = session.Plugins;
|
||||
_ = session.Start();
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||
|
||||
Assert.Equal(1, plugins.LoadedCount);
|
||||
|
|
@ -47,12 +54,17 @@ public sealed class HeadlessPluginSessionTests
|
|||
Assert.Equal(2, plugins.Host.State.Entities.Count);
|
||||
|
||||
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
|
||||
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
|
||||
Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString());
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "pluginLoaded", "pluginFailed", "connected",
|
||||
"characterList", "enteredWorld",
|
||||
],
|
||||
EventNames(statuses));
|
||||
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
|
||||
Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString());
|
||||
Assert.Contains(
|
||||
"entry dll not found",
|
||||
statuses[1].GetProperty("error").GetString()!,
|
||||
statuses[2].GetProperty("error").GetString()!,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
|
||||
|
||||
|
|
@ -79,13 +91,126 @@ public sealed class HeadlessPluginSessionTests
|
|||
Descriptor([], statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(output),
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
|
||||
_ = session.Start();
|
||||
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||
Assert.False(File.Exists(statusPath));
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
EventNames(ReadStatuses(statusPath)));
|
||||
Assert.DoesNotContain("fixture-", output.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
string pluginDirectory = InstallFixture(
|
||||
temporary.Path,
|
||||
ThrowingId,
|
||||
"throwing-fixture");
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "throw-after-register"),
|
||||
string.Empty);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var session = new HeadlessSessionHost(
|
||||
Descriptor([ThrowingId], statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
|
||||
_ = session.Start();
|
||||
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||
Assert.False(File.Exists(
|
||||
Path.Combine(pluginDirectory, "unexpected-callback")));
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "pluginFailed", "connected", "characterList",
|
||||
"enteredWorld",
|
||||
],
|
||||
EventNames(ReadStatuses(statusPath)));
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
session.Plugins.CaptureLoadContextWeakReferences());
|
||||
session.Dispose();
|
||||
Collect(context);
|
||||
Assert.False(context.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, FixtureId);
|
||||
string configPath = Path.Combine(temporary.Path, "probe.json");
|
||||
File.WriteAllText(
|
||||
configPath,
|
||||
LauncherCoreSessionConfigFixture.ComposeProbe());
|
||||
HeadlessSessionDescriptor descriptor = Assert.Single(
|
||||
HeadlessConfigurationLoader.Load(configPath).Sessions)! with
|
||||
{
|
||||
StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"),
|
||||
};
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var session = new HeadlessSessionHost(
|
||||
descriptor,
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
|
||||
RuntimeSessionStartResult result = session.Start();
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status);
|
||||
Assert.NotNull(descriptor.Plugins);
|
||||
Assert.Empty(descriptor.Plugins);
|
||||
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList"],
|
||||
EventNames(ReadStatuses(descriptor.StatusFile!)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var session = new HeadlessSessionHost(
|
||||
Descriptor([], Path.Combine(temporary.Path, "status.jsonl")),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||
HeadlessPluginHost host = session.Plugins.Host;
|
||||
using var replayCaptured = new ManualResetEventSlim();
|
||||
using var releaseReplay = new ManualResetEventSlim();
|
||||
host.ReplayCapturedForTest = () =>
|
||||
{
|
||||
replayCaptured.Set();
|
||||
Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10)));
|
||||
};
|
||||
var observed = new List<uint>();
|
||||
Action<WorldEntitySnapshot> handler = snapshot =>
|
||||
{
|
||||
lock (observed)
|
||||
observed.Add(snapshot.Id);
|
||||
};
|
||||
|
||||
Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
|
||||
Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
|
||||
releaseReplay.Set();
|
||||
await subscribe.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
host.Events.EntitySpawned -= handler;
|
||||
|
||||
Assert.Equal([1_000_000u, 1_000_001u], observed);
|
||||
}
|
||||
|
||||
private static HeadlessSessionDescriptor Descriptor(
|
||||
List<string> plugins,
|
||||
string statusPath) => new()
|
||||
|
|
@ -144,15 +269,19 @@ public sealed class HeadlessPluginSessionTests
|
|||
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||
|
||||
private static void InstallFixture(string root, string id)
|
||||
private static string InstallFixture(
|
||||
string root,
|
||||
string id,
|
||||
string directoryName = "host-fixture")
|
||||
{
|
||||
string source = FixtureAssemblyPath();
|
||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||
string pluginDirectory = Path.Combine(root, "host-fixture");
|
||||
string pluginDirectory = Path.Combine(root, directoryName);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
string fileName = Path.GetFileName(source);
|
||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||
WriteManifest(pluginDirectory, id, fileName);
|
||||
return pluginDirectory;
|
||||
}
|
||||
|
||||
private static void InstallBrokenPlugin(string root, string id)
|
||||
|
|
@ -214,6 +343,39 @@ public sealed class HeadlessPluginSessionTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
private static readonly CharacterList.Parsed Characters = new(
|
||||
0u,
|
||||
[new CharacterList.Character(0x50000001u, "Fixture", 0u)],
|
||||
[],
|
||||
1,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
|
||||
|
||||
public void Connect(WorldSession session, string user, string password)
|
||||
{
|
||||
}
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters;
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||
{
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session) => session.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
|
|
@ -228,8 +390,22 @@ public sealed class HeadlessPluginSessionTests
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
Directory.Delete(Path, recursive: true);
|
||||
for (int attempt = 0; Directory.Exists(Path); attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
return;
|
||||
}
|
||||
catch (Exception error)
|
||||
when (error is IOException or UnauthorizedAccessException
|
||||
&& attempt < 9)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(
|
||||
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
|
||||
|
||||
Assert.NotNull(session.Plugins);
|
||||
Assert.Empty(session.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderPreservesProbeLoadNoneAllowList()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
LauncherCoreSessionConfigFixture.ComposeProbe());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(
|
||||
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
|
||||
|
||||
Assert.Equal(HeadlessSessionMode.Probe, session.Mode);
|
||||
Assert.NotNull(session.Plugins);
|
||||
Assert.Empty(session.Plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue