Skip to content

AndroidMessageHandler does not rewind HttpContent stream if sending it failed #10542

@tipa

Description

@tipa

Android framework version

net10.0-android (Preview)

Affected platform version

.NET10 RC2

Description

In my app, I have retry logic where I am re-using the same HttpContent if it failed to send in the first attempt. This was made possible by a change from last year: #8764

However, I today discovered a new problem: If the HttpContent is large enough (e.g. 30 MB), an exception might be thrown in this line, halfway through the sending operation - from my experience/logging this often System.IO.IOException: Socket closed or sometimes net_http_request_timedout, 100, but can also happen by just cancelling the provided CancellationToken:

await stream.CopyToAsync(httpConnection.OutputStream!, 4096, cancellationToken).ConfigureAwait(false);

In this case, the stream position might have moved on and is no longer 0. And because an exception it thrown, this code to rewind the stream position is not executed:

if (stream.CanSeek)
stream.Seek (0, SeekOrigin.Begin);

Now, when I re-send the request, either the server rejects it because the request is invalid (e.g. when sending a MultipartContent) or the request succeeds, but only parts of the stream are actually uploaded (e.g. when uploading a ByteArrayContent), causing the uploaded file to be corrupted.

In my WinUI 3 app (also using .NET10), there appears to be some logic to rewind the stream position after sending the request (haven't find the code but stream position is at 0 even when I cancel a 30 MB upload after 1 second).

On iOS & macOS, the stream is rewound before it is sent: https://github.com/dotnet/macios/blob/22ededc6faa175101bd68e8f79bd0b915d0c6e52/src/Foundation/NSUrlSessionHandler.cs#L516-L520

I would suggest that this line is being wrapped in a try-catch and rewind the stream position also if an exception occurec, so that the HttpContent can be reused safely

Steps to Reproduce

var tcs = new CancellationTokenSource();
tcs.CancelAfter(1000); // cancel after 1 sec
var client = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All });
var byc = new ByteArrayContent(new byte[30_000_000]); // 30 MB of data
var request = new HttpRequestMessage(method, url) { Content = byc };
var stream = await byc.ReadAsStreamAsync();
var position = stream.Position; // 0 - OK
try { await client.SendAsync(request, tcs.Token).ConfigureAwait(false); }
catch (Exception e) // usually TaskCanceledException or System.IO.IOException: Socket closed
{
    var stream2 = await byc.ReadAsStreamAsync();
    var position2 = stream2.Position; // 30000000 - NOK

    stream2.Position = 0; // rewind stream manually
    var request2 = new HttpRequestMessage(method, url) { Content = byc };
    await client.SendAsync(request2).ConfigureAwait(false); // don't cancel this request
    
    var stream3 = await byc.ReadAsStreamAsync();
    var position3 = stream3.Position; // 0 - OK
}

Did you find any workaround?

Manually retrieving the content stream before (re-)sending and setting the stream position to 0

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: App RuntimeIssues in `libmonodroid.so`.needs-triageIssues that need to be assigned.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions