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.
111 lines
4.8 KiB
C#
111 lines
4.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Iciclecreek.Terminal;
|
|
|
|
namespace ClaudeDo.Ui.Services;
|
|
|
|
/// <summary>
|
|
/// Thin wrapper around <see cref="TerminalControl.LaunchProcess()"/> — the library owns the
|
|
/// Porta.Pty spawn, keyboard input, rendering, resize, and focus end to end (see
|
|
/// <c>spikes/ConPtyTerminal/MainWindow.axaml.cs</c>, which proved this renders correctly and
|
|
/// stays responsive). We only:
|
|
/// 1) apply <see cref="TerminalLaunchDescriptor.Env"/> onto the current process environment
|
|
/// before launching — Porta.Pty inherits the calling process's environment and there is no
|
|
/// per-launch env seam on <see cref="TerminalControl"/>/<see cref="TerminalControl.LaunchProcess()"/> —
|
|
/// 2) relay the control's own <see cref="TerminalControl.ProcessExited"/> event and
|
|
/// <see cref="TerminalControl.Kill()"/> method.
|
|
/// </summary>
|
|
public sealed class PtyTerminalSession : IDisposable
|
|
{
|
|
// Guards the set-env + LaunchProcess critical section below: two sessions starting
|
|
// back-to-back (e.g. planning sessions for two different tasks) could otherwise interleave
|
|
// their SetEnvironmentVariable calls before either LaunchProcess() forks, so one process
|
|
// 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;
|
|
|
|
public bool IsRunning { get; private set; }
|
|
public int? ExitCode { get; private set; }
|
|
|
|
/// <summary>Raised when the child process exits, on the UI thread, with its exit code.</summary>
|
|
public event EventHandler<int>? ProcessExited;
|
|
|
|
/// <summary>
|
|
/// Applies <paramref name="descriptor"/>'s env vars to the current process, then drives
|
|
/// <paramref name="control"/> to launch it via <see cref="TerminalControl.LaunchProcess()"/>.
|
|
/// </summary>
|
|
public async Task StartAsync(TerminalLaunchDescriptor descriptor, TerminalControl control, CancellationToken ct = default)
|
|
{
|
|
if (_control is not null) throw new InvalidOperationException("Session already started.");
|
|
_control = control;
|
|
control.ProcessExited += OnControlProcessExited;
|
|
|
|
control.Process = descriptor.Exe;
|
|
control.Args = new List<string>(descriptor.Args);
|
|
control.StartingDirectory = descriptor.Cwd;
|
|
|
|
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
|
|
try
|
|
{
|
|
foreach (var (key, value) in descriptor.Env)
|
|
Environment.SetEnvironmentVariable(key, value);
|
|
|
|
await control.LaunchProcess();
|
|
}
|
|
finally
|
|
{
|
|
s_launchGate.Release();
|
|
}
|
|
|
|
// Permanent reparent mode: TerminalView.OnDetachedFromLogicalTree kills the child
|
|
// process unless BeginReparent() suppressed it, and Mission Control detaches pane
|
|
// views routinely (overview-grid rebuilds on pane add/remove, focus-mode tab
|
|
// switches). ClaudeDo owns teardown explicitly instead — ConPtyPaneViewModel.Dispose
|
|
// calls Kill() when a pane closes — so EndReparent is deliberately never called.
|
|
control.BeginReparent();
|
|
IsRunning = true;
|
|
}
|
|
|
|
private void OnControlProcessExited(object? sender, ProcessExitedEventArgs e)
|
|
{
|
|
IsRunning = false;
|
|
ExitCode = e.ExitCode;
|
|
ProcessExited?.Invoke(this, e.ExitCode);
|
|
}
|
|
|
|
public void Kill()
|
|
{
|
|
try { _control?.Kill(); }
|
|
catch (Exception) { /* already exited */ }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
if (_control is not null)
|
|
_control.ProcessExited -= OnControlProcessExited;
|
|
}
|
|
}
|