diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs index aa1f5750..e9f6b43f 100644 --- a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs +++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs @@ -25,7 +25,21 @@ public sealed class PtyTerminalSession : IDisposable // inherits the other's env (e.g. CLAUDEDO_PLANNING_TOKEN, breaking that session's own MCP // auth). Process-wide env leakage AFTER a launch has forked remains a documented limitation // — Porta.Pty has no per-launch env seam, so the vars stay set on the whole UI process. + // The wait is bounded (see WaitForLaunchGateAsync) — a hung launch (slow disk, AV scanning + // claude.exe, a Porta.Pty/ConPTY hiccup) must not freeze every other pane open behind it. private static readonly SemaphoreSlim s_launchGate = new(1, 1); + private static readonly TimeSpan s_launchGateTimeout = TimeSpan.FromSeconds(30); + + /// + /// Waits on for at most , throwing + /// instead of blocking forever. Never acquires the gate on + /// timeout, so callers must not release it in that case. + /// + internal static async Task WaitForLaunchGateAsync(SemaphoreSlim gate, TimeSpan timeout, CancellationToken ct) + { + if (!await gate.WaitAsync(timeout, ct)) + throw new TimeoutException("Another terminal launch is still starting up. Please retry in a moment."); + } private TerminalControl? _control; private bool _disposed; @@ -50,7 +64,7 @@ public sealed class PtyTerminalSession : IDisposable control.Args = new List(descriptor.Args); control.StartingDirectory = descriptor.Cwd; - await s_launchGate.WaitAsync(ct); + await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct); try { foreach (var (key, value) in descriptor.Env) diff --git a/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs b/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs new file mode 100644 index 00000000..5e6f23fc --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/PtyTerminalSessionTests.cs @@ -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(() => + 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(() => + 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 + } +}