Research notes / File I/O
Zero-copy transfers do not freeze source bytes
/ Measured Linux API behavior
A successful sendfile() can leave the receiver holding references to mutable file pages. In our file-to-pipe test, overwriting the source after the call returned changed the bytes later read from the pipe. Adding fsync() did not freeze them.
This is a documented source-lifetime obligation, not evidence of a Linux bug: zero-copy socket or pipe transfers require the caller to keep transferred regions unchanged until consumption. [1]
The same byte count, different byte versions
Each private test file begins with one page of A. We hash it, enqueue the page, then perform the named operation before consuming the pipe, except in the consume-first control. B is the replacement payload; C is written through an old descriptor after pathname replacement. There are no sleeps or concurrent-writer races.
| Sequence | Read | Path | Old inode |
|---|---|---|---|
| read + write, then overwrite | A | B | B |
| sendfile, then overwrite | B | B | B |
| splice, then overwrite | B | B | B |
| sendfile, overwrite, fsync | B | B | B |
| sendfile, replace pathname | A | B | A |
| Replace pathname, then write old inode | C | B | C |
| sendfile, close input fd, overwrite | B | B | B |
| sendfile, consume, then overwrite | A | B | B |
The buffered control had already copied the bytes. The consume-first control had already read them. Both retained A. The other mutation cases demonstrate why enqueue completion alone cannot establish an immutable source version. These are 40 controlled checks, not independent samples estimating a failure rate.
A page reference is not a byte snapshot
In upstream Linux v6.8, splice_folio_into_pipe() puts a page reference in a pipe buffer and increases its reference count. filemap_splice_read() obtains pages from the file's page cache for that path. The reference preserves lifetime; it does not create a private copy of each byte. [3]
This source explains the observed behavior, but we did not trace or rebuild the distribution kernel. Nor does every splice path share pages: the kernel documents copy_splice_read(), which reads into newly allocated pages. A successful API call alone cannot prove that all copying was avoided. [6]
SPLICE_F_MOVE is not a way to demand exclusive ownership or frozen content; the manual documents it as a no-op. The experiment uses zero flags. [2] We also verify the returned count: short successful transfers must not be mistaken for a complete page.
Replacing a pathname helps only if the old object stays unchanged
Renaming a separately written replacement over the source changes what a new pathname lookup opens, while existing descriptors still refer to their previous object. [4] Our replacement-only case therefore delivered A while the pathname read B.
The next case is the important qualification. A writable descriptor still referred to the old inode. Writing C through it changed the queued data to C, even though the pathname continued to expose B. Atomic publication is useful for immutable file versions; rename by itself does not enforce immutability. See pathname publication and directory durability.
The separate close-input case also delivered B. Closing the descriptor used for transfer is not a receiver-consumption barrier. This connects to the broader distinction between descriptor, mapping and object lifetimes.
A checksum can be correct for the wrong intended version
In the overwrite case, a digest computed before enqueue describes A, but the receiver gets B. Hashing the pathname afterwards would describe B too. Agreement between those latter hashes would not prove that the earlier version was transferred. This follows directly from the retained payload hashes; it is not a hash-collision claim.
For backup or content-serving pipelines, bind verification to an identified, stable source version. A snapshot, immutable object version or coordinated writer protocol may provide that contract. Copying into an independent buffer protects bytes already copied, but reading a large changing file in multiple chunks still does not create an atomic snapshot. Compare backup consistency and recovery and checksums and object identity.
Two consumers can disagree after tee(2)
A separate experiment duplicates the queued page into another pipe with the Linux tee(2) system call. It then reads and hashes the first branch, overwrites the source, and reads and hashes the second. The syscall duplicates references without consuming the input; it does not promise independent byte snapshots. [8]
| Pipe filled by | First read | Next read | Hashes agree |
|---|---|---|---|
| sendfile | A | B | No |
| buffered | A | A | Yes |
The file-backed path gave the first consumer A and the second B. The buffered control gave both A. A hash calculated on one branch therefore need not describe bytes later consumed on the other when the source-stability rule is broken. A hashing branch does not itself enforce that rule.
Download the supplement harness, observations and tests. The harness imports the main harness from the same directory and calls libc through Python ctypes; run python3 -B tee-source-lifetime.py --repetitions 5 --output tee-observations.json. It measures neither GNU tee nor any backup product.
IO-Lite made stable bytes part of the design
Pai, Druschel and Zwaenepoel's OSDI 1999 paper offers a useful contrast. IO-Lite shares immutable data buffers, while mutable buffer aggregates describe how their slices fit together. Editing an aggregate allocates new storage for changed bytes. Its file cache replaces buffers on a write, retaining earlier buffers while readers still reference them. [9]
The design also pairs a buffer address with a generation number so cached Internet checksums can identify unchanged contents across reuse. The transferable lesson is to make byte identity and mutation rights explicit when removing copies. This is our design comparison, not a claim that Linux sendfile implements IO-Lite.
The prototype used modified FreeBSD 2.2.6 and an application library. Its historical web-server measurements do not predict current Linux performance; the experiment here measures a different API contract.
Three completions that should not be conflated
Enqueue completion: this experiment records bytes accepted into a pipe. Source-lifetime completion: the receiver has consumed the referenced data. Persistence completion: synchronization has met the relevant storage contract. fsync() addresses synchronization, not snapshot isolation; our second-fsync case still delivered the changed bytes. [5]
Do not import completion rules from a different API. MSG_ZEROCOPY has socket error-queue notifications for buffer reuse, but they are not transmit-completion notifications and copying may still occur. Those notifications are not supplied by sendfile(). No MSG_ZEROCOPY or TCP experiment was run here. [7]
Reproduce the sequence, not a timing accident
The dataset contains the CSV, full JSON, harness, tests and source hashes. Download the Python harness and run it on Linux with Python 3.10 or later and GNU stat:
python3 zero-copy-lifetime.py --repetitions 5 --output observations.jsonOnly newly created files in private temporary directories are used. Choose the test filesystem with --temp-dir. The harness checks capacity, exact transfer counts, complete received payloads and cleanup. Unexpected API errors abort instead of being counted as successful observations.
The recorded environment is Python 3.12.3, Linux 6.8.0-146-generic, x86_64; stat -f reports ext2/ext3. That magic label does not identify an ext-family version or mount configuration. Input bytes are synthetic, but the calls and observations are real. This is not a throughput benchmark, crash test, TCP transfer test or survey of filesystems. Mutation cases deliberately violate the source-stability requirement; their particular resulting bytes are observations, not a portable promise.
Corpus definitions: zero-copy I/O, sendfile, splice and tee(2).
Primary sources
- Linux man-pages 6.19: sendfile(2), return value and source lifetime
- Linux man-pages 6.19: splice(2), pipe buffers and flags
- Linux v6.8 mm/filemap.c: splice_folio_into_pipe and filemap_splice_read
- Linux man-pages 6.19: rename(2), replacement and open descriptors
- Linux man-pages 6.19: fsync(2), synchronization and directory entries
- Linux 6.12 documentation: splice and pipes, copy_splice_read
- Linux kernel documentation: MSG_ZEROCOPY, notifications and deferred copies
- Linux man-pages 6.19: tee(2), duplication without consuming and shared references
- Pai, Druschel and Zwaenepoel: IO-Lite, OSDI 1999, sections 2.1, 2.5, 2.9 and 3