From c6c212d78f38452d5a0cdb673cb07fa46c6efd3d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:45:16 -0700 Subject: [PATCH] feat(console): add testable ConsoleInputPump rendezvous with EOF handling --- .../Tests/Console/ConsoleInputPumpTests.cs | 100 +++++++++++++ Projects/Server/Console/ConsoleInputPump.cs | 138 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs create mode 100644 Projects/Server/Console/ConsoleInputPump.cs diff --git a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs new file mode 100644 index 000000000..b7c7fca4d --- /dev/null +++ b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Server; +using Xunit; + +namespace Server.Tests; + +// These tests exercise the pump's cross-thread rendezvous directly, so they block on +// bounded-timeout Task.Wait() calls by design rather than using async/await. +#pragma warning disable xUnit1031 +public class ConsoleInputPumpTests +{ + // A TextReader whose ReadLine() blocks until a line is fed, and returns null after Complete(). + private sealed class BlockingTextReader : TextReader + { + private readonly BlockingCollection _lines = new(); + + public void Feed(string line) => _lines.Add(line); + public void Complete() => _lines.CompleteAdding(); + + public override string ReadLine() + { + try + { + return _lines.Take(); + } + catch (InvalidOperationException) + { + return null; // CompleteAdding + empty => EOF + } + } + } + + [Fact] + public void Eof_ends_loop_without_spinning() + { + var pump = new ConsoleInputPump(new StringReader(""), _ => null); + + var ran = Task.Run(pump.Run); + + Assert.True(ran.Wait(TimeSpan.FromSeconds(2)), "Run() did not return on EOF"); + Assert.False(pump.Running); + } + + [Fact] + public void Recognized_line_dispatches_command() + { + var invokedWith = new TaskCompletionSource(); + Action onPing = arg => invokedWith.TrySetResult(arg); + + var pump = new ConsoleInputPump( + new StringReader("ping hello\n"), + cmd => cmd == "ping" ? onPing : null + ); + + Task.Run(pump.Run); + + Assert.True(invokedWith.Task.Wait(TimeSpan.FromSeconds(2)), "command not dispatched"); + Assert.Equal("hello", invokedWith.Task.Result); + } + + [Fact] + public void Pending_prompt_receives_next_line() + { + var reader = new BlockingTextReader(); + var pump = new ConsoleInputPump(reader, _ => null); + Task.Run(pump.Run); + + var prompt = Task.Run(pump.ReadLine); + // Give the prompt a moment to register, then feed a line. + Thread.Sleep(50); + reader.Feed("the answer"); + + Assert.True(prompt.Wait(TimeSpan.FromSeconds(2)), "prompt did not receive a line"); + Assert.Equal("the answer", prompt.Result); + + reader.Complete(); + } + + [Fact] + public void Eof_while_prompt_pending_completes_prompt_with_null() + { + var reader = new BlockingTextReader(); + var pump = new ConsoleInputPump(reader, _ => null); + Task.Run(pump.Run); + + var prompt = Task.Run(pump.ReadLine); + Thread.Sleep(50); + reader.Complete(); // EOF while prompt is waiting + + Assert.True(prompt.Wait(TimeSpan.FromSeconds(2)), "prompt hung on EOF"); + Assert.Null(prompt.Result); + Assert.False(pump.Running); + } +} +#pragma warning restore xUnit1031 diff --git a/Projects/Server/Console/ConsoleInputPump.cs b/Projects/Server/Console/ConsoleInputPump.cs new file mode 100644 index 000000000..e5ee9ee01 --- /dev/null +++ b/Projects/Server/Console/ConsoleInputPump.cs @@ -0,0 +1,138 @@ +using System; +using System.IO; +using System.Threading; + +namespace Server; + +/// +/// Owns a console input stream. Each line read is atomically either delivered to a +/// waiting caller (a prompt) or dispatched as a command via the +/// supplied lookup. EOF ends the loop instead of spinning. Correct-by-construction: +/// the prompt-vs-command decision is made under a single lock at the moment a line is read. +/// +internal sealed class ConsoleInputPump +{ + private readonly TextReader _input; + private readonly Func> _lookup; + private readonly object _gate = new(); + private readonly AutoResetEvent _promptDelivered = new(false); + + private bool _promptPending; + private string _promptResult; + private volatile bool _running = true; + + public ConsoleInputPump(TextReader input, Func> lookup) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + _lookup = lookup ?? throw new ArgumentNullException(nameof(lookup)); + } + + public bool Running => _running; + + public void Stop() + { + _running = false; + ReleasePendingPrompt(null); + } + + public void Run() + { + while (_running && !Core.Closing) + { + string line; + try + { + line = _input.ReadLine(); + } + catch + { + break; + } + + if (line == null) + { + break; // EOF — never spin + } + + bool isCommand; + lock (_gate) + { + if (_promptPending) + { + _promptResult = line; + _promptPending = false; + _promptDelivered.Set(); + isCommand = false; + } + else + { + isCommand = true; + } + } + + if (!isCommand) + { + continue; + } + + var trimmed = line.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + var split = trimmed.Split(' ', 2); + var action = _lookup(split[0].ToLower()); + if (action == null) + { + continue; + } + + try + { + action(split.Length > 1 ? split[1] : string.Empty); + } + catch + { + // Command handlers log their own failures; never let one kill the loop. + } + } + + _running = false; + ReleasePendingPrompt(null); + } + + public string ReadLine() + { + lock (_gate) + { + if (!_running) + { + return null; + } + + _promptResult = null; + _promptPending = true; + } + + _promptDelivered.WaitOne(); + + lock (_gate) + { + return _promptResult; + } + } + + private void ReleasePendingPrompt(string result) + { + lock (_gate) + { + if (_promptPending) + { + _promptResult = result; + _promptPending = false; + _promptDelivered.Set(); + } + } + } +}