diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs index 7937705..90db645 100644 --- a/src/TeamsISO.App/App.xaml.cs +++ b/src/TeamsISO.App/App.xaml.cs @@ -26,6 +26,8 @@ public partial class App : Application $"Local\\WildDragon.TeamsISO.SingleInstance.{Environment.UserName}"; private System.Threading.Mutex? _singleInstanceMutex; + private bool _ownsSingleInstanceMutex; + private ThreadMessageEventHandler? _bringToFrontHandler; private ILoggerFactory? _loggerFactory; private NdiInteropPInvoke? _interop; private IsoController? _controller; @@ -49,6 +51,7 @@ public partial class App : Application // with the same default name, and two writers to config.json all raced. bool createdNew; _singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, SingleInstanceMutexName, out createdNew); + _ownsSingleInstanceMutex = createdNew; if (!createdNew) { var bringToFront = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); @@ -59,9 +62,11 @@ public partial class App : Application } // Listen for the broadcast — if a *new* instance launches and finds us already - // running, it'll send this message; we surface our window in response. + // running, it'll send this message; we surface our window in response. Hold the + // delegate in a field so OnExit can unsubscribe cleanly even though the + // AppDomain teardown would also drop it. var bringToFrontMsg = RegisterWindowMessageW("WildDragon.TeamsISO.BringToFront"); - ComponentDispatcher.ThreadFilterMessage += (ref System.Windows.Interop.MSG msg, ref bool handled) => + _bringToFrontHandler = (ref System.Windows.Interop.MSG msg, ref bool handled) => { if (msg.message == (int)bringToFrontMsg && MainWindow is not null) { @@ -72,6 +77,7 @@ public partial class App : Application handled = true; } }; + ComponentDispatcher.ThreadFilterMessage += _bringToFrontHandler; try { @@ -161,7 +167,18 @@ public partial class App : Application } finally { - try { _singleInstanceMutex?.ReleaseMutex(); } catch { /* not owned */ } + // Unsubscribe the bring-to-front filter so the delegate doesn't outlive + // the App; ComponentDispatcher is process-static. + if (_bringToFrontHandler is not null) + { + ComponentDispatcher.ThreadFilterMessage -= _bringToFrontHandler; + _bringToFrontHandler = null; + } + // Release the Mutex iff we acquired it. The "lost the race" path above + // sets _ownsSingleInstanceMutex=false and we skip ReleaseMutex (which + // would throw ApplicationException on an unowned Mutex). + try { if (_ownsSingleInstanceMutex) _singleInstanceMutex?.ReleaseMutex(); } + catch { /* defensive: already-released or invalid handle */ } _singleInstanceMutex?.Dispose(); } base.OnExit(e); diff --git a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs index b75d98f..3becbdf 100644 --- a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs +++ b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs @@ -41,6 +41,13 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable { // Empty/whitespace -> default (Public). Otherwise allocate a UTF-8 buffer // for the comma-separated group list and pin a settings struct around it. + // + // Memory ownership: the NDI SDK's NDIlib_find_create_v2 (and _send_create, + // _recv_create_v3) copy the strings out of the settings struct synchronously + // before returning — they don't retain pointers into our buffers. This is the + // same lifetime contract CreateReceiver / CreateSender below have relied on + // since Phase B-2; if it ever turns out to be wrong, those will fail too. The + // loopback discovery integration test would catch a regression here. var trimmed = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim(); if (trimmed is null) { diff --git a/src/TeamsISO.Engine/Logging/EngineLogging.cs b/src/TeamsISO.Engine/Logging/EngineLogging.cs index ec02e69..c5952aa 100644 --- a/src/TeamsISO.Engine/Logging/EngineLogging.cs +++ b/src/TeamsISO.Engine/Logging/EngineLogging.cs @@ -77,11 +77,11 @@ public static class EngineLogging flushToDiskInterval: TimeSpan.FromMilliseconds(250)) // flush often so support tools see live tails .CreateLogger(); - // Set the Serilog static singleton too. SerilogLoggerFactory writes through the - // explicit logger we pass in, but anything in the engine that reaches for - // Serilog.Log.* directly would otherwise miss our sinks. Belt & suspenders. - Serilog.Log.Logger = serilog; - + // Note: deliberately NOT setting Serilog.Log.Logger here. xUnit can run tests + // in parallel and two factories from different tests would otherwise race on + // the static singleton. SerilogLoggerFactory writes through the explicit + // logger we pass in, so the static fallback isn't needed; engine code uses + // the injected ILogger, not Serilog.Log.* directly. var factory = new SerilogLoggerFactory(serilog, dispose: true); // Surface the path at startup so support has it without digging. factory.CreateLogger("TeamsISO.Engine") diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs index 1f549c1..27471f8 100644 --- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs +++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs @@ -24,7 +24,13 @@ public sealed class IsoPipeline : IAsyncDisposable // restart. Reads via Volatile.Read are safe from any thread (UI's stats poll). private NdiReceiver? _liveReceiver; private NdiSender? _liveSender; - private RawFrame? _lastReceivedFrame; + + // Last-frame metadata, snapshotted out of the RawFrame on capture so we don't + // hold a reference to the frame's pixel buffer past its useful life. Two ints + // and a DateTimeOffset are atomically writable on x64; we accept tearing on x86 + // (purely advisory stats display). + private int _lastWidth; + private int _lastHeight; private DateTimeOffset? _lastReceivedAt; public Guid ParticipantId { get; } @@ -41,7 +47,8 @@ public sealed class IsoPipeline : IAsyncDisposable { var receiver = Volatile.Read(ref _liveReceiver); var sender = Volatile.Read(ref _liveSender); - var lastFrame = Volatile.Read(ref _lastReceivedFrame); + var w = Volatile.Read(ref _lastWidth); + var h = Volatile.Read(ref _lastHeight); var lastAt = _lastReceivedAt; if (receiver is null || sender is null) @@ -54,8 +61,8 @@ public sealed class IsoPipeline : IAsyncDisposable FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet LastFrameAt: lastAt, IncomingFps: 0, // running rate computation is a follow-up - IncomingWidth: lastFrame?.Width ?? 0, - IncomingHeight: lastFrame?.Height ?? 0); + IncomingWidth: w, + IncomingHeight: h); } /// @@ -110,7 +117,11 @@ public sealed class IsoPipeline : IAsyncDisposable }, onFrame: frame => { - Volatile.Write(ref _lastReceivedFrame, frame); + // Snapshot dimensions only — don't hold the RawFrame reference past + // the channel write so the GC can reclaim it on schedule, and so a + // late stats read can never resurrect a dropped frame's pixel buffer. + Volatile.Write(ref _lastWidth, frame.Width); + Volatile.Write(ref _lastHeight, frame.Height); _lastReceivedAt = DateTimeOffset.UtcNow; }); }