fix: preserve visible terminal input on reconnect

This commit is contained in:
sladro 2026-04-06 10:52:10 +08:00
parent db1f274069
commit e5966cbcce
5 changed files with 260 additions and 1 deletions

View File

@ -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');
}
}

View File

@ -123,6 +123,7 @@ public static class TerminalWebSocketHandler
await HandleClientMessageAsync(
Encoding.UTF8.GetString(message.ToArray()),
context.RequestServices.GetRequiredService<SessionRegistry>(),
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);
}

View File

@ -11,6 +11,7 @@ public sealed class SessionRegistry
private readonly ConcurrentDictionary<string, SessionRecord> _records = new();
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
private readonly ConcurrentDictionary<string, TerminalReplayBuffer> _replayBySession = new();
private readonly ConcurrentDictionary<string, PendingInputEchoTracker> _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);
}
}

View File

@ -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<SessionRegistry>();
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<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[4096];

View File

@ -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()
{