Do not trust the engine's count as the pace
The second probe run read TxBufLen as 0 one millisecond and again fifty milliseconds after twenty-one characters had been pushed, while those characters were already going out: the first of them was decoded back off the air 420 ms later. The number is right when it is a second old and wrong when it is fresh, so it runs behind the engine. A pump that fed on it would have handed over the whole message in half a second and put all of it beyond reach, which is worse than the clock it replaced. So the clock is the pace again, and the count is a check on it: the engine is never given more than Lead + Slack characters however fast the clock says to feed, and a message ends when the count and the clock estimate both say it has. Characters fed since the last answer are added to it, so a stale answer cannot be spent twice. The engine is set to Character out: ABCD with no space after it went out at once. Word out would have held it, so the holding case stays, but it now needs the count to be neither going down nor being added to, since an engine kept exactly at the cap has a count that does not move either. The probe writes down every reading for two seconds after a push and does not believe an empty one in that window, which is what the run needed to measure the lag and did not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
@@ -19,16 +19,23 @@ namespace Nonemm.Digital;
|
||||
/// which is where the operator is typing: the pump stops when it reaches the
|
||||
/// cursor, because the operator has not finished the word yet.
|
||||
///
|
||||
/// How far the engine has got comes from the engine when it can say. MMTTY
|
||||
/// answers `TxBufLen` with the number of characters it still has to transmit,
|
||||
/// so the pump asks, feeds while the answer is under `Lead`, and asks again. An
|
||||
/// engine that will not answer is paced by the clock at the baud rate instead,
|
||||
/// which drifts and is what the baud setting is for.
|
||||
/// The pump is paced by the clock at the baud rate, and the engine's own count
|
||||
/// of what it has left is a check on it rather than the pace. MMTTY answers
|
||||
/// `TxBufLen` with the number of characters it still has to transmit, but the
|
||||
/// answer is behind what the engine is really doing: the probe read 0 fifty
|
||||
/// milliseconds after twenty-one characters had been pushed and were already
|
||||
/// going out, and read a right-looking 12 a second after a push. A pump that
|
||||
/// believed a stale 0 would hand over the whole message in half a second and
|
||||
/// put all of it beyond reach, so the count is used two ways only:
|
||||
///
|
||||
/// - as a cap. The engine holding more than `Lead + Slack` characters stops the
|
||||
/// pump until it comes down, whatever the clock thinks.
|
||||
/// - as the end of a message, together with the clock estimate.
|
||||
///
|
||||
/// A count that stops going down means the engine is holding what it has:
|
||||
/// MMTTY set to Word out keeps a word until the space after it arrives. The
|
||||
/// pump feeds one character per look while that lasts, so the space gets there
|
||||
/// and the word goes out.
|
||||
/// pump feeds a character anyway while that lasts, so the space gets there and
|
||||
/// the word goes out.
|
||||
public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
||||
@@ -52,6 +59,11 @@ public sealed class TypeAhead : IDisposable
|
||||
/// stands while the operator is not typing into the pane.
|
||||
public const int NoCursor = int.MaxValue;
|
||||
|
||||
/// How many characters the engine's count may be behind what it is really
|
||||
/// doing. The count is what stops the pump when the engine falls behind the
|
||||
/// clock, so it has to allow for the lag rather than fight it.
|
||||
public const int Slack = 3;
|
||||
|
||||
/// How often the engine is asked how much it has left.
|
||||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
@@ -60,9 +72,6 @@ public sealed class TypeAhead : IDisposable
|
||||
/// character going out, so it is short against a character time.
|
||||
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
|
||||
|
||||
/// An engine that has not answered by now is not going to.
|
||||
private static readonly TimeSpan AnswerPatience = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Func<char, CancellationToken, Task> send;
|
||||
private readonly EngineBuffer? counter;
|
||||
private readonly Lock gate = new();
|
||||
@@ -71,9 +80,16 @@ public sealed class TypeAhead : IDisposable
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
private TaskCompletionSource<int>? asking;
|
||||
private int cursor = NoCursor;
|
||||
|
||||
/// The engine's last answer: how many characters it still had to transmit,
|
||||
/// or -1 before it has answered at all.
|
||||
private int counted = -1;
|
||||
|
||||
/// How many answers have arrived, so the pump can tell a new one from the
|
||||
/// one it has already added its own characters to.
|
||||
private int answers;
|
||||
|
||||
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
||||
{
|
||||
this.send = send;
|
||||
@@ -81,7 +97,7 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
|
||||
/// An engine that holds a buffer of its own and can say how much of it is
|
||||
/// left, which is what paces the pump instead of the clock.
|
||||
/// left, which is what the pump checks the clock against.
|
||||
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
|
||||
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
|
||||
{
|
||||
@@ -89,16 +105,15 @@ public sealed class TypeAhead : IDisposable
|
||||
engine.Buffered += WhenBuffered;
|
||||
}
|
||||
|
||||
/// The speed the engine transmits at, which paces the pump when the engine
|
||||
/// will not say how much it holds.
|
||||
/// The speed the engine transmits at, which is what paces the pump.
|
||||
public double Baud { get; set; }
|
||||
|
||||
/// How many characters may sit in the engine at once.
|
||||
public int Lead { get; set; } = DefaultLead;
|
||||
|
||||
/// False once the engine has refused to say how much it holds, which puts
|
||||
/// the pump back on the clock.
|
||||
public bool Counts { get; private set; } = true;
|
||||
/// True once the engine has said how much it holds. An engine that will not
|
||||
/// say is paced by the clock alone.
|
||||
public bool Counts { get; private set; }
|
||||
|
||||
public TimeSpan CharacterTime =>
|
||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
||||
@@ -259,109 +274,79 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds the engine on the clock, holds off when its own count says it is
|
||||
/// further behind than that, and stops when both agree there is nothing
|
||||
/// left.
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
int inEngine = 0;
|
||||
int last = int.MaxValue;
|
||||
int fedSince = 0;
|
||||
int seen = -1;
|
||||
DateTime nextOut = DateTime.MinValue;
|
||||
DateTime lastAsked = DateTime.MinValue;
|
||||
DateTime moved = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
if (counter is not null && Counts && await CountedAsync(cancellation).ConfigureAwait(false))
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
DateTime now = DateTime.UtcNow;
|
||||
while (inEngine > 0 && now >= nextOut)
|
||||
{
|
||||
inEngine--;
|
||||
nextOut += CharacterTime;
|
||||
}
|
||||
if (counter is not null && now - lastAsked >= PollInterval)
|
||||
{
|
||||
lastAsked = now;
|
||||
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
|
||||
}
|
||||
int answered = Volatile.Read(ref answers);
|
||||
if (answered != seen)
|
||||
{
|
||||
seen = answered;
|
||||
fedSince = 0;
|
||||
}
|
||||
int left = Volatile.Read(ref counted);
|
||||
if (left < last)
|
||||
{
|
||||
moved = now;
|
||||
}
|
||||
last = left;
|
||||
// the answer says nothing about the characters fed since it
|
||||
int held = left < 0 ? -1 : left + fedSince;
|
||||
bool holding = left > 0 && now - moved > HoldingPatience;
|
||||
bool room = holding
|
||||
|| (inEngine < Lead && (held < 0 || held < Lead + Slack));
|
||||
if (room && Take() is { } next)
|
||||
{
|
||||
if (inEngine == 0)
|
||||
{
|
||||
nextOut = now + CharacterTime;
|
||||
}
|
||||
inEngine++;
|
||||
fedSince++;
|
||||
// feeding explains a count that is not going down, so it
|
||||
// counts as movement: an engine is only holding what it has
|
||||
// if it neither transmits nor is given anything
|
||||
moved = now;
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
continue;
|
||||
}
|
||||
if (inEngine == 0 && left <= 0 && !IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
await PacedAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the engine how much it has left, feeds it up to `Lead`, and asks
|
||||
/// again. The pump is the only thing that writes to the engine, so an
|
||||
/// answer is about characters it has already been given.
|
||||
///
|
||||
/// Returns false if the engine will not say, which puts the pump on the
|
||||
/// clock instead.
|
||||
private async Task<bool> CountedAsync(CancellationToken cancellation)
|
||||
{
|
||||
DateTime moved = DateTime.UtcNow;
|
||||
int last = int.MaxValue;
|
||||
bool wasEmpty = false;
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
int left = await AskAsync(cancellation).ConfigureAwait(false);
|
||||
if (left < 0)
|
||||
{
|
||||
Counts = false;
|
||||
return false;
|
||||
}
|
||||
if (left < last)
|
||||
{
|
||||
moved = DateTime.UtcNow;
|
||||
}
|
||||
bool holding = DateTime.UtcNow - moved > HoldingPatience;
|
||||
while ((left < Lead || holding) && Take() is { } next)
|
||||
{
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
left++;
|
||||
holding = false;
|
||||
moved = DateTime.UtcNow;
|
||||
}
|
||||
last = left;
|
||||
// the engine takes a moment to count what it has just been given,
|
||||
// so one empty answer is not the end of the message
|
||||
if (left == 0 && !IsSending)
|
||||
{
|
||||
if (wasEmpty)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
wasEmpty = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
wasEmpty = false;
|
||||
}
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The fallback: count character times off the clock at the baud rate, so
|
||||
/// the engine is fed at the speed it transmits. It drifts, which shows up
|
||||
/// as a gap between characters near the end of a long message.
|
||||
private async Task PacedAsync(CancellationToken cancellation)
|
||||
{
|
||||
int inEngine = 0;
|
||||
DateTime nextOut = DateTime.MinValue;
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
while (inEngine > 0 && now >= nextOut)
|
||||
{
|
||||
inEngine--;
|
||||
nextOut += CharacterTime;
|
||||
}
|
||||
if (inEngine < Lead && Take() is { } next)
|
||||
{
|
||||
if (inEngine == 0)
|
||||
{
|
||||
nextOut = now + CharacterTime;
|
||||
}
|
||||
inEngine++;
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
continue;
|
||||
}
|
||||
if (inEngine == 0 && !IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan Tick
|
||||
{
|
||||
get
|
||||
@@ -371,35 +356,11 @@ public sealed class TypeAhead : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// One question and its answer, or -1 when the engine would not say.
|
||||
private async Task<int> AskAsync(CancellationToken cancellation)
|
||||
{
|
||||
if (counter is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
TaskCompletionSource<int> answer = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
lock (gate)
|
||||
{
|
||||
asking = answer;
|
||||
}
|
||||
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await answer.Task.WaitAsync(AnswerPatience, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void WhenBuffered(object? sender, int left)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
asking?.TrySetResult(left);
|
||||
}
|
||||
Counts = left >= 0;
|
||||
Volatile.Write(ref counted, left);
|
||||
Interlocked.Increment(ref answers);
|
||||
}
|
||||
|
||||
/// The next character to send, or null when there is none to send now:
|
||||
|
||||
Reference in New Issue
Block a user