Files
gramps 64a5a6d06f perf(pump): slice 18 — C4 blit-on-change composite cache
ty-1841: capture fixed (band ~20 updates/s, no tears) but FramePump stalls
on EVERY iteration (totalMs 21-44, render=full-render split=0 elements=6
dynamic=4) — the render ceiling, ~22-28 composites/s, caps the desktop in a
60fps file. SceneGraph can't help: the live backdrop is element 0 and cannot
be baked (a cached capture goes stale), so the cache lives at the pump.

RenderFull wraps both full-render call sites: BuildFullRenderSignature hashes
the full input identity (options rect, social bar, per-element layout/visual
bits + the frame each element would resolve through the SAME resolver seam,
using array identity + Epoch + CropBounds); unchanged identity reuses the last
composite with one Buffer.BlockCopy (~3ms) instead of a ~30ms re-composite.
Cache buffer is a separate long-lived array, written pre-burn/pre-recycle
(the caller burns the frame counter and recycles scratch AFTER render).
Engagement gated on the 1:1 config (the only deployed tier). Telemetry
surfaces `cache NR/WH` on the 5s stats line.

Same shape as OBS (sources cache their surface, the scene blits on update) —
docs.obsproject.com/backend-design, the pattern this repo cites since the
2026-09-04 paste-cache slice.

Good Dog: FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges
(static scene renders ONCE + byte-identical reuse; new frame+Epoch invalidates).
297/297 green, 0 warnings, verify.sh scope-locked (FramePump.cs, FramePumpTests.cs
+ ai.md/HANDOFF/MyMistakes). No push — device re-verify next.
2026-09-15 07:57:09 -07:00

