From 8bb548a0736bac7bca25f0fb15047f7666c3a23c Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 16 Jun 2026 10:29:52 +0200 Subject: [PATCH 1/7] [NSUrlSessionHandler] Surface cancelled requests with a better exception. Fixes #25667. Add a test: that sets up a stalling HTTP server, reads the first byte successfully, then cancels the second ReadAsync via a caller-owned CancellationToken. TODO: * Implement the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NSUrlSessionHandlerTest.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index 79c787d387c..ae05785441e 100644 --- a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs +++ b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs @@ -321,6 +321,91 @@ public void BasicAuthWorksWhenBearerIsAdvertisedFirst () } } + // https://github.com/dotnet/macios/issues/25667 + [Test] + public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () + { + if (!HttpListener.IsSupported) { + Assert.Inconclusive ("HttpListener is not supported"); + } + + var httpListener = StartListenerOnAvailablePort (out var listeningPort); + if (httpListener is null) { + Assert.Inconclusive ("Could not find an available port for the test server."); + return; + } + + var serverReady = new SemaphoreSlim (0, 1); + + // Server sends headers + 1 byte, then stalls forever. + var serverTask = Task.Run (async () => { + serverReady.Release (); + try { + var context = await httpListener.GetContextAsync ().ConfigureAwait (false); + var response = context.Response; + response.ContentLength64 = 2; // declare 2 bytes + response.StatusCode = 200; + var outputStream = response.OutputStream; + // Send 1 byte, then stall + await outputStream.WriteAsync (new byte [] { (byte) 'A' }, 0, 1).ConfigureAwait (false); + await outputStream.FlushAsync ().ConfigureAwait (false); + // Wait until the test is done (never send the second byte) + await Task.Delay (TimeSpan.FromMinutes (5)).ConfigureAwait (false); + } catch (ObjectDisposedException) { + // listener was stopped + } catch (HttpListenerException) { + // listener was stopped + } + }); + + Type caughtExceptionType = null; + Type innerExceptionType = null; + + try { + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + await serverReady.WaitAsync ().ConfigureAwait (false); + + using var handler = new NSUrlSessionHandler (); + using var client = new HttpClient (handler); + client.Timeout = TimeSpan.FromMinutes (5); + + using var request = new HttpRequestMessage (HttpMethod.Get, $"http://127.0.0.1:{listeningPort}/stall"); + var response = await client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait (false); + var stream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false); + + // First read succeeds (server sent 1 byte) + var buffer = new byte [1024]; + var bytesRead = await stream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false); + Assert.That (bytesRead, Is.GreaterThan (0), "First read should return data"); + + // Second read: cancel after 250ms via caller token + using var cts = new CancellationTokenSource (TimeSpan.FromMilliseconds (250)); + try { + await stream.ReadAsync (buffer, 0, buffer.Length, cts.Token).ConfigureAwait (false); + Assert.Fail ("Expected an exception from the cancelled ReadAsync"); + } catch (Exception ex) { + caughtExceptionType = ex.GetType (); + innerExceptionType = ex.InnerException?.GetType (); + } + }, out var ex2); + + if (!done) { + TestRuntime.IgnoreInCI ("Transient localhost server failure - ignore in CI"); + Assert.Inconclusive ("Request timed out."); + } + TestRuntime.IgnoreInCIIfBadNetwork (ex2); + Assert.That (ex2, Is.Null, $"Unexpected exception: {ex2}"); + + // Caller cancellation should surface as OperationCanceledException (or a subclass like TaskCanceledException), + // not as TimeoutException. TimeoutException should be reserved for actual request timeouts. + Assert.That (typeof (OperationCanceledException).IsAssignableFrom (caughtExceptionType), Is.True, + $"Expected OperationCanceledException but got {caughtExceptionType}"); + } finally { + httpListener.Stop (); + httpListener.Close (); + } + } + static HttpListener? StartListenerOnAvailablePort (out int listeningPort) { // IANA suggested range for dynamic or private ports From 03840d17d2f984b559e039a4fd4ccd70621b8847 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Tue, 16 Jun 2026 18:49:18 +0200 Subject: [PATCH 2/7] Don't ignore test failures in CI Localhost server access should be reliable, so there's no need to paper over failures with IgnoreInCI/Inconclusive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Net.Http/NSUrlSessionHandlerTest.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index ae05785441e..9fb6281e6e0 100644 --- a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs +++ b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs @@ -389,11 +389,7 @@ public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () } }, out var ex2); - if (!done) { - TestRuntime.IgnoreInCI ("Transient localhost server failure - ignore in CI"); - Assert.Inconclusive ("Request timed out."); - } - TestRuntime.IgnoreInCIIfBadNetwork (ex2); + Assert.That (done, Is.True, "Test timed out"); Assert.That (ex2, Is.Null, $"Unexpected exception: {ex2}"); // Caller cancellation should surface as OperationCanceledException (or a subclass like TaskCanceledException), From 5df6dd7c40dd1fd85aefc4a11993a74b83dbe71a Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 08:49:11 +0200 Subject: [PATCH 3/7] Use chunked encoding so the first byte is sent immediately With Content-Length set, HttpListener may buffer the output until all bytes are written. Switching to chunked transfer encoding ensures the first byte reaches the client immediately, so the first ReadAsync completes and the test can proceed to the cancellation scenario. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Net.Http/NSUrlSessionHandlerTest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index 9fb6281e6e0..b4a12f26997 100644 --- a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs +++ b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs @@ -343,13 +343,13 @@ public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () try { var context = await httpListener.GetContextAsync ().ConfigureAwait (false); var response = context.Response; - response.ContentLength64 = 2; // declare 2 bytes + response.SendChunked = true; response.StatusCode = 200; var outputStream = response.OutputStream; - // Send 1 byte, then stall + // Send 1 byte immediately via chunked encoding, then stall await outputStream.WriteAsync (new byte [] { (byte) 'A' }, 0, 1).ConfigureAwait (false); await outputStream.FlushAsync ().ConfigureAwait (false); - // Wait until the test is done (never send the second byte) + // Wait until the test is done (never send the next chunk) await Task.Delay (TimeSpan.FromMinutes (5)).ConfigureAwait (false); } catch (ObjectDisposedException) { // listener was stopped From 35b474ac9a64e1524717b2cadcf99c179f45eb2e Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 15:26:22 +0200 Subject: [PATCH 4/7] Switch to TcpListener for full control over byte delivery HttpListener buffers output even with chunked encoding, preventing the first byte from reaching the client in time. Using a raw TcpListener with NoDelay lets us write HTTP response bytes directly to the socket, ensuring the first chunk is delivered immediately so the test can proceed to the cancellation scenario. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NSUrlSessionHandlerTest.cs | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index b4a12f26997..c6db30a9cd6 100644 --- a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs +++ b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs @@ -5,6 +5,7 @@ using System; using System.Net; using System.Net.Http; +using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -325,35 +326,41 @@ public void BasicAuthWorksWhenBearerIsAdvertisedFirst () [Test] public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () { - if (!HttpListener.IsSupported) { - Assert.Inconclusive ("HttpListener is not supported"); - } - - var httpListener = StartListenerOnAvailablePort (out var listeningPort); - if (httpListener is null) { - Assert.Inconclusive ("Could not find an available port for the test server."); - return; - } + // Use a raw TCP server so we have full control over when bytes are sent on the wire. + // HttpListener buffers output and may not flush individual bytes immediately. + var tcpListener = new TcpListener (IPAddress.Loopback, 0); + tcpListener.Start (); + var port = ((IPEndPoint) tcpListener.LocalEndpoint).Port; var serverReady = new SemaphoreSlim (0, 1); - // Server sends headers + 1 byte, then stalls forever. + // Server sends HTTP response headers + 1 body byte, then stalls forever. var serverTask = Task.Run (async () => { serverReady.Release (); try { - var context = await httpListener.GetContextAsync ().ConfigureAwait (false); - var response = context.Response; - response.SendChunked = true; - response.StatusCode = 200; - var outputStream = response.OutputStream; - // Send 1 byte immediately via chunked encoding, then stall - await outputStream.WriteAsync (new byte [] { (byte) 'A' }, 0, 1).ConfigureAwait (false); - await outputStream.FlushAsync ().ConfigureAwait (false); - // Wait until the test is done (never send the next chunk) + using var client = await tcpListener.AcceptTcpClientAsync ().ConfigureAwait (false); + client.NoDelay = true; + var stream = client.GetStream (); + + // Read (and discard) the HTTP request + var requestBuffer = new byte [4096]; + await stream.ReadAsync (requestBuffer, 0, requestBuffer.Length).ConfigureAwait (false); + + // Send chunked HTTP response: headers + one chunk with 1 byte + var headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes (headers); + await stream.WriteAsync (headerBytes, 0, headerBytes.Length).ConfigureAwait (false); + + var chunk = "1\r\nA\r\n"; + var chunkBytes = Encoding.ASCII.GetBytes (chunk); + await stream.WriteAsync (chunkBytes, 0, chunkBytes.Length).ConfigureAwait (false); + await stream.FlushAsync ().ConfigureAwait (false); + + // Stall: never send the terminating chunk await Task.Delay (TimeSpan.FromMinutes (5)).ConfigureAwait (false); } catch (ObjectDisposedException) { // listener was stopped - } catch (HttpListenerException) { + } catch (SocketException) { // listener was stopped } }); @@ -369,7 +376,7 @@ public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () using var client = new HttpClient (handler); client.Timeout = TimeSpan.FromMinutes (5); - using var request = new HttpRequestMessage (HttpMethod.Get, $"http://127.0.0.1:{listeningPort}/stall"); + using var request = new HttpRequestMessage (HttpMethod.Get, $"http://127.0.0.1:{port}/stall"); var response = await client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait (false); var stream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false); @@ -397,8 +404,7 @@ public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () Assert.That (typeof (OperationCanceledException).IsAssignableFrom (caughtExceptionType), Is.True, $"Expected OperationCanceledException but got {caughtExceptionType}"); } finally { - httpListener.Stop (); - httpListener.Close (); + tcpListener.Stop (); } } From 913e4f8291e137868e04bb0c35bd4038759a15ea Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Wed, 17 Jun 2026 19:08:00 +0200 Subject: [PATCH 5/7] Fix test: send 4KB body with Content-Length, wait for full HTTP request Three changes to make the test work reliably: 1. Wait for the complete HTTP request (\r\n\r\n) before sending the response, to avoid confusing the HTTP framing. 2. Use Content-Length framing instead of chunked encoding, and send 4KB of body data (NSUrlSession may buffer very small payloads before calling DidReceiveData). 3. Declare a Content-Length 10x larger than the data actually sent, so the connection stays open and ReadAsync blocks in the polling loop waiting for more data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NSUrlSessionHandlerTest.cs | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index c6db30a9cd6..31499a57e16 100644 --- a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs +++ b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs @@ -327,36 +327,48 @@ public void BasicAuthWorksWhenBearerIsAdvertisedFirst () public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () { // Use a raw TCP server so we have full control over when bytes are sent on the wire. - // HttpListener buffers output and may not flush individual bytes immediately. var tcpListener = new TcpListener (IPAddress.Loopback, 0); tcpListener.Start (); var port = ((IPEndPoint) tcpListener.LocalEndpoint).Port; var serverReady = new SemaphoreSlim (0, 1); - // Server sends HTTP response headers + 1 body byte, then stalls forever. + // Server accepts the HTTP request, sends response headers and a large + // first body chunk, then stalls (never sends the rest of the declared body). var serverTask = Task.Run (async () => { - serverReady.Release (); try { - using var client = await tcpListener.AcceptTcpClientAsync ().ConfigureAwait (false); - client.NoDelay = true; - var stream = client.GetStream (); - - // Read (and discard) the HTTP request - var requestBuffer = new byte [4096]; - await stream.ReadAsync (requestBuffer, 0, requestBuffer.Length).ConfigureAwait (false); - - // Send chunked HTTP response: headers + one chunk with 1 byte - var headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"; - var headerBytes = Encoding.ASCII.GetBytes (headers); - await stream.WriteAsync (headerBytes, 0, headerBytes.Length).ConfigureAwait (false); - - var chunk = "1\r\nA\r\n"; - var chunkBytes = Encoding.ASCII.GetBytes (chunk); - await stream.WriteAsync (chunkBytes, 0, chunkBytes.Length).ConfigureAwait (false); + serverReady.Release (); + using var tcpClient = await tcpListener.AcceptTcpClientAsync ().ConfigureAwait (false); + tcpClient.NoDelay = true; + var stream = tcpClient.GetStream (); + + // Wait for the full HTTP request (ends with \r\n\r\n) + var requestBytes = new byte [8192]; + var totalRead = 0; + while (true) { + var n = await stream.ReadAsync (requestBytes, totalRead, requestBytes.Length - totalRead).ConfigureAwait (false); + if (n == 0) + return; + totalRead += n; + var requestSoFar = Encoding.ASCII.GetString (requestBytes, 0, totalRead); + if (requestSoFar.Contains ("\r\n\r\n")) + break; + } + + // Declare a large Content-Length, send a smaller body, then stall. + // This ensures NSUrlSession delivers the initial body data via + // DidReceiveData while keeping the connection open for more. + var bodyChunk = new string ('A', 4096); + var responseText = "HTTP/1.1 200 OK\r\n" + + $"Content-Length: {bodyChunk.Length * 10}\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + bodyChunk; + var responseBytes = Encoding.ASCII.GetBytes (responseText); + await stream.WriteAsync (responseBytes, 0, responseBytes.Length).ConfigureAwait (false); await stream.FlushAsync ().ConfigureAwait (false); - // Stall: never send the terminating chunk + // Stall: never send the remaining body await Task.Delay (TimeSpan.FromMinutes (5)).ConfigureAwait (false); } catch (ObjectDisposedException) { // listener was stopped @@ -366,33 +378,34 @@ public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () }); Type caughtExceptionType = null; - Type innerExceptionType = null; try { var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { await serverReady.WaitAsync ().ConfigureAwait (false); using var handler = new NSUrlSessionHandler (); - using var client = new HttpClient (handler); - client.Timeout = TimeSpan.FromMinutes (5); + using var httpClient = new HttpClient (handler); + httpClient.Timeout = TimeSpan.FromMinutes (5); using var request = new HttpRequestMessage (HttpMethod.Get, $"http://127.0.0.1:{port}/stall"); - var response = await client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait (false); + var response = await httpClient.SendAsync (request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait (false); var stream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false); - // First read succeeds (server sent 1 byte) - var buffer = new byte [1024]; + // First read succeeds (server sent 4KB of body data) + var buffer = new byte [8192]; var bytesRead = await stream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false); Assert.That (bytesRead, Is.GreaterThan (0), "First read should return data"); - // Second read: cancel after 250ms via caller token + // Second read: cancel after 250ms via caller token. + // The server declared a much larger Content-Length but stopped + // sending, so ReadAsync will block in the polling loop until + // the caller token fires. using var cts = new CancellationTokenSource (TimeSpan.FromMilliseconds (250)); try { await stream.ReadAsync (buffer, 0, buffer.Length, cts.Token).ConfigureAwait (false); Assert.Fail ("Expected an exception from the cancelled ReadAsync"); } catch (Exception ex) { caughtExceptionType = ex.GetType (); - innerExceptionType = ex.InnerException?.GetType (); } }, out var ex2); From 8f5be86c3545c3ec2c0bf69ce461b7eb617b0957 Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 18 Jun 2026 09:06:51 +0200 Subject: [PATCH 6/7] Distinguish caller cancellation from request timeout in ReadAsync When the caller's CancellationToken triggers cancellation in the ReadAsync polling loop, rethrow as OperationCanceledException (via ThrowIfCancellationRequested) instead of wrapping it in TimeoutException. TimeoutException is now only thrown when the cancellation comes from an internal source (e.g. HttpClient.Timeout), not from the caller's token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Foundation/NSUrlSessionHandler.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index e34081682fa..7ccfd2596f2 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -1493,8 +1493,13 @@ public override async Task ReadAsync (byte [] buffer, int offset, int count try { await Task.Delay (50, cancellationToken).ConfigureAwait (false); } catch (TaskCanceledException ex) { - // add a nicer exception for the user to catch, add the cancelation exception - // to have a decent stack + // If the caller's token triggered the cancellation, surface it + // as OperationCanceledException so callers can distinguish + // between a caller-requested cancellation and a request timeout. + cancellationToken.ThrowIfCancellationRequested (); + // If the caller's token is not cancelled, this is an internal + // cancellation (e.g. HttpClient.Timeout), so wrap it in a + // TimeoutException. throw new TimeoutException ("The request timed out.", ex); } } From ff3034fa3543c4c3e3979267211b43136c2b3f7d Mon Sep 17 00:00:00 2001 From: Rolf Bjarne Kvinge Date: Thu, 18 Jun 2026 18:48:56 +0200 Subject: [PATCH 7/7] Preserve TaskCanceledException as InnerException Use 'new OperationCanceledException(message, ex, cancellationToken)' instead of ThrowIfCancellationRequested so the original TaskCanceledException stack trace is preserved as InnerException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Foundation/NSUrlSessionHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index 7ccfd2596f2..fd63e033dad 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -1496,7 +1496,8 @@ public override async Task ReadAsync (byte [] buffer, int offset, int count // If the caller's token triggered the cancellation, surface it // as OperationCanceledException so callers can distinguish // between a caller-requested cancellation and a request timeout. - cancellationToken.ThrowIfCancellationRequested (); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException (ex.Message, ex, cancellationToken); // If the caller's token is not cancelled, this is an internal // cancellation (e.g. HttpClient.Timeout), so wrap it in a // TimeoutException.