From e5966cbcce91b807a1478fa77023e1bc7faddc68 Mon Sep 17 00:00:00 2001 From: sladro Date: Mon, 6 Apr 2026 10:52:10 +0800 Subject: [PATCH] fix: preserve visible terminal input on reconnect --- .../History/PendingInputEchoTracker.cs | 155 ++++++++++++++++++ .../Realtime/TerminalWebSocketHandler.cs | 3 + .../Sessions/SessionRegistry.cs | 29 +++- .../Realtime/TerminalWebSocketHandlerTests.cs | 29 ++++ .../Sessions/SessionRegistryTests.cs | 45 +++++ 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs new file mode 100644 index 0000000..c9c2e76 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs @@ -0,0 +1,155 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace TermRemoteCtl.Agent.History; + +public sealed class PendingInputEchoTracker +{ + private const int RecentOutputCharacterLimit = 4096; + private static readonly Regex AnsiSequenceRegex = new(@"\x1B\[[0-?]*[ -/]*[@-~]", RegexOptions.Compiled); + + private readonly StringBuilder _pendingVisibleInput = new(); + private readonly StringBuilder _recentVisibleOutput = new(); + + public void Record(string input) + { + ArgumentNullException.ThrowIfNull(input); + + for (var index = 0; index < input.Length; index += 1) + { + var current = input[index]; + switch (current) + { + case '\r': + _pendingVisibleInput.Append("\r\n"); + if (index + 1 < input.Length && input[index + 1] == '\n') + { + index += 1; + } + break; + case '\n': + _pendingVisibleInput.Append("\r\n"); + break; + case '\b': + case '\u007f': + RemoveLastVisibleCharacter(); + break; + case '\t': + _pendingVisibleInput.Append('\t'); + break; + case '\u001b': + index = SkipEscapeSequence(input, index); + break; + default: + if (!char.IsControl(current)) + { + _pendingVisibleInput.Append(current); + } + break; + } + } + } + + public void ObserveOutput(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + if (_pendingVisibleInput.Length == 0) + { + return; + } + + var visibleOutput = StripAnsiSequences(chunk); + if (string.IsNullOrEmpty(visibleOutput)) + { + return; + } + + _recentVisibleOutput.Append(visibleOutput); + TrimRecentOutput(); + + var normalizedPending = NormalizeForMatching(_pendingVisibleInput.ToString()); + if (normalizedPending.Length == 0) + { + return; + } + + var normalizedRecentOutput = NormalizeForMatching(_recentVisibleOutput.ToString()); + if (normalizedRecentOutput.Contains(normalizedPending, StringComparison.Ordinal)) + { + _pendingVisibleInput.Clear(); + _recentVisibleOutput.Clear(); + } + } + + public string GetVisibleSuffix() + { + return _pendingVisibleInput.ToString(); + } + + private void RemoveLastVisibleCharacter() + { + if (_pendingVisibleInput.Length == 0) + { + return; + } + + if (_pendingVisibleInput.Length >= 2 && + _pendingVisibleInput[^2] == '\r' && + _pendingVisibleInput[^1] == '\n') + { + return; + } + + _pendingVisibleInput.Remove(_pendingVisibleInput.Length - 1, 1); + } + + private static int SkipEscapeSequence(string input, int startIndex) + { + var index = startIndex + 1; + if (index >= input.Length) + { + return startIndex; + } + + if (input[index] != '[') + { + return index; + } + + index += 1; + while (index < input.Length) + { + var current = input[index]; + if (current >= '@' && current <= '~') + { + return index; + } + + index += 1; + } + + return input.Length - 1; + } + + private void TrimRecentOutput() + { + if (_recentVisibleOutput.Length <= RecentOutputCharacterLimit) + { + return; + } + + _recentVisibleOutput.Remove(0, _recentVisibleOutput.Length - RecentOutputCharacterLimit); + } + + private static string StripAnsiSequences(string chunk) + { + return AnsiSequenceRegex.Replace(chunk, string.Empty); + } + + private static string NormalizeForMatching(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs index d2294ec..2ec398a 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs @@ -123,6 +123,7 @@ public static class TerminalWebSocketHandler await HandleClientMessageAsync( Encoding.UTF8.GetString(message.ToArray()), + context.RequestServices.GetRequiredService(), host, diagnostics, sessionId, @@ -132,6 +133,7 @@ public static class TerminalWebSocketHandler private static async Task HandleClientMessageAsync( string payload, + SessionRegistry registry, ISessionHost host, ITerminalDiagnosticsSink diagnostics, string sessionId, @@ -162,6 +164,7 @@ public static class TerminalWebSocketHandler { if (!string.IsNullOrEmpty(message.Input)) { + registry.RecordInputEcho(sessionId, message.Input); diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input)); await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false); } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index 610ef24..42f25fa 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -11,6 +11,7 @@ public sealed class SessionRegistry private readonly ConcurrentDictionary _records = new(); private readonly ConcurrentDictionary _historyBySession = new(); private readonly ConcurrentDictionary _replayBySession = new(); + private readonly ConcurrentDictionary _pendingInputEchoBySession = new(); private readonly SessionHistoryStore _historyStore; private readonly int _ringBufferLineLimit; @@ -40,6 +41,7 @@ public sealed class SessionRegistry _records[record.SessionId] = record; _historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit); _replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit); + _pendingInputEchoBySession[record.SessionId] = new PendingInputEchoTracker(); return record; } @@ -99,10 +101,31 @@ public sealed class SessionRegistry sessionId, _ => new TerminalReplayBuffer(ReplayCharacterLimit)); replay.Append(chunk); + var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd( + sessionId, + _ => new PendingInputEchoTracker()); + pendingInputEcho.ObserveOutput(chunk); _records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow }; await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false); } + public void RecordInputEcho(string sessionId, string input) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(input); + + if (!_records.TryGetValue(sessionId, out var record)) + { + throw new KeyNotFoundException($"Session '{sessionId}' was not found."); + } + + var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd( + sessionId, + _ => new PendingInputEchoTracker()); + pendingInputEcho.Record(input); + _records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow }; + } + public SessionHistorySnapshot GetHistory(string sessionId, int lineCount) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); @@ -137,7 +160,10 @@ public sealed class SessionRegistry var replay = _replayBySession.GetOrAdd( sessionId, _ => new TerminalReplayBuffer(ReplayCharacterLimit)); - return replay.GetSnapshot(); + var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd( + sessionId, + _ => new PendingInputEchoTracker()); + return string.Concat(replay.GetSnapshot(), pendingInputEcho.GetVisibleSuffix()); } public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken) @@ -151,6 +177,7 @@ public sealed class SessionRegistry _historyBySession.TryRemove(sessionId, out _); _replayBySession.TryRemove(sessionId, out _); + _pendingInputEchoBySession.TryRemove(sessionId, out _); await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false); } } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs index ddda5ba..c188d30 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs @@ -104,6 +104,35 @@ public sealed class TerminalWebSocketHandlerTests await AssertNoAdditionalTextFrameAsync(socket, TimeSpan.FromMilliseconds(200)); } + [Fact] + public async Task Reattach_Replays_Visible_User_Input_When_No_Output_Echo_Has_Arrived_Yet() + { + await using var fixture = new TerminalApiFixture(); + var registry = fixture.Services.GetRequiredService(); + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + using (WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync( + new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"), + CancellationToken.None)) + { + _ = await ReceiveTextAsync(socket, CancellationToken.None); + + var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" }); + await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None); + + await WaitForConditionAsync(() => fixture.TerminalHost.Inputs.Contains(("input", session.SessionId, "dir")), TimeSpan.FromSeconds(2)); + } + + using WebSocket replaySocket = await fixture.Server.CreateWebSocketClient().ConnectAsync( + new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"), + CancellationToken.None); + + _ = await ReceiveTextAsync(replaySocket, CancellationToken.None); + var replayFrame = await ReceiveTextAsync(replaySocket, CancellationToken.None); + + Assert.Equal("dir", replayFrame); + } + private static async Task ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken) { var buffer = new byte[4096]; diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs index 7cd4b41..e02029a 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -83,6 +83,51 @@ public class SessionRegistryTests Assert.Equal("prompt> dir\r\n", replay); } + [Fact] + public void RecordInputEcho_Includes_Visible_User_Input_In_Replay_Snapshot() + { + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + registry.RecordInputEcho(session.SessionId, "dir"); + + var replay = registry.GetReplaySnapshot(session.SessionId); + + Assert.Equal("dir", replay); + } + + [Fact] + public async Task AppendOutputAsync_Clears_Pending_Input_After_Command_Is_Echoed() + { + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + registry.RecordInputEcho(session.SessionId, "dir\r"); + await registry.AppendOutputAsync(session.SessionId, "prompt> dir\r\nnext> ", CancellationToken.None); + + var replay = registry.GetReplaySnapshot(session.SessionId); + + Assert.Equal("prompt> dir\r\nnext> ", replay); + } + + [Fact] + public async Task AppendOutputAsync_Clears_Pending_Input_When_Echo_Arrives_Across_Multiple_Chunks() + { + using var harness = SessionRegistryHarness.Create(); + var registry = harness.Registry; + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + registry.RecordInputEcho(session.SessionId, "dir\r"); + await registry.AppendOutputAsync(session.SessionId, "prompt> d", CancellationToken.None); + await registry.AppendOutputAsync(session.SessionId, "ir\r\nnext> ", CancellationToken.None); + + var replay = registry.GetReplaySnapshot(session.SessionId); + + Assert.Equal("prompt> dir\r\nnext> ", replay); + } + [Fact] public async Task Delete_Removes_Session_Record_And_History_Log() {