Skip to content

fix: bound IPC message allocations by the bytes actually read - #10522

Open
ranflarion wants to merge 2 commits into
apache:mainfrom
ranflarion:bound-ipc-body-allocation
Open

fix: bound IPC message allocations by the bytes actually read#10522
ranflarion wants to merge 2 commits into
apache:mainfrom
ranflarion:bound-ipc-body-allocation

Conversation

@ranflarion

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

MessageReader::maybe_next reserves both the metadata length and the message body length before reading any of the bytes they describe. Both come out of the stream itself, so a corrupted or truncated stream is handed straight to the allocator: MutableBuffer::from_len_zeroed(message.bodyLength() as usize) on an implausible length either aborts the process (memory allocation of N bytes failed, which is not catchable and takes the host process with it) or panics on LayoutError. A negative bodyLength is accepted too, since as usize wraps it to a large positive length.

I hit this fuzzing real IPC blocks rather than crafted ones: single-bit flips over the framing region of genuine streams produced memory allocation of 1125899907497992 bytes failed and SIGABRT. Where those blocks cross disk or a network, one flipped bit ends the process instead of failing a read the caller could retry.

What changes are included in this PR?

bodyLength now goes through usize::try_from, so a negative length is a parse error rather than a huge positive one.

Neither length reserves more than MAX_PREALLOC_BYTES (64 MiB) before the bytes behind it have arrived. Bodies up to that size are allocated in one go exactly as before; larger ones grow as the data arrives, which costs the reallocations that MutableBuffer::reserve doubling implies. That constant is the one judgement call here, trading the size of the bounded allocation a malformed stream can still ask for against how large a body keeps the single-allocation path, so it is worth a second opinion.

The metadata read switches from resize(meta_len, 0) plus read_exact to take(meta_len).read_to_end(&mut self.buf). That reuses the retained capacity across messages and drops the zeroing entirely, so it should be slightly cheaper than what it replaces rather than a cost, and Take returns Ok(0) at its limit so there is no extra read.

Only the streaming path is touched. read_block on the file side has the same shape at arrow-ipc/src/reader.rs:875 and two unwrap()s on block metadata besides; I left it alone to keep this reviewable, and noted it in the issue.

This overlaps #9777, which is after the same zeroing for performance reasons. The two want the same thing here, and I am happy to rebase onto whatever lands first.

Are these changes tested?

Yes, two tests in arrow-ipc/src/reader.rs. test_stream_reader_rejects_implausible_body_length covers i64::MAX, 1 << 50 and -1; test_stream_reader_rejects_unbacked_metadata_length covers a metadata length of i32::MAX with nineteen bytes behind it. Both fail without the change: the first panics inside MutableBuffer::from_len_zeroed, and the second spends 7.7s zeroing 2 GiB before reporting the wrong error.

The existing arrow-ipc suite passes (139 tests), along with cargo fmt --all --check and cargo clippy -p arrow-ipc --all-targets --all-features -- -D warnings.

Are there any user-facing changes?

No API changes. A stream that previously aborted or panicked now returns an ArrowError. No breaking changes.

@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-ipc labels Aug 3, 2026
Comment thread arrow-ipc/src/reader.rs
/// and the resulting allocation failure aborts the process instead of surfacing an error
/// the caller can handle.
#[test]
fn test_stream_reader_rejects_implausible_body_length() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test that exercises a body with length shorter than the message.bodyLength, to test the early exit on EOF path

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added, test_stream_reader_rejects_truncated_body covers a 1024-byte declared body backed by 10 bytes, which fails before the first growth step, and a MAX_PREALLOC_BYTES + 1 body backed by exactly MAX_PREALLOC_BYTES, which fails after one, so the resize path is exercised before hitting EOF.

@Jefffrey

Jefffrey commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

run benchmark ipc_reader

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing bound-ipc-body-allocation (c1a91eb) to ed92960 (merge-base) diff

