fix: preserve visible terminal input on reconnect
This commit is contained in:
parent
db1f274069
commit
e5966cbcce
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user