From f1218e7449233dd89c587eb5204d3b5fda25db5b Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Fri, 21 Aug 2026 11:02:11 +0000 Subject: [PATCH] fix(tui): stop talking to the terminal when nobody is using it Terminal.Gui runs its main loop 25 times a second whether or not anything has changed, and rewrites cursor state every time round. Measured on an idle TUI with nothing happening at all: ~315 bytes/second of escape sequences, in 25 separate writes per second, for as long as the tool is open. On a local terminal that is invisible. Over SSH it is 25 packets a second the far end can never stop servicing, and a link that is congested never gets a quiet moment to catch up. The loop now steps down when the tool is left alone - 10/s after ten seconds untouched, 4/s after a minute - and the first key or mouse event puts it straight back to the library's own rate. Two stages rather than one keeps the cost off the case that would be noticed: a pause to think slows the waking key by ~45ms, and only walking away costs the full quarter second. Measured behind a pty that answers the driver's queries like a real terminal: typing normally 29-49ms -> 29-49ms (10 keypresses, unchanged) idle after 20s 315 B/s -> 128 B/s idle after 70s 315 B/s -> 54 B/s waking keypress 25ms -> 60-248ms (4 samples: 248, 60, 235, 70) This is a candidate fix for "the UI goes laggy after a few minutes", not a confirmed one, and it is worth being straight about which. The lag does not reproduce locally: twelve minutes idle held keypress latency flat at 44-73ms, CPU at 2.1% and file handles constant, and 150 open-and-close cycles of the channel editor held latency flat at ~60ms with no handle growth either. The constant output is what the tool was doing wrong regardless of whether it turns out to be the cause. Terminal.Gui 2.4.18-develop.31 behaves identically on the same measurement, so there is nothing to pick up from a version bump. The policy is a pure function of how long it is since the last input, so it is unit tested directly: rates, the two thresholds, that it is monotonic, and that it never speeds the loop up past what the library asked for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FkDFej82QnbjMJYFcwYyAZ --- CHANGELOG.md | 7 ++ README.md | 2 + .../M0LTE.Tait.Codeplug.Cli.csproj | 4 ++ src/M0LTE.Tait.Codeplug.Cli/Tui.cs | 38 ++++++++++ src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs | 59 ++++++++++++++++ .../M0LTE.Tait.Codeplug.Tests.csproj | 1 + .../TuiIdlePolicyTests.cs | 70 +++++++++++++++++++ 7 files changed, 181 insertions(+) create mode 100644 src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs create mode 100644 tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f2ed5..0a642e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ What changed in each release. The section for a version is lifted into that vers Newest first. Add a section before tagging. +## 0.6.1 - 2026-08-21 + +- **The interactive mode stops talking to the terminal when nobody is using it.** Terminal.Gui runs its main loop 25 times a second whether or not anything has changed, and rewrites cursor state every time round: sitting there with nothing happening, the tool was emitting ~315 bytes a second in 25 separate writes, for as long as it was open. The loop now steps down after ten seconds untouched and again after a minute. Measured idle output falls from 315 bytes/sec to 128 after a short pause and to 54 after a long one. +- Typing is not affected: ten keypresses measured at 29-49 ms before the change and 29-49 ms after, because ten seconds is far longer than any pause in typing. What it costs is the single keypress that wakes it up after a long pause, measured over four attempts at 248, 60, 235 and 70 ms - up to a quarter of a second, once, and everything after it is back to normal. + +This is a candidate fix for "the UI goes laggy after a few minutes", not a confirmed one, and it is worth being straight about which. On a local terminal the lag does not reproduce: twelve minutes idle held latency flat at 44-73 ms, CPU at 2.1% and file handles constant, and 150 open-and-close cycles of the channel editor held latency flat at ~60 ms with no handle growth. What the tool was doing wrong regardless is the constant output, which over SSH is 25 packets a second the far end can never stop servicing. If it still goes laggy, the thing to say is which terminal and what connection - SSH, tmux, mosh, Windows Terminal - because that is where the remaining suspects live. + ## 0.6.0 - 2026-08-21 - **Keyboard navigation between panels.** `F6` (and `Shift+F6`) moves between Radio, Channels, PDN preset and Log; `Tab` moves within a panel. The panel holding the keyboard now lights its border white, so where you are is visible rather than guesswork. Previously `Tab` could not leave the Radio panel at all, which also meant `Enter` on the channel list never fired: the list could not be reached. diff --git a/README.md b/README.md index 70c1934..8eb1a4d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Run it with no arguments and you get a screen instead of a verb: pick a port, re The radio work runs off the UI thread, so the screen stays live through the ~25s read and the 90s the connect will wait for your power-cycle. +Left alone, it goes quiet: the main loop steps down after ten seconds untouched and again after a minute, so an editor left open over SSH is not writing to your terminal 25 times a second all afternoon. Typing is unaffected; the one key that wakes it after a long pause can take up to a quarter of a second to register, and everything after it is normal. + Colours are true-colour: a dark slate palette, green for read, amber for write (it is the one that changes your radio), red for errors. Terminal.Gui maps them down on a 16- or 256-colour terminal, so it stays legible on a plain console. To try the editor without a radio on the bench, open a saved codeplug: `tait-codeplug tui radio.m8p`. diff --git a/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj b/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj index a675803..a7ac0c2 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj +++ b/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs index b1c5a6e..2f95419 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs +++ b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs @@ -44,12 +44,18 @@ internal static class Tui private static Window _window = null!; private static IApplication _app = null!; + /// The loop rate the library starts with, restored the moment a key or mouse event arrives. + private static ushort _activeIterationsPerSecond; + + private static DateTime _lastInputUtc = DateTime.UtcNow; + internal static int Run(CodeplugImage? initial = null, string? source = null) { _app = Application.Create(); try { _app.Init(); + GoQuietWhenLeftAlone(); TuiTheme.Apply(); _window = Build(); if (initial is not null) @@ -70,12 +76,44 @@ internal static int Run(CodeplugImage? initial = null, string? source = null) } finally { + // The loop rate is a static on the library, so hand it back as we found it rather than + // leaving a slowed-down rate behind for anything that runs the TUI again in-process. + if (_activeIterationsPerSecond > 0) + { + Application.MaximumIterationsPerSecond = _activeIterationsPerSecond; + } + _app.Dispose(); } return 0; } + /// + /// Stop talking to the terminal when nobody is using the tool. The main loop otherwise ticks - and + /// rewrites cursor state - 25 times a second for ever, which is invisible locally and is a redraw + /// the far end of an SSH link can never stop servicing. has the numbers. + /// + private static void GoQuietWhenLeftAlone() + { + _activeIterationsPerSecond = Application.MaximumIterationsPerSecond; + _lastInputUtc = DateTime.UtcNow; + + _app.Keyboard.KeyDown += (_, _) => NoteInput(); + _app.Mouse.MouseEvent += (_, _) => NoteInput(); + + _app.Iteration += (_, _) => + Application.MaximumIterationsPerSecond = + TuiIdlePolicy.RateFor(DateTime.UtcNow - _lastInputUtc, _activeIterationsPerSecond); + } + + /// Someone is here: back to the normal loop rate, and the quiet clock restarts. + private static void NoteInput() + { + _lastInputUtc = DateTime.UtcNow; + Application.MaximumIterationsPerSecond = _activeIterationsPerSecond; + } + private static Window Build() { var win = new Window diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs new file mode 100644 index 0000000..b77ef95 --- /dev/null +++ b/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs @@ -0,0 +1,59 @@ +namespace M0LTE.Tait.Codeplug.Cli; + +/// +/// How fast the interactive mode's main loop should tick, given how long it is since anyone touched +/// the keyboard or mouse. +/// +/// Terminal.Gui iterates its main loop ~25 times a second whether or not anything has changed, and +/// every iteration rewrites cursor state whether or not the cursor moved. Measured on an idle TUI +/// with nothing happening at all: ~318 bytes/second of escape sequences, in 25 separate writes per +/// second, for as long as the tool is open. Locally that costs nothing you would notice. Over SSH it +/// is 25 packets a second the far end must keep servicing for ever, and a link that is congested or +/// stalls never gets a quiet moment to catch up. +/// +/// So the loop runs at the library's normal rate while the tool is being used, and steps down when it +/// is left alone. Stepping down in two stages keeps the cost off the cases that would be noticed: a +/// pause to think does not slow anything perceptibly, and only walking away drops it right down. +/// +/// touched within 10s library rate (25/s) ~315 bytes/sec typing unaffected +/// 10s to 60s 10/s ~128 bytes/sec waking key measured at 45ms +/// over 60s 4/s ~54 bytes/sec waking key measured at 60-248ms +/// +/// The first keypress after a pause pays that once, and restores the fast rate for everything after +/// it. Ten seconds is far longer than any pause in typing, so the cost never lands on a burst of +/// keystrokes. +/// +internal static class TuiIdlePolicy +{ + /// A pause long enough that a slightly slower loop will not be felt. + internal static readonly TimeSpan PausedAfter = TimeSpan.FromSeconds(10); + + /// Long enough that whoever started the tool has walked away from it. + internal static readonly TimeSpan AwayAfter = TimeSpan.FromSeconds(60); + + /// Iterations per second during a pause: 100ms worst case, which reads as instant. + internal const ushort PausedIterationsPerSecond = 10; + + /// Iterations per second once nobody is there. 250ms worst case on the key that wakes it. + internal const ushort AwayIterationsPerSecond = 4; + + /// + /// The rate to run at. is whatever the library was configured with at + /// startup, so this never invents a rate of its own for the active case, and never speeds the loop + /// up beyond what the library asked for. + /// + internal static ushort RateFor(TimeSpan sinceLastInput, ushort activeRate) + { + if (sinceLastInput >= AwayAfter) + { + return Math.Min(AwayIterationsPerSecond, activeRate); + } + + if (sinceLastInput >= PausedAfter) + { + return Math.Min(PausedIterationsPerSecond, activeRate); + } + + return activeRate; + } +} diff --git a/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj b/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj index 20bc487..3be6e5d 100644 --- a/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj +++ b/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj @@ -2,6 +2,7 @@ + diff --git a/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs b/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs new file mode 100644 index 0000000..b75e506 --- /dev/null +++ b/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs @@ -0,0 +1,70 @@ +using M0LTE.Tait.Codeplug.Cli; + +namespace M0LTE.Tait.Codeplug.Tests; + +/// +/// The interactive mode steps its main loop down when it is left alone, so that a tool sitting there +/// doing nothing stops writing to the terminal 25 times a second. The rates themselves are measured +/// (see ); what is worth pinning down here is the shape of the decision, +/// because getting it wrong is either a tool that feels sluggish to type into or one that never goes +/// quiet. +/// +public class TuiIdlePolicyTests +{ + private const ushort LibraryRate = 25; + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(9)] + [InlineData(9.999)] + public void Typing_runs_at_the_librarys_own_rate(double secondsSinceInput) + { + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate) + .Should().Be(LibraryRate, "a pause shorter than the threshold must not slow typing down"); + } + + [Theory] + [InlineData(10)] + [InlineData(30)] + [InlineData(59.999)] + public void A_pause_steps_down_to_a_rate_that_still_reads_as_instant(double secondsSinceInput) + { + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate) + .Should().Be(TuiIdlePolicy.PausedIterationsPerSecond); + } + + [Theory] + [InlineData(60)] + [InlineData(3600)] + public void Walking_away_steps_down_again(double secondsSinceInput) + { + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate) + .Should().Be(TuiIdlePolicy.AwayIterationsPerSecond); + } + + [Fact] + public void The_step_down_is_monotonic() + { + ushort previous = LibraryRate; + + for (double s = 0; s <= 120; s += 0.5) + { + ushort rate = TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(s), LibraryRate); + rate.Should().BeLessThanOrEqualTo(previous, $"the rate must never rise again at {s}s idle"); + previous = rate; + } + } + + [Fact] + public void It_never_speeds_the_loop_up_beyond_what_the_library_asked_for() + { + // A library (or a future version of it) that already ticks slowly than our idle rates must be + // left alone: stepping "down" to 10/s would be stepping up. + const ushort SlowLibrary = 2; + + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(0), SlowLibrary).Should().Be(SlowLibrary); + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(30), SlowLibrary).Should().Be(SlowLibrary); + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(300), SlowLibrary).Should().Be(SlowLibrary); + } +}