Pitch: Span adoption in FileDescriptor I/O

Hello, I'd like to pitch adding Span family of overloads to read/write operations on FileDescriptor. You can see the gist here

Span-based File I/O for Swift System

Introduction

We propose adding Span-based APIs to Swift System's FileDescriptor type for reading and writing data. These new APIs complement the existing UnsafeRawBufferPointer-based methods, providing memory-safe alternatives.

Note: Because this is the first time we are revisiting old API to adopt Span types, we are writing this up as a proposal and posting on the forums. This proposal establishes some of the conventions and thinking regarding Span types and their adoption. Going forwards, Span adoption following similar patterns can generally be reviewed in PR.

Motivation

Swift System's existing file I/O methods predate Span and thus use the UnsafeRawBufferPointer family of types for borrowed views into contiguous memory. These types are unsafe and do not track partial initialization (e.g. for partial reads/writes).

Swift 6.2 introduces the Span family of types, which provide safe alternatives. For example, reading a chunk and processing it changes from:

let chunk = try [UInt8](unsafeUninitializedCapacity: 4096) { buf, count in
    let rawBuf = UnsafeMutableRawBufferPointer(buf)
    count = try fd.read(into: rawBuf)
}
process(chunk)

to:

let chunk = try [UInt8](unsafeUninitializedCapacity: 4096) { buf, count in
    var output = OutputRawSpan(
        buffer: UnsafeMutableRawBufferPointer(buf), initializedCount: 0)
    try fd.read(into: &output)
    count = output.byteCount
}
process(chunk)

Note that this particular example still requires constructing the OutputRawSpan manually due gaps in stdlib API. When the stdlib gains better conversions between typed and raw memory spans and better raw memory inits, this example will read more like:

// Note: Requires future stdlib Array init addition
let chunk = try [UInt8](rawCapacity: 4096) { buffer in
    try fd.read(into: &buffer)
}
process(chunk)

Proposed Solution

We propose adding Span-based overloads to FileDescriptor that mirror the existing unsafe pointer APIs:

  • write(_: RawSpan)
  • write(toAbsoluteOffset:_: RawSpan)
  • writeAll(_: RawSpan)
  • writeAll(toAbsoluteOffset:_: RawSpan)
  • read(into: inout OutputRawSpan)
  • read(fromAbsoluteOffset: Int64, into: inout OutputRawSpan)

Write operations take RawSpan (read-only bytes). Read operations take inout OutputRawSpan, which tracks how many bytes have been initialized, making it a natural fit for partial reads.

We also propose new convenience methods which provide analogues to writeAll:

  • read(filling: inout OutputRawSpan)
  • read(fromAbsoluteOffset: Int64, filling: inout OutputRawSpan)

These loop until the buffer is full (or EOF), handling partial reads automatically.

Note: These additions require a Swift 6.2 compiler.

Detailed Design

Write Operations

extension FileDescriptor {
    /// Writes the contents of a buffer at the current file offset.
    ///
    /// - Parameters:
    ///   - data: The region of memory that contains the data being written.
    ///   - retryOnInterrupt: Whether to retry the write operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were written.
    ///
    /// After writing,
    /// this method increments the file's offset by the number of bytes written.
    /// To change the file's offset,
    /// call the ``seek(offset:from:)`` method.
    ///
    /// The corresponding C function is `write`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    public func write(
        _ data: RawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
    
    /// Writes the contents of a buffer at the specified offset.
    ///
    /// - Parameters:
    ///   - offset: The file offset where writing begins.
    ///   - data: The region of memory that contains the data being written.
    ///   - retryOnInterrupt: Whether to retry the write operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were written.
    ///
    /// Unlike ``write(_:retryOnInterrupt:)``,
    /// this method leaves the file's existing offset unchanged.
    ///
    /// The corresponding C function is `pwrite`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    public func write(
        toAbsoluteOffset offset: Int64,
        _ data: RawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
    
    /// Writes the entire contents of a buffer, retrying on partial writes.
    ///
    /// - Parameters:
    ///   - data: The region of memory that contains the data being written.
    ///   - retryOnInterrupt: Whether to retry the write operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were written.
    ///
    /// After writing,
    /// this method increments the file's offset by the number of bytes written.
    /// To change the file's offset,
    /// call the ``seek(offset:from:)`` method.
    ///
    /// The corresponding C function is `write`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    public func writeAll(
        _ data: RawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
    
