fix: prevent launcher exit disposal deadlock

This commit is contained in:
Erik 2026-08-18 08:43:29 +02:00
parent 15539a22a6
commit 0a934cf578
9 changed files with 2596 additions and 15 deletions

View file

@ -500,6 +500,58 @@ public sealed class LauncherProcessSupervisorTests
Assert.Equal(23, supervisor.ExitCode);
}
[Fact]
public async Task DisposeAllowsAnAlreadyCapturedExitCallbackToComplete()
{
var factory = new ExitDisposeRaceChildProcessFactory();
var supervisor = new LauncherProcessSupervisor(factory);
var states = new ConcurrentQueue<LauncherSessionState>();
supervisor.StateChanged += (_, state) => states.Enqueue(state);
supervisor.Start(Spec(), password: null);
ExitDisposeRaceChildProcess child = factory.LastCreated!;
child.BeginExit(47);
Assert.True(
child.ExitCallbackReady.Wait(TimeSpan.FromSeconds(5)),
"the captured exit callback did not reach its barrier");
Task disposeTask = Task.Run(supervisor.Dispose);
try
{
// Child disposal releases the captured callback and then waits
// for it to return. If the supervisor still holds _gate around
// child.Dispose(), the callback blocks in SetState and this
// bounded wait deterministically times out.
await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
}
finally
{
// Keeps the test process recoverable against the old deadlocking
// implementation: release the fake's disposal wait so the failed
// assertion cannot leave a ThreadPool worker permanently blocked.
child.ReleaseDisposeForCleanup();
await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
await child.ExitCallbackTask.WaitAsync(TimeSpan.FromSeconds(5));
}
Assert.True(child.Disposed);
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.Equal(47, supervisor.ExitCode);
Assert.Equal(
[
LauncherSessionState.Starting,
LauncherSessionState.Running,
LauncherSessionState.Exited,
],
states);
// Idempotent convergence: a second explicit disposal neither touches
// the child again nor republishes terminal state.
supervisor.Dispose();
Assert.Equal(1, child.DisposeCallCount);
Assert.Single(states, state => state == LauncherSessionState.Exited);
}
[Fact]
public void LauncherProcessSpecCarriesNoCredentialLikeMember()
{
@ -891,6 +943,108 @@ public sealed class LauncherProcessSupervisorTests
}
}
private sealed class ExitDisposeRaceChildProcessFactory
: ILauncherChildProcessFactory
{
public ExitDisposeRaceChildProcess? LastCreated { get; private set; }
public ILauncherChildProcess Create(LauncherProcessSpec spec)
{
LastCreated = new ExitDisposeRaceChildProcess();
return LastCreated;
}
}
/// <summary>
/// Deterministically models the System.Diagnostics.Process exit/dispose
/// lock cycle. BeginExit captures the current delegate before the
/// supervisor can unsubscribe it. Dispose then releases that callback and
/// waits for it to return, just as Process.Dispose can wait on in-flight
/// exit machinery. The cleanup release exists only to let the test fail
/// boundedly against the old implementation instead of hanging its host.
/// </summary>
private sealed class ExitDisposeRaceChildProcess : ILauncherChildProcess
{
private readonly StringWriter _standardInput = new();
private readonly ManualResetEventSlim _exitCallbackReady = new(false);
private readonly ManualResetEventSlim _releaseExitCallback = new(false);
private readonly ManualResetEventSlim _exitCallbackReturned = new(false);
private readonly ManualResetEventSlim _releaseDisposeForCleanup = new(false);
private EventHandler? _exited;
public bool HasExited { get; private set; }
public int ExitCode { get; private set; }
public TextWriter StandardInput => _standardInput;
public bool Disposed { get; private set; }
public int DisposeCallCount { get; private set; }
public ManualResetEventSlim ExitCallbackReady => _exitCallbackReady;
public Task ExitCallbackTask { get; private set; } = Task.CompletedTask;
public event EventHandler? Exited
{
add => _exited += value;
remove => _exited -= value;
}
public void Start()
{
}
public void BeginExit(int exitCode)
{
HasExited = true;
ExitCode = exitCode;
EventHandler? captured = _exited;
ExitCallbackTask = Task.Run(() =>
{
_exitCallbackReady.Set();
_releaseExitCallback.Wait();
try
{
captured?.Invoke(this, EventArgs.Empty);
}
finally
{
_exitCallbackReturned.Set();
}
});
}
public bool TryRequestGracefulStop() => false;
public bool CloseMainWindow() => false;
public void Kill()
{
throw new InvalidOperationException(
"the already-exited race child must not be killed");
}
public bool WaitForExit(TimeSpan timeout) => HasExited;
public void ReleaseDisposeForCleanup() =>
_releaseDisposeForCleanup.Set();
public void Dispose()
{
DisposeCallCount++;
_releaseExitCallback.Set();
_ = WaitHandle.WaitAny(
[
_exitCallbackReturned.WaitHandle,
_releaseDisposeForCleanup.WaitHandle,
]);
Disposed = true;
_standardInput.Dispose();
}
}
private sealed class FakeChildProcess(
LauncherProcessSpec spec,
bool exitsWithinStopTimeout,