fix(ui): bound the ConPTY launch-gate wait so a hung launch can't freeze other panes

StartAsync awaited the static launch-gate semaphore with no timeout, so one
hung launch (slow disk, AV scanning claude.exe, a Porta.Pty/ConPTY hiccup)
blocked every subsequent pane open behind it indefinitely, including Retry.
Extracted the wait into WaitForLaunchGateAsync with a 30s timeout that
throws TimeoutException before the try/finally (never releasing a gate it
didn't acquire); the exception flows through StartCoreAsync's existing
catch into StartError, so the pane shows an error + Retry instead of
hanging. StartError renders ex.Message directly (no locale key involved),
so no locale changes were needed.

PtyTerminalSession.StartAsync itself needs a real TerminalControl and stays
impractical to unit-test directly, so tests target the extracted
WaitForLaunchGateAsync helper against a plain SemaphoreSlim: timeout throws,
timeout never acquires the gate, and success still acquires it.
This commit is contained in:
mika kuns
2026-08-06 14:29:05 +02:00
parent bac8387069
commit 4310f88ebf
2 changed files with 53 additions and 1 deletions
@@ -0,0 +1,38 @@
using System.Threading;
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests.Services;
public class PtyTerminalSessionTests
{
[Fact]
public async Task WaitForLaunchGateAsync_ThrowsTimeoutException_WhenGateStaysHeld()
{
using var gate = new SemaphoreSlim(0, 1); // never released — simulates a hung launch
await Assert.ThrowsAsync<TimeoutException>(() =>
PtyTerminalSession.WaitForLaunchGateAsync(gate, TimeSpan.FromMilliseconds(50), CancellationToken.None));
}
[Fact]
public async Task WaitForLaunchGateAsync_DoesNotReleaseGate_ItNeverAcquired()
{
using var gate = new SemaphoreSlim(0, 1);
await Assert.ThrowsAsync<TimeoutException>(() =>
PtyTerminalSession.WaitForLaunchGateAsync(gate, TimeSpan.FromMilliseconds(50), CancellationToken.None));
Assert.Equal(0, gate.CurrentCount); // still held; a bad fix would Release() what it never acquired
}
[Fact]
public async Task WaitForLaunchGateAsync_AcquiresGate_WhenAvailable()
{
using var gate = new SemaphoreSlim(1, 1);
await PtyTerminalSession.WaitForLaunchGateAsync(gate, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.Equal(0, gate.CurrentCount); // acquired
}
}