diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index e34081682faa..fd63e033dadb 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -1493,8 +1493,14 @@ 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. + 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. throw new TimeoutException ("The request timed out.", ex); } } diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index 79c787d387c1..31499a57e16b 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; @@ -321,6 +322,105 @@ public void BasicAuthWorksWhenBearerIsAdvertisedFirst () } } + // https://github.com/dotnet/macios/issues/25667 + [Test] + public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () + { + // Use a raw TCP server so we have full control over when bytes are sent on the wire. + var tcpListener = new TcpListener (IPAddress.Loopback, 0); + tcpListener.Start (); + var port = ((IPEndPoint) tcpListener.LocalEndpoint).Port; + + var serverReady = new SemaphoreSlim (0, 1); + + // 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 () => { + try { + 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 remaining body + await Task.Delay (TimeSpan.FromMinutes (5)).ConfigureAwait (false); + } catch (ObjectDisposedException) { + // listener was stopped + } catch (SocketException) { + // listener was stopped + } + }); + + Type caughtExceptionType = null; + + try { + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + await serverReady.WaitAsync ().ConfigureAwait (false); + + using var handler = new NSUrlSessionHandler (); + 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 httpClient.SendAsync (request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait (false); + var stream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false); + + // 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. + // 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 (); + } + }, out var 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), + // 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 { + tcpListener.Stop (); + } + } + static HttpListener? StartListenerOnAvailablePort (out int listeningPort) { // IANA suggested range for dynamic or private ports