diff --git a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs new file mode 100644 index 000000000..50d015555 --- /dev/null +++ b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +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 +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + // Deterministic synchronization: spin until a real condition holds (the pump's + // rendezvous state or the reader's progress) instead of sleeping a fixed interval, + // which races on slow/loaded CI runners. + private static void WaitUntil(Func condition, string message) => + Assert.True(SpinWait.SpinUntil(condition, Timeout), message); + + // 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(); + private int _readCalls; + + // Number of times ReadLine() has been entered — lets a test wait until the pump's + // reader loop is blocked waiting for the next line (e.g. after consuming a command). + public int ReadCallCount => Volatile.Read(ref _readCalls); + + public void Feed(string line) => _lines.Add(line); + public void Complete() => _lines.CompleteAdding(); + + public override string ReadLine() + { + Interlocked.Increment(ref _readCalls); + 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(Timeout), "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(Timeout), "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); + + // Register the prompt, then wait until it is actually pending before feeding a + // line — otherwise the line could be consumed as a command before registration. + var prompt = Task.Run(pump.ReadLine); + WaitUntil(() => pump.HasPendingPrompt, "prompt did not register"); + reader.Feed("the answer"); + + Assert.True(prompt.Wait(Timeout), "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); + WaitUntil(() => pump.HasPendingPrompt, "prompt did not register"); + reader.Complete(); // EOF while prompt is waiting + + Assert.True(prompt.Wait(Timeout), "prompt hung on EOF"); + Assert.Null(prompt.Result); + Assert.False(pump.Running); + } + + [Fact] + public void Throwing_lookup_does_not_hang_pending_prompt() + { + var reader = new BlockingTextReader(); + var pump = new ConsoleInputPump(reader, _ => throw new InvalidOperationException("boom")); + Task.Run(pump.Run); + + // Wait until the reader loop is blocked on its first read, then feed a bad command. + WaitUntil(() => reader.ReadCallCount >= 1, "reader did not start"); + reader.Feed("badcommand"); + + // Wait until the loop has consumed that command (throwing lookup swallowed) and is + // blocked waiting for the NEXT line — a second ReadLine() entry. This guarantees + // the command was dispatched (not delivered to a prompt) before we register one. + WaitUntil(() => reader.ReadCallCount >= 2, "bad command was not consumed"); + + var prompt = Task.Run(pump.ReadLine); + WaitUntil(() => pump.HasPendingPrompt, "prompt did not register"); + reader.Complete(); // EOF while prompt is waiting + + Assert.True(prompt.Wait(Timeout), "prompt hung after throwing lookup"); + Assert.Null(prompt.Result); + Assert.False(pump.Running); + } +} +#pragma warning restore xUnit1031 diff --git a/Projects/Server/Configuration/ExpansionConfigurationPrompts.cs b/Projects/Server/Configuration/ExpansionConfigurationPrompts.cs index 54d508b08..44881716f 100644 --- a/Projects/Server/Configuration/ExpansionConfigurationPrompts.cs +++ b/Projects/Server/Configuration/ExpansionConfigurationPrompts.cs @@ -23,7 +23,7 @@ public static class ExpansionConfigurationPrompts do { Console.Write("[enter for {0}]> ", maxExpansionName); - var input = Console.ReadLine(); + var input = ConsoleInputHandler.ReadLine(); Expansion expansion; if (string.IsNullOrWhiteSpace(input)) @@ -77,7 +77,7 @@ public static class ExpansionConfigurationPrompts do { OutputSelectedMaps(expansion, selectedMaps); - lastInput = Console.ReadLine()?.TrimEnd(); + lastInput = ConsoleInputHandler.ReadLine()?.TrimEnd(); if (string.IsNullOrWhiteSpace(lastInput)) { diff --git a/Projects/Server/Configuration/ServerConfigurationPrompts.cs b/Projects/Server/Configuration/ServerConfigurationPrompts.cs index 60eaadec0..ebd41e907 100644 --- a/Projects/Server/Configuration/ServerConfigurationPrompts.cs +++ b/Projects/Server/Configuration/ServerConfigurationPrompts.cs @@ -16,7 +16,7 @@ public static class ServerConfigurationPrompts do { Console.Write("{0}> ", directories.Count > 0 ? "[enter to finish]" : " "); - var directory = Console.ReadLine(); + var directory = ConsoleInputHandler.ReadLine(); if (string.IsNullOrWhiteSpace(directory)) { break; @@ -55,7 +55,7 @@ public static class ServerConfigurationPrompts { // IP:Port? Console.Write("[{0}]> ", ips.Count > 0 ? "enter to finish" : "0.0.0.0:2593"); - var ipStr = Console.ReadLine(); + var ipStr = ConsoleInputHandler.ReadLine(); IPEndPoint ip; if (string.IsNullOrWhiteSpace(ipStr)) @@ -102,7 +102,7 @@ public static class ServerConfigurationPrompts do { Console.Write("[ModernUO]> "); - serverName = Console.ReadLine(); + serverName = ConsoleInputHandler.ReadLine(); if (string.IsNullOrWhiteSpace(serverName)) { diff --git a/Projects/Server/Console/ConsoleInputHandler.cs b/Projects/Server/Console/ConsoleInputHandler.cs index ca3d0ce58..0cd091110 100644 --- a/Projects/Server/Console/ConsoleInputHandler.cs +++ b/Projects/Server/Console/ConsoleInputHandler.cs @@ -24,13 +24,9 @@ namespace Server; public static class ConsoleInputHandler { - private static readonly AutoResetEvent _receivedUserInput = new(false); - private static readonly AutoResetEvent _endUserInput = new(false); - private static bool _initialized; - private static bool _expectUserInput; + private static ConsoleInputPump _pump; private static readonly Dictionary _inputCommands = new(); private static string[] _commandDescriptions; - private static string _input; [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void RegisterCommand(string command, string description, Action function) => @@ -73,7 +69,6 @@ public static class ConsoleInputHandler lock (_inputCommands) { var action = _inputCommands.GetValueOrDefault(command)?.Function; - _commandDescriptions = null; return action; } } @@ -86,9 +81,15 @@ public static class ConsoleInputHandler [CallPriority(0)] public static void Initialize() { - _initialized = true; + if (Core.Headless) + { + logger.Information("Console input disabled (headless: stdin is not a TTY)."); + return; + } - new Thread(ProcessConsoleInput) + _pump = new ConsoleInputPump(Console.In, GetInputCommand, logger); + + new Thread(_pump.Run) { IsBackground = true, Name = "Console Input Handler" @@ -150,69 +151,21 @@ public static class ConsoleInputHandler private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConsoleInputHandler)); - private static async void ProcessConsoleInput() - { - while (!Core.Closing) - { - string input; - try - { - input = Console.ReadLine()?.Trim(); - } - catch - { - logger.Warning("Console commands have been disabled due to an error."); - _initialized = false; - return; - } - - if (Volatile.Read(ref _expectUserInput)) - { - _input = input; - _receivedUserInput.Set(); - _endUserInput.WaitOne(); - continue; - } - - if (string.IsNullOrEmpty(input)) - { - continue; - } - - var splitInput = input.Split(' ', 2); - var command = splitInput[0].ToLower(); - - try - { - Action action; - lock (_inputCommands) - { - action = _inputCommands.GetValueOrDefault(command)?.Function; - } - - action?.Invoke(splitInput.Length > 1 ? splitInput[1] : string.Empty); - } - catch (Exception e) - { - logger.Error(e, "Failed to execute console command: {Command}", input); - } - } - } - public static string ReadLine() { - if (!_initialized) + if (Core.Headless) + { + throw new HeadlessConsoleInputException("ConsoleInputHandler.ReadLine"); + } + + // Early startup (before Initialize) or after the loop ended: read directly. + var pump = _pump; + if (pump is not { Running: true }) { return Console.ReadLine(); } - Volatile.Write(ref _expectUserInput, true); - _receivedUserInput.WaitOne(); - var line = _input; - Volatile.Write(ref _expectUserInput, false); - _endUserInput.Set(); - - return line; + return pump.ReadLine(); } private class ConsoleCommand(string[] commands, string description, Action function) diff --git a/Projects/Server/Console/ConsoleInputPump.cs b/Projects/Server/Console/ConsoleInputPump.cs new file mode 100644 index 000000000..b18ffccc1 --- /dev/null +++ b/Projects/Server/Console/ConsoleInputPump.cs @@ -0,0 +1,162 @@ +using System; +using System.IO; +using System.Threading; +using Server.Logging; + +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. +/// Command handlers dispatched by execute on the reader thread and +/// must never call — doing so would deadlock the pump (the reader +/// thread would be blocked waiting on itself to read the next line). +/// +internal sealed class ConsoleInputPump +{ + private readonly TextReader _input; + private readonly Func> _lookup; + private readonly Server.Logging.ILogger _logger; + private readonly Lock _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, Server.Logging.ILogger logger = null) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + _lookup = lookup ?? throw new ArgumentNullException(nameof(lookup)); + _logger = logger; + } + + public bool Running => _running; + + // Test-observable: true while a ReadLine() caller is registered and waiting for the + // next line. Lets tests synchronize on the rendezvous state instead of sleeping. + internal bool HasPendingPrompt + { + get + { + lock (_gate) + { + return _promptPending; + } + } + } + + public void Run() + { + try + { + while (_running && !Core.Closing) + { + string line; + try + { + line = _input.ReadLine(); + } + catch + { + _logger?.Warning("Console commands have been disabled due to an error."); + 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); + + try + { + var action = _lookup(split[0].ToLower()); + action?.Invoke(split.Length > 1 ? split[1] : string.Empty); + } + catch (Exception e) + { + _logger?.Error(e, "Failed to execute console command: {Command}", line); + } + } + } + finally + { + _running = false; + ReleasePendingPrompt(null); + } + } + + /// + /// Blocks the calling thread until the next console line is available, or until the + /// pump stops (returning null). Intended for a single, sequential caller at a + /// time — ModernUO's console prompts run one after another during startup/steps. + /// Concurrent callers are not supported: a second caller overlapping with a pending + /// prompt will race with it for the next line. Must not be called from the reader + /// thread (i.e. from within a command handler dispatched by ), as + /// that would deadlock the pump. + /// + 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(); + } + } + } +} diff --git a/Projects/Server/Console/HeadlessConsoleInputException.cs b/Projects/Server/Console/HeadlessConsoleInputException.cs new file mode 100644 index 000000000..ff02fcc94 --- /dev/null +++ b/Projects/Server/Console/HeadlessConsoleInputException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Server; + +/// +/// Thrown when interactive console input is required but the server is running +/// headless (stdin is not a TTY). Rides the fatal-shutdown path. +/// +public sealed class HeadlessConsoleInputException : Exception +{ + public HeadlessConsoleInputException(string context) + : base( + $"Interactive console input required but the server is headless (stdin is not a TTY). " + + $"Pre-supply the required configuration/save data. Prompt: {context}." + ) + { + } +} diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 9f6553904..f89fd3084 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -147,6 +147,8 @@ public static class Core public static bool Closing => ClosingTokenSource.IsCancellationRequested; + public static bool Headless { get; private set; } + public static int GlobalUpdateRange { get; set; } = 18; public static int GlobalMaxUpdateRange { get; set; } = 24; @@ -258,7 +260,7 @@ public static class Core // ignored } - if (!close) + if (!close && !Core.Headless) { Console.WriteLine("This exception is fatal, press return to exit"); ConsoleInputHandler.ReadLine(); @@ -407,6 +409,12 @@ public static class Core Console.CancelKeyPress += Console_CancelKeyPressed; + Headless = Console.IsInputRedirected; + if (Headless) + { + logger.Information("Headless mode detected (stdin is not a TTY); interactive console input is disabled."); + } + // LibDeflate is not thread safe, so we need to create a new instance for each thread var standard = Deflate.Standard; AppDomain.CurrentDomain.ProcessExit += (_, _) => standard.Dispose(); diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 78aae06f1..501e84aeb 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -166,7 +166,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer Console.Write($"Error: Type '{typeName}' was {issue}. Delete all of those types? (y/n): "); - if (Console.ReadLine().InsensitiveEquals("y")) + if (ConsoleInputHandler.ReadLine().InsensitiveEquals("y")) { Console.WriteLine("Loading..."); return null; @@ -430,7 +430,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer if (!deleteAllFailures) { Console.Write("Delete the object and continue? (y/n/a): "); - var pressedKey = Console.ReadLine(); + var pressedKey = ConsoleInputHandler.ReadLine(); if (pressedKey.InsensitiveEquals("a")) { diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 8e6c77c37..0afd64210 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -104,7 +104,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable Console.WriteLine(error); Console.Write("Skip this file and continue? (y/n): "); - var y = Console.ReadLine(); + var y = ConsoleInputHandler.ReadLine(); if (!y.InsensitiveEquals("y")) {