ADR 0051 — Sending a file on a stream without copying it
- Status: Accepted by the owner, 2026-11-02 ("Yes, I accept all five ADRs with your recommendations"): every question of §5 as recommended. Asked for by the owner for CeeSafe.Web (
~/dev/CeeSafe.Web/docs/ceesafe-upstream-work.md, item U7). Its static files (M9) are sent this way. - Scope: lib/std's
AsyncTcpStream, the hosted runtime on every reactor, the interpreter's model.
1. Context
A static file is sent today by reading it into a buffer (AsyncFiles.read, on a worker thread) and writing the buffer to the socket. Every byte crosses into the process and back out. The kernels can send a file's pages to a socket directly:
- Linux
sendfile; - macOS and the BSDs
sendfile, with a different signature; - Windows
TransmitFile.
Three facts shape the design:
Fileis a compiler built-in, and its C form is its descriptor, anint32_t(rt.h'sceesafe_fd_drop(int32_t *)). lib/std can pass the runtime a pointer to it, under the same contract. The borrow of theFilekeeps it open for as long as the operation is in flight: the frame that holds the borrow outlives the operation (ADR 0035).- A file read can block on the disk.
sendfileon a non-blocking socket waits for the socket without blocking, but it still reads pages the cache does not hold. nginx's default accepts that; itsaio threadsavoid it. - Windows'
TransmitFilesends at most 2³¹ − 1 bytes a call, and takes the file's offset in theOVERLAPPED.
2. Options
io_uring. IORING_OP_SPLICE sends a file without blocking on the disk, but through a pipe: a pipe per stream, and two linked submissions per step. Proposed: on io_uring, the readiness path's sendfile, until a measurement shows the disk wait matters. The splice path can come later without an API change.
Where a step reads the disk. On the executor's thread (proposed, as nginx's default), or always on a worker thread. A worker per step adds a thread hand-off to every chunk, the common case being a cached file.
3. Proposed decision
3.1 The API (lib/std)
// AsyncTcpStream
public async fn sendFile(borrow this, file: borrow File, offset: u64, length: u64) -> Result<u64, AsyncError>;
public async fn sendFileAll(borrow this, file: borrow File, offset: u64, length: u64) -> Result<void, AsyncError>;sendFileis one step. The task waits until the socket takes at least one byte, and the result is how many the kernel sent, fromoffset. Fewer thanlengthis normal; 0 means the file ends atoffset. Alengthof 0 isOk(0)without a system call.sendFileAllloops untillengthbytes are sent. If the file ends first, the result isErr(Io(InvalidInput)), with the bytes before it sent.- Offsets and lengths are 64-bit. The runtime splits a step to the platform's limit, so files over 4 GiB work.
- The file's own position is not used or moved: the offset is explicit. So one file may be sent on many streams at once.
- Cancellation follows ADR 0035: on IOCP an operation in flight is cancelled and waited for, and a step completed meanwhile reports its count.
3.2 The runtime, on every reactor
- Linux (epoll, and io_uring per §2):
sendfile(socket, file, &offset, count). OnEAGAINthe step registers for writability. - macOS and the BSDs:
sendfile(file, socket, offset, &len, NULL, 0). OnEAGAIN,lenholds what was sent: a partial count, or the wait. - Windows, IOCP: an overlapped
TransmitFilewith the offset in theOVERLAPPED, at most 2³¹ − 1 bytes. The handle is the CRT descriptor's (_get_osfhandle). - Windows
WSAPoll, CosmoOS userland, and any system call that refuses (EINVALorENOSYSfromsendfile, a file that is not a regular file): the runtime returnsUnsupported. lib/std then falls back to a bounded copy: up to 64 KiB read withpreadsemantics on a worker thread (Async.blocking), and sent withwrite. Same results, with a copy. - The record is the plain operation record (
AsyncOp, ADR 0035): no buffers are lent to the kernel.
3.3 The interpreter
It models a step with the host's file read at an offset and the host's socket send: at most 64 KiB, a legal partial send. The host lib/std has both, so no chain step is needed.
4. Consequences
- CeeSafe.Web's static files go to the socket without passing through the process, where the system can.
- A step may block the executor's thread on a disk read (§2). A server for files that are rarely cached should know it. A later option can move steps to a worker.
- New diagnostics: none.
5. Questions for the owner
sendFile(one step, a partial count) andsendFileAll, with an explicit 64-bit offset and the file's position untouched? Recommended: yes.sendfileon the executor's thread, as nginx's default, rather than a worker per step? Recommended: yes.- io_uring uses the readiness path's
sendfilefor now, not splice through a pipe? Recommended: yes. - Where the system cannot, a bounded 64 KiB copy in lib/std, with the same results? Recommended: yes.
6. Implementation plan (once accepted)
- Spec: §40.4, c-backend.md §6, the guide's sockets section.
- Runtime:
ceesafe_async_op_sendfile(op, socket, file, offset, length)on each backend;tools/gen_runtime.py. - lib/std:
sendFile,sendFileAlland the fallback;tools/gen_library.py. - The interpreter's model.
- Tests:
- corpus run-pass: files of 0 and 1 bytes, a file larger than the socket buffers to a slow reader (partial steps), an offset in the middle, a length past the end (
sendFileAll'sInvalidInput), the same file on two streams at once. The received bytes are checked against the file's (Python writes the file and its digest); - a file larger than 4 GiB, sparse, so it takes no disk space: native only, in a tool beside the contract suite, since the corpus runs in the interpreter too;
- a step cancelled in flight under ASan, and
tools/cancel_sweep.py; - every reactor, Debian, and Windows 11 ARM64 (IOCP's
TransmitFile;WSAPoll's fallback).
- corpus run-pass: files of 0 and 1 bytes, a file larger than the socket buffers to a slow reader (partial steps), an offset in the middle, a length past the end (
- ROADMAP §10.
7. Implementation notes (2026-11-02)
- Windows.
TransmitFilerefuses a count that runs past the file's end, so the runtime clamps the step to the file's size (GetFileSizeEx), and a step at the end returns 0 assendfiledoes. On a synchronous handle bothTransmitFileand aReadFileat an offset move the file pointer, so each runs on a handle of its own fromReOpenFile, closed at the completion (or at once if the submission fails): theFile's position is never moved. - The interpreter has no file send:
ceesafe_async_op_sendfilereturnsCEE_ASYNC_NO_SENDFILE, and itsceesafe_file_read_atreopens the file's path (each open file's path is kept beside it) and reads pastoffset, since stage 0'sFilecannot seek. No chain step was needed. - Linux needs
_GNU_SOURCEinceesafe_rt_io.cforpreadunder glibc's strict C11 headers, asceesafe_rt_thread.chas fornanosleep(ADR 0049). - Tests as §6 planned: the 4 GiB case is
tools/sendfile_check.py, in the contract suite. Writing it found B7, a closure capture namedcode(ROADMAP §10).