    /// Writes the entire contents of a buffer at the specified offset,
    /// retrying on partial writes.
    ///
    /// - Parameters:
    ///   - offset: The file offset where writing begins.
    ///   - data: The region of memory that contains the data being written.
    ///   - retryOnInterrupt: Whether to retry the write operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were written.
    ///
    /// Unlike ``writeAll(_:retryOnInterrupt:)``,
    /// this method leaves the file's existing offset unchanged.
    ///
    /// The corresponding C function is `pwrite`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    public func writeAll(
        toAbsoluteOffset offset: Int64,
        _ data: RawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
}

Read Operations

Read operations use OutputRawSpan, which tracks both the capacity of the buffer and how many bytes have been initialized.

extension FileDescriptor {
    /// Reads bytes at the current file offset into a buffer.
    ///
    /// - Parameters:
    ///   - buffer: The region of memory to read into.
    ///   - retryOnInterrupt: Whether to retry the read operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were read.
    ///
    /// After reading,
    /// this method increments the file's offset by the number of bytes read.
    /// To change the file's offset,
    /// call the ``seek(offset:from:)`` method.
    ///
    /// The corresponding C function is `read`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    public func read(
        into buffer: inout OutputRawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
    
    /// Reads bytes at the specified offset into a buffer.
    ///
    /// - Parameters:
    ///   - offset: The file offset where reading begins.
    ///   - buffer: The region of memory to read into.
    ///   - retryOnInterrupt: Whether to retry the read operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were read.
    ///
    /// Unlike ``read(into:retryOnInterrupt:)``,
    /// this method leaves the file's existing offset unchanged.
    ///
    /// The corresponding C function is `pread`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    public func read(
        fromAbsoluteOffset offset: Int64,
        into buffer: inout OutputRawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
}

Looping Read Operations

The read(filling:) methods loop until the buffer is completely filled (or EOF), handling partial reads automatically. This is the read-side analog to writeAll.

extension FileDescriptor {
    /// Reads bytes into a buffer, retrying until it is full.
    ///
    /// - Parameters:
    ///   - buffer: The region of memory to read into.
    ///   - retryOnInterrupt: Whether to retry the read operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were read.
    ///
    /// After reading,
    /// this method increments the file's offset by the number of bytes read.
    /// To change the file's offset,
    /// call the ``seek(offset:from:)`` method.
    ///
    /// The corresponding C function is `read`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    @discardableResult
    public func read(
        filling buffer: inout OutputRawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
    
    /// Reads bytes at the specified offset into a buffer,
    /// retrying until it is full.
    ///
    /// - Parameters:
    ///   - offset: The file offset where reading begins.
    ///   - buffer: The region of memory to read into.
    ///   - retryOnInterrupt: Whether to retry the read operation
    ///     if it throws ``Errno/interrupted``.
    ///     The default is `true`.
    ///     Pass `false` to try only once and throw an error upon interruption.
    /// - Returns: The number of bytes that were read.
    ///
    /// Unlike ``read(filling:retryOnInterrupt:)``,
    /// this method leaves the file's existing offset unchanged.
    ///
    /// The corresponding C function is `pread`.
    @available(SwiftStdlib 6.2, *)
    @_alwaysEmitIntoClient
    @discardableResult
    public func read(
        fromAbsoluteOffset offset: Int64,
        filling buffer: inout OutputRawSpan,
        retryOnInterrupt: Bool = true
    ) throws(Errno) -> Int
}

Source Compatibility

This proposal is purely additive. The existing UnsafeRawBufferPointer-based methods remain available and unchanged. Existing code continues to compile and function identically.

ABI Compatibility

This proposal is purely additive. All new methods use @_alwaysEmitIntoClient, meaning they are inlined at the call site and do not affect the library's ABI. This enables deployment to older OS versions that support Swift System but predate Swift 6.2.

Implications on Adoption

Adopting the new Span-based APIs requires:

