Netty version
4.2.13.Final (and current 4.2 branch)
Expected behavior
FileRegion#transferTo() is not invoked once the region reports transferred() == count(). epoll's AbstractEpollStreamChannel#writeFileRegion honors this:
if (region.transferred() >= region.count()) {
in.remove();
return 0;
}
https://github.com/netty/netty/blob/netty-4.2.13.Final/transport-classes-epoll/src/main/java/io/netty/channel/epoll/AbstractEpollStreamChannel.java#L403-L406
so it never calls transferTo() past completion.
Actual behavior
The generic FileRegion fallback added in #16571 drains the region into a direct ByteBuf chunk and exits the drain loop only when the chunk buffer has no more writable bytes:
while (buf.writableBytes() > 0) {
long t = region.transferTo(ch, region.transferred());
if (t <= 0) {
break;
}
}
https://github.com/netty/netty/blob/netty-4.2.13.Final/transport-classes-io_uring/src/main/java/io/netty/channel/uring/AbstractIoUringStreamChannel.java#L344-L349
There is no check on region.transferred() < region.count(). If a single transferTo() advances transferred to count while leaving spare capacity in the chunk buffer, the loop calls transferTo() again past transferred() == count().
Why it matters
The FileRegion contract lets implementations assume transferTo() is not invoked once the region reports completion. Adapters that wrap an inner source with per-chunk framing — encryption, compression, length-prefixed framing, MACs, etc. — lazily build the next chunk on each transferTo() call. When the loop invokes them again after the region has been fully transferred, they build an empty next chunk and ship its framing into the buffer, which then lands on the wire and breaks the receiver's parsing of subsequent frames.
The same FileRegion patterns work without surprises on NIO and epoll, and the same kind of fallback exists on epoll for the same purpose, but only io_uring exhibits the overshoot.
Reproducer
A FileRegion that writes a single byte on its first transferTo() call and advances transferred() to count() in one shot (a legal pattern for any layer that flushes a completed inner chunk in one call) receives chunkSize - 1 additional transferTo() invocations from the io_uring transport before the chunk buffer fills — for the default 64 KiB chunk size and a 16-byte region the extra calls are 15. None of those are licensed by the contract.
A regression test and a one-line fix mirroring epoll's guard are in the accompanying PR.
Netty version
4.2.13.Final (and current
4.2branch)Expected behavior
FileRegion#transferTo()is not invoked once the region reportstransferred() == count(). epoll'sAbstractEpollStreamChannel#writeFileRegionhonors this:https://github.com/netty/netty/blob/netty-4.2.13.Final/transport-classes-epoll/src/main/java/io/netty/channel/epoll/AbstractEpollStreamChannel.java#L403-L406
so it never calls
transferTo()past completion.Actual behavior
The generic
FileRegionfallback added in #16571 drains the region into a directByteBufchunk and exits the drain loop only when the chunk buffer has no more writable bytes:https://github.com/netty/netty/blob/netty-4.2.13.Final/transport-classes-io_uring/src/main/java/io/netty/channel/uring/AbstractIoUringStreamChannel.java#L344-L349
There is no check on
region.transferred() < region.count(). If a singletransferTo()advancestransferredtocountwhile leaving spare capacity in the chunk buffer, the loop callstransferTo()again pasttransferred() == count().Why it matters
The
FileRegioncontract lets implementations assumetransferTo()is not invoked once the region reports completion. Adapters that wrap an inner source with per-chunk framing — encryption, compression, length-prefixed framing, MACs, etc. — lazily build the next chunk on eachtransferTo()call. When the loop invokes them again after the region has been fully transferred, they build an empty next chunk and ship its framing into the buffer, which then lands on the wire and breaks the receiver's parsing of subsequent frames.The same
FileRegionpatterns work without surprises on NIO and epoll, and the same kind of fallback exists on epoll for the same purpose, but only io_uring exhibits the overshoot.Reproducer
A
FileRegionthat writes a single byte on its firsttransferTo()call and advancestransferred()tocount()in one shot (a legal pattern for any layer that flushes a completed inner chunk in one call) receiveschunkSize - 1additionaltransferTo()invocations from the io_uring transport before the chunk buffer fills — for the default 64 KiB chunk size and a 16-byte region the extra calls are 15. None of those are licensed by the contract.A regression test and a one-line fix mirroring epoll's guard are in the accompanying PR.