Skip to content

io_uring stream channel does not support generic FileRegion, causing ClassCastException #16570

@LuciferYang

Description

@LuciferYang

Summary

AbstractIoUringStreamChannel only handles DefaultFileRegion in its write path. Writing any other FileRegion implementation to an io_uring channel causes an UnsupportedOperationException from filterOutboundMessage, or a ClassCastException in scheduleWriteSingle. This is inconsistent with both EPOLL and NIO, which accept any FileRegion.

Expected behavior

Any FileRegion implementation should be writable to an io_uring channel, consistent with the FileRegion interface contract and the behavior of EPOLL and NIO transports.

Actual behavior

Writing a custom FileRegion (that is not DefaultFileRegion) to an IoUringSocketChannel fails with:

java.lang.UnsupportedOperationException: unsupported message type: MyFileRegion
    at io.netty.channel.uring.AbstractIoUringChannel.filterOutboundMessage(AbstractIoUringChannel.java:1248)
    at io.netty.channel.AbstractChannel$AbstractUnsafe.write(AbstractChannel.java:877)

Additionally, scheduleWriteSingle has no FileRegion branch -- it only handles IoUringFileRegion and ByteBuf with a hard cast:

} else {
    ByteBuf buf = (ByteBuf) msg;  // would ClassCastException if a FileRegion reached here

Root cause

In AbstractIoUringStreamChannel.filterOutboundMessage():

protected Object filterOutboundMessage(Object msg) {
    if (IoUring.isSpliceSupported() && msg instanceof DefaultFileRegion) {
        return new IoUringFileRegion((DefaultFileRegion) msg);
    }
    // Any non-DefaultFileRegion FileRegion falls through to super,
    // which only accepts ByteBuf and throws UnsupportedOperationException.
    return super.filterOutboundMessage(msg);
}

Compare with EPOLL's AbstractEpollStreamChannel.filterOutboundMessage(), which accepts any FileRegion:

protected Object filterOutboundMessage(Object msg) {
    if (msg instanceof ByteBuf) { ... }
    if (msg instanceof FileRegion || msg instanceof SpliceOutTask) {
        return msg;  // accepts ANY FileRegion
    }
    throw new UnsupportedOperationException(...);
}

And EPOLL's doWriteSingle() has a two-tier fallback:

if (msg instanceof DefaultFileRegion) {
    return writeDefaultFileRegion(in, (DefaultFileRegion) msg);  // sendfile
} else if (msg instanceof FileRegion) {
    return writeFileRegion(in, (FileRegion) msg);                // transferTo fallback
}

io_uring has no such fallback.

Affected use case

Apache Spark wraps DefaultFileRegion inside a composite FileRegion implementation (MessageWithHeader) that prepends a frame header before the file body. This is a common pattern for protocols that need framing around file transfers. The composite FileRegion delegates transferTo() to the inner DefaultFileRegion, so it works correctly with NIO and EPOLL but crashes with io_uring.

I am currently evaluating Apache Spark's support for io_uring transport. This issue blocks io_uring adoption for any framework that wraps DefaultFileRegion in a custom FileRegion.

Proposed fix

Add a generic FileRegion fallback in filterOutboundMessage. Since io_uring cannot use synchronous transferTo in its async write path, convert the FileRegion to a direct ByteBuf eagerly:

protected Object filterOutboundMessage(Object msg) {
    if (IoUring.isSpliceSupported() && msg instanceof DefaultFileRegion) {
        return new IoUringFileRegion((DefaultFileRegion) msg);
    }

    if (msg instanceof FileRegion) {
        FileRegion region = (FileRegion) msg;
        long remaining = region.count() - region.transferred();
        ByteBuf buf = alloc().directBuffer((int) remaining);
        try {
            ByteBufWritableByteChannel channel = new ByteBufWritableByteChannel(buf);
            while (region.transferred() < region.count()) {
                long transferred = region.transferTo(channel, region.transferred());
                if (transferred <= 0) {
                    break;
                }
            }
        } catch (IOException e) {
            buf.release();
            throw new RuntimeException("Failed to convert FileRegion to ByteBuf", e);
        } finally {
            region.release();
        }
        return buf;
    }

    return super.filterOutboundMessage(msg);
}

This is not zero-copy but is functionally correct and consistent with EPOLL's writeFileRegion fallback. DefaultFileRegion still uses the optimized splice path via IoUringFileRegion.

Comparison across transports

Transport DefaultFileRegion Generic FileRegion
NIO FileChannel.transferTo(SocketChannel) -- JDK sendfile region.transferTo(SocketChannel) -- works
EPOLL socket.sendFile() -- native sendfile region.transferTo(byteChannel) -- fallback, works
io_uring (current) splice() via IoUringFileRegion -- works crashes
io_uring (proposed) splice() via IoUringFileRegion -- unchanged convert to ByteBuf, send via async path -- works

Environment

  • Netty version: 4.2.x
  • Discovered while evaluating io_uring for Apache Spark's shuffle file transfer

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    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