From eec7ecdde9568d9d9e1e38095e9b7e127a64a7e6 Mon Sep 17 00:00:00 2001 From: Michael Ilseman Date: Fri, 23 Jan 2026 06:30:32 -0700 Subject: [PATCH 1/3] Add Span-based file I/O APIs Implement read/write methods accepting RawSpan and OutputRawSpan, with support for absolute offset operations and fill-until-complete semantics. Refactor buffer handling into reusable helpers. --- Sources/System/FileHelpers.swift | 194 ++++++++-- Sources/System/FileOperations.swift | 156 ++++++++ Tests/SystemTests/SpanFileIOTests.swift | 463 ++++++++++++++++++++++++ 3 files changed, 791 insertions(+), 22 deletions(-) create mode 100644 Tests/SystemTests/SpanFileIOTests.swift diff --git a/Sources/System/FileHelpers.swift b/Sources/System/FileHelpers.swift index 5b082766..6f982689 100644 --- a/Sources/System/FileHelpers.swift +++ b/Sources/System/FileHelpers.swift @@ -62,18 +62,25 @@ extension FileDescriptor { _ sequence: S ) -> Result where S.Element == UInt8 { sequence._withRawBufferPointer { buffer in - var idx = 0 - while idx < buffer.count { - switch _write( - UnsafeRawBufferPointer(rebasing: buffer[idx...]), retryOnInterrupt: true - ) { - case .success(let numBytes): idx += numBytes - case .failure(let err): return .failure(err) - } + _writeAllBuffer(buffer) + } + } + + @_alwaysEmitIntoClient + internal func _writeAllBuffer( + _ buffer: UnsafeRawBufferPointer + ) -> Result { + var idx = 0 + while idx < buffer.count { + switch _write( + UnsafeRawBufferPointer(rebasing: buffer[idx...]), retryOnInterrupt: true + ) { + case .success(let numBytes): idx += numBytes + case .failure(let err): return .failure(err) } - assert(idx == buffer.count) - return .success(buffer.count) } + assert(idx == buffer.count) + return .success(buffer.count) } /// Writes a sequence of bytes to the given offset. @@ -104,19 +111,162 @@ extension FileDescriptor { toAbsoluteOffset offset: Int64, _ sequence: S ) -> Result where S.Element == UInt8 { sequence._withRawBufferPointer { buffer in - var idx = 0 - while idx < buffer.count { - switch _write( - toAbsoluteOffset: offset + Int64(idx), - UnsafeRawBufferPointer(rebasing: buffer[idx...]), - retryOnInterrupt: true - ) { - case .success(let numBytes): idx += numBytes - case .failure(let err): return .failure(err) - } + _writeAllBuffer(toAbsoluteOffset: offset, buffer) + } + } + + @_alwaysEmitIntoClient + internal func _writeAllBuffer( + toAbsoluteOffset offset: Int64, _ buffer: UnsafeRawBufferPointer + ) -> Result { + var idx = 0 + while idx < buffer.count { + switch _write( + toAbsoluteOffset: offset + Int64(idx), + UnsafeRawBufferPointer(rebasing: buffer[idx...]), + retryOnInterrupt: true + ) { + case .success(let numBytes): idx += numBytes + case .failure(let err): return .failure(err) + } + } + assert(idx == buffer.count) + return .success(buffer.count) + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + public func writeAll( + _ data: RawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + do { + return try data.withUnsafeBytes { buffer in + try _writeAllBuffer(buffer).get() + } + } catch let error as Errno { + throw error + } catch { + fatalError("Unexpected error type") + } + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + public func writeAll( + toAbsoluteOffset offset: Int64, + _ data: RawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + do { + return try data.withUnsafeBytes { buffer in + try _writeAllBuffer(toAbsoluteOffset: offset, buffer).get() + } + } catch let error as Errno { + throw error + } catch { + fatalError("Unexpected error type") + } + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + @discardableResult + public func read( + filling buffer: inout OutputRawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + let originalCapacity = buffer.freeCapacity + while buffer.freeCapacity > 0 { + let bytesRead = try read(into: &buffer, retryOnInterrupt: retryOnInterrupt) + if bytesRead == 0 { + break // EOF + } + } + return originalCapacity - buffer.freeCapacity + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + @discardableResult + public func read( + fromAbsoluteOffset offset: Int64, + filling buffer: inout OutputRawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + let originalCapacity = buffer.freeCapacity + var currentOffset = offset + while buffer.freeCapacity > 0 { + let bytesRead = try read(fromAbsoluteOffset: currentOffset, into: &buffer, retryOnInterrupt: retryOnInterrupt) + if bytesRead == 0 { + break // EOF } - assert(idx == buffer.count) - return .success(buffer.count) + currentOffset += Int64(bytesRead) } + return originalCapacity - buffer.freeCapacity } } diff --git a/Sources/System/FileOperations.swift b/Sources/System/FileOperations.swift index 9ddc16c3..c0b72530 100644 --- a/Sources/System/FileOperations.swift +++ b/Sources/System/FileOperations.swift @@ -365,6 +365,162 @@ extension FileDescriptor { buffer, retryOnInterrupt: retryOnInterrupt) } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + public func write( + _ data: RawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + do { + return try data.withUnsafeBytes { bytes in + try write(bytes, retryOnInterrupt: retryOnInterrupt) + } + } catch let error as Errno { + throw error + } catch { + fatalError("Unexpected error type") + } + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + public func write( + toAbsoluteOffset offset: Int64, + _ data: RawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + do { + return try data.withUnsafeBytes { bytes in + try write(toAbsoluteOffset: offset, bytes, retryOnInterrupt: retryOnInterrupt) + } + } catch let error as Errno { + throw error + } catch { + fatalError("Unexpected error type") + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + internal func _readIntoOutputBuffer( + _ buffer: UnsafeMutableRawBufferPointer, + initializedCount: inout Int, + retryOnInterrupt: Bool + ) -> Result { + // Read into the uninitialized portion (starting at offset 'initializedCount') + let uninitializedPortion = UnsafeMutableRawBufferPointer( + start: buffer.baseAddress?.advanced(by: initializedCount), + count: buffer.count - initializedCount + ) + do { + let result = try _read(into: uninitializedPortion, retryOnInterrupt: retryOnInterrupt) + if case .success(let bytesRead) = result { + initializedCount += bytesRead // Add to existing count, don't replace it! + } + return result + } catch { + fatalError("Unexpected error from _read") + } + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + public func read( + into buffer: inout OutputRawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + try buffer.withUnsafeMutableBytes { buf, count throws(Errno) -> Int in + try _readIntoOutputBuffer(buf, initializedCount: &count, retryOnInterrupt: retryOnInterrupt).get() + } + } + + /// 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(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @_alwaysEmitIntoClient + public func read( + fromAbsoluteOffset offset: Int64, + into buffer: inout OutputRawSpan, + retryOnInterrupt: Bool = true + ) throws(Errno) -> Int { + do { + return try buffer.withUnsafeMutableBytes { buf, count in + // Read into the uninitialized portion (starting at offset 'count') + let uninitializedPortion = UnsafeMutableRawBufferPointer( + start: buf.baseAddress?.advanced(by: count), + count: buf.count - count + ) + let bytesRead = try read(fromAbsoluteOffset: offset, into: uninitializedPortion, retryOnInterrupt: retryOnInterrupt) + count += bytesRead // Add to existing count, don't replace it! + return bytesRead + } + } catch let error as Errno { + throw error + } catch { + fatalError("Unexpected error type") + } + } } #if !os(WASI) diff --git a/Tests/SystemTests/SpanFileIOTests.swift b/Tests/SystemTests/SpanFileIOTests.swift new file mode 100644 index 00000000..d94a29a2 --- /dev/null +++ b/Tests/SystemTests/SpanFileIOTests.swift @@ -0,0 +1,463 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift System open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift System project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Testing + +#if SYSTEM_PACKAGE +@testable import SystemPackage +#else +@testable import System +#endif + +@Suite("Span-based File I/O") +private struct SpanFileIOTests { + + // MARK: - Basic Functionality + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func basicReadWriteOperations() async throws { + try withTemporaryFilePath(basename: "basicReadWriteOperations") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + // Test write(_:) + let data1: [UInt8] = [0x61, 0x62, 0x63] // "abc" + let written1 = try data1.withUnsafeBytes { bytes in + try fd.write(RawSpan(_unsafeBytes: bytes)) + } + #expect(written1 == 3) + + // Test write(toAbsoluteOffset:_:) + let data2: [UInt8] = [0x64, 0x65, 0x66] // "def" + let written2 = try data2.withUnsafeBytes { bytes in + try fd.write(toAbsoluteOffset: 3, RawSpan(_unsafeBytes: bytes)) + } + #expect(written2 == 3) + + // Test writeAll(_:) + try fd.seek(offset: 6, from: .start) + let data3: [UInt8] = [0x67, 0x68, 0x69] // "ghi" + let written3 = try data3.withUnsafeBytes { bytes in + try fd.writeAll(RawSpan(_unsafeBytes: bytes)) + } + #expect(written3 == 3) + + // Test writeAll(toAbsoluteOffset:_:) + let data4: [UInt8] = [0x6A, 0x6B, 0x6C] // "jkl" + let written4 = try data4.withUnsafeBytes { bytes in + try fd.writeAll(toAbsoluteOffset: 9, RawSpan(_unsafeBytes: bytes)) + } + #expect(written4 == 3) + + // Test read(into:) + try fd.seek(offset: 0, from: .start) + let result1 = try [UInt8](unsafeUninitializedCapacity: 12) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + _ = try fd.read(into: &output) + count = output.byteCount + } + #expect(result1.count <= 12) // May be partial + + // Test read(filling:) + try fd.seek(offset: 0, from: .start) + let result2 = try [UInt8](unsafeUninitializedCapacity: 12) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(filling: &output) + } + #expect(result2 == data1 + data2 + data3 + data4) + + // Test read(fromAbsoluteOffset:into:) + let result3 = try [UInt8](unsafeUninitializedCapacity: 3) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + _ = try fd.read(fromAbsoluteOffset: 3, into: &output) + count = output.byteCount + } + #expect(result3.count <= 3) // May be partial + + // Test read(fromAbsoluteOffset:filling:) + let result4 = try [UInt8](unsafeUninitializedCapacity: 3) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(fromAbsoluteOffset: 6, filling: &output) + } + #expect(result4 == data3) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func writeOperations() async throws { + try withTemporaryFilePath(basename: "writeOperations") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + // Small write + let small: [UInt8] = [0x61, 0x62, 0x63] + _ = try small.withUnsafeBytes { bytes in + try fd.writeAll(RawSpan(_unsafeBytes: bytes)) + } + + // Large write + let large: [UInt8] = Array((0..<1024).map { UInt8($0 % 256) }) + _ = try large.withUnsafeBytes { bytes in + try fd.writeAll(RawSpan(_unsafeBytes: bytes)) + } + + // Verify size + let size = try fd.seek(offset: 0, from: .end) + #expect(size == 3 + 1024) + + // Verify content + try fd.seek(offset: 0, from: .start) + let readBack = try [UInt8](unsafeUninitializedCapacity: 3 + 1024) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(filling: &output) + } + #expect(readBack == small + large) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func readOperations() async throws { + try withTemporaryFilePath(basename: "readOperations") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + let data: [UInt8] = Array((0..<1024).map { UInt8($0 % 256) }) + try fd.writeAll(data) + try fd.seek(offset: 0, from: .start) + + // Small read + let small = try [UInt8](unsafeUninitializedCapacity: 10) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(filling: &output) + } + #expect(small == Array(0..<10)) + + // Large read + try fd.seek(offset: 0, from: .start) + let large = try [UInt8](unsafeUninitializedCapacity: 1024) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(filling: &output) + } + #expect(large == data) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func absoluteOffsetOperations() async throws { + try withTemporaryFilePath(basename: "absoluteOffsetOperations") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + let data: [UInt8] = Array(0..<100) + try fd.writeAll(data) + + // Write at absolute offset should not change file position + let initialOffset = try fd.seek(offset: 0, from: .current) + let patchData: [UInt8] = [0xFF, 0xFE] + _ = try patchData.withUnsafeBytes { bytes in + try fd.writeAll(toAbsoluteOffset: 50, RawSpan(_unsafeBytes: bytes)) + } + let afterWriteOffset = try fd.seek(offset: 0, from: .current) + #expect(initialOffset == afterWriteOffset) + + // Read at absolute offset should not change file position + try fd.seek(offset: 10, from: .start) + let readAtOffset = try [UInt8](unsafeUninitializedCapacity: 2) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(fromAbsoluteOffset: 50, filling: &output) + } + #expect(readAtOffset == patchData) + let afterReadOffset = try fd.seek(offset: 0, from: .current) + #expect(afterReadOffset == 10) // Should still be at 10 + } + } + } + + // MARK: - Semantic Behavior + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func partialReadBehavior() async throws { + try withTemporaryFilePath(basename: "partialReadBehavior") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + let data: [UInt8] = Array(0..<100) + try fd.writeAll(data) + try fd.seek(offset: 0, from: .start) + + // read(into:) may return partial data - this is valid behavior + let output = UnsafeMutableRawBufferPointer.allocate(byteCount: 100, alignment: 1) + defer { output.deallocate() } + var span = OutputRawSpan(buffer: output, initializedCount: 0) + + var totalRead = 0 + while span.freeCapacity > 0 { + let bytesRead = try fd.read(into: &span) + if bytesRead == 0 { break } // EOF + totalRead += bytesRead + #expect(bytesRead > 0) + #expect(bytesRead <= span.freeCapacity + bytesRead) // Can't read more than capacity + } + + // Should eventually get all data with multiple reads + #expect(totalRead == 100) + #expect(span.byteCount == 100) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func readFillingBehavior() async throws { + try withTemporaryFilePath(basename: "readFillingBehavior") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + // Test 1: read(filling:) fills buffer completely when data available + let fullData: [UInt8] = Array(0..<100) + try fd.writeAll(fullData) + try fd.seek(offset: 0, from: .start) + + let result1 = try [UInt8](unsafeUninitializedCapacity: 100) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + let bytesRead = try fd.read(filling: &output) + #expect(bytesRead == 100) + #expect(output.freeCapacity == 0) // Should be completely full + count = output.byteCount + } + #expect(result1 == fullData) + + // Test 2: read(filling:) stops at EOF even if buffer not full + try fd.seek(offset: 0, from: .start) + let result2 = try [UInt8](unsafeUninitializedCapacity: 200) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + let bytesRead = try fd.read(filling: &output) + #expect(bytesRead == 100) // Only 100 bytes available + #expect(output.freeCapacity == 100) // 100 bytes unfilled + count = output.byteCount + } + #expect(result2 == fullData) + } + } + } + + // MARK: - Edge Cases + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func emptyIO() async throws { + try withTemporaryFilePath(basename: "emptyIO") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + // Empty write + let empty: [UInt8] = [] + let written = try empty.withUnsafeBytes { bytes in + try fd.writeAll(RawSpan(_unsafeBytes: bytes)) + } + #expect(written == 0) + + // Empty read (from empty file) + let result1 = try [UInt8](unsafeUninitializedCapacity: 10) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(into: &output) + } + #expect(result1.isEmpty) + + // Zero-capacity buffer + let data: [UInt8] = [0x61, 0x62, 0x63] + try fd.writeAll(data) + try fd.seek(offset: 0, from: .start) + + let result2 = try [UInt8](unsafeUninitializedCapacity: 0) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(into: &output) + } + #expect(result2.isEmpty) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func eofBehavior() async throws { + try withTemporaryFilePath(basename: "eofBehavior") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + let data: [UInt8] = [0x61, 0x62, 0x63] + try fd.writeAll(data) + try fd.seek(offset: 0, from: .start) + + // Read all data + let result1 = try [UInt8](unsafeUninitializedCapacity: 3) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(filling: &output) + } + #expect(result1 == data) + + // Read at EOF returns 0 + let result2 = try [UInt8](unsafeUninitializedCapacity: 10) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(into: &output) + } + #expect(result2.isEmpty) + + // read(filling:) with buffer larger than file stops at EOF + try fd.seek(offset: 0, from: .start) + let result3 = try [UInt8](unsafeUninitializedCapacity: 10) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + let bytesRead = try fd.read(filling: &output) + #expect(bytesRead == 3) // Only 3 bytes available + count = bytesRead + } + #expect(result3 == data) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func preInitializedSpan() async throws { + try withTemporaryFilePath(basename: "preInitializedSpan") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + let fileData: [UInt8] = [0x61, 0x62, 0x63] // "abc" + try fd.writeAll(fileData) + try fd.seek(offset: 0, from: .start) + + // Create buffer with pre-existing initialized data + let result = try [UInt8](unsafeUninitializedCapacity: 10) { buffer, count in + // Pre-initialize first 2 bytes + buffer[0] = 0xFF + buffer[1] = 0xFE + + // Create OutputRawSpan that knows about pre-initialized portion + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 2) + + // Read more data - should append after pre-initialized data + let bytesRead = try fd.read(filling: &output) + #expect(bytesRead == 3) // Read "abc" + #expect(output.byteCount == 5) // 2 pre-initialized + 3 read = 5 total + + count = output.byteCount + } + + // Verify: first 2 bytes are pre-initialized, next 3 are from file + #expect(result == [0xFF, 0xFE, 0x61, 0x62, 0x63]) + } + } + } + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func errorHandling() throws { + // Test 1: Reading from closed FD + let fd1 = try FileDescriptor.open(FilePath("/dev/null"), .readOnly) + try fd1.close() + + #expect(throws: Errno.badFileDescriptor) { + let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 10, alignment: 1) + defer { buffer.deallocate() } + var output = OutputRawSpan(buffer: buffer, initializedCount: 0) + _ = try fd1.read(into: &output) + } + + // Test 2: Writing to closed FD + let fd2 = try FileDescriptor.open(FilePath("/dev/null"), .writeOnly) + try fd2.close() + + let data: [UInt8] = [0x61, 0x62, 0x63] + #expect(throws: Errno.badFileDescriptor) { + try data.withUnsafeBytes { bytes in + try fd2.write(RawSpan(_unsafeBytes: bytes)) + } + } + + // Test 3: Writing to read-only FD + try withTemporaryFilePath(basename: "errorHandling") { path in + // Create file first + let fdWrite = try FileDescriptor.open( + path.appending("test.txt"), .writeOnly, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + _ = try fdWrite.closeAfter { + try fdWrite.writeAll(data) + } + + // Open read-only and try to write + let fdRead = try FileDescriptor.open( + path.appending("test.txt"), .readOnly + ) + _ = try fdRead.closeAfter { + #expect(throws: Errno.badFileDescriptor) { + try data.withUnsafeBytes { bytes in + try fdRead.write(RawSpan(_unsafeBytes: bytes)) + } + } + } + } + } + + // MARK: - Regression/Examples + + @available(macOS 15, iOS 18, watchOS 11, tvOS 18, visionOS 2, *) + @Test func proposalExample() async throws { + // Example from the proposal + try withTemporaryFilePath(basename: "proposalExample") { path in + let fd = try FileDescriptor.open( + path.appending("test.txt"), .readWrite, + options: [.create, .truncate], + permissions: .ownerReadWrite + ) + try fd.closeAfter { + let writeData: [UInt8] = Array(repeating: 42, count: 4096) + try fd.writeAll(writeData) + try fd.seek(offset: 0, from: .start) + + let chunk = try [UInt8](unsafeUninitializedCapacity: 4096) { buffer, count in + var output = OutputRawSpan(buffer: UnsafeMutableRawBufferPointer(buffer), initializedCount: 0) + count = try fd.read(filling: &output, retryOnInterrupt: true) + } + + #expect(chunk.count == 4096) + #expect(chunk == writeData) + } + } + } +} From 44ef20a1d858bbe137fbf95a0f587a06a64ab8b0 Mon Sep 17 00:00:00 2001 From: Michael Ilseman Date: Fri, 23 Jan 2026 06:46:29 -0700 Subject: [PATCH 2/3] wip: cleanup --- Sources/System/FileOperations.swift | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/Sources/System/FileOperations.swift b/Sources/System/FileOperations.swift index c0b72530..539a15f2 100644 --- a/Sources/System/FileOperations.swift +++ b/Sources/System/FileOperations.swift @@ -388,14 +388,8 @@ extension FileDescriptor { _ data: RawSpan, retryOnInterrupt: Bool = true ) throws(Errno) -> Int { - do { - return try data.withUnsafeBytes { bytes in - try write(bytes, retryOnInterrupt: retryOnInterrupt) - } - } catch let error as Errno { - throw error - } catch { - fatalError("Unexpected error type") + try data.withUnsafeBytes { bytes throws(Errno) -> Int in + try _write(bytes, retryOnInterrupt: retryOnInterrupt).get() } } @@ -421,14 +415,8 @@ extension FileDescriptor { _ data: RawSpan, retryOnInterrupt: Bool = true ) throws(Errno) -> Int { - do { - return try data.withUnsafeBytes { bytes in - try write(toAbsoluteOffset: offset, bytes, retryOnInterrupt: retryOnInterrupt) - } - } catch let error as Errno { - throw error - } catch { - fatalError("Unexpected error type") + try data.withUnsafeBytes { bytes throws(Errno) -> Int in + try _write(toAbsoluteOffset: offset, bytes, retryOnInterrupt: retryOnInterrupt).get() } } From cb614dc013dfb5a52dca0d0c485b921d42f3cc52 Mon Sep 17 00:00:00 2001 From: Michael Ilseman Date: Fri, 23 Jan 2026 06:49:07 -0700 Subject: [PATCH 3/3] wip: cleanup --- Sources/System/FileHelpers.swift | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/Sources/System/FileHelpers.swift b/Sources/System/FileHelpers.swift index 6f982689..4de4ef12 100644 --- a/Sources/System/FileHelpers.swift +++ b/Sources/System/FileHelpers.swift @@ -156,14 +156,8 @@ extension FileDescriptor { _ data: RawSpan, retryOnInterrupt: Bool = true ) throws(Errno) -> Int { - do { - return try data.withUnsafeBytes { buffer in - try _writeAllBuffer(buffer).get() - } - } catch let error as Errno { - throw error - } catch { - fatalError("Unexpected error type") + try data.withUnsafeBytes { buffer throws(Errno) -> Int in + try _writeAllBuffer(buffer).get() } } @@ -190,14 +184,8 @@ extension FileDescriptor { _ data: RawSpan, retryOnInterrupt: Bool = true ) throws(Errno) -> Int { - do { - return try data.withUnsafeBytes { buffer in - try _writeAllBuffer(toAbsoluteOffset: offset, buffer).get() - } - } catch let error as Errno { - throw error - } catch { - fatalError("Unexpected error type") + try data.withUnsafeBytes { buffer throws(Errno) -> Int in + try _writeAllBuffer(toAbsoluteOffset: offset, buffer).get() } }