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
+ }
+}