1+ #nullable enable
2+ using System . Collections . Concurrent ;
3+
4+ namespace Terminal . Gui ;
5+
6+ /// <summary>
7+ /// Manages <see cref="AnsiEscapeSequenceRequest"/> made to an <see cref="IAnsiResponseParser"/>.
8+ /// Ensures there are not 2+ outstanding requests with the same terminator, throttles request sends
9+ /// to prevent console becoming unresponsive and handles evicting ignored requests (no reply from
10+ /// terminal).
11+ /// </summary>
12+ public class AnsiRequestScheduler
13+ {
14+ private readonly IAnsiResponseParser _parser ;
15+
16+ /// <summary>
17+ /// Function for returning the current time. Use in unit tests to
18+ /// ensure repeatable tests.
19+ /// </summary>
20+ internal Func < DateTime > Now { get ; set ; }
21+
22+ private readonly HashSet < Tuple < AnsiEscapeSequenceRequest , DateTime > > _queuedRequests = new ( ) ;
23+
24+ internal IReadOnlyCollection < AnsiEscapeSequenceRequest > QueuedRequests => _queuedRequests . Select ( r => r . Item1 ) . ToList ( ) ;
25+
26+ /// <summary>
27+ /// <para>
28+ /// Dictionary where key is ansi request terminator and value is when we last sent a request for
29+ /// this terminator. Combined with <see cref="_throttle"/> this prevents hammering the console
30+ /// with too many requests in sequence which can cause console to freeze as there is no space for
31+ /// regular screen drawing / mouse events etc to come in.
32+ /// </para>
33+ /// <para>
34+ /// When user exceeds the throttle, new requests accumulate in <see cref="_queuedRequests"/> (i.e. remain
35+ /// queued).
36+ /// </para>
37+ /// </summary>
38+ private readonly ConcurrentDictionary < string , DateTime > _lastSend = new ( ) ;
39+
40+ /// <summary>
41+ /// Number of milliseconds after sending a request that we allow
42+ /// another request to go out.
43+ /// </summary>
44+ private readonly TimeSpan _throttle = TimeSpan . FromMilliseconds ( 100 ) ;
45+
46+ private readonly TimeSpan _runScheduleThrottle = TimeSpan . FromMilliseconds ( 100 ) ;
47+
48+ /// <summary>
49+ /// If console has not responded to a request after this period of time, we assume that it is never going
50+ /// to respond. Only affects when we try to send a new request with the same terminator - at which point
51+ /// we tell the parser to stop expecting the old request and start expecting the new request.
52+ /// </summary>
53+ private readonly TimeSpan _staleTimeout = TimeSpan . FromSeconds ( 1 ) ;
54+
55+ private readonly DateTime _lastRun ;
56+
57+ /// <summary>
58+ /// Creates a new instance.
59+ /// </summary>
60+ /// <param name="parser"></param>
61+ /// <param name="now"></param>
62+ public AnsiRequestScheduler ( IAnsiResponseParser parser , Func < DateTime > ? now = null )
63+ {
64+ _parser = parser ;
65+ Now = now ?? ( ( ) => DateTime . Now ) ;
66+ _lastRun = Now ( ) ;
67+ }
68+
69+ /// <summary>
70+ /// Sends the <paramref name="request"/> immediately or queues it if there is already
71+ /// an outstanding request for the given <see cref="AnsiEscapeSequenceRequest.Terminator"/>.
72+ /// </summary>
73+ /// <param name="request"></param>
74+ /// <returns><see langword="true"/> if request was sent immediately. <see langword="false"/> if it was queued.</returns>
75+ public bool SendOrSchedule ( AnsiEscapeSequenceRequest request ) { return SendOrSchedule ( request , true ) ; }
76+
77+ private bool SendOrSchedule ( AnsiEscapeSequenceRequest request , bool addToQueue )
78+ {
79+ if ( CanSend ( request , out ReasonCannotSend reason ) )
80+ {
81+ Send ( request ) ;
82+
83+ return true ;
84+ }
85+
86+ if ( reason == ReasonCannotSend . OutstandingRequest )
87+ {
88+ // If we can evict an old request (no response from terminal after ages)
89+ if ( EvictStaleRequests ( request . Terminator ) )
90+ {
91+ // Try again after evicting
92+ if ( CanSend ( request , out _ ) )
93+ {
94+ Send ( request ) ;
95+
96+ return true ;
97+ }
98+ }
99+ }
100+
101+ if ( addToQueue )
102+ {
103+ _queuedRequests . Add ( Tuple . Create ( request , Now ( ) ) ) ;
104+ }
105+
106+ return false ;
107+ }
108+
109+ private void EvictStaleRequests ( )
110+ {
111+ foreach ( string stale in _lastSend . Where ( v => IsStale ( v . Value ) ) . Select ( k => k . Key ) )
112+ {
113+ EvictStaleRequests ( stale ) ;
114+ }
115+ }
116+
117+ private bool IsStale ( DateTime dt ) { return Now ( ) - dt > _staleTimeout ; }
118+
119+ /// <summary>
120+ /// Looks to see if the last time we sent <paramref name="withTerminator"/>
121+ /// is a long time ago. If so we assume that we will never get a response and
122+ /// can proceed with a new request for this terminator (returning <see langword="true"/>).
123+ /// </summary>
124+ /// <param name="withTerminator"></param>
125+ /// <returns></returns>
126+ private bool EvictStaleRequests ( string withTerminator )
127+ {
128+ if ( _lastSend . TryGetValue ( withTerminator , out DateTime dt ) )
129+ {
130+ if ( IsStale ( dt ) )
131+ {
132+ _parser . StopExpecting ( withTerminator , false ) ;
133+
134+ return true ;
135+ }
136+ }
137+
138+ return false ;
139+ }
140+
141+ /// <summary>
142+ /// Identifies and runs any <see cref="_queuedRequests"/> that can be sent based on the
143+ /// current outstanding requests of the parser.
144+ /// </summary>
145+ /// <param name="force">
146+ /// Repeated requests to run the schedule over short period of time will be ignored.
147+ /// Pass <see langword="true"/> to override this behaviour and force evaluation of outstanding requests.
148+ /// </param>
149+ /// <returns>
150+ /// <see langword="true"/> if a request was found and run. <see langword="false"/>
151+ /// if no outstanding requests or all have existing outstanding requests underway in parser.
152+ /// </returns>
153+ public bool RunSchedule ( bool force = false )
154+ {
155+ if ( ! force && Now ( ) - _lastRun < _runScheduleThrottle )
156+ {
157+ return false ;
158+ }
159+
160+ // Get oldest request
161+ Tuple < AnsiEscapeSequenceRequest , DateTime > ? opportunity = _queuedRequests . MinBy ( r => r . Item2 ) ;
162+
163+ if ( opportunity != null )
164+ {
165+ // Give it another go
166+ if ( SendOrSchedule ( opportunity . Item1 , false ) )
167+ {
168+ _queuedRequests . Remove ( opportunity ) ;
169+
170+ return true ;
171+ }
172+ }
173+
174+ EvictStaleRequests ( ) ;
175+
176+ return false ;
177+ }
178+
179+ private void Send ( AnsiEscapeSequenceRequest r )
180+ {
181+ _lastSend . AddOrUpdate ( r . Terminator , _ => Now ( ) , ( _ , _ ) => Now ( ) ) ;
182+ _parser . ExpectResponse ( r . Terminator , r . ResponseReceived , r . Abandoned , false ) ;
183+ r . Send ( ) ;
184+ }
185+
186+ private bool CanSend ( AnsiEscapeSequenceRequest r , out ReasonCannotSend reason )
187+ {
188+ if ( ShouldThrottle ( r ) )
189+ {
190+ reason = ReasonCannotSend . TooManyRequests ;
191+
192+ return false ;
193+ }
194+
195+ if ( _parser . IsExpecting ( r . Terminator ) )
196+ {
197+ reason = ReasonCannotSend . OutstandingRequest ;
198+
199+ return false ;
200+ }
201+
202+ reason = default ( ReasonCannotSend ) ;
203+
204+ return true ;
205+ }
206+
207+ private bool ShouldThrottle ( AnsiEscapeSequenceRequest r )
208+ {
209+ if ( _lastSend . TryGetValue ( r . Terminator , out DateTime value ) )
210+ {
211+ return Now ( ) - value < _throttle ;
212+ }
213+
214+ return false ;
215+ }
216+ }
0 commit comments