From 661cf0b8c830f55b9147dfe1b69eb02354aaee19 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Tue, 25 Aug 2026 12:14:04 +0200 Subject: [PATCH] Keep Graphics Capture on the UI thread and bump to 1.0.2 1.0.1 still crashed in the Store with AccessViolationException from IObjectReference.Finalize. Disposing session and frame fields was not enough: GraphicsCapturePicker was abandoned after the pick, and CreateFreeThreaded built frame RCWs on a worker. Resize triggered GC and Marshal.Release ran off the apartment. Create a WinRT DispatcherQueue on the WPF UI thread, switch the frame pool to Create so FrameArrived stays there, and Release every capture RCW on that thread, including the picker. VersionPrefix is 1.0.2. --- Directory.Build.props | 2 +- TODO.md | 2 +- src/SQLBI.Whiteboard/App.xaml.cs | 2 + .../LiveView/LiveViewCaptureSession.cs | 83 +++++++---------- .../LiveView/WinRtThreading.cs | 88 +++++++++++++++++++ src/SQLBI.Whiteboard/MainWindow.xaml.cs | 14 ++- 6 files changed, 137 insertions(+), 54 deletions(-) create mode 100644 src/SQLBI.Whiteboard/LiveView/WinRtThreading.cs diff --git a/Directory.Build.props b/Directory.Build.props index c4b7b87..f06e988 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ scripts/build-installer.ps1 both read it from here, so releasing is a reviewed change to this line rather than an edit in a pipeline variable group. --> - 1.0.1 + 1.0.2 latest enable enable diff --git a/TODO.md b/TODO.md index 7356a7f..73f6a34 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ The delivery chain works end to end: a merge to `main` builds, signs, and publis pre-release to GitHub Releases, and one approval promotes that same build to a release. reads its download links from the release manifest deployed beside it and needs no edit per release. The current product version is `VersionPrefix` in `Directory.Build.props` -(1.0.1). Identity version for the Store package is `VersionPrefix.0` (`1.0.1.0`). +(1.0.2). Identity version for the Store package is `VersionPrefix.0` (`1.0.2.0`). Declaring that number is decision 20 in [docs/decisions.md](docs/decisions.md). What 1.0 was waiting on shipped during 0.9.x: Preferences, `.wimport`, Explorer and VS Code diff --git a/src/SQLBI.Whiteboard/App.xaml.cs b/src/SQLBI.Whiteboard/App.xaml.cs index ada1346..fbc9cdd 100644 --- a/src/SQLBI.Whiteboard/App.xaml.cs +++ b/src/SQLBI.Whiteboard/App.xaml.cs @@ -1,5 +1,6 @@ using System.IO; using System.Windows; +using SQLBI.Whiteboard.LiveView; namespace SQLBI.Whiteboard; @@ -8,6 +9,7 @@ public partial class App : Application protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + WinRtThreading.EnsureDispatcherQueue(); var window = new MainWindow(FindBoardPath(e.Args)); MainWindow = window; window.Show(); diff --git a/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs b/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs index 5184479..e609f7b 100644 --- a/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs +++ b/src/SQLBI.Whiteboard/LiveView/LiveViewCaptureSession.cs @@ -111,7 +111,7 @@ public void AttachDevice(ID3D11Device1 device) { ThrowIfDisposed(); StopCaptureCore(); - DisposeWinRt(_winRtDevice); + WinRtThreading.Release(_winRtDevice); _winRtDevice = winRtDevice; StartCaptureCore(); } @@ -123,7 +123,7 @@ public void DetachDevice() lock (_gate) { StopCaptureCore(); - DisposeWinRt(_winRtDevice); + WinRtThreading.Release(_winRtDevice); _winRtDevice = null; } } @@ -229,12 +229,8 @@ public bool TryPresent(ID3D11DeviceContext1 context, ID3D11Texture2D destination } finally { - // Close the surface RCW on this thread. Leaving it for the GC - // finalizer AVs in Store-packaged processes: those WinRT objects - // are apartment-bound and Marshal.Release from GC.RunFinalizers - // is the 0xC0000005 that closes the app a few seconds after resize. - DisposeWinRt(surface); - DisposeWinRt(frame); + WinRtThreading.Release(surface); + WinRtThreading.Release(frame); } } @@ -256,7 +252,7 @@ public void Dispose() _disposed = true; StopCaptureCore(); ReleaseCaptureItem(); - DisposeWinRt(_winRtDevice); + WinRtThreading.Release(_winRtDevice); _winRtDevice = null; } } @@ -268,8 +264,12 @@ private void StartCaptureCore() return; } + WinRtThreading.EnsureDispatcherQueue(); _contentSize = SanitizeSize(_captureItem.Size); - Direct3D11CaptureFramePool framePool = Direct3D11CaptureFramePool.CreateFreeThreaded( + // Create, not CreateFreeThreaded: frames and their RCWs must be born + // on this dispatcher. Free-threaded FrameArrived ran on a worker, + // and those IObjectReferences finalized with 0xC0000005 in the Store. + Direct3D11CaptureFramePool framePool = Direct3D11CaptureFramePool.Create( _winRtDevice, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, @@ -307,13 +307,19 @@ private void StopCaptureCore() framePool.FrameArrived -= FramePool_FrameArrived; } - DisposeWinRt(captureSession); - DisposeWinRt(framePool); - DisposeWinRt(pendingFrame); + WinRtThreading.Release(captureSession); + WinRtThreading.Release(framePool); + WinRtThreading.Release(pendingFrame); } private void FramePool_FrameArrived(Direct3D11CaptureFramePool sender, object args) { + if (!_dispatcher.CheckAccess()) + { + _ = _dispatcher.BeginInvoke(() => FramePool_FrameArrived(sender, args)); + return; + } + Direct3D11CaptureFrame? frame = null; try { @@ -328,7 +334,7 @@ private void FramePool_FrameArrived(Direct3D11CaptureFramePool sender, object ar { if (!ReferenceEquals(sender, _framePool) || _isFrozen) { - DisposeWinRt(frame); + WinRtThreading.Release(frame); return; } @@ -337,7 +343,7 @@ private void FramePool_FrameArrived(Direct3D11CaptureFramePool sender, object ar // Recreate discards native frames. Close the pending wrapper // first so its finalizer cannot Release a pointer Recreate // has already invalidated. - DisposeWinRt(_pendingFrame); + WinRtThreading.Release(_pendingFrame); _pendingFrame = null; _contentSize = size; sender.Recreate( @@ -345,7 +351,7 @@ private void FramePool_FrameArrived(Direct3D11CaptureFramePool sender, object ar DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, size); - DisposeWinRt(frame); + WinRtThreading.Release(frame); frame = null; } } @@ -361,7 +367,7 @@ private void FramePool_FrameArrived(Direct3D11CaptureFramePool sender, object ar long previous = Interlocked.Read(ref _lastAcceptedTimestamp); if (previous != 0 && now - previous < minimumTicks) { - DisposeWinRt(frame); + WinRtThreading.Release(frame); return; } @@ -370,23 +376,30 @@ private void FramePool_FrameArrived(Direct3D11CaptureFramePool sender, object ar { if (!ReferenceEquals(sender, _framePool) || _isFrozen) { - DisposeWinRt(frame); + WinRtThreading.Release(frame); return; } Direct3D11CaptureFrame? replaced = _pendingFrame; _pendingFrame = frame; frame = null; - DisposeWinRt(replaced); + WinRtThreading.Release(replaced); } FrameAvailable?.Invoke(); } catch (Exception exception) { - DisposeWinRt(frame); + WinRtThreading.Release(frame); CaptureFailed?.Invoke(exception); } + finally + { + if (args is IWinRTObject && !ReferenceEquals(args, sender)) + { + WinRtThreading.Release(args); + } + } } private void CaptureItem_Closed(GraphicsCaptureItem sender, object args) @@ -438,9 +451,7 @@ private void ReleaseCaptureItem() } _captureItem.Closed -= CaptureItem_Closed; - // GraphicsCaptureItem is not IClosable. Drop the RCW's COM pointer here - // so IObjectReference.Finalize never Release's it from the GC thread. - DisposeWinRt(_captureItem); + WinRtThreading.Release(_captureItem); _captureItem = null; } @@ -480,32 +491,6 @@ private static WinRtDirect3DDevice CreateWinRtDevice(IDXGIDevice dxgiDevice) private void VerifyDispatcherAccess() => _dispatcher.VerifyAccess(); - private static void DisposeWinRt(object? value) - { - if (value is null) - { - return; - } - - try - { - if (value is IDisposable disposable) - { - disposable.Dispose(); - return; - } - - if (value is IWinRTObject winrt) - { - winrt.NativeObject.Dispose(); - } - } - catch (Exception exception) - { - Debug.WriteLine($"[LiveView] WinRT release failed: {exception}"); - } - } - [DllImport("d3d11.dll", ExactSpelling = true)] private static extern int CreateDirect3D11DeviceFromDXGIDevice( nint dxgiDevice, diff --git a/src/SQLBI.Whiteboard/LiveView/WinRtThreading.cs b/src/SQLBI.Whiteboard/LiveView/WinRtThreading.cs new file mode 100644 index 0000000..f6d6eda --- /dev/null +++ b/src/SQLBI.Whiteboard/LiveView/WinRtThreading.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.System; +using WinRT; + +namespace SQLBI.Whiteboard.LiveView; + +/// +/// WinRT Graphics Capture RCWs are apartment-bound in a packaged (Store) +/// process. Releasing them from the GC finalizer is the AccessViolation +/// that closes the app. Keep a DispatcherQueue on the UI thread so +/// +/// delivers frames there, and Release every RCW on that same thread. +/// +internal static class WinRtThreading +{ + // Native ref kept for the process lifetime. Wrapping it in a CsWinRT + // RCW would only recreate the finalizer problem this exists to avoid. + private static nint _dispatcherQueueController; + + public static void EnsureDispatcherQueue() + { + if (DispatcherQueue.GetForCurrentThread() is not null) + { + return; + } + + DispatcherQueueOptions options = new() + { + dwSize = Marshal.SizeOf(), + threadType = 2, // DQTYPE_THREAD_CURRENT + apartmentType = 0, // DQTAT_COM_NONE — WPF already initialized STA + }; + + int hr = CreateDispatcherQueueController(options, out nint pointer); + Marshal.ThrowExceptionForHR(hr); + _dispatcherQueueController = pointer; + } + + /// + /// Close IClosable WinRT objects, then drop the CsWinRT COM pointer on + /// this thread so IObjectReference.Finalize has nothing to Release. + /// + public static void Release(object? value) + { + if (value is null) + { + return; + } + + if (value is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception exception) + { + Debug.WriteLine($"[LiveView] WinRT Close failed: {exception}"); + } + } + + if (value is IWinRTObject winrt) + { + try + { + winrt.NativeObject.Dispose(); + } + catch (Exception exception) + { + Debug.WriteLine($"[LiveView] WinRT Release failed: {exception}"); + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct DispatcherQueueOptions + { + public int dwSize; + public int threadType; + public int apartmentType; + } + + [DllImport("CoreMessaging.dll", ExactSpelling = true)] + private static extern int CreateDispatcherQueueController( + DispatcherQueueOptions options, + out nint dispatcherQueueController); +} diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index 9718395..bbc0454 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -3541,10 +3541,18 @@ private async Task ReconnectLiveViewAsync(LiveViewBoardObject liveView) private async Task PickLiveViewTargetAsync() { + WinRtThreading.EnsureDispatcherQueue(); GraphicsCapturePicker picker = new(); - nint windowHandle = new WindowInteropHelper(this).Handle; - WinRT.Interop.InitializeWithWindow.Initialize(picker, windowHandle); - return await picker.PickSingleItemAsync(); + try + { + nint windowHandle = new WindowInteropHelper(this).Handle; + WinRT.Interop.InitializeWithWindow.Initialize(picker, windowHandle); + return await picker.PickSingleItemAsync(); + } + finally + { + WinRtThreading.Release(picker); + } } private void AttachLiveViewPresenter(