Merge claudedo/43bb79c93e694f7cbec8ae41a05004bc

This commit is contained in:
mika kuns
2026-08-06 14:40:24 +02:00
2 changed files with 53 additions and 1 deletions
+15 -1
View File
@@ -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);
/// <summary>
/// Waits on <paramref name="gate"/> for at most <paramref name="timeout"/>, throwing
/// <see cref="TimeoutException"/> instead of blocking forever. Never acquires the gate on
/// timeout, so callers must not release it in that case.
/// </summary>
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<string>(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)
@@ -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
}
}