Interactive/streaming sessions are persistent claude.exe processes that wait on stdin and never exit on their own. The only teardown was an explicit StopInteractiveSession from the UI — there is no client-disconnect or shutdown sweep — so an abandoned chat (UI closed, navigated away, crashed) kept its claude.exe (+ conhost) alive for the worker's whole lifetime. Under a long-running autostart worker these accumulate to dozens of orphaned child processes. LiveSessionRegistry now tracks per-session activity (Touch on every output line and user action) and exposes ReapIdleAsync, which stops sessions idle past a timeout while skipping any with a turn in flight. IdleSessionReaper (BackgroundService) sweeps every 5 min; idle timeout defaults to 30 min, configurable via interactive_idle_timeout_minutes (0 disables).
146 lines
4.5 KiB
C#
146 lines
4.5 KiB
C#
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Runner.Interfaces;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public sealed class LiveSessionRegistryTests
|
|
{
|
|
private sealed class FakeLiveSession : ILiveSession
|
|
{
|
|
public bool StopCalled { get; private set; }
|
|
public bool IsTurnInFlight { get; set; }
|
|
|
|
public Task SendUserMessageAsync(string text, CancellationToken ct) => Task.CompletedTask;
|
|
public Task RemoveQueuedAsync(string text, CancellationToken ct) => Task.CompletedTask;
|
|
public Task InterruptAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task StopAsync()
|
|
{
|
|
StopCalled = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_ThenTryGet_ReturnsSession()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var session = new FakeLiveSession();
|
|
|
|
registry.Register("task-1", session);
|
|
|
|
Assert.True(registry.TryGet("task-1", out var retrieved));
|
|
Assert.Same(session, retrieved);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryGet_Missing_ReturnsFalse()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
|
|
Assert.False(registry.TryGet("no-such-task", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void Unregister_RemovesSession()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
registry.Register("task-1", new FakeLiveSession());
|
|
registry.Unregister("task-1");
|
|
|
|
Assert.False(registry.TryGet("task-1", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Register_WhenSessionAlreadyExists_StopsPreviousSession()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var first = new FakeLiveSession();
|
|
var second = new FakeLiveSession();
|
|
|
|
registry.Register("task-1", first);
|
|
registry.Register("task-1", second);
|
|
|
|
// Give the fire-and-forget stop a tick to run.
|
|
await Task.Delay(50);
|
|
|
|
Assert.True(first.StopCalled);
|
|
Assert.True(registry.TryGet("task-1", out var retrieved));
|
|
Assert.Same(second, retrieved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsync_StopsAndRemovesSession()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var session = new FakeLiveSession();
|
|
registry.Register("task-1", session);
|
|
|
|
await registry.StopAsync("task-1");
|
|
|
|
Assert.True(session.StopCalled);
|
|
Assert.False(registry.TryGet("task-1", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsync_MissingTask_DoesNotThrow()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
await registry.StopAsync("no-such-task"); // should not throw
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReapIdleAsync_StopsAndRemovesIdleSession()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var session = new FakeLiveSession();
|
|
registry.Register("task-1", session);
|
|
|
|
// Sweep "now" is an hour past registration, well beyond the 30-min idle window.
|
|
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow.AddMinutes(60), TimeSpan.FromMinutes(30));
|
|
|
|
Assert.Contains("task-1", reaped);
|
|
Assert.True(session.StopCalled);
|
|
Assert.False(registry.TryGet("task-1", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReapIdleAsync_KeepsRecentlyActiveSession()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var session = new FakeLiveSession();
|
|
registry.Register("task-1", session);
|
|
|
|
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow, TimeSpan.FromMinutes(30));
|
|
|
|
Assert.Empty(reaped);
|
|
Assert.False(session.StopCalled);
|
|
Assert.True(registry.TryGet("task-1", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReapIdleAsync_SkipsSessionWithTurnInFlight()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var session = new FakeLiveSession { IsTurnInFlight = true };
|
|
registry.Register("task-1", session);
|
|
|
|
// Idle long enough to reap, but a turn is in flight → must be left alone.
|
|
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow.AddMinutes(60), TimeSpan.FromMinutes(30));
|
|
|
|
Assert.Empty(reaped);
|
|
Assert.False(session.StopCalled);
|
|
Assert.True(registry.TryGet("task-1", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReapIdleAsync_NoSessions_ReturnsEmpty()
|
|
{
|
|
var registry = new LiveSessionRegistry();
|
|
var reaped = await registry.ReapIdleAsync(DateTime.UtcNow, TimeSpan.FromMinutes(30));
|
|
Assert.Empty(reaped);
|
|
}
|
|
}
|