Things to check first
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.
Things to check first
Feature description
Add a public API for sending a file through a
SocketStreamusing the backend's native sendfile mechanism.A possible API would be:
This should be exposed specifically on
SocketStream, rather than on the genericByteSendStreaminterface, 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:
Using
loop.sendfile()rather thanloop.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 precedingsend()calls and the file contents.For the Trio backend, the implementation could use
os.sendfile()on the underlying non-blocking socket and wait for writability afterEAGAINorEWOULDBLOCK.A rough implementation could look like this:
The exact set of errors translated to
SendfileNotAvailableErrorwould need to be determined per supported platform. In particular,EINVALcan 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 existingBusyResourceErrorbehavior.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:
offsetandcount;SendfileNotAvailableError;countbytes 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 whenos.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
AsyncFiledoes 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. Callingloop.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.