fix(plugins): close LA5 ownership races

This commit is contained in:
Erik 2026-08-14 19:28:14 +02:00
parent fbe9c8a288
commit f820eb258d
14 changed files with 467 additions and 107 deletions

View file

@ -52,8 +52,9 @@ internal sealed class HeadlessPluginHost
public ISelectionService Selection => _runtime.ActionOwner.Selection;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
/// <summary>Test-only barrier after the borrowed replay snapshot is
/// captured and before delivery starts.</summary>
/// <summary>Test-only barrier invoked after the first replay item is
/// captured while Runtime's exact active-membership read lease is still
/// held.</summary>
internal Action? ReplayCapturedForTest { get; set; }
/// <summary>
@ -88,11 +89,12 @@ internal sealed class HeadlessPluginHost
// Arm the pending queue before borrowing Runtime's snapshot. This
// avoids a host-lock/Runtime-lock inversion while the identity
// dedup below collapses any registration present in both views.
var visitor = new SnapshotVisitor(_runtime);
var visitor = new SnapshotVisitor(
_runtime,
ReplayCapturedForTest);
_runtime.Entities.Visit(visitor);
ReplayEntity[] replay = visitor.Items.ToArray();
ReplayCapturedForTest?.Invoke();
foreach (ReplayEntity item in replay)
{
lock (_eventGate)
@ -234,16 +236,23 @@ internal sealed class HeadlessPluginHost
catch { }
}
private sealed class SnapshotVisitor(GameRuntime runtime)
private sealed class SnapshotVisitor(
GameRuntime runtime,
Action? captureBarrier = null)
: IRuntimeEntityVisitor
{
private Action? _captureBarrier = captureBarrier;
internal List<ReplayEntity> Items { get; } =
new(runtime.Entities.Count);
public void Visit(in RuntimeEntitySnapshot entity) =>
public void Visit(in RuntimeEntitySnapshot entity)
{
Items.Add(new ReplayEntity(
entity.Identity,
Convert(runtime, entity)));
Interlocked.Exchange(ref _captureBarrier, null)?.Invoke();
}
}
private void RebuildLiveSnapshotLocked()