Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ansi parser #3791

Open
wants to merge 90 commits into
base: v2_develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 86 commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
ac09a6a
Initial test to define the task of the parser
tznind Oct 11, 2024
7f17f5e
Add example responses we might see
tznind Oct 11, 2024
22e612e
Explore parser more
tznind Oct 11, 2024
75ec589
prepare to handle more rules
tznind Oct 11, 2024
a0c3363
Update handling to have state
tznind Oct 11, 2024
6cef9d7
Fix ResetState and add DispatchResponse
tznind Oct 11, 2024
390f2d0
Change to listing known terminators according to CSI spec
tznind Oct 11, 2024
bb0bdb0
Merge branch 'v2_develop' into ansi-parser
tznind Oct 11, 2024
af2ea00
Allow multiple expected responses at once
tznind Oct 11, 2024
3af5c6a
Test all permutations of the input stream
tznind Oct 11, 2024
964196d
Change to hashmap char since all terminators we ignore are single cha…
tznind Oct 11, 2024
b83219d
WIP - Add lots of tests, needs validation as expectations are wrong i…
tznind Oct 11, 2024
fa15389
Fix unit test cases
tznind Oct 12, 2024
d1669a5
Allow attatching arbitrary metadata to each char handled by AnsiRespo…
tznind Oct 12, 2024
af3a805
Proof of concept DAR request
tznind Oct 12, 2024
9f8fbc0
Refactor so there is an abstract base parser
tznind Oct 12, 2024
4d54292
Add string version
tznind Oct 12, 2024
c99487f
Double up all tests so that generic and string versions are both tested
tznind Oct 12, 2024
dbfdffb
Give AnsiResponseParser a chance to release its content each iteratio…
tznind Oct 12, 2024
16a787e
Fix base class dropping T all over the place
tznind Oct 12, 2024
88922c2
Fix use case of 2 esc in a row
tznind Oct 12, 2024
1877cc7
Add exploration of Esc + typing and standardize unicode to \u001b
tznind Oct 12, 2024
d642fb6
Add WindowsDriverKeyPairer
tznind Oct 12, 2024
204d640
WIP: Scenario
tznind Oct 12, 2024
67b2892
Change WindowsDriver to send down/up on key down and ignore key up ev…
tznind Oct 13, 2024
30ad592
Investigate adding to CursesDriver
tznind Oct 13, 2024
ac90ed9
AnsiParser working with CursesDriver and WindowsDriver
tznind Oct 13, 2024
effe199
Remove 'seen' debug aid
tznind Oct 13, 2024
563eace
Mostly working for NetDriver
tznind Oct 13, 2024
aa1c9bf
Add some comments to main parser method and mark as protected
tznind Oct 13, 2024
d56daac
Merge branch 'v2_develop' into ansi-parser
tznind Oct 20, 2024
53924da
Add test for making step by step assertions on parser state after eac…
tznind Oct 20, 2024
f6cb3bb
Merge branch 'ansi-parser' of https://github.com/tznind/gui.cs into a…
tznind Oct 20, 2024
e0415e6
tidyup and streamline naming
tznind Oct 20, 2024
db1977a
Add stress test for ansi requests
tznind Oct 20, 2024
d345fc0
Add AnsiRequestScheduler that prevents 2+ queries executing at once a…
tznind Oct 20, 2024
5356bf4
Set scenario timeout to 50ms
tznind Oct 20, 2024
92648e5
typo and fix FakeDriver
tznind Oct 22, 2024
5052024
Make scheduler part of main loop and hide implementation details from…
tznind Oct 22, 2024
6de2032
Explain purpose of throttle and move to seperate class
tznind Oct 22, 2024
23af0d9
Add evicting and xmldoc
tznind Oct 22, 2024
d3ac6e1
Support for late responses collection
tznind Oct 22, 2024
2324217
Add tests and fix late queue
tznind Oct 22, 2024
c1f880e
WIP: Trying to unpick NetDriver
tznind Oct 25, 2024
eaddbc6
Support for persistent expectations of responses
tznind Oct 26, 2024
abee8b7
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 26, 2024
d9144e5
Simplify code
tznind Oct 26, 2024
fdf4953
Add missing class (moved from parser)
tznind Oct 26, 2024
aaf3341
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 26, 2024
8c71412
Add IHeld
tznind Oct 26, 2024
3b53363
Move to seperate folder
tznind Oct 26, 2024
6e4a674
Refactoring and xmldoc
tznind Oct 26, 2024
085a0cf
Remove unused usings
tznind Oct 26, 2024
bdcf36c
Support for getting the full T set when using generic AnsiResponseParser
tznind Oct 26, 2024
057aeea
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 26, 2024
65cfa49
WIP integrating ansi parser
tznind Oct 26, 2024
acdd483
Add UnknownResponseHandler
tznind Oct 26, 2024
7fafa45
Add UnexpectedResponseHandler
tznind Oct 26, 2024
d5095a2
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 26, 2024
512af81
WIP: Trying to get NetDriver to work properly
tznind Oct 27, 2024
8417387
Change _resultQueue to not hold nulls and use concurrent collection
tznind Oct 27, 2024
1ec7aa2
Simplify NetEvents further with BlockingCollection
tznind Oct 27, 2024
792170a
Change NetEvents to BlockingCollection
tznind Oct 27, 2024
b30c92b
Fix MapConsoleKeyInfo not working with Esc properly
tznind Oct 27, 2024
edb43a0
Add lock around access of expected responses for thread safety
tznind Oct 27, 2024
7c1275d
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 27, 2024
0adc4cd
Add lock for answers in AnsiRequestScenario
tznind Oct 27, 2024
35ba6f4
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 27, 2024
becca1a
WIP: Start simplifying the tokens in NetDriver
tznind Oct 28, 2024
a746888
Remove unneeded `_waitForStart` and other now uncalled code
tznind Oct 28, 2024
4dd2ad0
Merge branch 'v2_develop' into ansi-parser
tznind Oct 28, 2024
26e86c5
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 28, 2024
2fa855f
Lock all public members of AnsiResponseParser to ensure consistent st…
tznind Oct 28, 2024
d7183f6
Merge branch 'ansi-parser' into ansi-parser-net-driver
tznind Oct 28, 2024
9aff491
Merge pull request #172 from tznind/ansi-parser-net-driver
tznind Oct 28, 2024
2b7d0c7
Add extra try/catch cancellation ex
tznind Oct 28, 2024
fdfd339
Make AnsiRequestScheduler testable by introducing a Func for DateTime…
tznind Oct 30, 2024
fbbda69
Code Cleanup (resharper)
tznind Oct 30, 2024
1d8e756
Start adding tests for AnsiRequestScheduler
tznind Oct 30, 2024
43aef42
Add more unit tests for scheduler
tznind Oct 30, 2024
f84a58f
Fix eviction not happening as part of RunSchedule
tznind Oct 31, 2024
0275f9a
More tests for AnsiRequestScheduler
tznind Nov 1, 2024
395bee4
Add support for sending other types of requests
tznind Nov 2, 2024
b104b1b
Evict stale requests even just when running schedule
tznind Nov 2, 2024
27f1317
Sixel detection and added Abandoned event
tznind Nov 2, 2024
5340073
Get rid of StoppedExpecting event and made methods internal again in …
tznind Nov 2, 2024
5cbb230
Fix to have CanFocus true
tznind Nov 2, 2024
fa7fb65
Revert changes to launchSettings.json
tznind Nov 2, 2024
4d232e4
Add CanFocus true
tznind Nov 2, 2024
9d86336
tidyup
tznind Nov 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Terminal.Gui/Application/MainLoop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ internal void RunIteration ()
}
}

