diff --git a/StarterNG.Tests/SimulatorExitTests.cs b/StarterNG.Tests/SimulatorExitTests.cs new file mode 100644 index 0000000..ab905aa --- /dev/null +++ b/StarterNG.Tests/SimulatorExitTests.cs @@ -0,0 +1,65 @@ +using StarterNG.Application; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SimulatorExitTests +{ + private static readonly TimeSpan Session = TimeSpan.FromMinutes(20); + + [Fact] + public void A_session_ended_cleanly_is_no_failure() + { + Assert.False(new SimulatorExit(0, Session, []).Failed); + } + + [Fact] + public void An_error_code_is_a_failure() + { + Assert.True(new SimulatorExit(1, Session, []).Failed); + } + + [Fact] + public void A_window_that_only_flashed_is_a_failure_even_with_a_clean_code() + { + var exit = new SimulatorExit(0, TimeSpan.FromSeconds(0.2), []); + + Assert.True(exit.Failed); + Assert.True(exit.Quick); + } + + [Theory] + [InlineData(139, "139 (SIGSEGV)")] + [InlineData(134, "134 (SIGABRT)")] + [InlineData(-1073741819, "0xC0000005 (access violation)")] + [InlineData(3, "3")] + public void The_code_says_what_killed_it(int code, string described) + { + Assert.Equal(described, new SimulatorExit(code, Session, []).DescribeCode()); + } + + [Fact] + public void Colour_codes_and_blank_lines_are_dropped_from_the_errors() + { + var exit = new SimulatorExit(1, Session, ["\u001b[31mBad file: texture\u001b[0m", "", " "]); + + Assert.Equal(["Bad file: texture"], exit.CleanErrors); + } + + [Fact] + public async Task A_real_process_leaves_its_exit_code_and_error_stream() + { + var launcher = new SystemProcessLauncher(new TestInstallation().Log); + var (shell, script) = OperatingSystem.IsWindows() + ? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" }) + : ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" }); + + var process = launcher.Start(shell, script, null, captureErrors: true, out string? error); + Assert.Null(error); + await process!.WaitForExitAsync(); + + Assert.Equal(3, process.ExitCode); + Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim()); + } +} diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs index 08219ff..e21f256 100644 --- a/StarterNG.Tests/StartSimulationTests.cs +++ b/StarterNG.Tests/StartSimulationTests.cs @@ -54,6 +54,30 @@ public class StartSimulationTests Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory); } + [Fact] + public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it() + { + var (start, launcher, installation) = Rig(files => files.WithExecutable(Build)); + + start.Execute(freeFly: true, saveSettings: false); + Assert.True(launcher.CapturedErrors); + + // Closing right after the launch would leave the pipe with no reader. + installation.SettingsStore.Settings.AutoCloseStarter = true; + start.Execute(freeFly: true, saveSettings: false); + Assert.False(launcher.CapturedErrors); + } + + [Fact] + public void The_end_of_the_simulator_log_is_read_for_a_silent_crash() + { + var (start, _, _) = Rig(files => files + .WithExecutable(Build) + .WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n")); + + Assert.Equal(new[] { "second", "third" }, start.SimulatorLogTail(2)); + } + [Fact] public void A_file_that_cannot_be_run_is_not() { @@ -102,10 +126,13 @@ public class StartSimulationTests public IReadOnlyList? Arguments { get; private set; } + public bool? CapturedErrors { get; private set; } + public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - out string? error) + bool captureErrors, out string? error) { Executable = executablePath; + CapturedErrors = captureErrors; Arguments = arguments; WorkingDirectory = workingDirectory; error = null; @@ -121,6 +148,10 @@ public class StartSimulationTests { public bool HasExited => false; + public int? ExitCode => null; + + public IReadOnlyList ErrorTail => Array.Empty(); + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } } diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs index a8ecab1..a62eacf 100644 --- a/StarterNG/Application/Abstractions/IProcessLauncher.cs +++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs @@ -8,13 +8,24 @@ public interface IProcessHandle { bool HasExited { get; } + /// Once it has ended; null while running, or for a process the starter did not start. + int? ExitCode { get; } + + /// The last lines of the error stream, when it was captured. + IReadOnlyList ErrorTail { get; } + Task WaitForExitAsync(CancellationToken cancellationToken = default); } public interface IProcessLauncher { + /// + /// Starts a process. With captureErrors its error stream is kept for the + /// handle - only for a process the starter outlives: a write to a pipe nobody + /// reads any more can kill the writer. + /// IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - out string? error); + bool captureErrors, out string? error); bool OpenInShell(string pathOrUrl); diff --git a/StarterNG/Application/SimulatorExit.cs b/StarterNG/Application/SimulatorExit.cs new file mode 100644 index 0000000..1ac75a5 --- /dev/null +++ b/StarterNG/Application/SimulatorExit.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace StarterNG.Application; + +/// How a simulator the starter launched has ended. +public sealed record SimulatorExit(int ExitCode, TimeSpan RunTime, IReadOnlyList Errors) +{ + /// Gone before anyone could have played - however clean the exit code. + public static readonly TimeSpan QuickExit = TimeSpan.FromSeconds(10); + + public bool Quick => RunTime < QuickExit; + + public bool Failed => ExitCode != 0 || Quick; + + /// The error stream without its colour codes and blank lines. + public IReadOnlyList CleanErrors => + Errors.Select(line => AnsiCode.Replace(line, "").TrimEnd()) + .Where(line => line.Length > 0) + .ToList(); + + /// The exit code, with what it means where it is a known way to die. + public string DescribeCode() + { + // Linux reports a death by signal as 128 plus the signal. + string? meaning = ExitCode switch + { + 132 => "SIGILL", + 134 => "SIGABRT", + 135 => "SIGBUS", + 136 => "SIGFPE", + 137 => "SIGKILL", + 139 => "SIGSEGV", + 143 => "SIGTERM", + _ => null + }; + + // Windows reports an unhandled exception as its NTSTATUS. + meaning ??= unchecked((uint)ExitCode) switch + { + 0xC0000005 => "access violation", + 0xC00000FD => "stack overflow", + 0xC0000374 => "heap corruption", + 0xC0000409 => "stack buffer overrun", + _ => null + }; + + string code = ExitCode < 0 + ? "0x" + unchecked((uint)ExitCode).ToString("X8", CultureInfo.InvariantCulture) + : ExitCode.ToString(CultureInfo.InvariantCulture); + return meaning is null ? code : $"{code} ({meaning})"; + } + + private static readonly Regex AnsiCode = new(@"\x1B\[[0-9;]*m"); +} diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs index d208922..9678958 100644 --- a/StarterNG/Application/StartSimulation.cs +++ b/StarterNG/Application/StartSimulation.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -62,6 +63,29 @@ public sealed class StartSimulation _log = log; } + /// The end of the simulator's own log, for when it died without a word on its error stream. + public IReadOnlyList SimulatorLogTail(int count) + { + string path = _paths.FromRoot("log.txt"); + try + { + if (!_files.FileExists(path)) + return Array.Empty(); + + var lines = LegacyText.Decode(_files.ReadAllBytes(path)) + .Split('\n') + .Select(line => line.TrimEnd('\r')) + .Where(line => line.Trim().Length > 0) + .ToList(); + return lines.Skip(Math.Max(0, lines.Count - count)).ToList(); + } + catch (Exception ex) + { + _log.Log($"reading {path}", ex); + return Array.Empty(); + } + } + public static string? StartableVehicle(Trainset? trainset, string? preferred) { if (trainset is null || trainset.Vehicles.Count == 0) @@ -139,7 +163,10 @@ public sealed class StartSimulation // The installation, not the folder of the binary: a simulator picked from a // build tree lives beside the data, and the data is what it opens. var (program, programArguments) = _executables.LaunchCommand(executable, arguments); - var process = _processes.Start(program, programArguments, _paths.Root, out string? error); + // Its error stream is kept for a report on a crash - unless the starter closes + // now, leaving nobody to read it or to report. + bool watched = !_settings.Settings.AutoCloseStarter; + var process = _processes.Start(program, programArguments, _paths.Root, watched, out string? error); if (process is null) return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable, Detail: error); diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs index c72eae0..59ceacd 100644 --- a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs +++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs @@ -18,29 +18,38 @@ public sealed class SystemProcessLauncher : IProcessLauncher } public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - out string? error) + bool captureErrors, out string? error) { var info = new ProcessStartInfo { FileName = executablePath, WorkingDirectory = workingDirectory ?? string.Empty, - UseShellExecute = false + UseShellExecute = false, + RedirectStandardError = captureErrors }; foreach (string argument in arguments) info.ArgumentList.Add(argument); try { - var process = Process.Start(info); - if (process is null) + var process = new Process { StartInfo = info }; + var handle = new ProcessHandle(process, started: true); + if (captureErrors) + process.ErrorDataReceived += (_, e) => handle.AddError(e.Data); + + if (!process.Start()) { error = executablePath; _log.Log($"start {executablePath}: process not created"); return null; } + // Drained as it comes: a full pipe would stall the simulator. + if (captureErrors) + process.BeginErrorReadLine(); + error = null; - return new ProcessHandle(process); + return handle; } catch (Exception ex) { @@ -69,7 +78,7 @@ public sealed class SystemProcessLauncher : IProcessLauncher try { var process = Process.GetProcessesByName(processName).FirstOrDefault(); - return process is null ? null : new ProcessHandle(process); + return process is null ? null : new ProcessHandle(process, started: false); } catch (Exception ex) { @@ -80,11 +89,16 @@ public sealed class SystemProcessLauncher : IProcessLauncher private sealed class ProcessHandle : IProcessHandle { - private readonly Process _process; + private const int KeptErrorLines = 20; - public ProcessHandle(Process process) + private readonly Process _process; + private readonly bool _started; + private readonly Queue _errors = new(); + + public ProcessHandle(Process process, bool started) { _process = process; + _started = started; } public bool HasExited @@ -96,6 +110,39 @@ public sealed class SystemProcessLauncher : IProcessLauncher } } + // Only a process started here has an exit code to read; one merely found + // running throws on it. + public int? ExitCode + { + get + { + try { return _started && _process.HasExited ? _process.ExitCode : null; } + catch { return null; } + } + } + + public IReadOnlyList ErrorTail + { + get + { + lock (_errors) + return _errors.ToList(); + } + } + + public void AddError(string? line) + { + if (line is null) + return; + + lock (_errors) + { + _errors.Enqueue(line); + if (_errors.Count > KeptErrorLines) + _errors.Dequeue(); + } + } + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _process.WaitForExitAsync(cancellationToken); } diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 8fba8f8..f9f2972 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -531,6 +531,7 @@ public partial class MainWindow : Window /// private void WatchSimulator(IProcessHandle? simulator) { + var started = DateTime.UtcNow; simulator ??= AppServices.Current.Processes.FindRunning( Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable())); @@ -540,8 +541,46 @@ public partial class MainWindow : Window return; } - _ = simulator.WaitForExitAsync().ContinueWith( - _ => Dispatcher.UIThread.Post(RestoreFromSimulator), TaskScheduler.Default); + _ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => + { + RestoreFromSimulator(); + ReportIfCrashed(simulator, DateTime.UtcNow - started); + }), TaskScheduler.Default); + } + + /// + /// Says why the simulator is gone when it did not end the way a session does: + /// an error code, or a close so soon after the start that nobody played - a + /// window that only flashed. Its own words come from the error stream, or from + /// its log when that stayed silent. + /// + private async void ReportIfCrashed(IProcessHandle simulator, TimeSpan runTime) + { + if (simulator.ExitCode is not { } code) + return; + + var exit = new SimulatorExit(code, runTime, simulator.ErrorTail); + if (!exit.Failed) + return; + + var words = exit.CleanErrors.Count > 0 + ? exit.CleanErrors + : AppServices.Current.StartSimulation.SimulatorLogTail(12); + + var message = new StringBuilder(); + message.AppendLine(string.Format(App.Loc["SimExitCode"], exit.DescribeCode())); + if (exit.Quick) + message.AppendLine(string.Format(App.Loc["SimExitQuick"], runTime.TotalSeconds)); + if (words.Count > 0) + { + message.AppendLine().AppendLine(App.Loc["SimExitMessages"]); + foreach (string line in words) + message.AppendLine(line); + } + message.AppendLine().Append(App.Loc["SimExitSeeLog"]); + + Diagnostics.Log(message.ToString().Replace(Environment.NewLine, " | ")); + await MessageBox.Show(this, message.ToString(), App.Loc["SimExitTitle"], MessageBoxButtons.Ok); } private async void CheckExternalSettings() diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml index 446a9f4..e6a40bf 100644 --- a/StarterNG/startercfg/lang/cz.xml +++ b/StarterNG/startercfg/lang/cz.xml @@ -253,6 +253,11 @@ Spustitelný soubor simulátoru nebyl nalezen. Soubor není označen jako spustitelný (chmod +x). Tento binární soubor je pro jiný operační systém. Na Linuxu může verze pro Windows běžet přes Wine nebo Proton - pokud je používáte, nejde o chybu. + Simulátor se neočekávaně ukončil + Návratový kód: {0} + Ukončil se {0:0.0} s po spuštění. + Poslední zprávy: + Celý průběh je v souboru log.txt ve složce simulátoru. Informace Nastavení Soubor eu07.ini byl změněn mimo starter. Načíst jej znovu? diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml index 2ab1fe9..3451d1f 100644 --- a/StarterNG/startercfg/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -253,6 +253,11 @@ The simulator executable was not found. The file is not marked as executable (chmod +x). This binary is built for a different operating system. On Linux a Windows build can still run through Wine or Proton - if you use them, this is not an error. + The simulator ended unexpectedly + Exit code: {0} + It closed {0:0.0} s after starting. + Last messages: + The full run is in log.txt in the simulator folder. Info Settings eu07.ini was changed outside the starter. Reload it? diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml index 7740b1e..f9ba872 100644 --- a/StarterNG/startercfg/lang/hu.xml +++ b/StarterNG/startercfg/lang/hu.xml @@ -253,6 +253,11 @@ A szimulátor futtatható fájlja nem található. A fájl nincs futtathatóként megjelölve (chmod +x). Ez a bináris másik operációs rendszerhez készült. Linuxon a Windows-verzió Wine vagy Proton segítségével futtatható - ha ezeket használod, ez nem hiba. + A szimulátor váratlanul leállt + Kilépési kód: {0} + {0:0.0} másodperccel az indítás után bezárult. + Utolsó üzenetek: + A teljes futás a szimulátor mappájában lévő log.txt fájlban található. Információ Beállítások Az eu07.ini fájl a starteren kívül módosult. Újratölti? diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml index e6d3e0e..5afc1d1 100644 --- a/StarterNG/startercfg/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -253,6 +253,11 @@ Nie znaleziono pliku wykonywalnego symulatora. Plik nie ma prawa wykonywania (chmod +x). To jest plik wykonywalny dla innego systemu niż uruchomiony. Na Linuksie wersja dla Windows może działać przez Wine lub Proton - jeśli ich używasz, to nie jest błąd. + Symulator zakończył pracę nieoczekiwanie + Kod wyjścia: {0} + Zamknął się {0:0.0} s po uruchomieniu. + Ostatnie komunikaty: + Pełny przebieg jest w pliku log.txt w katalogu symulatora. Informacje Ustawienia Plik eu07.ini został zmieniony poza starterem. Wczytać ponownie? diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml index 69a8d14..f9de835 100644 --- a/StarterNG/startercfg/lang/ru.xml +++ b/StarterNG/startercfg/lang/ru.xml @@ -253,6 +253,11 @@ Исполняемый файл симулятора не найден. Файл не помечен как исполняемый (chmod +x). Этот исполняемый файл собран для другой операционной системы. В Linux версия для Windows может работать через Wine или Proton - если вы их используете, это не ошибка. + Симулятор неожиданно завершил работу + Код выхода: {0} + Он закрылся через {0:0.0} с после запуска. + Последние сообщения: + Полный журнал находится в файле log.txt в папке симулятора. Информация Настройки Файл eu07.ini изменён вне стартера. Загрузить заново?