Skip to content

Expose a sendfile API for socket streams #1259

Description

@graingert

Things to check first

  • I have searched the existing issues and didn't find my feature already requested there

Feature description

Add a public API for sending a file through a SocketStream using the backend's native sendfile mechanism.

A possible API would be:

async def send_file(
    self,
    file: BinaryIO,
    *,
    offset: int = 0,
    count: int | None = None,
) -> int:
    ...

This should be exposed specifically on SocketStream, rather than on the generic ByteSendStream interface, because native sendfile requires access to an underlying socket and is not generally meaningful for TLS streams, memory streams, or arbitrary stream wrappers.

For the asyncio backend, the implementation should delegate to:

await asyncio.get_running_loop().sendfile(
    transport,
    file,
    offset=offset,
    count=count,
    fallback=False,
)

Using loop.sendfile() rather than loop.sock_sendfile() is important because AnyIO's asyncio socket stream is backed by an asyncio transport. loop.sendfile() waits for data already buffered in the transport to be written before transferring the file, preserving the ordering between preceding send() calls and the file contents.

try:
    return await asyncio.get_running_loop().sendfile(
        self._transport,
        file,
        offset=offset,
        count=count,
        fallback=False,
    )
except asyncio.SendfileNotAvailableError as exc:
    raise anyio.SendfileNotAvailableError from exc
except (BrokenPipeError, ConnectionResetError) as exc:
    raise anyio.BrokenResourceError from exc

For the Trio backend, the implementation could use os.sendfile() on the underlying non-blocking socket and wait for writability after EAGAIN or EWOULDBLOCK.

A rough implementation could look like this:

import errno
import os

import trio


async def send_file(
    self,
    file: BinaryIO,
    *,
    offset: int = 0,
    count: int | None = None,
) -> int:
    with self._send_guard:
        socket_fd = self._raw_socket.fileno()
        file_fd = file.fileno()

        if count is None:
            count = max(0, os.fstat(file_fd).st_size - offset)
        elif count < 0:
            raise ValueError("count must be non-negative")

        total_sent = 0

        try:
            while total_sent < count:
                try:
                    sent = os.sendfile(
                        socket_fd,
                        file_fd,
                        offset + total_sent,
                        count - total_sent,
                    )
                except InterruptedError:
                    continue
                except BlockingIOError:
                    await trio.lowlevel.wait_writable(socket_fd)
                    continue
                except OSError as exc:
                    if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
                        await trio.lowlevel.wait_writable(socket_fd)
                        continue

                    if exc.errno in (
                        errno.EPIPE,
                        errno.ECONNRESET,
                        errno.ENOTCONN,
                        errno.ESHUTDOWN,
                    ):
                        raise BrokenResourceError from exc

                    if total_sent == 0 and exc.errno in (
                        errno.ENOSYS,
                        errno.EOPNOTSUPP,
                        errno.ENOTSUP,
                        errno.EINVAL,
                    ):
                        raise SendfileNotAvailableError from exc

                    raise

                if sent == 0:
                    break

                total_sent += sent

                # os.sendfile() may repeatedly complete immediately, so retain
                # cancellation and scheduler fairness.
                await trio.lowlevel.checkpoint()

        finally:
            if total_sent:
                file.seek(offset + total_sent)

        return total_sent

The exact set of errors translated to SendfileNotAvailableError would need to be determined per supported platform. In particular, EINVAL can indicate either an unsupported file/socket combination or an invalid argument, so it may require more careful handling than the simplified example above.

The operation should use the same send-resource guard as an ordinary SocketStream.send() call, so concurrent sends follow AnyIO's existing BusyResourceError behavior.

There should be no userspace read-and-send fallback. If native sendfile is unavailable for the platform, socket, transport, or file, the operation should raise an AnyIO exception such as SendfileNotAvailableError.

The API should define consistent behavior across backends for:

  • the meaning of offset and count;
  • updating the file object's position after successful or partially successful transmission;
  • cancellation after a partial transmission;
  • translating connection errors to AnyIO exceptions;
  • which unsupported cases raise SendfileNotAvailableError;
  • how a zero return value before count bytes have been sent is interpreted.

Asyncio updates the file position to offset + bytes_sent, including after a partially successful operation. The Trio implementation should provide matching behavior even when os.sendfile() is called with an explicit offset and therefore does not itself update the descriptor's file position.

The API should accept an ordinary binary file object with a usable file descriptor. Supporting AsyncFile does not seem necessary because the native operating-system operation works directly with the underlying file descriptor, and asyncio's corresponding API also accepts a regular file object.

Use case

HTTP and other network servers frequently need to send large regular files after first writing protocol metadata, such as HTTP response headers.

At present, AnyIO applications must either read the file into userspace buffers and call stream.send(), or access backend-specific and private details such as the asyncio transport or Trio socket and implement sendfile separately for each backend.

The first option cannot provide native zero-copy file transfer. The second breaks AnyIO's backend abstraction and makes it difficult to preserve correct buffering, ordering, cancellation, exception, and file-position behavior.

This is particularly important on asyncio. Calling loop.sock_sendfile() directly on the raw socket underlying an asyncio transport can bypass data already buffered by the transport and break wire ordering. Calling loop.sendfile() is necessary because it coordinates with the transport and waits for its write buffer to become empty first.

A public AnyIO API would let servers and frameworks use native file transfer where supported while failing explicitly when it is unavailable, rather than silently switching to a different I/O strategy.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions