Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/Verify.Tests/SynchronizationContextTests.cs
Original file line number Diff line number Diff line change
@@ -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<VerifyException>(() => 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);
}
}
}
20 changes: 18 additions & 2 deletions src/Verify/SettingsTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,24 @@ public VerifySettings CurrentSettings
}

[Pure]
public Task<VerifyResult> ToTask() =>
task ??= buildTask(CurrentSettings);
public Task<VerifyResult> 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<VerifyResult> ConfigureAwait(bool continueOnCapturedContext) =>
Expand Down
Loading