  • Swift 6.2 or later (for Span types)
  • No minimum OS deployment target changes (due to @_alwaysEmitIntoClient)

Existing code using the unsafe APIs can migrate incrementally; both API styles can coexist.

Alternatives Considered

Deprecate the Unsafe Pointer APIs

We considered deprecating the existing UnsafeRawBufferPointer-based methods in favor of the new Span APIs. Since Swift System targets low-level systems programming where C interoperability and direct pointer manipulation are common, we retain the unsafe APIs for now.

This proposal is the first instance of updating existing System API for Span, so it sets precedent. We are keeping the pre-existing UnsafeRawBufferPointer overloads rather than deprecating them. This does not set a precedent of adding UnsafeRawBufferPointer overloads for new API going forward; new API should prefer Span.

We may revisit deprecation in the future, pending investigation into how easily code can migrate between the two interfaces.

Add OutputSpan<UInt8> Overloads

We considered providing OutputSpan<UInt8> overloads in addition to OutputRawSpan. This would allow callers using standard library APIs like Array.init(capacity:initializingWith:) (which provides an OutputSpan<Element>) to pass the buffer directly without conversion.

However, there is currently no direct conversion path from OutputSpan<UInt8> to OutputRawSpan. Rather than work around this limitation by doubling the API surface, we encourage the standard library and language to provide better interconversion between typed and raw span types. File I/O at the System level operates below concerns like memory layout and endianness; we are simply moving bytes. The raw span types are the natural fit for this use case.

For write operations, Span<UInt8> already has the .bytes property to obtain a RawSpan, so no additional overloads are needed there.

Add MutableRawSpan overloads for read operations

We use OutputRawSpan rather than MutableRawSpan for read operations because it better models the semantics of reading. Reads should always be treated as partial (due to EOF) and OutputRawSpan models this.

A MutableRawSpan represents fully initialized memory. In the event of a partial fill, the span would still claim its full size is valid, requiring careful bookkeeping and checking. This is an error-prone pattern common in C buffer APIs that OutputRawSpan explicitly solves.

Naming: read(filling:) vs readAll

We chose read(filling:) over readAll because it describes the operation more accurately: reading until the provided buffer is completely filled. The name readAll suggests reading all of the file, not filling all of the buffer.

Returning Count vs Throwing on EOF

read(filling:) returns the byte count rather than throwing on EOF because EOF is not an error. The return value and the buffer's initialized count provide redundant but consistent information: callers can use whichever is more convenient. To check for a complete fill, compare the return value against the original free capacity, or check if the buffer is full.

The return value is marked @discardableResult since callers can inspect the buffer instead.

Typed Span Variants

We do not provide typed variants like OutputSpan<T> or Span<T> for arbitrary element types. File I/O operates on raw bytes, and reading or writing multi-byte types requires explicit handling of endianness, even for trivial bitwise-copyable types. General typed variants could be added in a follow-on proposal once ecosystem patterns for byte-level serialization are established.

Future Directions

InputSpan for Write Operations

This proposal reveals an asymmetry in the Span family. For reads, OutputRawSpan tracks how many bytes have been initialized, so partial reads update the span automatically. For writes, RawSpan has no equivalent tracking, so callers must manually slice the buffer after a partial write:

var remaining = data
while remaining.byteCount > 0 {
    let written = try fd.write(remaining)
    remaining = remaining.extracting(droppingFirst: written)
}

An InputSpan type that tracks consumption would complete the symmetry:

// Future direction (not proposed)
var input = InputRawSpan(data)
while input.byteCount > 0 {
    try fd.write(consuming: &input)  // span updates automatically
}

This motivates future work on the Span family.

Vectored I/O (readv/writev)

Vectored I/O operations that accept multiple spans could improve performance for scatter-gather patterns:

// Future direction (not proposed)
extension FileDescriptor {
    func write(gatheringFrom spans: some Sequence<RawSpan>) throws(Errno) -> Int
    func read(scatteringInto spans: inout some MutableCollection<OutputRawSpan>) throws(Errno) -> Int
}

This kind of API is not currently expressible in Swift. Span types are non-escapable and have lifetime dependencies that prevent them from being stored in standard collections or passed as sequences.

Acknowledgments

This proposal builds on the Span family of types designed by Guillaume Lessard and the Swift Standard Library team. The design follows patterns established in the ongoing Swift System evolution.

13 Likes

+1. This seems like a logical and straight forward addition following the ecosystem wide Span adoption.

2 Likes

Nice! This seems to align well with the recent borrowing Iteration proposal. The idea of adopting Span-based overloads in FileDescriptor for safe memory access looks like a natural next step, especially given the context of improving performance and safety in low-level file I/O.

retryOnInterrupt: Bool = true – while this might be good default, in certain cases it might be needed to control retry behavior more explicitly / flexible, or exit after several retries.

Thanks! These seem like good additions.

Did you check whether all of SwiftNIO can be implemented using these and other proposals?

List of SwiftNIO System calls

If it helps, Claude thinks this is about it and I can't find any immediate holes:

A comprehensive list of all system calls made by SwiftNIO, organized by category.

Networking

Syscall Man page
socket socket(2)
bind bind(2)
listen listen(2)
accept accept(2)
accept4 accept4(2) (Linux)
connect connect(2)
shutdown shutdown(2)
setsockopt setsockopt(2)
getsockopt getsockopt(2)
getpeername getpeername(2)
getsockname getsockname(2)
socketpair socketpair(2)
if_nametoindex if_nametoindex(3)

Data Transfer

Syscall Man page
sendmsg sendmsg(2)
recvmsg recvmsg(2)
sendmmsg sendmmsg(2) (Linux)
recvmmsg recvmmsg(2) (Linux)
sendfile sendfile(2) (Linux)

File I/O

Syscall Man page
open open(2)
openat openat(2)
close close(2)
read read(2)
write write(2)
pread pread(2)
pwrite pwrite(2)
writev writev(2)
lseek lseek(2)
ftruncate ftruncate(2)
dup dup(2)
ioctl ioctl(2)
fcntl fcntl(2)

File Status & Metadata

Syscall Man page
stat stat(2)
lstat lstat(2)
fstat fstat(2)
fchmod fchmod(2)
fsync fsync(2)
futimens futimens(2)

Directory Operations

Syscall Man page
mkdir mkdir(2)
opendir opendir(3)
readdir readdir(3)
closedir closedir(3)
fdopendir fdopendir(3)

File System Manipulation

Syscall Man page
unlink unlink(2)
rename rename(2)
renameat2 renameat2(2) (Linux)
renamex_np renamex_np(2) (Darwin)
symlink symlink(2)
readlink readlink(2)
link link(2)
linkat linkat(2) (Linux)
remove remove(3)

Extended Attributes

Syscall Man page
flistxattr flistxattr(2)
fgetxattr fgetxattr(2)
fsetxattr fsetxattr(2)
fremovexattr fremovexattr(2)

I/O Multiplexing & Event Notification

Syscall Man page
poll poll(2)
epoll_create epoll_create(2) (Linux)
epoll_ctl epoll_ctl(2) (Linux)
epoll_wait epoll_wait(2) (Linux)
kqueue kqueue(2) (Darwin/BSD)
kevent kevent(2) (Darwin/BSD)
eventfd eventfd(2) (Linux)
eventfd_read eventfd_read(3) (Linux)
eventfd_write eventfd_write(3) (Linux)
timerfd_create timerfd_create(2) (Linux)
timerfd_settime timerfd_settime(2) (Linux)

io_uring (Linux)

Syscall Man page
io_uring_queue_init io_uring_queue_init(3)
io_uring_queue_exit io_uring_queue_exit(3)
io_uring_get_sqe io_uring_get_sqe(3)
io_uring_submit io_uring_submit(3)
io_uring_wait_cqe io_uring_wait_cqe(3)
io_uring_wait_cqe_timeout io_uring_wait_cqe_timeout(3)
io_uring_peek_batch_cqe io_uring_peek_batch_cqe(3)
io_uring_cqe_seen io_uring_cqe_seen(3)

File Copying (Darwin)

Syscall Man page
copyfile copyfile(3)
fcopyfile fcopyfile(3)

Directory Tree Traversal

Syscall Man page
fts_open fts_open(3)
fts_read fts_read(3)
fts_close fts_close(3)

DNS / Host Resolution

Syscall Man page
getaddrinfo getaddrinfo(3)
getnameinfo getnameinfo(3)

Process & User

Syscall Man page
getuid getuid(2)
getpwuid_r getpwuid_r(3)
getcwd getcwd(3)
confstr confstr(3)

Darwin-only Extensions

Syscall Man page
mkpath_np mkpath_np(3) (Darwin)

Total: 85 unique system calls across networking, file I/O, event notification, io_uring, file system operations, and platform-specific extensions.

Key wrapper files:

