fix: prevent launcher exit disposal deadlock
This commit is contained in:
parent
15539a22a6
commit
0a934cf578
9 changed files with 2596 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue