acdream/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs

673 lines
27 KiB
C#

using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.Configuration;
using AcDream.App.Plugins;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Settings;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.Plugins;
/// <summary>
/// Crosses the real external-package boundary. The descriptor and asset source
/// originate in a collectible plugin ALC; everything after registration is the
/// graphical host's production catalog/settings/controller path.
/// </summary>
public sealed class ExternalRenderPackPackageLifecycleTests
{
private const string PackageId = "acdream.test.external-render-pack-package";
private const string PackId = "acdream.test.external-render-pack";
private static readonly RenderPackActivationExtent Extent = new(1280, 720, 1);
[Fact]
public void LiveProductionCatalogWithdrawsAtFrameBoundaryAndAcceptsCorrectedReregistration()
{
using var temporary = new TemporaryDirectory();
RunLiveProductionCatalogLifecycle(temporary);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void RunLiveProductionCatalogLifecycle(TemporaryDirectory temporary)
{
ApplicationPathSet paths = Paths(temporary.Path);
_ = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json");
Directory.CreateDirectory(paths.ConfigDirectory);
var selected = new RenderPackSelectionSettings(PackId, "1.0.0", "low");
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
DisplaySettings.Default with { RenderPack = selected });
using var registry = new BufferedRenderPackRegistry();
var source = new RenderPackCatalogSource(
registry,
RenderPackHostCapabilities.Conformance);
using var device = new RecordingGpuDevice();
var factory = new CountingFactory(device);
using var controller = new RenderPackController(
source.Snapshot,
factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance,
catalogSource: source);
var settings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(settingsPath),
log: static _ => { });
using var binding = new RenderPackSelectionBinding(settings, controller);
GraphicalPluginSession first = StartSession(
paths,
Path.Combine(temporary.Path, "live-v1-status.jsonl"),
registry);
Assert.Equal(
RenderPackActivationState.Active,
binding.ApplyAtFrameBoundary(Extent).State);
WeakReference firstAssets = CaptureAssetWeakReference(registry);
WeakReference firstContext = Assert.Single(first.CaptureLoadContextWeakReferences());
// No settings write/request accompanies uninstall. The registry event
// alone must retire the active production runtime at the next frame.
first.Dispose();
RenderPackActivationSnapshot withdrawn = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, withdrawn.State);
Assert.Contains("was withdrawn", withdrawn.Reason, StringComparison.Ordinal);
Assert.True(settings.Display.RenderPack.IsRetail);
Assert.Null(controller.ActiveRuntime);
Assert.Empty(registry.Snapshot());
Collect(firstAssets);
Collect(firstContext);
// The stable persisted identity is deliberately unchanged. A new
// registration generation clears the old quarantine on an explicit
// re-selection; production composition/controller objects stay live.
GraphicalPluginSession corrected = StartSession(
paths,
Path.Combine(temporary.Path, "live-corrected-status.jsonl"),
registry);
long correctedRegistration = CaptureRegistrationId(registry);
Assert.True(correctedRegistration > 1);
settings.SaveDisplay(settings.Display with { RenderPack = selected });
RenderPackActivationSnapshot recovered = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.Active, recovered.State);
Assert.Equal(selected, recovered.Selection);
Assert.Equal(2, factory.BuildCount);
WeakReference correctedAssets = CaptureAssetWeakReference(registry);
WeakReference correctedContext = Assert.Single(
corrected.CaptureLoadContextWeakReferences());
corrected.Dispose();
Assert.Equal(
RenderPackActivationState.FailedToRetail,
binding.ApplyAtFrameBoundary(Extent).State);
binding.Dispose();
controller.Dispose();
Collect(correctedAssets);
Collect(correctedContext);
AssertZeroGpuPackResources(device);
}
[Fact]
public void PackageSelectionUpdateWithdrawalAndPersistenceConvergeWithoutPackResources()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
string settingsPath = Path.Combine(paths.ConfigDirectory, "settings.json");
Directory.CreateDirectory(paths.ConfigDirectory);
var persistedV1 = new RenderPackSelectionSettings(PackId, "1.0.0", "low");
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
DisplaySettings.Default with { RenderPack = persistedV1 });
using var registry = new BufferedRenderPackRegistry();
using var device = new RecordingGpuDevice();
LifetimeReferences v1 = RunV1Lifecycle(
paths,
temporary.Path,
settingsPath,
persistedV1,
registry,
device);
Collect(v1.Assets);
Collect(v1.Context);
WritePackageVersion(packageDirectory, new Version(2, 0, 0));
WriteManifest(packageDirectory, new Version(2, 0, 0));
new JsonRuntimeSettingsStorage(settingsPath).SaveDisplay(
DisplaySettings.Default with { RenderPack = persistedV1 });
LifetimeReferences v2 = RunV2Lifecycle(
paths,
temporary.Path,
settingsPath,
persistedV1,
registry,
device);
Collect(v2.Assets);
Collect(v2.Context);
Assert.Empty(registry.Snapshot());
AssertZeroGpuPackResources(device);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static LifetimeReferences RunV1Lifecycle(
ApplicationPathSet paths,
string root,
string settingsPath,
RenderPackSelectionSettings persistedV1,
BufferedRenderPackRegistry registry,
RecordingGpuDevice device)
{
var factory = new CountingFactory(device);
using var controller = Controller(registry, factory);
var settings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(settingsPath),
log: static _ => { });
using var binding = new RenderPackSelectionBinding(settings, controller);
GraphicalPluginSession session = StartSession(
paths,
Path.Combine(root, "v1-status.jsonl"),
registry);
Assert.Equal(1, session.LoadedCount);
AssertRegisteredVersion(registry, new Version(1, 0, 0));
WeakReference assets = CaptureAssetWeakReference(registry);
RenderPackActivationSnapshot active = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.Active, active.State);
Assert.Equal(persistedV1, active.Selection);
Assert.IsAssignableFrom<IDefaultWorldPathRenderPackRuntime>(controller.ActiveRuntime);
Assert.Equal(1, factory.BuildCount);
AssertZeroGpuPackResources(device);
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
session.Dispose();
Assert.Empty(registry.Snapshot());
settings.SaveDisplay(settings.Display with
{
RenderPack = RenderPackSelectionSettings.Retail,
});
Assert.Equal(
RenderPackActivationState.Retail,
binding.ApplyAtFrameBoundary(Extent).State);
Assert.Null(controller.ActiveRuntime);
settings.SaveDisplay(settings.Display with { RenderPack = persistedV1 });
RenderPackActivationSnapshot removed = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, removed.State);
Assert.Contains("is not installed", removed.Reason, StringComparison.Ordinal);
Assert.True(settings.Display.RenderPack.IsRetail);
Assert.True(
new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack.IsRetail);
Assert.Equal(1, factory.BuildCount);
controller.Request(persistedV1);
RenderPackActivationSnapshot latched = controller.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, latched.State);
Assert.Contains(
"failed for the current registration",
latched.Reason,
StringComparison.Ordinal);
Assert.Equal(1, factory.BuildCount);
return new LifetimeReferences(context, assets);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static LifetimeReferences RunV2Lifecycle(
ApplicationPathSet paths,
string root,
string settingsPath,
RenderPackSelectionSettings persistedV1,
BufferedRenderPackRegistry registry,
RecordingGpuDevice device)
{
var factory = new CountingFactory(device);
using var controller = Controller(registry, factory);
GraphicalPluginSession session = StartSession(
paths,
Path.Combine(root, "v2-status.jsonl"),
registry);
Assert.Equal(1, session.LoadedCount);
AssertRegisteredVersion(registry, new Version(2, 0, 0));
WeakReference assets = CaptureAssetWeakReference(registry);
var settings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(settingsPath),
log: static _ => { });
using var binding = new RenderPackSelectionBinding(settings, controller);
RenderPackActivationSnapshot stale = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, stale.State);
Assert.Equal(
"Render pack 'acdream.test.external-render-pack' version 1.0.0 was selected, "
+ "but version 2.0.0 is installed.",
stale.Reason);
Assert.True(settings.Display.RenderPack.IsRetail);
Assert.Equal(0, factory.BuildCount);
controller.Request(persistedV1);
RenderPackActivationSnapshot noRetry = controller.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.FailedToRetail, noRetry.State);
Assert.Contains("will not be retried", noRetry.Reason, StringComparison.Ordinal);
Assert.Equal(0, factory.BuildCount);
var selectedV2 = new RenderPackSelectionSettings(PackId, "2.0.0", "high");
settings.SaveDisplay(settings.Display with { RenderPack = selectedV2 });
RenderPackActivationSnapshot updated = binding.ApplyAtFrameBoundary(Extent);
Assert.Equal(RenderPackActivationState.Active, updated.State);
Assert.Equal(selectedV2, updated.Selection);
Assert.Equal("high", controller.ActiveRuntime!.Preset.Id);
Assert.Equal(1, factory.BuildCount);
Assert.Equal(
selectedV2,
new JsonRuntimeSettingsStorage(settingsPath).LoadDisplay().RenderPack);
AssertZeroGpuPackResources(device);
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
session.Dispose();
Assert.Empty(registry.Snapshot());
settings.SaveDisplay(settings.Display with
{
RenderPack = RenderPackSelectionSettings.Retail,
});
Assert.Equal(
RenderPackActivationState.Retail,
binding.ApplyAtFrameBoundary(Extent).State);
Assert.Null(controller.ActiveRuntime);
return new LifetimeReferences(context, assets);
}
[Fact]
public void MalformedThenFailingPackageCanBeCorrectedWithoutLeakingRegistrationOrAlc()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string packageDirectory = InstallPackage(paths.PluginsDirectory, new Version(1, 0, 0));
string manifestPath = Path.Combine(packageDirectory, "plugin.json");
File.WriteAllText(manifestPath, "{ this is not a plugin manifest }");
using var registry = new BufferedRenderPackRegistry();
GraphicalPluginSession malformed = StartSession(
paths,
Path.Combine(temporary.Path, "malformed-status.jsonl"),
registry);
Assert.Equal(0, malformed.LoadedCount);
Assert.Empty(registry.Snapshot());
Assert.Empty(malformed.CaptureLoadContextWeakReferences());
JsonElement malformedFailure = Assert.Single(ReadStatuses(
Path.Combine(temporary.Path, "malformed-status.jsonl")),
value => value.GetProperty("e").GetString() == "pluginFailed");
Assert.Contains(
"invalid start of a property name",
malformedFailure.GetProperty("error").GetString(),
StringComparison.OrdinalIgnoreCase);
malformed.Dispose();
WriteManifest(packageDirectory, new Version(1, 0, 0));
string failureMarker = Path.Combine(
packageDirectory,
"throw-after-render-pack-register");
File.WriteAllText(failureMarker, string.Empty);
GraphicalPluginSession failing = StartSession(
paths,
Path.Combine(temporary.Path, "failing-status.jsonl"),
registry);
Assert.Equal(0, failing.LoadedCount);
Assert.Empty(registry.Snapshot());
WeakReference failedContext = Assert.Single(
failing.CaptureLoadContextWeakReferences());
Assert.Contains(
"failed after publishing its descriptor",
Assert.Single(ReadStatuses(
Path.Combine(temporary.Path, "failing-status.jsonl")),
value => value.GetProperty("e").GetString() == "pluginFailed")
.GetProperty("error").GetString(),
StringComparison.Ordinal);
failing.Dispose();
Collect(failedContext);
File.Delete(failureMarker);
string zeroMarker = Path.Combine(packageDirectory, "register-no-render-packs");
File.WriteAllText(zeroMarker, string.Empty);
GraphicalPluginSession zeroRegistration = StartSession(
paths,
Path.Combine(temporary.Path, "zero-registration-status.jsonl"),
registry);
Assert.Equal(0, zeroRegistration.LoadedCount);
Assert.Empty(registry.Snapshot());
WeakReference zeroContext = Assert.Single(
zeroRegistration.CaptureLoadContextWeakReferences());
Assert.Contains(
"registered no packs",
Assert.Single(ReadStatuses(
Path.Combine(temporary.Path, "zero-registration-status.jsonl")),
value => value.GetProperty("e").GetString() == "pluginFailed")
.GetProperty("error").GetString(),
StringComparison.Ordinal);
zeroRegistration.Dispose();
Collect(zeroContext);
File.Delete(zeroMarker);
GraphicalPluginSession corrected = StartSession(
paths,
Path.Combine(temporary.Path, "corrected-status.jsonl"),
registry);
Assert.Equal(1, corrected.LoadedCount);
AssertCompatibleRegistration(registry);
WeakReference correctedAssets = CaptureAssetWeakReference(registry);
WeakReference correctedContext = Assert.Single(
corrected.CaptureLoadContextWeakReferences());
corrected.Dispose();
Assert.Empty(registry.Snapshot());
Collect(correctedAssets);
Collect(correctedContext);
}
[Theory]
[InlineData(
"AcDream.Plugin.Tests.Fixtures.InvalidRenderPackMultiple",
"must contain exactly one IRenderPackPlugin implementation")]
[InlineData(
"AcDream.Plugin.Tests.Fixtures.InvalidRenderPackInternal",
"must be public")]
public void InvalidRenderPackEntrypointShapeRollsBackCollectiblePackage(
string fixtureName,
string expectedFailure)
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string packageId = "acdream.test.invalid-render-pack";
string source = FixtureAssemblyPath(fixtureName);
string packageDirectory = Path.Combine(paths.PluginsDirectory, packageId);
Directory.CreateDirectory(packageDirectory);
File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source)));
File.WriteAllText(
Path.Combine(packageDirectory, "plugin.json"),
JsonSerializer.Serialize(new
{
id = packageId,
displayName = "Invalid render-pack entry fixture",
version = "1.0.0",
entryDll = Path.GetFileName(source),
apiVersion = 1,
kinds = new[] { "RenderPack" },
}));
using var registry = new BufferedRenderPackRegistry();
string statusPath = Path.Combine(temporary.Path, fixtureName + ".jsonl");
GraphicalPluginSession session = StartSession(
paths,
statusPath,
registry,
packageId);
Assert.Equal(0, session.LoadedCount);
Assert.Empty(registry.Snapshot());
Assert.Contains(
expectedFailure,
Assert.Single(ReadStatuses(statusPath),
value => value.GetProperty("e").GetString() == "pluginFailed")
.GetProperty("error").GetString(),
StringComparison.Ordinal);
WeakReference context = Assert.Single(session.CaptureLoadContextWeakReferences());
session.Dispose();
Collect(context);
}
private static RenderPackController Controller(
BufferedRenderPackRegistry registry,
IRenderPackRuntimeFactory factory)
{
var source = new RenderPackCatalogSource(
registry,
RenderPackHostCapabilities.Conformance);
return new RenderPackController(
source.Snapshot,
factory,
preparationScheduler: InlineRenderPackPreparationScheduler.Instance,
catalogSource: source);
}
private static GraphicalPluginSession StartSession(
ApplicationPathSet paths,
string statusPath,
BufferedRenderPackRegistry registry,
string packageId = PackageId)
{
var host = new AppPluginHost(
new NullLogger(),
new WorldGameState(),
new WorldEvents(),
new SelectionState(),
new BufferedUiRegistry(),
NoOpAutomationSurface.Instance);
var session = GraphicalPluginSession.Create(
paths,
[packageId],
"external-render-pack-test",
host,
new SessionStatusWriter(statusPath),
registry);
session.Start();
return session;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void AssertRegisteredVersion(
BufferedRenderPackRegistry registry,
Version expected)
{
BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot());
Assert.Equal(PackId, registration.Descriptor.Id);
Assert.Equal(expected, registration.Descriptor.PackVersion);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference CaptureAssetWeakReference(
BufferedRenderPackRegistry registry) =>
new(Assert.Single(registry.Snapshot()).Assets);
[MethodImpl(MethodImplOptions.NoInlining)]
private static long CaptureRegistrationId(BufferedRenderPackRegistry registry) =>
Assert.Single(registry.Snapshot()).RegistrationId;
[MethodImpl(MethodImplOptions.NoInlining)]
private static void AssertCompatibleRegistration(BufferedRenderPackRegistry registry)
{
BufferedRenderPackRegistration registration = Assert.Single(registry.Snapshot());
Assert.Equal(PackId, registration.Descriptor.Id);
Assert.True(RenderPackCatalog.Build(
registry.Snapshot(),
RenderPackHostCapabilities.Conformance).TryGet(
PackId,
out RenderPackCatalogEntry catalogEntry));
Assert.True(catalogEntry.IsCompatible, catalogEntry.IncompatibilityReason);
}
private static ApplicationPathSet Paths(string root) => new(
Path.Combine(root, "config"),
Path.Combine(root, "data"),
Path.Combine(root, "cache"),
LegacyConfigDirectory: null);
private static string InstallPackage(string root, Version version)
{
string source = FixtureAssemblyPath();
string packageDirectory = Path.Combine(root, PackageId);
Directory.CreateDirectory(packageDirectory);
File.Copy(source, Path.Combine(packageDirectory, Path.GetFileName(source)));
WritePackageVersion(packageDirectory, version);
WriteManifest(packageDirectory, version);
return packageDirectory;
}
private static void WritePackageVersion(string directory, Version version) =>
File.WriteAllText(
Path.Combine(directory, "render-pack-version.txt"),
version.ToString());
private static void WriteManifest(string directory, Version version) =>
File.WriteAllText(
Path.Combine(directory, "plugin.json"),
JsonSerializer.Serialize(new
{
id = PackageId,
displayName = "External render-pack package fixture",
version = version.ToString(),
entryDll = Path.GetFileName(FixtureAssemblyPath()),
apiVersion = 1,
kinds = new[] { "RenderPack" },
}));
private static string FixtureAssemblyPath()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
return Path.Combine(
FindRepoRoot(),
"tests",
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
"bin",
configuration,
"net10.0",
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
}
private static string FixtureAssemblyPath(string projectName)
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
return Path.Combine(
FindRepoRoot(),
"tests",
projectName,
"bin",
configuration,
"net10.0",
projectName + ".dll");
}
private static JsonElement[] ReadStatuses(string path) =>
File.ReadAllLines(path)
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
private static void AssertZeroGpuPackResources(RecordingGpuDevice device)
{
Assert.Empty(device.CreatedBuffers);
Assert.Empty(device.CreatedPipelines);
Assert.Empty(device.CreatedTextures);
Assert.Empty(device.CreatedRenderTargets);
Assert.Empty(device.CreatedDirectionalDepthTargets);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Collect(WeakReference context)
{
for (int attempt = 0; attempt < 12 && context.IsAlive; attempt++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
Assert.False(context.IsAlive);
// On Windows the collectible context can become unreachable one GC
// before CoreCLR closes the mapped assembly file. One finalizer/GC
// turn makes the file-lifetime assertion in TemporaryDirectory exact.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
private static string FindRepoRoot()
{
string? configured = Environment.GetEnvironmentVariable("ACDREAM_REPO_ROOT");
if (!string.IsNullOrWhiteSpace(configured)
&& File.Exists(Path.Combine(configured, "AcDream.slnx")))
{
return Path.GetFullPath(configured);
}
foreach (string start in new[]
{
AppContext.BaseDirectory,
Directory.GetCurrentDirectory(),
})
{
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
}
throw new InvalidOperationException("Repository root not found.");
}
private sealed class CountingFactory(IGpuDevice device) : IRenderPackRuntimeFactory
{
private readonly AtmosphericRenderPackRuntimeFactory _inner = new(device);
internal int BuildCount { get; private set; }
public IRenderPackRuntime Build(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
BuildCount++;
return _inner.Build(descriptor, assets, preset, userSettingOverrides);
}
}
private sealed class NullLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private readonly record struct LifetimeReferences(
WeakReference Context,
WeakReference Assets);
private sealed class TemporaryDirectory : IDisposable
{
internal TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-external-pack-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
internal string Path { get; }
public void Dispose()
{
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 < 249)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Thread.Sleep(20);
}
}
}
}
}