diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs index 8aef90f..01ec45b 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs @@ -22,6 +22,7 @@ internal sealed class HelperBackedConPtySession : IConPtySession private StreamWriter? _commandWriter; private StreamReader? _outputReader; private Task? _outputPumpTask; + private readonly CancellationTokenSource _lifetime = new(); private bool _started; private bool _disposed; @@ -104,7 +105,7 @@ internal sealed class HelperBackedConPtySession : IConPtySession throw new InvalidOperationException($"Unexpected ConPTY helper startup handshake: {handshake}"); } - _outputPumpTask = Task.Run(() => PumpOutputAsync(_outputReader, cancellationToken), cancellationToken); + _outputPumpTask = Task.Run(() => PumpOutputAsync(_outputReader, _lifetime.Token), CancellationToken.None); _started = true; } @@ -149,6 +150,7 @@ internal sealed class HelperBackedConPtySession : IConPtySession } _disposed = true; + _lifetime.Cancel(); if (_commandWriter is not null) { @@ -182,6 +184,7 @@ internal sealed class HelperBackedConPtySession : IConPtySession _commandPipe?.Dispose(); _outputPipe?.Dispose(); _helperProcess?.Dispose(); + _lifetime.Dispose(); } private async Task PumpOutputAsync(StreamReader reader, CancellationToken cancellationToken) diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs index 8b7aa8c..0a6ebff 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalSmokeCheckTests.cs @@ -44,6 +44,46 @@ public sealed class TerminalSmokeCheckTests } } + [Fact] + public async Task BuiltAgentExe_Reconnects_Existing_Terminal_And_Accepts_Input() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + await using var fixture = new BuiltAgentFixture(); + await fixture.StartAsync(); + + try + { + var session = await fixture.CreateSessionAsync("smoke-reconnect"); + + using (var firstSocket = await fixture.ConnectTerminalAsync(session.SessionId)) + { + _ = await fixture.ReceiveTextAsync(firstSocket, TimeSpan.FromSeconds(20)); + await fixture.SendTextAsync(firstSocket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output first\r" })); + _ = await fixture.ReceiveTextContainingAsync(firstSocket, "first", TimeSpan.FromSeconds(20)); + } + + using var secondSocket = await fixture.ConnectTerminalAsync(session.SessionId); + var attached = await fixture.ReceiveTextAsync(secondSocket, TimeSpan.FromSeconds(20)); + var attachedPayload = JsonSerializer.Deserialize(attached, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.NotNull(attachedPayload); + Assert.Equal("attached", attachedPayload!.Type); + Assert.Equal(session.SessionId, attachedPayload.SessionId); + + await fixture.SendTextAsync(secondSocket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output second\r" })); + var output = await fixture.ReceiveTextContainingAsync(secondSocket, "second", TimeSpan.FromSeconds(20)); + + Assert.Contains("second", output, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) + { + throw new InvalidOperationException($"{ex.Message}{Environment.NewLine}{fixture.GetDiagnostics()}", ex); + } + } + private sealed class BuiltAgentFixture : IAsyncDisposable { private readonly string _projectRoot;