RunAnsiScheduler ();

MainLoopDriver.Iteration ();

var runIdle = false;
Expand All @@ -285,6 +287,11 @@ internal void RunIteration ()
}
}

private void RunAnsiScheduler ()
{
Application.Driver?.GetRequestScheduler ().RunSchedule ();
}

/// <summary>Stops the main loop driver and calls <see cref="IMainLoopDriver.Wakeup"/>. Used only for unit tests.</summary>
internal void Stop ()
{
Expand Down
67 changes: 67 additions & 0 deletions Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#nullable enable
namespace Terminal.Gui;

/// <summary>
/// Describes an ongoing ANSI request sent to the console.
/// Use <see cref="ResponseReceived"/> to handle the response
/// when console answers the request.
/// </summary>
public class AnsiEscapeSequenceRequest
{
/// <summary>
/// Request to send e.g. see
/// <see>
/// <cref>EscSeqUtils.CSI_SendDeviceAttributes.Request</cref>
/// </see>
/// </summary>
public required string Request { get; init; }

/// <summary>
/// Invoked when the console responds with an ANSI response code that matches the
/// <see cref="Terminator"/>
/// </summary>
public Action<string> ResponseReceived;

Check warning on line 23 in Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs

View workflow job for this annotation

GitHub Actions / build_release

Non-nullable field 'ResponseReceived' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 23 in Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs

View workflow job for this annotation

GitHub Actions / build_and_test_debug (ubuntu-latest)

Non-nullable field 'ResponseReceived' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 23 in Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs

View workflow job for this annotation

GitHub Actions / build_and_test_debug (windows-latest)

Non-nullable field 'ResponseReceived' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 23 in Terminal.Gui/ConsoleDrivers/AnsiEscapeSequenceRequest.cs

View workflow job for this annotation

GitHub Actions / build_and_test_debug (macos-latest)

Non-nullable field 'ResponseReceived' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

/// <summary>
/// Invoked if the console fails to responds to the ANSI response code
/// </summary>
public Action? Abandoned;

/// <summary>
/// <para>
/// The terminator that uniquely identifies the type of response as responded
/// by the console. e.g. for
/// <see>
/// <cref>EscSeqUtils.CSI_SendDeviceAttributes.Request</cref>
/// </see>
/// the terminator is
/// <see>
/// <cref>EscSeqUtils.CSI_SendDeviceAttributes.Terminator</cref>
/// </see>
/// .
/// </para>
/// <para>
/// After sending a request, the first response with matching terminator will be matched
/// to the oldest outstanding request.
/// </para>
/// </summary>
public required string Terminator { get; init; }

/// <summary>
/// Sends the <see cref="Request"/> to the raw output stream of the current <see cref="ConsoleDriver"/>.
/// Only call this method from the main UI thread. You should use <see cref="AnsiRequestScheduler"/> if
/// sending many requests.
/// </summary>
public void Send () { Application.Driver?.RawWrite (Request); }


/// <summary>
/// The value expected in the response e.g.
/// <see>
/// <cref>EscSeqUtils.CSI_ReportTerminalSizeInChars.Value</cref>
/// </see>
/// which will have a 't' as terminator but also other different request may return the same terminator with a
/// different value.
/// </summary>
public string? Value { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
#nullable enable
using System.Collections.Concurrent;

namespace Terminal.Gui;

/// <summary>
/// Manages <see cref="AnsiEscapeSequenceRequest"/> made to an <see cref="IAnsiResponseParser"/>.
/// Ensures there are not 2+ outstanding requests with the same terminator, throttles request sends
/// to prevent console becoming unresponsive and handles evicting ignored requests (no reply from
/// terminal).
/// </summary>
internal class AnsiRequestScheduler
{
private readonly IAnsiResponseParser _parser;

/// <summary>
/// Function for returning the current time. Use in unit tests to
/// ensure repeatable tests.
/// </summary>
internal Func<DateTime> Now { get; set; }

private readonly HashSet<Tuple<AnsiEscapeSequenceRequest, DateTime>> _queuedRequests = new ();

internal IReadOnlyCollection<AnsiEscapeSequenceRequest> QueuedRequests => _queuedRequests.Select (r => r.Item1).ToList ();

/// <summary>
/// <para>
/// Dictionary where key is ansi request terminator and value is when we last sent a request for
/// this terminator. Combined with <see cref="_throttle"/> this prevents hammering the console
/// with too many requests in sequence which can cause console to freeze as there is no space for
/// regular screen drawing / mouse events etc to come in.
/// </para>
/// <para>
/// When user exceeds the throttle, new requests accumulate in <see cref="_queuedRequests"/> (i.e. remain
/// queued).
/// </para>
/// </summary>
private readonly ConcurrentDictionary<string, DateTime> _lastSend = new ();

/// <summary>
/// Number of milliseconds after sending a request that we allow
/// another request to go out.
/// </summary>
private readonly TimeSpan _throttle = TimeSpan.FromMilliseconds (100);

private readonly TimeSpan _runScheduleThrottle = TimeSpan.FromMilliseconds (100);

/// <summary>
/// If console has not responded to a request after this period of time, we assume that it is never going
/// to respond. Only affects when we try to send a new request with the same terminator - at which point
/// we tell the parser to stop expecting the old request and start expecting the new request.
/// </summary>
private readonly TimeSpan _staleTimeout = TimeSpan.FromSeconds (1);

private readonly DateTime _lastRun;

public AnsiRequestScheduler (IAnsiResponseParser parser, Func<DateTime>? now = null)
{
_parser = parser;
Now = now ?? (() => DateTime.Now);
_lastRun = Now ();
}

/// <summary>
/// Sends the <paramref name="request"/> immediately or queues it if there is already
/// an outstanding request for the given <see cref="AnsiEscapeSequenceRequest.Terminator"/>.
/// </summary>
/// <param name="request"></param>
/// <returns><see langword="true"/> if request was sent immediately. <see langword="false"/> if it was queued.</returns>
public bool SendOrSchedule (AnsiEscapeSequenceRequest request)
{
return SendOrSchedule (request, true);
}
private bool SendOrSchedule (AnsiEscapeSequenceRequest request,bool addToQueue)
{
if (CanSend (request, out ReasonCannotSend reason))
{
Send (request);

return true;
}

if (reason == ReasonCannotSend.OutstandingRequest)
{
// If we can evict an old request (no response from terminal after ages)
if (EvictStaleRequests (request.Terminator))
{
// Try again after evicting
if (CanSend (request, out _))
{
Send (request);

return true;
}
}
}

if (addToQueue)
{
_queuedRequests.Add (Tuple.Create (request, Now ()));
}

return false;
}

private void EvictStaleRequests ()
{
foreach (var stale in _lastSend.Where (v => IsStale (v.Value)).Select (k => k.Key))
{
EvictStaleRequests (stale);
}
}

private bool IsStale (DateTime dt) => Now () - dt > _staleTimeout;

/// <summary>
/// Looks to see if the last time we sent <paramref name="withTerminator"/>
/// is a long time ago. If so we assume that we will never get a response and
/// can proceed with a new request for this terminator (returning <see langword="true"/>).
/// </summary>
/// <param name="withTerminator"></param>
/// <returns></returns>
private bool EvictStaleRequests (string withTerminator)
{
if (_lastSend.TryGetValue (withTerminator, out DateTime dt))
{
if (IsStale (dt))
{
_parser.StopExpecting (withTerminator, false);

return true;
}
}

return false;
}

/// <summary>
/// Identifies and runs any <see cref="_queuedRequests"/> that can be sent based on the
/// current outstanding requests of the parser.
/// </summary>
/// <param name="force">
/// Repeated requests to run the schedule over short period of time will be ignored.
/// Pass <see langword="true"/> to override this behaviour and force evaluation of outstanding requests.
/// </param>
/// <returns>
/// <see langword="true"/> if a request was found and run. <see langword="false"/>
/// if no outstanding requests or all have existing outstanding requests underway in parser.
/// </returns>
public bool RunSchedule (bool force = false)
{
if (!force && Now () - _lastRun < _runScheduleThrottle)
{
return false;
}

// Get oldest request
Tuple<AnsiEscapeSequenceRequest, DateTime>? opportunity = _queuedRequests.MinBy (r=>r.Item2);

if (opportunity != null)
{
// Give it another go
if (SendOrSchedule (opportunity.Item1, false))
{
_queuedRequests.Remove (opportunity);
return true;
}
}

EvictStaleRequests ();

return false;
}


private void Send (AnsiEscapeSequenceRequest r)
{
_lastSend.AddOrUpdate (r.Terminator, _ => Now (), (_, _) => Now ());
_parser.ExpectResponse (r.Terminator, r.ResponseReceived, r.Abandoned, false);
r.Send ();
}

private bool CanSend (AnsiEscapeSequenceRequest r, out ReasonCannotSend reason)
{
if (ShouldThrottle (r))
{
reason = ReasonCannotSend.TooManyRequests;

return false;
}

if (_parser.IsExpecting (r.Terminator))
{
reason = ReasonCannotSend.OutstandingRequest;

return false;
}

reason = default (ReasonCannotSend);

return true;
}

private bool ShouldThrottle (AnsiEscapeSequenceRequest r)
{
if (_lastSend.TryGetValue (r.Terminator, out DateTime value))
{
return Now () - value < _throttle;
}

return false;
}
}

internal enum ReasonCannotSend
{
/// <summary>
/// No reason given.
/// </summary>
None = 0,

/// <summary>
/// The parser is already waiting for a request to complete with the given terminator.
/// </summary>
OutstandingRequest,

/// <summary>
/// There have been too many requests sent recently, new requests will be put into
/// queue to prevent console becoming unresponsive.
/// </summary>
TooManyRequests
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#nullable enable
namespace Terminal.Gui;

internal record AnsiResponseExpectation (string Terminator, Action<IHeld> Response, Action? Abandoned)
{
public bool Matches (string cur) { return cur.EndsWith (Terminator); }
}
Loading
Loading