namespace RemSound.App; /// /// Lightweight tab-separated log file written next to the executable in logs\. /// Two row kinds: /// SNAP — periodic snapshot of runtime counters (one per second) /// EVT — one-off events (connect, disconnect, codec change, errors) /// Format: /// SNAP\t{Timestamp}\t{Machine}\t{Connected}\t{SendRunning}\t{ReceiveRunning}\t{Codec}\t /// {MaxLatencyMs}\t{TargetLatencyMs}\t{BufferMs}\t{SenderPackets}\t{SenderKB}\t /// {SenderDevice}\t{ReceiverPackets}\t{ReceiverKB}\t{Underruns}\t{Drops}\t{ReceiveDevice} /// EVT\t{Timestamp}\t{Message} /// /// The log file is created lazily on the first write that arrives while /// is true. When the user has logs turned off in Preferences (the default), no file is created /// at all — the App can construct the log object freely without spawning empty files in /// logs\. Flipping back on mid-session is also safe: the next /// write creates the file with its header and the session continues normally. /// internal sealed class RemSoundLog : IDisposable { // MaxLatencyMsAsio / TargetLatencyMsAsio columns hold the per-lane numbers in // BothIndependent mode. In WasapiOnly they are 0 and the legacy MaxLatencyMs / // TargetLatencyMs columns continue to mean "the receiver's only latency". private const string SnapHeader = "Kind\tTimestamp\tMachine\tConnected\tSendRunning\tReceiveRunning\tCodec\t" + "MaxLatencyMs\tTargetLatencyMs\tBufferMs\tSenderPackets\tSenderKB\t" + "SenderDevice\tReceiverPackets\tReceiverKB\tUnderruns\tDrops\tReceiveDevice\tHeartbeat\t" + "OpusFecRecoveries\tOpusUnrecoveredGaps\tMaxLatencyMsAsio\tTargetLatencyMsAsio"; private StreamWriter? writer; private bool fileCreationFailed; /// Serialises all writes. StreamWriter is documented as non-thread-safe and /// concurrent WriteLine calls from the mix loop, heartbeat thread, network listener, /// UI thread and ASIO callback can interleave bytes into a single line in the output /// file. Worse, an interleaved write can leave the StreamWriter's internal char buffer /// in a state that throws on the next Flush — that exception escapes the inner try/catch /// and can bring the process down. A single gate around every WriteLine, every Dispose /// and every lazy-init step serialises writes cleanly; the cost is microseconds and /// worth the diagnostic integrity. private readonly object writeGate = new(); /// Path of the log file once it has been created. Null until the first write /// arrives with true (or null forever if logging is never enabled /// or file creation fails). public string? Path { get; private set; } /// Master gate for all writes. When false, both and /// short-circuit before touching the file system — no creation, /// no headers, no data. Defaults to false so the App can construct the log object /// before reading the user's preference; the App pushes the real value in after. public bool Enabled { get; set; } public RemSoundLog() { // No file work in the constructor. EnsureFileOpenLocked does it lazily on first // write, only when Enabled has been confirmed true. } /// Open the underlying file if it hasn't been opened yet and write the schema /// header + a "log started" event line. Must be called while holding /// . Returns true on success or if the file is already open; /// false if creation has failed (either now or earlier) — caller should give up on the /// current write. private bool EnsureFileOpenLocked() { if (writer is not null) return true; if (fileCreationFailed) return false; try { var dir = RemSound.Core.AppConfig.LogsDirectory; Directory.CreateDirectory(dir); var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log"; Path = System.IO.Path.Combine(dir, name); writer = new StreamWriter(new FileStream(Path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite)) { AutoFlush = true, }; writer.WriteLine(SnapHeader); writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog started"); return true; } catch { // Logging is best-effort. Locked dir, permissions, disk full — any of these // shouldn't kill the app. Park the file as failed so we don't keep retrying // creation on every subsequent write attempt. writer = null; Path = null; fileCreationFailed = true; return false; } } public void Snapshot( bool connected, bool sendRunning, bool receiveRunning, string codec, int maxLatencyMs, int targetLatencyMs, int bufferMs, long senderPackets, long senderBytes, string senderDevice, long receiverPackets, long receiverBytes, long underruns, long drops, string receiveDevice, string heartbeat, long opusFecRecoveries, long opusUnrecoveredGaps, int maxLatencyMsAsio = 0, int targetLatencyMsAsio = 0) { if (!Enabled) return; lock (writeGate) { if (!EnsureFileOpenLocked()) return; try { writer!.WriteLine(string.Join('\t', "SNAP", DateTime.Now.ToString("o"), Environment.MachineName, connected, sendRunning, receiveRunning, codec, maxLatencyMs, targetLatencyMs, bufferMs, senderPackets, senderBytes / 1024, Sanitize(senderDevice), receiverPackets, receiverBytes / 1024, underruns, drops, Sanitize(receiveDevice), Sanitize(heartbeat), opusFecRecoveries, opusUnrecoveredGaps, maxLatencyMsAsio, targetLatencyMsAsio)); } catch { /* swallow — log is best-effort */ } } } public void Event(string message) { if (!Enabled) return; lock (writeGate) { if (!EnsureFileOpenLocked()) return; try { writer!.WriteLine($"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}"); } catch { /* swallow */ } } } public void Dispose() { lock (writeGate) { try { if (writer is not null && Enabled) { // Inline the "log stopped" write so we don't re-acquire the gate. writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog stopped"); } writer?.Dispose(); } catch { /* swallow */ } } } private static string Sanitize(string value) => value.Replace('\t', ' ').Replace('\n', ' '); }