Merge claudedo/ab96ad81d417402088aa669ed52259e9
This commit is contained in:
@@ -13,9 +13,18 @@ namespace ClaudeDo.Ui.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable
|
public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
|
// A process that dies within this window of becoming "running" is treated as a startup
|
||||||
|
// failure (see OnSessionProcessExited) rather than a normal session end, even though the
|
||||||
|
// spawn itself succeeded — e.g. `claude --session-id <guid>` exiting immediately on an
|
||||||
|
// auth/network hiccup. Long enough to clear normal CLI startup, short enough not to
|
||||||
|
// mistake a real crash-after-use for a start failure.
|
||||||
|
private static readonly TimeSpan StartupGraceWindow = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
private readonly PtyTerminalSession _session = new();
|
private readonly PtyTerminalSession _session = new();
|
||||||
|
private readonly Func<DateTime> _utcNow;
|
||||||
private TerminalControl? _control;
|
private TerminalControl? _control;
|
||||||
private TerminalLaunchDescriptor? _pendingDescriptor;
|
private TerminalLaunchDescriptor? _pendingDescriptor;
|
||||||
|
private DateTime? _startedAtUtc;
|
||||||
|
|
||||||
[ObservableProperty] private bool _isRunning;
|
[ObservableProperty] private bool _isRunning;
|
||||||
[ObservableProperty] private bool _hasExited;
|
[ObservableProperty] private bool _hasExited;
|
||||||
@@ -27,12 +36,23 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
|||||||
/// can show a spinner instead of an empty black pane.</summary>
|
/// can show a spinner instead of an empty black pane.</summary>
|
||||||
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
||||||
|
|
||||||
partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
partial void OnIsRunningChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsStarting));
|
||||||
|
if (value) _startedAtUtc = _utcNow();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||||
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
||||||
|
|
||||||
public InteractiveTerminalViewModel()
|
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
|
||||||
{
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test-only seam for controlling "time since launch" without real delays.</summary>
|
||||||
|
internal InteractiveTerminalViewModel(Func<DateTime> utcNow)
|
||||||
|
{
|
||||||
|
_utcNow = utcNow;
|
||||||
_session.ProcessExited += OnSessionProcessExited;
|
_session.ProcessExited += OnSessionProcessExited;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +96,18 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSessionProcessExited(object? sender, int exitCode)
|
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
|
||||||
|
internal void OnSessionProcessExited(object? sender, int exitCode)
|
||||||
{
|
{
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
HasExited = true;
|
HasExited = true;
|
||||||
ExitCode = exitCode;
|
ExitCode = exitCode;
|
||||||
|
|
||||||
|
// Died at startup: route through the same banner as a launch-time failure instead of
|
||||||
|
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
|
||||||
|
// requires StartError).
|
||||||
|
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow)
|
||||||
|
StartError = $"Session exited immediately (code {exitCode}).";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
|
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
|
||||||
|
|||||||
@@ -134,5 +134,21 @@ public class ConPtyPaneViewModelTests
|
|||||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Died-at-startup (fix: an immediate ConPTY exit used to leave StartError null, so Retry
|
||||||
|
// never became reachable and the pane was stuck reusing the same persisted session id) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetryCommand_Enabled_AfterProcessExitsNonzeroRightAfterLaunch()
|
||||||
|
{
|
||||||
|
using var pane = NewTaskPane(NeverCompletes);
|
||||||
|
SetRunning(pane);
|
||||||
|
Assert.False(pane.RetryCommand.CanExecute(null));
|
||||||
|
|
||||||
|
pane.Terminal.OnSessionProcessExited(null, 1);
|
||||||
|
|
||||||
|
Assert.NotNull(pane.Terminal.StartError);
|
||||||
|
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl;
|
||||||
|
|
||||||
|
public class InteractiveTerminalViewModelTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime LaunchedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// ── Died-at-startup detection (fix: an immediate ConPTY exit left StartError null, so
|
||||||
|
// ConPtyPaneViewModel.CanRetry — which requires StartError — never offered Retry) ────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessExitsNonzero_RightAfterLaunch_SetsStartError()
|
||||||
|
{
|
||||||
|
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||||
|
terminal.IsRunning = true; // simulates a successful ConPTY spawn
|
||||||
|
|
||||||
|
terminal.OnSessionProcessExited(null, 1);
|
||||||
|
|
||||||
|
Assert.False(terminal.IsRunning);
|
||||||
|
Assert.True(terminal.HasExited);
|
||||||
|
Assert.Equal(1, terminal.ExitCode);
|
||||||
|
Assert.NotNull(terminal.StartError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessExitsNonzero_AfterRunningAWhile_DoesNotSetStartError()
|
||||||
|
{
|
||||||
|
var exitedAt = LaunchedAt.AddSeconds(10);
|
||||||
|
var callCount = 0;
|
||||||
|
using var terminal = new InteractiveTerminalViewModel(() => callCount++ == 0 ? LaunchedAt : exitedAt);
|
||||||
|
terminal.IsRunning = true; // records LaunchedAt via the first clock call
|
||||||
|
|
||||||
|
terminal.OnSessionProcessExited(null, 1); // crash after running for a while
|
||||||
|
|
||||||
|
Assert.True(terminal.HasExited);
|
||||||
|
Assert.Null(terminal.StartError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessExitsZero_RightAfterLaunch_DoesNotSetStartError()
|
||||||
|
{
|
||||||
|
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||||
|
terminal.IsRunning = true;
|
||||||
|
|
||||||
|
terminal.OnSessionProcessExited(null, 0); // user typed exit / session ended cleanly
|
||||||
|
|
||||||
|
Assert.True(terminal.HasExited);
|
||||||
|
Assert.Null(terminal.StartError);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user