Run configuration
run benchmark ipc_reader
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                                       bound-ipc-body-allocation              main
-----                                                       -------------------------              ----
arrow_ipc_reader/FileReader/no_validation/read_10           1.00    118.8±1.70µs        ? ?/sec    1.07    126.7±2.52µs        ? ?/sec
arrow_ipc_reader/FileReader/no_validation/read_10/mmap      1.00     56.7±0.38µs        ? ?/sec    1.00     56.9±0.41µs        ? ?/sec
arrow_ipc_reader/FileReader/read_10                         1.00   421.4±31.30µs        ? ?/sec    1.03   436.1±32.23µs        ? ?/sec
arrow_ipc_reader/FileReader/read_10/mmap                    1.00   485.0±34.66µs        ? ?/sec    1.01   488.9±34.73µs        ? ?/sec
arrow_ipc_reader/StreamReader/no_validation/read_10         1.00    120.7±2.54µs        ? ?/sec    1.01    122.0±3.92µs        ? ?/sec
arrow_ipc_reader/StreamReader/no_validation/read_10/zstd    1.00      2.4±0.02ms        ? ?/sec    1.00      2.4±0.02ms        ? ?/sec
arrow_ipc_reader/StreamReader/read_10                       1.02   429.4±33.28µs        ? ?/sec    1.00   422.8±32.66µs        ? ?/sec
arrow_ipc_reader/StreamReader/read_10/zstd                  1.00      2.7±0.05ms        ? ?/sec    1.02      2.8±0.08ms        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 85.0s
Peak memory 19.2 MiB
Avg memory 15.1 MiB
CPU user 72.8s
CPU sys 10.1s
Peak spill 0 B

branch

Metric Value
Wall time 85.0s
Peak memory 16.2 MiB
Avg memory 12.1 MiB
CPU user 71.7s
CPU sys 10.3s
Peak spill 0 B

File an issue against this benchmark runner

@Jefffrey

Jefffrey commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ranflarion

Comment thread arrow-ipc/src/reader.rs
const MAX_PREALLOC_BYTES: usize = 64 * 1024 * 1024;

/// Reads exactly `len` bytes of message body, without reserving `len` before reading it.
fn read_body_bounded<R: Read>(reader: &mut R, len: usize) -> Result<MutableBuffer, ArrowError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems reasonable -- basically it reallocates in 64MB chunks when reading

for a 1GB message, this will result in quite a few reallocations, but I suppose the idea is that such message sizes are rare.

I was thinking about some usecase reading 8K rows record batch where each row has a 1MB document -- that is 8GB and would likely reallocate 128K times, which is probably not great for performance

Is there some way to use a built in rust function (or maybe employ a doubling strategy) or something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MutableBuffer::resize goes through reserve, which already grows capacity to max(required, capacity * 2) (arrow-buffer/src/buffer/mutable.rs:256), so the 64MB steps only moved len while capacity doubled underneath: the 8GB case reallocates 7 times (64M→128M→…→8G) with cumulative copy under one extra pass over the body, not 128K times. I've made the doubling explicit in read_body_bounded anyway (resize(len.min(target * 2))), so the growth policy is visible in the loop instead of relying on reserve internals, and the loop runs log2(n / 64MB) iterations. Total zeroing is unchanged from before this PR, from_len_zeroed zeroed the whole body up front too. On a built-in, take(len).read_to_end is what the metadata path uses, but read_to_end only fills a Vec and the body has to stay a MutableBuffer for the 64-byte aligned allocation, so the body loop stays hand-rolled.

@ranflarion

Copy link
Copy Markdown
Contributor Author

yes i missed that, #9869 is after the same three changes (take/read_to_end for the metadata, try_from on bodyLength, bounded body reads). The difference is the body path: #9869 copies through a 64KiB scratch chunk via extend_from_slice, so every body pays a second full copy, while here the bytes land directly in the destination MutableBuffer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-ipc

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IPC StreamReader aborts the process on a malformed message body length

5 participants