Skip to content

Android/IL2CPP crash when using streaming PostAsync (DownloadHandlerCallback) #114

@StephenHodgson

Description

@StephenHodgson

Summary

When Rest.PostAsync is used with a streaming callback (Action<Response> dataReceivedEventCallback) and a non-null eventChunkSize, the app can crash on Android (IL2CPP) with a native SIGSEGV in Unity's memory manager. The crash occurs in MemoryProfiler::UnregisterAllocation while Unity is freeing the buffer that was passed into DownloadHandlerScript.ReceiveData. The root cause is that Unity's buffer is forwarded to base.ReceiveData() in DownloadHandlerCallback; copying the chunk and passing only the copy to the base fixes the crash.


Environment

  • Platform: Android (Quest/IL2CPP observed; other IL2CPP platforms may be affected).
  • ABI: arm64.
  • Unity: 6.x (or version using DownloadHandlerScript with temporary buffers).
  • Consumer: com.rest.elevenlabs streaming TTS via Rest.PostAsync(..., streamCallback, 8192, ...).

Crash details

  • Signal: Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR).
  • Thread: UnityMain.
  • Location: Inside Unity's allocator while unregistering an allocation (free path).

Relevant backtrace (symbols only; no paths/addresses):

#00  libunity.so  MemoryProfiler::UnregisterAllocation(void*, unsigned long, MemLabelId const&)
#01  libunity.so  MemoryManager::RegisterDeallocation(...)
#02  libunity.so  MemoryManager::TryDeallocateWithLabel(...)
#03  libunity.so  MemoryManager::Deallocate(...)
...
#05  libunity.so  UnsafeUtility_CUSTOM_FreeTracked(void*, NativeCollection::Allocator)
#06  libil2cpp.so  (IL2CPP / managed boundary)
...
#15  libunity.so  scripting_method_invoke(...)
#16  libunity.so  ScriptingInvocation::Invoke(...)
#17  libunity.so  DownloadHandlerScript::InvokeReceiveData(ScriptingObjectPtr, ScriptingArrayPtr, int)
#18  libunity.so  DownloadHandlerScriptCached::InvokeReceiveDataForCurrentData(ScriptingObjectPtr)
#19  libunity.so  UnityWebRequestManager::InvokeScriptHandlers()

Interpretation: Unity invokes the C# ReceiveData(byte[] data, int dataLength) callback (via DownloadHandlerScript). Later, when freeing the buffer that was passed as data, the free goes through Unity's tracked allocator and crashes (e.g. double-free or wrong allocator). Passing Unity's buffer into base.ReceiveData(unprocessedData, dataLength) causes the base (and/or native side) to retain or reuse that buffer, leading to the faulty free.


Root cause