  • Sources/NIOPosix/System.swift — Main POSIX syscall wrappers
  • Sources/NIOPosix/Linux.swift — Linux-specific syscalls (epoll, eventfd, timerfd, io_uring)
  • Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift — File system syscalls
  • Sources/NIOPosix/LinuxUring.swift — io_uring implementation

This pitch doesn't add any new syscalls, just Span-family taking overloads. You may be referring to the doc I posted in the other thread.

I ran a similar research task (with a few rounds of refinement) resulting in Swift System API Gaps for SwiftNIO Support. Gives us a good back-of-the-envelope idea.

edit: as to your question of whether all of these can be covered by System, my current understanding is that yes, they can, though I haven't looked into every last detail here yet (e.g. setting pthread affinity). For what's covered by the current main, and near-term slew of pitches, that's in this table. Clearly event processing is the next big area and ties directly into sockets. Everything else that SwiftNIO uses definitely looks like something that we want to support or could support.

1 Like

This may already be the case, but I would suggest that, where possible, the new overloads should have distinct arity and argument labels from the existing overloads. This minimizes the risk that introducing those overloads will break source compatibility, either by introducing ambiguity in a situation where there was none before, or a "cannot be type checked in reasonable time" if an existing expression was already close to the complexity limit, and now there are more choices to consider than before.

2 Likes