583 lines
25 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Text;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The live frame producer (TASK 4 ship step 5): composites the active scene at
/// the tier's FPS and paces frames into the encoder. The integration test drives
/// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake
/// IFfmpegEncoder — proving the composite frame actually reaches the encoder and
/// that stop tears the pump down cleanly. The units pin the failure edges: the
/// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death.
/// </summary>
public class FramePumpTests
{
private sealed class FakeEncoder : IFfmpegEncoder
{
public readonly List<VideoFrame> Frames = new();
public readonly List<byte[]> Backings = new();
public int StartCount;
public int StopCount;
public bool Disposed;
public EncoderOptions? LastOptions;
public Exception? StartError;
public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
public event EventHandler<StreamHealth>? HealthUpdated;
public event EventHandler<string>? ProcessFailed;
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
StartCount++;
LastOptions = options;
if (StartError != null) throw StartError;
return Task.CompletedTask;
}
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
// Snapshot the bytes like the production FfmpegEncoder does (WriteAsync to
// stdin copies before returning). Holding the reference would race the
// pump's scratch pool legitimately reusing the buffer one tick later.
Frames.Add(new VideoFrame(frame.Width, frame.Height, (byte[])frame.BgraPixels.Clone()));
Backings.Add(frame.BgraPixels); // the pre-copy buffer — pool-reuse witness
FrameArrived.TrySetResult();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken = default)
{
StopCount++;
return Task.CompletedTask;
}
public void Dispose() => Disposed = true;
public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
public int DroppedFrames { get; set; }
}
private static Scene BackgroundScene()
{
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" });
return scene;
}
private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
List<string>? log = null,
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
{
return new FramePump(
sceneProvider: scene ?? (() => BackgroundScene()),
frameResolver: resolve ?? (_ => null),
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
OutputWidth = 64, OutputHeight = 48,
},
encoderOptions: options ?? (() => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = 64, Height = 48, Fps = 60,
}),
encoderFactory: () => encoder,
log: log != null ? m => log.Add(m) : null,
pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits
socialBar: socialBar);
}
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
{
var i = (y * frame.Width + x) * 4;
var br = frame.BgraPixels[i + 2];
var bg = frame.BgraPixels[i + 1];
var bb = frame.BgraPixels[i];
Assert.True(
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
}
private sealed class FakeHttpHandler : HttpMessageHandler
{
public FakeHttpHandler(string body) => Body = body;
public string Body;
public string? LastUri;
public int RequestCount;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
LastUri = request.RequestUri?.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(Body, Encoding.UTF8, "application/json"),
});
}
}
/// <summary>The ONE integration test for TASK 5: the real service (hermetic
/// HTTP) returns the reusable stream's RTMP URL, the provider seam hands it
/// to the pump, and the pump starts the encoder with that exact URL — the
/// chain that makes go-live actually push. The stream is reused (listed),
/// never recreated (no POST).</summary>
[Fact]
public async Task ReusableStream_Url_From_Service_Feeds_Encoder_Startup()
{
var handler = new FakeHttpHandler(
"""
{"items":[{"id":"S456","cdn":{"isReusable":true,"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY123"}}}]}
""");
var auth = new YouTubeAuthService("test-id", "test-secret");
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
var service = new YouTubeStreamService(auth, new HttpClient(handler));
var url = (await service.GetOrCreateReusableStreamAsync())?.RtmpUrl;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, options: () => url == null
? null
: new EncoderOptions { RtmpUrl = url, Width = 64, Height = 48, Fps = 60 });
await pump.StartAsync();
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", url);
Assert.Equal(1, encoder.StartCount);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", encoder.LastOptions?.RtmpUrl);
Assert.Equal(1, handler.RequestCount); // reuse path: list only, no insert
Assert.NotNull(handler.LastUri);
Assert.Contains("liveStreams", handler.LastUri);
Assert.Contains("mine=true", handler.LastUri);
await pump.StopAsync();
}
[Fact]
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackground: true } ? red : null);
await pump.StartAsync();
Assert.True(pump.IsRunning);
Assert.Equal(1, encoder.StartCount);
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
var frame = encoder.Frames[0];
Assert.Equal(64, frame.Width);
Assert.Equal(48, frame.Height);
AssertColor(frame, 0, 0, 255, 0, 0); // the background really was composited in
await pump.StopAsync();
Assert.False(pump.IsRunning);
Assert.Equal(1, encoder.StopCount);
Assert.True(encoder.Disposed);
}
[Fact]
public async Task Start_WithoutRtmpUrl_SkipsEncoder()
{
var encoder = new FakeEncoder();
var log = new List<string>();
using var pump = NewPump(encoder, options: () => null, log: log);
await pump.StartAsync();
Assert.False(pump.IsRunning);
Assert.Equal(0, encoder.StartCount);
Assert.Contains(log, m => m.Contains("no output configured"));
await pump.StopAsync(); // no-op after a skipped start
Assert.Equal(0, encoder.StopCount);
}
[Fact]
public async Task Start_WhileRunning_IsNoop()
{
var encoder = new FakeEncoder();
using var pump = NewPump(encoder);
await pump.StartAsync();
await pump.StartAsync();
Assert.Equal(1, encoder.StartCount);
}
[Fact]
public async Task Stop_WithoutStart_IsNoop()
{
var encoder = new FakeEncoder();
using var pump = NewPump(encoder);
await pump.StopAsync();
Assert.Equal(0, encoder.StopCount);
Assert.False(pump.IsRunning);
}
[Fact]
public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]);
Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip
var encoder = new FakeEncoder();
var position = SocialBarPosition.Top;
using var pump = NewPump(encoder,
resolve: e => e is Source { IsBackground: true } ? red : null,
socialBar: () => (bar, position));
await pump.StartAsync();
await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotEmpty(encoder.Frames);
AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge
// Flip to Bottom: the pump re-reads the seam each frame, so a later frame
// lands the bar at the bottom edge (SourceRectHeight - bar height) and the
// top corner clears back to background.
position = SocialBarPosition.Bottom;
VideoFrame? flipped = null;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && flipped == null)
{
for (var i = 1; i < encoder.Frames.Count; i++)
{
var f = encoder.Frames[i];
if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0)
{
flipped = f;
break;
}
}
if (flipped == null) await Task.Delay(10);
}
Assert.NotNull(flipped);
AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge
await pump.StopAsync();
}
[Fact]
public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
{
var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") };
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.Failed += (_, m) => failed.TrySetResult(m);
await pump.StartAsync();
Assert.False(pump.IsRunning);
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("access denied", message);
Assert.True(encoder.Disposed);
}
[Fact]
public async Task ProcessDeath_StopsPump_AndRaisesFailed()
{
var encoder = new FakeEncoder();
var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.Failed += (_, m) => failed.TrySetResult(m);
await pump.StartAsync();
encoder.RaiseProcessFailed("FFmpeg exited with code 1");
var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("1", message);
// The pump self-stops (fire-and-forget); poll the definitive teardown
// marker — the encoder's disposal — rather than the IsRunning flag, which
// StopAsync clears before the loop has fully drained.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!encoder.Disposed && DateTime.UtcNow < deadline)
await Task.Delay(10);
Assert.True(encoder.Disposed);
Assert.False(pump.IsRunning);
}
[Fact]
public async Task HealthUpdated_ForwardsEncoderHealth()
{
var encoder = new FakeEncoder();
var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
using var pump = NewPump(encoder);
pump.HealthUpdated += (_, h) => health.TrySetResult(h);
await pump.StartAsync();
encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 });
var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(StreamStatus.Streaming, h.Status);
Assert.Equal(59.9, h.FPS, 1);
}
/// <summary>The ONE integration test for the take-3 starvation fix (2026-09-03):
/// the frame interval is a DEADLINE — render cost eats into it, never piles on
/// top (the OBS libobs video-io.c pacing pattern). The old loop slept the full
/// interval AFTER each render, so 30 wall-seconds of 258ms renders muxed into a
/// 2.1s 60fps time-lapse (rawvideo stamps by arrival). Here the fake render
/// costs ≥60ms of a 200ms interval and the pacing seam records every requested
/// wait: no request may reach the full interval (that was the bug), none may be
/// negative, and a machine slow enough to blow every deadline is still honest
/// (zero waits &lt; interval passes too).</summary>
[Fact]
public async Task Pump_Paces_To_The_Deadline_Compensating_Render_Cost()
{
const int fps = 5; // 200ms interval — generous margin over the fake cost
const int renderCostMs = 60; // Thread.Sleep is a guaranteed lower bound
var interval = TimeSpan.FromSeconds(1d / fps);
var encoder = new FakeEncoder();
var scene = BackgroundScene();
var delays = new List<TimeSpan>();
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: _ =>
{
System.Threading.Thread.Sleep(renderCostMs);
return null;
},
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
OutputWidth = 64, OutputHeight = 48,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = 64, Height = 48, Fps = fps,
},
encoderFactory: () => encoder,
pacingDelay: async (d, ct) =>
{
lock (delays) delays.Add(d);
await Task.Delay(d, ct); // honors the request, like the real Task.Delay default
});
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline)
{
lock (delays) if (delays.Count >= 3) break;
await Task.Delay(10);
}
await pump.StopAsync();
List<TimeSpan> got;
lock (delays) got = delays.ToList();
Assert.True(got.Count >= 3, $"pump paced only {got.Count} frames in 10s");
foreach (var d in got)
{
Assert.True(d >= TimeSpan.Zero, $"requested wait {d} is negative");
Assert.True(d < interval,
$"requested wait {d} reaches the full {interval} interval — render cost must subtract from the deadline (the take-3 bug)");
}
}
/// <summary>The ONE integration test for the slice-15 pacing fix (2026-09-14):
/// a render that overruns the deadline must NOT skip the missed slots — a skip
/// authors fast playback: ty-1726 rendered one frame per 35ms overrun into a
/// 60fps container (163 frames for 2.93s of audio) and cut the audio tail off.
/// OBS fills an overrun's slots by DUPLICATING the newest frame (libobs
/// media-io/video-io.c; docs.obsproject.com/backend-design: "If the video frame
/// queue is full, it will duplicate the last frame") so recording duration stays
/// equal to wall duration. Here a 60fps pump renders every frame at ~35ms cost
/// (a genuine overrun): the slice-10 keep-fresh loop authored ~1 per 35ms; the
/// slot-filling loop must emit a frame for ~every 16.6ms of wall time.</summary>
[Fact]
public async Task Pump_Overrun_Renders_EmitsEverySlot_NotSkipped()
{
const int fps = 60;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
System.Threading.Thread.Sleep(35);
return null;
});
await pump.StartAsync();
await Task.Delay(7000); // ~420 slots of wall time
await pump.StopAsync();
// Slot-faithful: ~1 frame per 16.6ms -> ~415 in the window. Skipping (the
// slice-10 behavior): ~1 per 35ms -> ~200. 0.65 * fps * (elapsed seconds)
// is a middle line a skipped pump can never cross, a filling one only fails
// on a pathological run.
Assert.True(encoder.Frames.Count >= (int)(7.0 * fps * 0.65),
$"pump emitted {encoder.Frames.Count} frames in ~7s wall at {fps}fps — "
+ $"{7.0 / encoder.Frames.Count * fps:F1}x playback — an overrun must "
+ "DUPLICATE the newest frame per missed slot (OBS), not skip it");
}
/// <summary>The ONE integration test for the take-4 scratch pool: the pump recycles
/// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC
/// stalls inside "render") while EVERY frame's content stays correct — stale bytes
/// from an earlier tick would show as a wrong color. The backdrop alternates
/// red/blue per OUTPUT frame (keyed on the burned index, which is stable across the
/// tick's resolver passes) and a repeated backing-array identity proves the pool
/// actually recycles (an inert pool keeps the pixel assertions green while leaving
/// the churn in place). The scene instance is STABLE across ticks, like production
/// (MainViewModel's StagedScene), so the compositor signature doesn't churn on
/// unrelated element identity.</summary>
[Fact]
public async Task Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var blue = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
var scene = BackgroundScene();
var encoder = new FakeEncoder();
FramePump? pumpHolder = null;
using var pump = NewPump(encoder, scene: () => scene,
resolve: _ => pumpHolder!.OutputIndex % 2 == 0 ? red : blue);
pumpHolder = pump;
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline && encoder.Frames.Count < 6)
await Task.Delay(10);
await pump.StopAsync();
Assert.True(encoder.Frames.Count >= 6, $"only {encoder.Frames.Count} frames produced");
for (var i = 0; i < encoder.Frames.Count; i++)
{
if (i % 2 == 0) AssertColor(encoder.Frames[i], 0, 0, 255, 0, 0);
else AssertColor(encoder.Frames[i], 0, 0, 0, 0, 255);
}
Assert.True(encoder.Backings.Distinct().Count() < encoder.Backings.Count,
"every frame rode a fresh buffer — the scratch pool is inert");
}
/// <summary>The ONE integration test for the C4 blit-on-change cache (2026-09-15):
/// the ty-1841 take logged a render stall on EVERY iteration (totalMs 21-44, one
/// full composite per ~30ms even though the content moved only ~20 updates/s) — the
/// SceneGraph split can't help because the live backdrop sits at element 0 and cannot
/// be baked (a cached capture goes stale). A static scene must render the composite
/// ONCE then reuse it per tick (re-render == 1, cache hits > 0, byte-identical
/// content above the burn strip); a new backdrop frame (new array + monotonic Epoch)
/// must invalidate the cache and propagate the changed pixels.</summary>
[Fact]
public async Task FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var current = red;
// STABLE scene across ticks (production hands StagedScene) — a fresh scene per
// tick would churn the element references inside the render signature and force
// a re-composite every frame, hiding the cache entirely.
var scene = BackgroundScene();
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, scene: () => scene, resolve: _ => current);
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && (encoder.Frames.Count < 8 || pump.CacheHits < 6))
await Task.Delay(10);
Assert.True(encoder.Frames.Count >= 8, $"only {encoder.Frames.Count} frames produced");
Assert.Equal(1, pump.CacheRenders); // one composite for the whole static phase
Assert.True(pump.CacheHits >= 6, $"cache reused only {pump.CacheHits} times");
// Every frame's content above the burn strip must be the same — the cache is
// serving the identical pre-burn composite; the burn counter varies only the
// bottom-right strip (rows 39..47, cols 27..64 in a 64×48 frame).
var reference = encoder.Frames[^1].BgraPixels.AsSpan(0, 37 * 64 * 4).ToArray();
Assert.All(encoder.Frames, f => Assert.True(
f.BgraPixels.AsSpan(0, 37 * 64 * 4).SequenceEqual(reference),
"a static input re-composited a different frame — the cache served stale bytes or never engaged"));
// Mutate: new backing array + Epoch guarantees a full signature miss.
var blue = new VideoFrame(64, 48, new byte[64 * 48 * 4]) { Epoch = 42 };
for (var i = 0; i < blue.BgraPixels.Length; i += 4)
{
blue.BgraPixels[i] = 255; // B
blue.BgraPixels[i + 3] = 255; // A
}
current = blue;
VideoFrame? changed = null;
deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && changed == null)
{
foreach (var f in encoder.Frames)
{
if (!f.BgraPixels.AsSpan(0, 37 * 64 * 4).SequenceEqual(reference))
{
changed = f;
break;
}
}
if (changed == null) await Task.Delay(10);
}
Assert.NotNull(changed);
AssertColor(changed!, 0, 0, 0, 0, 255); // the new backdrop propagated
Assert.True(pump.CacheRenders >= 2, "the changed input never re-composited");
await pump.StopAsync();
}
/// <summary>One integration test for the take-8 off-UI fix: the pump loop must NOT
/// run on the starting thread's SynchronizationContext (StartAsync fires from a UI
/// command handler; the old loop inherited it, so every frame competed with the
/// live preview for the dispatcher — the "wait 10ms after a 22ms render" that the
/// quantum stats exposed). An inline-pumping sync context makes the OLD code run
/// its resolver on the starting thread; the Task.Run'd loop must never do so.</summary>
[Fact]
public async Task Pump_Produces_OffTheStartingContext()
{
var startThread = Environment.CurrentManagedThreadId;
var resolverThreads = new List<int>();
var firstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
lock (resolverThreads) resolverThreads.Add(Environment.CurrentManagedThreadId);
firstFrame.TrySetResult();
return null;
});
var prev = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new InlineSyncContext());
try
{
await pump.StartAsync();
await firstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
finally
{
SynchronizationContext.SetSynchronizationContext(prev);
await pump.StopAsync();
}
Assert.NotEmpty(resolverThreads);
Assert.All(resolverThreads, id => Assert.True(id != startThread,
$"resolver ran on the starting thread ({startThread}) — the loop is back on the caller context"));
}
private sealed class InlineSyncContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object? state) => d(state);
public override void Send(SendOrPostCallback d, object? state) => d(state);
public override SynchronizationContext CreateCopy() => this;
}
}