File: Packages/com.utilities.rest/Runtime/DownloadHandlerCallback.cs
Method: ReceiveData(byte[] unprocessedData, int dataLength)

  • Unity allocates a temporary buffer and passes it as unprocessedData (Unity Docs: ReceiveData).
  • The code:
    1. Writes from unprocessedData into its own MemoryStream.
    2. Builds its own buffer and passes it to OnDataReceived (correct).
    3. Calls return base.ReceiveData(unprocessedData, dataLength); so Unity's buffer is passed to the base.
  • The base (and/or Unity's native code) may store or reuse that buffer. When Unity later frees its temporary buffer, the free can crash (e.g. double-free or allocator mismatch on Android/IL2CPP).

Fix: Copy the chunk into a new byte[] immediately. Use that copy for the stream and pass only the copy to base.ReceiveData, so Unity's buffer is never passed to the base and can be freed safely by Unity.


Reproduction steps

1. Consumer setup (e.g. com.rest.elevenlabs)

  • Unity project that references com.utilities.rest (with streaming PostAsync) and com.rest.elevenlabs (or any client that uses streaming).
  • Build target: Android, IL2CPP, arm64.
  • In the ElevenLabs package, use the streaming TTS API, e.g.:
var response = await Rest.PostAsync(
    GetUrl(endpoint, parameters),
    payload,
    streamCallback,
    8192,
    new RestParameters(client.DefaultRequestHeaders),
    cancellationToken);

Where streamCallback is an Action<Response> invoked for each chunk (e.g. in TextToSpeechEndpoint.TextToSpeechAsync with the StreamCallback that creates VoiceClip from partialResponse.Data).

2. Trigger the crash

  • Run the app on a physical Android device (Quest or other).
  • Trigger a streaming TTS request (text-to-speech with streaming so that multiple chunks are received).
  • After several chunks (or when the stream completes), the app may crash with SIGSEGV in UnityMain.
  • Note: The crash may be intermittent depending on timing and chunk count; running multiple streamed requests increases likelihood.

3. Minimal repro without ElevenLabs

  • In a Unity project with com.utilities.rest only:
  • Use any endpoint that returns a streaming response (e.g. a server that sends body in chunks).
  • Call Rest.PostAsync(url, jsonBody, callback, 8192, parameters, cancellationToken) and run on Android IL2CPP until the crash is observed.

Suggested fix (for implementer / next agent)

File: com.utilities.restPackages/com.utilities.rest/Runtime/DownloadHandlerCallback.cs
Method: protected override bool ReceiveData(byte[] unprocessedData, int dataLength)

  1. Guard: If unprocessedData == null or dataLength <= 0, return base.ReceiveData(unprocessedData, dataLength) (preserve existing behavior).
  2. Copy: Allocate byte[] copy = new byte[dataLength] and copy the segment:
    offset = unprocessedData.Length - dataLength;
    Array.Copy(unprocessedData, offset, copy, 0, dataLength).
  3. Use copy only:
    • Write to the stream from copy: stream.Write(copy, 0, dataLength) (do not use unprocessedData for write).
    • Keep the rest of the logic (building buffer from the stream and invoking OnDataReceived) unchanged.
  4. Call base with copy: Return base.ReceiveData(copy, dataLength) instead of base.ReceiveData(unprocessedData, dataLength).

Result: Unity's buffer is never passed to the base; Unity can free it when it wants. The base only ever sees the managed copy; no native double-free or allocator mismatch.


New unit test (validate fix)

Add the following test to com.utilities.rest (e.g. Tests/TestFixture_03_StreamingCallback.cs or equivalent). It uses the same DownloadHandlerCallback path as streaming PostAsync (via Rest.GetAsync with callback + eventChunkSize).

Before fix: In Editor the test passes (data correctness). On Android IL2CPP device, running multiple streaming requests can trigger the SIGSEGV; this test stresses that path.
After fix: Test passes in Editor and on device; no crash.

// Licensed under the MIT License. See LICENSE in the project root for license information.
// Validates DownloadHandlerCallback streaming path (used by PostAsync(..., callback, eventChunkSize)).

using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

namespace Utilities.WebRequestRest.Tests
{
    internal class TestFixture_03_StreamingCallback
    {
        private const int StreamingChunkSize = 8192;
        // Same host as TestFixture_02_CRUD; GET /posts returns 100 posts (~30KB+), so we get multiple chunks.
        private static readonly Uri PostsUrl = new("https://jsonplaceholder.typicode.com/posts");

        [Test]
        public async Task Test_01_StreamingGet_ReceivesMultipleChunks_AndFullBodyMatches()
        {
            var chunks = new List<byte[]>();
            var chunkCount = 0;

            try
            {
                using var cts = new CancellationTokenSource();
                cts.CancelAfter(TimeSpan.FromSeconds(15));

                var response = await Rest.GetAsync(
                    PostsUrl,
                    dataReceivedEventCallback: r =>
                    {
                        chunkCount++;
                        Assert.IsTrue(r.Successful, "Streaming response should be successful");
                        Assert.IsNotNull(r.Data, "Chunk data should not be null");
                        if (r.Data != null && r.Data.Length > 0)
                            chunks.Add((byte[])r.Data.Clone());
                    },
                    eventChunkSize: StreamingChunkSize,
                    cancellationToken: cts.Token);

                response.Validate(debug: true);
                Assert.IsTrue(response.Successful, "Final response should be successful");
                Assert.IsNotNull(response.Data, "Final response data should not be null");

                // /posts is large enough to be delivered in multiple chunks (exercises ReceiveData multiple times)
                Assert.GreaterOrEqual(chunkCount, 2,
                    "Streaming callback should be invoked at least twice (validates DownloadHandlerCallback multi-chunk path)");

                var accumulatedLength = 0;
                foreach (var c in chunks)
                    accumulatedLength += c.Length;
                Assert.AreEqual(response.Data.Length, accumulatedLength,
                    "Accumulated chunk data length should equal final response body size");
            }
            catch (OperationCanceledException)
            {
                Assert.Ignore("Request timed out (jsonplaceholder may be slow or unavailable)");
            }
        }

        [Test]
        public async Task Test_02_StreamingGet_MultipleRequestsInSequence_NoException()
        {
            const int requestCount = 5;
            var totalChunks = 0;

            try
            {
                using var cts = new CancellationTokenSource();
                cts.CancelAfter(TimeSpan.FromSeconds(30));

                for (var i = 0; i < requestCount; i++)
                {
                    var response = await Rest.GetAsync(
                        PostsUrl,
                        dataReceivedEventCallback: r =>
                        {
                            if (r?.Data != null)
                                totalChunks++;
                        },
                        eventChunkSize: StreamingChunkSize,
                        cancellationToken: cts.Token);

                    Assert.IsTrue(response.Successful, $"Request {i + 1}/{requestCount} should be successful");
                }

                Assert.Greater(totalChunks, 0, "At least one chunk should have been received across all requests");
            }
            catch (OperationCanceledException)
            {
                Assert.Ignore("Requests timed out (jsonplaceholder may be slow or unavailable)");
            }
        }
    }
}

How to use: Add the test file, run in Editor (before and after the DownloadHandlerCallback fix). After the fix, run the same test on an Android IL2CPP build to confirm no device crash.


Related code references


Checklist before publishing

  • Remove or redact any internal-only links or project names if the issue is public.
  • Confirm target repo (com.utilities.rest) and label (e.g. bug, android, il2cpp).
  • Add version info (e.g. com.utilities.rest 5.1.x) if known.
  • Optionally attach a minimal repro project or link to a branch with the fix.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions