Repository navigation
Ranged GET can return an empty stream: AdjustedRangeSubscriber prematurely signals onComplete when the first chunk is smaller than the pending skip #517
Description
Problem:
A ranged getObject through S3EncryptionClient (with enableLegacyUnauthenticatedModes(true)) can intermittently complete with 0 bytes even though the requested range contains data — no exception is surfaced to the caller.
For any plaintext-relative range starting at offset ≥ 1, the crypto range sent to S3 is aligned down to a cipher-block boundary (one extra block for offsets ≥ 16, see RangedGetUtils.getCipherBlockLowerBound), so AdjustedRangeSubscriber must skip numBytesToSkip (1–31) bytes of decrypted output before delivering data (initializeForRead). The skip logic in onNext treats "chunk smaller than the remaining skip" as end-of-stream:
// legacy/internal/AdjustedRangeSubscriber.java (v3.6.1; byte-identical since at least v3.1.2, also on main)
if (numBytesToSkip > buf.length) {
numBytesToSkip -= buf.length;
wrappedSubscriber.onComplete(); // <-- premature: upstream is still delivering chunks
}If the first decrypted chunk is smaller than the pending skip, onComplete() is signaled while more chunks are still in flight. There is also no return after the branch, so execution falls through and throws NullPointerException at Math.min(virtualAvailable, outputBuffer.length) (the outputBuffer field is still null on the first chunk) — but only after onComplete was already delivered. Downstream subscribers that latch the first terminal signal — e.g. InputStreamSubscriber used by AsyncResponseTransformer.toBlockingInputStream(), which the synchronous S3EncryptionClient.getObject path joins on — silently drop the subsequent chunks and the error signal. The caller observes a successful, empty stream: the first read() returns -1.
Triggers
- Any transport delivery where the first
ByteBufferreaching the subscriber chain is smaller than the skip (1–31 bytes) — e.g. TLS-record / TCP-segmentation fragmentation producing a tiny first chunk. Timing dependent, hence intermittent; a retry usually succeeds. - AES/CBC (v1-format) objects are especially exposed:
CipherSubscriberintentionally emitsByteBuffer.allocate(0)whencipher.updateproduces no output (behavior introduced by the fix: do not signal onComplete when the incoming buffer length is less than the cipher block #209 fix), andAES/CBC/PKCS5Paddingdecryption produces no output for any input ≤ 31 bytes (a full block is withheld for padding). An empty buffer always satisfiesnumBytesToSkip > buf.length. - For CTR (i.e. ranged GETs of AES-GCM objects), a tiny-but-nonempty first chunk smaller than the skip triggers the same branch directly.
Expected behavior
The skip should consume bytes across successive chunks without signaling completion; onComplete should only propagate when the upstream actually completes.
Reproduction
Driving the subscriber chain directly with the released artifacts (amazon-s3-encryption-client-java:3.6.1, software.amazon.awssdk:utils:2.34.1):
- Construct
AdjustedRangeSubscriberfor desired range[100, 199]with a content range givingnumBytesToSkip = 20, wrapping anInputStreamSubscriber. - Call
onSubscribe(...), thenonNext(ByteBuffer.allocate(10))— a first chunk smaller than the skip. - Observe: the downstream
InputStreamSubscriberreceivesonComplete; the sameonNextcall throwsNullPointerException("this.outputBuffer" is null); a subsequent 200-byteonNextand a lateonErrorare silently ignored;read()returns -1, soreadNBytes(buf, off, len)returns 0.
In production we observe this as intermittent 0-byte results from ranged getObject calls through the encryption client (e.g. Parquet/ORC footer reads at large offsets) that succeed when retried. We have carried an application-level retry-on-zero-bytes workaround across client versions 3.1.2 → 3.6.1 because the behavior persists.
Versions affected
AdjustedRangeSubscriber.java is byte-identical in v3.1.2, v3.3.0, v3.4.0, v3.6.1, and current main (verified by diffing the tags), so all of these are affected.
Solution:
Possible solution
In the numBytesToSkip > buf.length branch, consume the chunk toward the skip and request the next chunk from the subscription instead of completing:
if (numBytesToSkip > buf.length) {
numBytesToSkip -= buf.length;
// need more data before anything can be delivered; do not complete —
// upstream will deliver the remaining chunks and the real onComplete
subscription.request(1);
return;
}(If the upstream genuinely ends before the skip is satisfied, the upstream onComplete still arrives through the normal path.) This also makes the empty-buffer emission from CipherSubscriber flow through harmlessly.
Related
- fix: regression in ranged gets when using buffered subscriber #107 — fixed an intermittent empty-result regression for ranged gets ("expected but was <>"); same symptom class.
- fix: do not signal onComplete when the incoming buffer length is less than the cipher block #209 (released in 3.1.2) — removed a similar premature
onCompletefromCipherSubscriberby emitting empty buffers instead; those empty buffers now feed this branch inAdjustedRangeSubscriber. - fix: address some edge cases to fix async CBC ranged gets #70 — async CBC ranged-get edge cases.
Activity
fix: don't signal onComplete when a chunk is fully consumed by the ra…
test: cover AdjustedRangeSubscriber demand across sub-skip chunks