Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

FileHandle Streaming I/O on macOS: Bounded Reads, EOF, and Ownership

Stream files and pipes with FileHandle using bounded reads, explicit descriptor ownership, partial-input parsing, and deterministic error cleanup.

FileHandle wraps a file descriptor and can access files, pipes, sockets, and devices. Its synchronous methods are simple, but a naive readToEnd() can load an unbounded input into memory, a pipe read can block waiting for data, and descriptor ownership can close a resource earlier or later than the code expects. Treat file I/O as a bounded stream with explicit lifetime, error handling, and a parser that tolerates chunk boundaries.

For a regular file, a read starts at the current file pointer and advances it. A read up to a requested count may return fewer bytes when fewer are available, and an empty Data indicates end of file. For a communications channel, the same empty data signals an end-of-file condition from the channel. A data chunk is not necessarily a logical record, line, or UTF-8 string.

Read in bounded chunks

Use read(upToCount:) in a loop when the input may be large. Feed each chunk to a parser or streaming consumer rather than accumulating every chunk in one Data value. Bound the consumer’s own buffering too; a fixed-size read does not help if the parser retains the entire file.

import Foundation

func streamFile(
    at url: URL,
    consume: (Data) throws -> Void
) throws {
    let handle = try FileHandle(forReadingFrom: url)
    defer { try? handle.close() }

    while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
        try consume(chunk)
    }
}

This sample uses a 64 KiB requested chunk as an example, not a universal optimum. The actual amount returned can be smaller. The consume closure must preserve state between calls if it parses records or text, and it must throw when the input violates the application’s format. A defer closes the handle on normal return and thrown error; the ignored close error is a deliberate simplification for this read-only example, not a general rule for output finalization.

For line-based protocols, search for delimiter bytes across chunk boundaries. The last bytes of one chunk may contain only the beginning of a multibyte UTF-8 sequence or half of a delimiter. Keep a small carry buffer for the incomplete suffix, enforce a maximum line length, and decode only complete byte sequences. For length-prefixed binary formats, validate that the declared length is within policy before allocating the record buffer.

Own file descriptors deliberately

Many FileHandle creation methods own the descriptor and are responsible for closing it later. If you wrap a descriptor opened elsewhere, select the initializer and closeOnDealloc behavior that matches the ownership contract. Two wrappers that both believe they own one descriptor can close it twice or use it after the other wrapper closes it. If no object owns closure, file descriptors can leak and eventually exhaust the process limit.

Prefer the URL-based throwing initializers for ordinary file access so failure is represented as a Swift error rather than a force-unwrapped optional. Keep the handle within a scoped service object when the operation spans multiple methods. Do not expose a raw fileDescriptor broadly unless an API specifically requires it; every caller that can close or mutate that descriptor expands the ownership problem.

Close input handles when the read is complete. For output handles, decide whether buffered data must be synchronized before close and whether the operation needs durable storage guarantees. synchronize() asks the system to write in-memory data and attributes to permanent storage, but this may be expensive and is not equivalent to a whole-file transactional publish. For a generated artifact, use a temporary file, validate it, then move or replace it as a separate commit step.

Pipes and sockets have different waiting behavior

A file read usually advances through bytes already present on disk; a pipe or socket can wait for another process or peer. Synchronous calls can block the calling thread. Never perform potentially blocking pipe or socket reads on the main thread. If using FileHandle asynchronous notifications for a socket, Apple documents a run-loop requirement: initiate those operations from a thread with an active run loop that processes events.

When coordinating a subprocess, read stdout and stderr concurrently if both can fill. If the parent waits for process exit while the child blocks writing to an unread pipe, neither side can progress. Likewise, do not call readToEnd() on a long-lived stream that has no protocol-level EOF. Define process termination, pipe closure, and cancellation ordering so the reader wakes and releases resources.

The bytes property exposes an asynchronous sequence for reading file contents. Choose an async API when the surrounding architecture already uses structured concurrency, and keep cancellation attached to the task that owns the read. Do not run a blocking read(upToCount:) inside an async function on an executor that must remain responsive unless the workload is explicitly moved to an appropriate blocking-work boundary.

Writes and partial failure

write(contentsOf:) writes synchronously at the current file pointer and advances the pointer by the bytes written. It throws when the descriptor is invalid or closed, the endpoint is an unconnected pipe or socket, disk space is exhausted, or another write failure occurs. Because the method is synchronous, large writes can block. Keep UI work off that path and define how backpressure is handled for pipes and sockets.

Do not assume one write call makes a multi-step file update atomic. If a process crashes halfway through generating a file, the destination may contain a partial artifact. Write to a temporary location, check the close and validation steps, then replace the final destination. If the file belongs to a document coordinated with other processes, use the relevant file coordination API rather than treating a FileHandle as cross-process coordination.

For append workflows, seek to the end deliberately and understand that another process can write between your seek and your write unless coordination or an append-specific contract prevents it. A FileHandle pointer is per descriptor, not an application-wide transaction lock. Avoid concurrent writers to the same file unless the format and synchronization model explicitly support them.

EOF, truncation, and retry semantics

EOF is not an error for a regular file, but it may indicate an incomplete protocol message for a pipe or socket. The parser should distinguish clean completion at a record boundary from an input that ends mid-header or mid-payload. Report truncation with an offset and record context, and preserve the source for diagnosis when safe. Do not silently treat malformed partial input as an empty successful file.

Retrying a read after failure may not restart from the beginning because the file pointer may already have advanced. Capture the offset before retry or reopen the handle when the format and source permit restart. For non-seekable streams, the consumed bytes cannot be recovered from the handle; retain only the bounded prefix needed for a safe parser retry or ask the producer to resend using a protocol-level resume token.

Security and diagnostics

Validate paths and file types before opening untrusted inputs. A URL extension does not prove the file content type. Apply size limits before decoding, avoid following unexpected symlinks when the threat model requires a stable target, and use sandbox access mechanisms for security-scoped URLs where applicable. Never log file contents or full user paths by default.

Useful diagnostics include operation ID, descriptor role, starting offset, bytes consumed, duration, normalized error, and whether EOF was clean. For writes, record the staged destination and final publish outcome without exposing sensitive names. Avoid high-volume per-chunk logs; summarize counts and preserve detailed traces only for controlled debugging.

Acceptance matrix

Test an empty file, a file smaller than one chunk, a file exactly on a chunk boundary, a file larger than several chunks, a malformed length prefix, a multibyte character split between reads, disk full during write, read from a closed descriptor, pipe EOF, blocked writer, cancellation during a read, and a process that fills stderr while stdout is being consumed. Verify bounded memory, exactly one close owner, correct final offset, no partial artifact at the published path, and a safe error for truncated data.

FileHandle gives a convenient object around a descriptor. Reliable streaming still depends on bounded buffers, parser state, blocking behavior, and ownership discipline. Treat EOF and errors as explicit parts of the input format and artifact lifecycle, and test the paths where data stops unexpectedly.

Related:

Sources:

Comments