Skip to content

JDK transport fully buffers non-replayable streaming request bodies in memory #113

@OmarAlJarrah

Description

@OmarAlJarrah

Summary

The java.net.http transport materialises every non-replayable streaming request body into a heap Buffer before sending it, defeating the streaming path it sits on. A large one-shot upload (e.g. a 2 GiB InputStream-backed body) is copied in full into memory rather than streamed, which blows the heap and hard-fails past ~2 GiB. The OkHttp transport streams the same body fine.

Where

sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/BodyPublishers.kt

  • adaptBody routes any body with contentLength > 64 KiB or unknown length (-1) to streamingPublisher (lines 118-124).

  • streamingPublisher then immediately forces the body replayable up front (lines 165-170):

    val replayable: SdkRequestBody =
        if (body.isReplayable()) {
            body
        } else {
            try {
                body.toReplayable()
            ...
  • RequestBody.toReplayable (sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt, lines 78-83) drains the whole body into an in-memory Buffer:

    public open fun toReplayable(provider: IoProvider = Io.provider): RequestBody {
        if (isReplayable()) return this
        val buffer = provider.buffer()
        writeTo(buffer)
        return BufferRequestBody(buffer, mediaType(), buffer.size)
    }

So on the "streaming" path, a body that is by definition large or of unknown length is fully read into heap before a single byte is published. The pipe-and-daemon-writer machinery downstream (newSubscriptionStream) then streams from the already-buffered in-memory copy — it never streams from the original source. The eager (<= 64 KiB) path is fine; the problem is that the streaming path buffers too.

Why it buffers

The JDK invokes the ofInputStream supplier once per subscription and re-subscribes the same publisher on internal resends (407 proxy-auth retry, HTTP/2 GOAWAY replay — see the class KDoc, lines 50-58). To make a second subscription replay the same bytes, the code makes the body replayable up front. The cost of that decision is full materialisation of every large/unknown-length one-shot body.

Failure mode

  • Heap blow-up proportional to body size; a 2 GiB upload needs ~2 GiB of contiguous heap.
  • A buffer-backed body cannot exceed the max byte-array/segment limits, so very large uploads fail outright instead of streaming.
  • Transport divergence: OkHttp honours the one-shot streaming contract for the same body, so the same SDK request that streams under OkHttp OOMs under the JDK transport.

Suggested direction

Stream the first subscription directly from the one-shot body (no up-front buffering), and make a second subscription fail loudly with a clear "one-shot body cannot be re-sent — supply a replayable body" error, matching the consume-once discipline already used by OneShotInputStreamRequestBody / BufferedSourceRequestBody in sdk-core. Reserve up-front buffering for bodies below a bounded threshold (mirroring the existing 64 KiB eager cutoff) so small bodies can still be replayed cheaply, while large/unknown-length bodies stream and refuse silent resend-corruption. This keeps the proxy-auth / GOAWAY resend correct (it fails visibly rather than truncating) without importing the OOM hazard the streaming path exists to avoid.

Relationship to PR #82

This is related to, but distinct from, the open PR #82 (fix: correct transport request-body handling for body-less and unbufferable streaming bodies). PR #82 only changes the catch (IOException) fallback in streamingPublisher so that a toReplayable() failure mid-write fails loudly (UncheckedIOException) instead of re-driving an already-consumed body. It does not alter the up-front toReplayable() buffering of non-replayable bodies — that line is byte-for-byte identical on main and on the PR #82 branch. The memory/heap issue described here remains regardless of whether PR #82 merges.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsdk-coresdk-core toolkit

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions