Expose a sendfile API for socket streams
- Vorherrschende Sprache
- Python
- Sterne
- 2.5k
- Forks
- 260
- Ø Merge
- 1 T. 8 Std.
- Gemergte PRs (30 T.)
- 17
Beschreibung
### Things to check first
- [x] 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:
```python
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:
```python
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.
```python
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:
```python
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.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.