ADR 0078 — sendFile never raises SIGPIPE; an open file's time and kind, and File.openRegular
- Status: Accepted by the owner, 2026-11-08 ("Go ahead and release CeeSafe"). Raised by CeeSafe.Web: bug B19 and request U22 (
CeeSafe.Web/docs/ceesafe-upstream-work.md), for its M9 milestone. The owner asked for both to be fixed here. - Scope: the hosted runtime (
ceesafe_rt_async.c,ceesafe_rt_io.c,rt.h), lib/std (host.cee,async.cee), the checker's File members, the C backend, the interpreter, type-system.md §26.3 and §40's sending files, guide/builtins.md, the corpus.
1. Context
- B19. On Linux, a program sending a file over TCP with lib/std's
sendFileorsendFileAllends with exit status 141 (SIGPIPE) when the peer resets the connection during the send. Every other send in the runtime passesMSG_NOSIGNAL.sendfile(2)has no flags argument, and nothing in the runtime blocks or ignores SIGPIPE.- macOS is not affected: its sockets get
SO_NOSIGPIPE. Windows has no SIGPIPE. - For a server, one client that disconnects in the middle of a download ends the process, and every connection with it. CeeSafe.Web found it with h2load, which resets its connections when its time is up.
- Two ways in, both seen on Linux 6.1:
- A failed call. Linux reports a reset once, as ECONNRESET, to whichever call on the socket comes first. A server with a read pending (any HTTP/1.1 server) takes it there, and its next
sendfilefails with EPIPE. - A partial count.
sendfilemoves a file in pieces. A piece after the reset raises SIGPIPE, while the call returns the bytes of the pieces before it.
- A failed call. Linux reports a reset once, as ECONNRESET, to whichever call on the socket comes first. A server with a read pending (any HTTP/1.1 server) takes it there, and its next
- macOS is not affected: its sockets get
- U22. A static-file server answers with validators (
ETag,Last-Modified, 304,If-Range; RFC 9110 §8.8, §13), and must refuse what is not a regular file. ADR 0077'sFile.modified(path)and §26.8'sFile.kind(path)read the path, which can name another file by the time it is opened: a check-then-use race. Opening a FIFO for reading waits for a writer, so a request for one would hold its worker.
2. Decision: sendFile never raises SIGPIPE
- On Linux, each
sendfilecall blocks SIGPIPE on its thread (pthread_sigmask).- SIGPIPE from a send is directed at the calling thread, so it stays pending there.
- If one became pending during the call and none was before it, it is taken with a zero-timeout
sigtimedwait. That holds whatever the call returned: an error, or a partial count (§1's second way). - The thread's mask is then restored. The process's disposition of SIGPIPE is never changed.
- A thread that already blocks SIGPIPE keeps whatever the call leaves pending, as after any write of its own.
- The result is the call's own. A partial count is returned as sent, and the next step's EPIPE is
IoError.BrokenPipe. SosendFileAllfails with an error, as awritewould. - Both reactors use the same step. epoll and io_uring share it (
cee_ready_sendfile). macOS and Windows are unchanged. - Rejected:
- Ignoring SIGPIPE process-wide at startup (nginx, Go). It changes every program, including one writing to a closed pipe on standard output, which today ends quietly.
splicethrough a pipe instead ofsendfile. It takes no flag that suppresses the signal either.
3. Decision: an open file's time and kind, and File.openRegular
f.modified()returnsResult<UnixTime, IoError>: the open file's last modification time, fromfstat(_fstat64on Windows), with nanoseconds where the system keeps them.f.kind()returnsResult<PathKind, IoError>, fromfstat:Filefor a regular file;Directoryfor a directory, where the system lets one be opened;Otherfor a device, FIFO or socket.
- How they are built. Both are lib/std's
FileMetadata.modified(f)andFileMetadata.kind(f), over the runtime'sceesafe_host_file_modifiedandceesafe_host_file_kind, which read the File's descriptor. The checker routes the methods as it routes slice algorithms, withfborrowed. They takeborrow this, so a file shared by reference can be asked. They are hosted only. File.openRegular(path)openspathfor reading if it is a regular file:- POSIX:
openwithO_RDONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC, so a FIFO's open never waits and a terminal never becomes the controlling one. Thenfstatthe descriptor. A regular file hasO_NONBLOCKcleared and is returned. Anything else is closed and refused. - Windows:
_open, then_fstat64. Windows refuses to open a directory (EACCES); a stat of the path, after the failed open, makes thatIsDirectory. No file is read, so this races with nothing. - Errors: a directory is
IoError.IsDirectory. Anything else that is not a regular file isInvalidInput, whichever device it is. A missing path isNotFound, as forFile.open. - Encoding: it is mode 6 of
ceesafe_file_open.
- POSIX:
AsyncFiles.openRegular(path),AsyncFiles.modified(file)andAsyncFiles.kind(file)run these on a worker thread, asAsyncFiles.lengthdoes, and takeborrow mut Fileas it does.- The interpreter:
f.modified()answersUnsupported, asFile.modified(path)does (the stage-0 compiler's library cannot stat).f.kind()andFile.openRegularuse the path's kind. Its files are reopened by path for each operation (ADR 0075 §2.1), and nothing else runs between the check and the use.- A file whose path was removed since it was opened is
NotFoundthere. Natively it still answers.
- Not changed:
File.openkeeps its behaviour. A program that opens a FIFO to wait for a writer still can.
4. Consequences
- A Linux server that sends files survives clients that disconnect. CeeSafe.Web's benchmark server, built with this runtime, served a whole 1 GiB response after 80 clients dropped in the middle of one and h2load's resets, under epoll and io_uring.
- A static-file handler can take a file's validators from the file it sends. It can refuse FIFOs and devices without blocking a worker.
- Each Linux
sendfilecosts twopthread_sigmaskcalls and twosigpendingcalls, and asigtimedwaitafter a reset. Each call moves up to the socket's free space, so this is small next to the copy. IoErrorgains no variant, so nomatchover it changes.
5. Tests
Run on 2026-11-08 (macOS arm64; Debian 12, Linux 6.1, x86-64, under epoll and io_uring; Windows 11 ARM64, under IOCP and WSAPoll; each natively and in the interpreter):
run-pass/async-send-file-reset: §1's two ways in, in rounds whose sends must fail or finish, never signal.- With ceesafec 0.21.0 on Linux it exits 141 under both reactors.
- So it does with a runtime that took the signal only after a failed call (exit 141, 6 runs of 6, in the mid-send rounds).
- With this runtime it passes everywhere.
- It passes the allocation fault sweep (85 failure points).
run-pass/file-open-regular: checks, in order:- a regular file opens, reads, is
File, and itsmodified()equalsFile.modified(path)(the path-based oracle); - after its path is removed, it still answers
kind()andmodified()(natively); - a directory is
IsDirectory; NULor/dev/nullisInvalidInput;- a missing path is
NotFound; - a FIFO made with
mkfifoisInvalidInput(POSIX); - the
AsyncFilesforms give the same answers.
A mutant runtime without
O_NONBLOCKfails it: the FIFO's open waits until the run times out.- a regular file opens, reads, is
On Windows,
/dev/nullwas a regular file on the test machine (C:\dev\null), andopenRegularrightly opened it. The test triesNUL, a device in every directory, first.