using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Text; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Task 7 — the /current + /sample legs: /// turning an MTConnectStreams document into , the /// ring-buffer sequence-gap signal (), and the /// multipart/x-mixed-replace chunk framing + heartbeat watchdog on the long-poll leg. /// Every test runs against canned fixtures or a stubbed — no /// socket is opened anywhere in this file. /// public sealed class MTConnectStreamsParseTests { private const string CurrentFixture = "Fixtures/current.xml"; private const string SampleFixture = "Fixtures/sample.xml"; private const string GapFixture = "Fixtures/sample-gap.xml"; private const string UnavailableFixture = "Fixtures/current-unavailable.xml"; /// The fixtures deliberately share one instanceId, so a gap — not a restart — is under test. private const long FixtureInstanceId = 1655000000L; private static readonly string[] AllFixtureDataItemIds = [ "dev1_avail", "dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program", "dev1_system_cond" ]; // ------------------------------------------------------------------ header sequence parsing [Fact] public void Current_parse_reads_header_sequences_and_observations() { var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); result.NextSequence.ShouldBeGreaterThan(0); result.Observations.ShouldNotBeEmpty(); } [Theory] [InlineData(CurrentFixture, 1L, 108L)] [InlineData(SampleFixture, 108L, 113L)] [InlineData(GapFixture, 5000L, 5005L)] [InlineData(UnavailableFixture, 200L, 207L)] public void Parse_reads_first_and_next_sequence_and_the_instance_id_from_every_fixture( string fixture, long firstSequence, long nextSequence) { var result = MTConnectStreamsParser.Parse(File.ReadAllText(fixture)); result.FirstSequence.ShouldBe(firstSequence); result.NextSequence.ShouldBe(nextSequence); result.InstanceId.ShouldBe(FixtureInstanceId); } // ------------------------------------------------------------------------ observation values [Fact] public void Current_parse_keys_every_observation_by_its_dataitem_id() { var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true); } [Fact] public void Current_parse_reads_the_good_value_the_agent_reported() { var observations = ParseCurrent(); // The observation ELEMENT is , not ; the value is its text content. observations["dev1_pos"].Value.ShouldBe("123.4567"); observations["dev1_execution"].Value.ShouldBe("ACTIVE"); observations["dev1_program"].Value.ShouldBe("O1234"); observations["dev1_avail"].Value.ShouldBe("AVAILABLE"); } [Fact] public void Current_parse_carries_the_unavailable_text_sentinel_through_verbatim() { // Task 8 maps UNAVAILABLE => BadNoCommunication; this layer must not pre-interpret it. ParseCurrent()["dev1_partcount"].Value.ShouldBe("UNAVAILABLE"); } [Fact] public void Current_parse_keeps_a_time_series_vector_as_its_raw_string() { ParseCurrent()["dev1_vibration_ts"].Value.ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0"); } // ---------------------------------------------------------------------- CONDITION observations [Fact] public void A_condition_observations_value_is_its_element_name_not_its_empty_text() { // — the element name IS the state, and the // element is empty, so a text-content read yields "" and loses the observation entirely. ParseCurrent()["dev1_system_cond"].Value.ShouldBe("Normal"); } [Theory] [InlineData("Normal", "Normal")] [InlineData("Warning", "Warning")] [InlineData("Fault", "Fault")] public void A_condition_state_element_name_becomes_the_value(string element, string expected) { var result = MTConnectStreamsParser.Parse(StreamsDocument( $"""<{element} dataItemId="c" timestamp="2026-07-24T12:00:00Z" sequence="1" type="SYSTEM"/>""")); result.Observations.ShouldHaveSingleItem().Value.ShouldBe(expected); } [Fact] public void A_condition_with_text_content_still_takes_its_value_from_the_element_name() { // Agents put the operator-facing condition message in the element text; the STATE is the name. var result = MTConnectStreamsParser.Parse(StreamsDocument( """Spindle overtemperature""")); result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault"); } [Fact] public void An_unavailable_condition_element_is_the_unavailable_sentinel() { // is genuinely no-comms and must land on the exact same sentinel Task 8 // matches for a Sample/Event's literal UNAVAILABLE text. var observations = ParseFixture(UnavailableFixture); observations["dev1_system_cond"].Value.ShouldBe("UNAVAILABLE"); } [Fact] public void The_all_unavailable_fixture_is_all_unavailable() { var result = MTConnectStreamsParser.Parse(File.ReadAllText(UnavailableFixture)); result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true); result.Observations.ShouldAllBe(o => o.Value == "UNAVAILABLE"); } // ------------------------------------------------------------------------------- timestamps [Fact] public void Parse_normalizes_every_timestamp_to_utc() { // A wrong Kind silently shifts the OPC UA SourceTimestamp by the machine's UTC offset. var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); result.Observations.ShouldAllBe(o => o.TimestampUtc.Kind == DateTimeKind.Utc); ParseCurrent()["dev1_pos"].TimestampUtc .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc)); } [Fact] public void An_offset_timestamp_is_converted_to_utc_rather_than_taken_at_face_value() { var result = MTConnectStreamsParser.Parse(StreamsDocument( """O1""")); var observation = result.Observations.ShouldHaveSingleItem(); observation.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc); observation.TimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)); } // -------------------------------------------------------------- sequence-gap detection (both ways) [Fact] public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from() { var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture)); MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue(); } [Fact] public void No_sequence_gap_is_reported_for_a_contiguous_chunk() { // The falsifiability leg: a helper hard-wired to true would pass the gap test alone. var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)); MTConnectAgentClient.IsSequenceGap(requestedFrom: 108, contiguous).ShouldBeFalse(); } [Theory] [InlineData(4999L, true)] // requested older than the buffer holds — data was lost [InlineData(5000L, false)] // exactly the oldest retained sequence — nothing was lost [InlineData(5001L, false)] // the agent simply had nothing older to send public void Sequence_gap_is_strictly_firstSequence_greater_than_requested_from(long requestedFrom, bool expected) { var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture)); MTConnectAgentClient.IsSequenceGap(requestedFrom, chunk).ShouldBe(expected); } // --------------------------------------------------------------- legal but degenerate documents [Fact] public void A_document_with_a_valid_header_and_no_observations_parses_rather_than_throwing() { // /sample chunks are deltas; an idle agent legitimately sends a document with nothing in it. const string Xml = """
"""; var result = MTConnectStreamsParser.Parse(Xml); result.Observations.ShouldBeEmpty(); result.InstanceId.ShouldBe(7); result.NextSequence.ShouldBe(10); result.FirstSequence.ShouldBe(10); } [Fact] public void A_component_stream_may_omit_any_of_samples_events_and_condition() { // sample.xml carries no at all; absence is normal, not an error. var result = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)); result.Observations.Select(o => o.DataItemId).ShouldBe( ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program"], ignoreOrder: true); } [Fact] public void Parse_collects_observations_across_every_device_and_component_stream() { const string Xml = """
AVAILABLE 1.0 """; var result = MTConnectStreamsParser.Parse(Xml); result.Observations.Select(o => o.DataItemId).ShouldBe(["a_avail", "b_pos", "b_cond"]); result.Observations[2].Value.ShouldBe("Warning"); } [Theory] [InlineData("urn:mtconnect.org:MTConnectStreams:1.3")] [InlineData("urn:mtconnect.org:MTConnectStreams:2.0")] [InlineData("")] public void Parse_is_agnostic_to_the_document_namespace_version(string namespaceUri) { var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\""; var xml = $"""
O1 """; MTConnectStreamsParser.Parse(xml).Observations.ShouldHaveSingleItem().DataItemId.ShouldBe("p"); } [Fact] public void Parse_from_a_stream_matches_the_string_overload() { using var stream = File.OpenRead(CurrentFixture); var fromStream = MTConnectStreamsParser.Parse(stream); var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); fromStream.NextSequence.ShouldBe(fromString.NextSequence); fromStream.Observations.ShouldBe(fromString.Observations); } // ------------------------------------------------------------- fails loudly, never silently empty [Theory] [InlineData("")] [InlineData(" ")] [InlineData("not xml at all")] [InlineData("")] public void Parse_throws_on_malformed_xml(string xml) { Should.Throw(() => MTConnectStreamsParser.Parse(xml)); } [Fact] public void Parse_throws_on_a_devices_document_served_where_streams_were_expected() { var ex = Should.Throw( () => MTConnectStreamsParser.Parse("")); ex.Message.ShouldContain("MTConnectStreams"); ex.Message.ShouldContain("MTConnectDevices"); } [Fact] public void Parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error() { // An agent answers an out-of-range `from` with an MTConnectError document under HTTP 200. const string Xml = """
'from' must be greater than 100 """; var ex = Should.Throw(() => MTConnectStreamsParser.Parse(Xml)); ex.Message.ShouldContain("OUT_OF_RANGE"); ex.Message.ShouldContain("'from' must be greater than 100"); } [Theory] [InlineData("")] // no
at all [InlineData("""
""")] // no nextSequence [InlineData("""
""")] // no firstSequence [InlineData("""
""")] // no instanceId [InlineData("""
""")] // non-numeric [InlineData("""
""")] // non-numeric public void Parse_throws_on_an_unusable_header(string headerXml) { var xml = $"{headerXml}"; Should.Throw(() => MTConnectStreamsParser.Parse(xml)); } [Theory] [InlineData("""1""")] // no dataItemId [InlineData("""1""")] // no timestamp [InlineData("""1""")] // unparseable timestamp public void Parse_throws_on_a_malformed_observation(string observationXml) { Should.Throw( () => MTConnectStreamsParser.Parse(StreamsDocument($"{observationXml}"))); } [Fact] public void Parse_reads_only_unqualified_attributes() { // A prefixed x:dataItemId is a vendor extension, not the observation's identity — reading it // would invent a data item that the probe never declared. var xml = StreamsDocument( """1"""); Should.Throw(() => MTConnectStreamsParser.Parse(xml)); } // ------------------------------------------------------------- MTConnectAgentClient.CurrentAsync [Fact] public async Task CurrentAsync_requests_the_agent_current_path_and_returns_the_parsed_snapshot() { var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var result = await client.CurrentAsync(Ct); handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/current"); result.NextSequence.ShouldBe(108); result.Observations.Count.ShouldBe(7); } [Fact] public async Task CurrentAsync_scopes_the_request_to_the_configured_device_name() { var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); using var client = NewClient( handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000/", DeviceName = "VMC 3Axis" }); await client.CurrentAsync(Ct); handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/VMC%203Axis/current"); } [Fact] public async Task CurrentAsync_bounds_a_hung_agent_by_the_configured_request_timeout() { using var client = NewClient( StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }); var started = Stopwatch.StartNew(); var ex = await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); ex.ShouldNotBeAssignableTo(); ex.Message.ShouldContain("http://agent:5000/current"); started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); } [Fact] public async Task CurrentAsync_throws_on_an_error_document_served_under_http_200() { var handler = StubHandler.RespondingWith( """ nope """); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); ex.Message.ShouldContain("NO_DEVICE"); } [Fact] public async Task CurrentAsync_throws_on_a_non_success_status() { var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); } // -------------------------------------------------------------- MTConnectAgentClient.SampleAsync [Fact] public async Task SampleAsync_composes_the_from_interval_count_and_heartbeat_query() { var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000", DeviceName = "VMC-3Axis", SampleIntervalMs = 250, SampleCount = 64, HeartbeatMs = 7000 }); await DrainAsync(client.SampleAsync(108, Ct)); handler.LastRequestUri!.AbsoluteUri.ShouldBe( "http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000"); } [Fact] public async Task SampleAsync_yields_one_result_per_multipart_chunk() { var handler = StubHandler.RespondingWithMultipart( "--------bnd", await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Count.ShouldBe(2); chunks[0].FirstSequence.ShouldBe(108); chunks[0].NextSequence.ShouldBe(113); chunks[1].FirstSequence.ShouldBe(5000); chunks[1].NextSequence.ShouldBe(5005); } [Fact] public async Task SampleAsync_frames_chunks_from_the_boundary_when_the_agent_omits_content_length() { var handler = StubHandler.RespondingWithMultipart( "bnd", withContentLength: false, await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); } [Fact] public async Task SampleAsync_exposes_the_gap_signal_to_the_caller_on_the_chunk_that_carries_it() { // Task 11's pump: advance `from` to NextSequence while contiguous, re-baseline on a gap. var handler = StubHandler.RespondingWithMultipart( "bnd", await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var from = 108L; var gaps = new List(); await foreach (var chunk in client.SampleAsync(from, Ct)) { gaps.Add(MTConnectAgentClient.IsSequenceGap(from, chunk)); from = chunk.NextSequence; } gaps.ShouldBe([false, true]); from.ShouldBe(5005); } [Fact] public async Task SampleAsync_ignores_an_empty_keepalive_part() { var handler = StubHandler.RespondingWithMultipart( "bnd", await File.ReadAllTextAsync(SampleFixture, Ct), "", await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Count.ShouldBe(2); } [Fact] public async Task SampleAsync_yields_a_heartbeat_document_that_carries_no_observations() { // The agent's real keep-alive is a well-formed but observation-free MTConnectStreams // document — it advances the sequence, so it must reach the caller, not be swallowed. const string Heartbeat = """
"""; var handler = StubHandler.RespondingWithMultipart("bnd", Heartbeat); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(113, Ct)); chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty(); MTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse(); } [Fact(Timeout = 30_000)] public async Task SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent() { // The R2-01 frozen-peer shape: the agent accepts the connection, sends one chunk, then goes // silent forever. Without a watchdog the pump wedges with no timeout ever firing. var prefix = StubHandler.MultipartBody("bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct)); var handler = StubHandler.RespondingWithStallingStream("bnd", prefix); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 100, HeartbeatMs = 100 }); var started = Stopwatch.StartNew(); await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct); (await chunks.MoveNextAsync()).ShouldBeTrue(); chunks.Current.FirstSequence.ShouldBe(108); var ex = await Should.ThrowAsync(async () => await chunks.MoveNextAsync()); ex.ShouldNotBeAssignableTo(); ex.Message.ShouldContain("sample"); started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20)); } [Fact(Timeout = 30_000)] public async Task SampleAsync_bounds_the_response_header_phase_by_the_request_timeout() { // A peer that accepts the TCP connection but never answers must not wedge the pump either. using var client = NewClient( StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }); var ex = await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); ex.ShouldNotBeAssignableTo(); ex.Message.ShouldContain("/sample"); } [Fact] public async Task SampleAsync_throws_on_an_error_document_served_under_http_200() { // Not a multipart body at all — the agent answers a bad `from` with an error document. var handler = StubHandler.RespondingWith( """ bad from """); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync( async () => await DrainAsync(client.SampleAsync(1, Ct))); ex.Message.ShouldContain("OUT_OF_RANGE"); } [Fact] public async Task SampleAsync_throws_on_a_non_success_status() { var handler = StubHandler.RespondingWith("nope", HttpStatusCode.BadRequest); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); } [Fact(Timeout = 30_000)] public async Task SampleAsync_honours_the_callers_cancellation_token() { var handler = StubHandler.RespondingWithStallingStream("bnd", []); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync( async () => await DrainAsync(client.SampleAsync(1, cts.Token))); } [Fact] public async Task SampleAsync_ends_cleanly_when_the_agent_closes_the_stream() { var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.ShouldHaveSingleItem(); } [Fact] public void SampleAsync_opens_no_connection_until_it_is_enumerated() { var handler = StubHandler.Hanging(); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); _ = client.SampleAsync(1, CancellationToken.None); handler.RequestCount.ShouldBe(0); } // ------------------------------------------------------------------------------------ helpers private static CancellationToken Ct => TestContext.Current.CancellationToken; private static MTConnectAgentClient NewClient(HttpMessageHandler handler, MTConnectDriverOptions options) => new(options, handler); private static async Task> DrainAsync(IAsyncEnumerable source) { var results = new List(); await foreach (var item in source) { results.Add(item); } return results; } private static Dictionary ParseCurrent() => ParseFixture(CurrentFixture); private static Dictionary ParseFixture(string path) => MTConnectStreamsParser.Parse(File.ReadAllText(path)).Observations.ToDictionary(o => o.DataItemId); /// Wraps observation-container XML in a minimal but valid streams document. private static string StreamsDocument(string componentStreamBody) => $"""
{componentStreamBody} """; /// A canned — no socket is ever opened. private sealed class StubHandler : HttpMessageHandler { private readonly Func? _responder; private readonly bool _hang; private StubHandler(Func? responder, bool hang) { _responder = responder; _hang = hang; } public Uri? LastRequestUri { get; private set; } public int RequestCount { get; private set; } public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) => new(() => new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "text/xml") }, hang: false); public static StubHandler Hanging() => new(responder: null, hang: true); public static StubHandler RespondingWithMultipart(string boundary, params string[] parts) => RespondingWithMultipart(boundary, withContentLength: true, parts); public static StubHandler RespondingWithMultipart(string boundary, bool withContentLength, params string[] parts) { var body = MultipartBody(boundary, withContentLength, closing: true, parts); return new StubHandler(() => Multipart(boundary, new ByteArrayContent(body)), hang: false); } public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) => new(() => Multipart(boundary, new StallingContent(prefix)), hang: false); /// Builds a multipart/x-mixed-replace body exactly as an Agent writes one. public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts) { var body = new List(); foreach (var part in parts) { var payload = Encoding.UTF8.GetBytes(part); var header = new StringBuilder() .Append("--").Append(boundary).Append("\r\n") .Append("Content-type: text/xml\r\n"); if (withContentLength) { header.Append("Content-length: ").Append(payload.Length).Append("\r\n"); } header.Append("\r\n"); body.AddRange(Encoding.ASCII.GetBytes(header.ToString())); body.AddRange(payload); body.AddRange("\r\n"u8.ToArray()); } if (closing) { body.AddRange(Encoding.ASCII.GetBytes($"--{boundary}--\r\n")); } return [.. body]; } private static HttpResponseMessage Multipart(string boundary, HttpContent content) { content.Headers.ContentType = MediaTypeHeaderValue.Parse($"multipart/x-mixed-replace; boundary={boundary}"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequestUri = request.RequestUri; RequestCount++; if (_hang) { await Task.Delay(Timeout.Infinite, cancellationToken); } return _responder!(); } } /// Serves , then never completes another read. private sealed class StallingContent(byte[] prefix) : HttpContent { protected override Task CreateContentReadStreamAsync() => Task.FromResult(new StallingStream(prefix)); protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => Task.CompletedTask; protected override bool TryComputeLength(out long length) { length = -1; return false; } } private sealed class StallingStream(byte[] prefix) : Stream { private int _position; public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (_position < prefix.Length) { var count = Math.Min(buffer.Length, prefix.Length - _position); prefix.AsMemory(_position, count).CopyTo(buffer); _position += count; return count; } await Task.Delay(Timeout.Infinite, cancellationToken); return 0; } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } }