using System.Diagnostics; namespace Nonemm.Digital; /// Runs the bridge and talks to it over its standard input and output. On /// Linux the bridge runs under Wine, which writes its own diagnostics to /// standard error, so the protocol has standard output to itself. On Windows /// `wine` is null and the bridge is started directly. public sealed class ProcessBridgeChannel : BridgeChannel { private const int KeptErrorLines = 20; private readonly string? wine; private readonly string bridgePath; private readonly string? prefix; private readonly string? logFolder; private readonly Queue lastErrors = new(); private readonly SemaphoreSlim writing = new(1, 1); private readonly Lock writingLog = new(); private Process? bridge; private StreamWriter? log; private DateTime started; private int lastCount = int.MinValue; /// `prefix` is the WINEPREFIX to run in. Left null, Wine uses its default, /// which is what a station with one prefix wants. `wine` is the Wine /// command, or null to run the bridge without Wine. `logFolder`, when it is /// given, is where the protocol log for this run is written. public ProcessBridgeChannel( string bridgePath, string? prefix = null, string? wine = "wine", string? logFolder = null) { this.bridgePath = bridgePath; this.prefix = prefix; this.wine = wine; this.logFolder = logFolder; } public event EventHandler? LineReceived; public event EventHandler? Failed; public Task StartAsync(CancellationToken cancellation = default) { OpenLog(); bridge = Start(bridgePath); _ = ReadOutputAsync(bridge); _ = ReadErrorsAsync(bridge); return Task.CompletedTask; } public async Task SendAsync(string line, CancellationToken cancellation = default) { Process running = bridge ?? throw new InvalidOperationException("the bridge is not started"); await writing.WaitAsync(cancellation).ConfigureAwait(false); try { await running.StandardInput.WriteLineAsync(line.AsMemory(), cancellation) .ConfigureAwait(false); await running.StandardInput.FlushAsync(cancellation).ConfigureAwait(false); Log(">", line); } finally { writing.Release(); } } public async Task ToWindowsPathAsync( string path, CancellationToken cancellation = default) { if (wine is null) { return Path.GetFullPath(path); } using Process winepath = Start("winepath", "-w", path); string converted = await winepath.StandardOutput.ReadToEndAsync(cancellation) .ConfigureAwait(false); await winepath.WaitForExitAsync(cancellation).ConfigureAwait(false); if (winepath.ExitCode != 0 || converted.Trim().Length == 0) { throw new InvalidOperationException( $"winepath -w {path} failed with exit code {winepath.ExitCode}"); } return converted.Trim(); } public void Dispose() { try { if (bridge is { HasExited: false }) { bridge.Kill(entireProcessTree: true); } } catch (InvalidOperationException) { // the bridge stopped on its own, so there is nothing left to kill } bridge?.Dispose(); writing.Dispose(); lock (writingLog) { log?.Dispose(); log = null; } } /// One file per run of the engine, named for when it started. It is the /// whole protocol with a millisecond stamp on every line, which is what /// says who keyed the transmitter and when. private void OpenLog() { if (logFolder is null) { return; } try { Directory.CreateDirectory(logFolder); started = DateTime.Now; log = new StreamWriter( Path.Combine(logFolder, $"digital-{started:yyyyMMdd-HHmmss}.log"), append: false) { AutoFlush = true, }; } catch (IOException) { // a log that cannot be written stops nothing log = null; } catch (UnauthorizedAccessException) { log = null; } } /// The buffer question goes out every 50 ms and is answered just as often, /// which would bury everything else. The question is left out and the /// answer is written only when the count has changed. private void Log(string direction, string line) { if (log is null) { return; } (string verb, string[] fields) = BridgeLine.Read(line); if (verb == "buffer") { if (direction == ">" || fields.Length == 0) { return; } if (!int.TryParse(fields[0], out int count) || count == lastCount) { return; } lastCount = count; } lock (writingLog) { log?.WriteLine( $"{(DateTime.Now - started).TotalMilliseconds,9:0} ms {direction} {line.Replace('\t', ' ')}"); } } private Process Start(string program, params string[] arguments) { ProcessStartInfo start = new() { FileName = wine ?? program, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; if (wine is not null) { start.ArgumentList.Add(program); } foreach (string argument in arguments) { start.ArgumentList.Add(argument); } if (prefix is not null) { start.Environment["WINEPREFIX"] = prefix; } return Process.Start(start) ?? throw new InvalidOperationException($"{wine} {program} did not start"); } private async Task ReadOutputAsync(Process running) { while (await running.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) { Log("<", line); LineReceived?.Invoke(this, line); } await running.WaitForExitAsync().ConfigureAwait(false); Failed?.Invoke(this, $"the bridge exited with code {running.ExitCode}: {ErrorTail()}"); } private async Task ReadErrorsAsync(Process running) { while (await running.StandardError.ReadLineAsync().ConfigureAwait(false) is { } line) { lock (lastErrors) { lastErrors.Enqueue(line); if (lastErrors.Count > KeptErrorLines) { lastErrors.Dequeue(); } } } } private string ErrorTail() { lock (lastErrors) { return string.Join(" / ", lastErrors); } } }