diff --git a/src/Verify.Tests/SynchronizationContextTests.cs b/src/Verify.Tests/SynchronizationContextTests.cs new file mode 100644 index 000000000..90376d44e --- /dev/null +++ b/src/Verify.Tests/SynchronizationContextTests.cs @@ -0,0 +1,49 @@ +public class SynchronizationContextTests +{ + // WinForms/WPF/Avalonia install a SynchronizationContext on the current thread when a control is + // created. During a test that context has no running message pump, so if Verify's async IO + // captured it, the continuation that writes the received file for a new or mismatched snapshot + // would be posted to a pump that never runs - deadlocking the pipeline and the awaiting test. + // SettingsTask.ToTask clears the ambient context so the pipeline runs free of it. This reproduces + // that scenario without a UI framework: it records whether the pipeline ever posts a continuation + // to the caller's context (dispatching it onward so the test itself never hangs, even on regression). + [Fact] + public async Task DoesNotCaptureCallerSynchronizationContext() + { + var context = new RecordingSynchronizationContext(); + var original = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + Task task; + try + { + var settings = new VerifySettings(); + settings.DisableDiff(); + // A binary target with no baseline writes the received file via IoHelpers.WriteStream, + // whose CopyToAsync onto an async FileStream suspends - the exact async IO that captured + // the UI context and deadlocked. (Strings use a synchronous write path and would not.) + task = Verify(new MemoryStream(new byte[1024 * 1024]), "bin", settings).ToTask(); + } + finally + { + // Restore before awaiting so the test's own continuations are unaffected by the fake context. + SynchronizationContext.SetSynchronizationContext(original); + } + + await Assert.ThrowsAsync(() => task); + + Assert.False( + context.Posted, + "Verify captured the caller's SynchronizationContext; its async IO must run free of it."); + } + + class RecordingSynchronizationContext : SynchronizationContext + { + public volatile bool Posted; + + public override void Post(SendOrPostCallback callback, object? state) + { + Posted = true; + base.Post(callback, state); + } + } +} diff --git a/src/Verify/SettingsTask.cs b/src/Verify/SettingsTask.cs index 43f63b546..80943c3ae 100644 --- a/src/Verify/SettingsTask.cs +++ b/src/Verify/SettingsTask.cs @@ -312,8 +312,24 @@ public VerifySettings CurrentSettings } [Pure] - public Task ToTask() => - task ??= buildTask(CurrentSettings); + public Task ToTask() + { + if (task is not null) + { + return task; + } + + // Verify's pipeline performs async IO without ConfigureAwait(false), so it must not run under + // a caller-supplied SynchronizationContext. UI frameworks (WinForms/WPF/Avalonia) install one + // on the current thread when a control is created; during a test it has no running message + // pump, so capturing it would deadlock the pipeline (and the awaiting test) on the received + // file write of a new or mismatched snapshot. Clearing it here - synchronously, before the + // task is built and before the caller's await captures a context - keeps the pipeline and the + // caller's continuation free of any context the caller already had. UI converters additionally + // restore the context around rendering, since Show() re-installs one mid-pipeline. + SynchronizationContext.SetSynchronizationContext(null); + return task = buildTask(CurrentSettings); + } [Pure] public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) =>