From c389b64acd0aaf2c02eb30cfe7ab1206912f95ed Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Mon, 8 Sep 2025 18:03:35 +0100 Subject: [PATCH 1/3] Add some spans to ByteBuffer Motivation: Users would like to be able to access the underlying memory of a ByteBuffer, as evidenced by the plethora of `withUnsafe*` methods that ByteBuffer has. As Swift 6.2 has introduced some initial APIs for safe memory access to underlying storage, we should offer similar APIs on ByteBuffer to enable users to get safer access to that storage. For now, the obvious APIs to be able to supplement are: - withUnsafeReadableBytes - withUnsafeMutableReadableBytes - writeWithUnsafeMutableWritableBytes We can also offer some new APIs to allow initializing a buffer directly from an OutputSpan. Note that we can only do this because the Language Steering Group has pinky promised that they will not break the "Lifetimes" experimental feature: see https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 for more details. We are taking them at their word, and so we are enabling that feature. Modifications: Many new methods and tests. Result: Safer access. --- Package.swift | 107 ++++---- Sources/NIOCore/ByteBuffer-aux.swift | 56 +++++ Sources/NIOCore/ByteBuffer-core.swift | 39 +++ Tests/NIOCoreTests/ByteBufferSpanTests.swift | 250 +++++++++++++++++++ 4 files changed, 402 insertions(+), 50 deletions(-) create mode 100644 Tests/NIOCoreTests/ByteBufferSpanTests.swift diff --git a/Package.swift b/Package.swift index 3d5543627e..80e340d73d 100644 --- a/Package.swift +++ b/Package.swift @@ -25,11 +25,18 @@ let historicalNIOPosixDependencyRequired: [Platform] = [.macOS, .iOS, .tvOS, .wa let strictConcurrencyDevelopment = false -let strictConcurrencySettings: [SwiftSetting] = { +// Standard Swift settings we apply to all Swift targets +let standardSwiftSettings: [SwiftSetting] = { var initialSettings: [SwiftSetting] = [] initialSettings.append(contentsOf: [ .enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("InferSendableFromCaptures"), + + // The Language Steering Group has promised that they won't break the APIs that currently exist under + // this "experimental" feature flag without two subsequent releases. We assume they will respect that + // promise, so we enable this here. For more, see: + // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 + .enableExperimentalFeature("Lifetimes"), ]) if strictConcurrencyDevelopment { @@ -83,15 +90,15 @@ let package = Package( swiftCollections, swiftAtomics, ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "_NIODataStructures", - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "_NIOBase64", - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOEmbedded", @@ -102,7 +109,7 @@ let package = Package( swiftAtomics, swiftCollections, ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOPosix", @@ -118,7 +125,7 @@ let package = Package( ], exclude: includePrivacyManifest ? [] : ["PrivacyInfo.xcprivacy"], resources: includePrivacyManifest ? [.copy("PrivacyInfo.xcprivacy")] : [], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIO", @@ -127,7 +134,7 @@ let package = Package( "NIOEmbedded", "NIOPosix", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "_NIOConcurrency", @@ -135,7 +142,7 @@ let package = Package( .target(name: "NIO", condition: .when(platforms: historicalNIOPosixDependencyRequired)), "NIOCore", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOFoundationCompat", @@ -143,7 +150,7 @@ let package = Package( .target(name: "NIO", condition: .when(platforms: historicalNIOPosixDependencyRequired)), "NIOCore", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "CNIOAtomics", @@ -190,7 +197,7 @@ let package = Package( dependencies: [ "CNIOAtomics" ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOHTTP1", @@ -201,7 +208,7 @@ let package = Package( "CNIOLLHTTP", swiftCollections, ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOWebSocket", @@ -212,7 +219,7 @@ let package = Package( "CNIOSHA1", "_NIOBase64", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "CNIOLLHTTP", @@ -228,7 +235,7 @@ let package = Package( "NIOCore", swiftCollections, ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOTestUtils", @@ -239,7 +246,7 @@ let package = Package( "NIOHTTP1", swiftAtomics, ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( name: "NIOFileSystem", @@ -255,7 +262,7 @@ let package = Package( path: "Sources/NIOFileSystem", exclude: includePrivacyManifest ? [] : ["PrivacyInfo.xcprivacy"], resources: includePrivacyManifest ? [.copy("PrivacyInfo.xcprivacy")] : [], - swiftSettings: strictConcurrencySettings + [ + swiftSettings: standardSwiftSettings + [ .define("ENABLE_MOCKING", .when(configuration: .debug)) ] ), @@ -266,7 +273,7 @@ let package = Package( "NIOFoundationCompat", ], path: "Sources/NIOFileSystemFoundationCompat", - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .target( @@ -283,7 +290,7 @@ let package = Package( path: "Sources/_NIOFileSystem", exclude: includePrivacyManifest ? [] : ["PrivacyInfo.xcprivacy"], resources: includePrivacyManifest ? [.copy("PrivacyInfo.xcprivacy")] : [], - swiftSettings: strictConcurrencySettings + [ + swiftSettings: standardSwiftSettings + [ .define("ENABLE_MOCKING", .when(configuration: .debug)) ] ), @@ -294,7 +301,7 @@ let package = Package( "NIOFoundationCompat", ], path: "Sources/_NIOFileSystemFoundationCompat", - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), // MARK: - Examples @@ -306,7 +313,7 @@ let package = Package( "NIOCore", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOTCPEchoClient", @@ -315,7 +322,7 @@ let package = Package( "NIOCore", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOEchoServer", @@ -325,7 +332,7 @@ let package = Package( "NIOConcurrencyHelpers", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOEchoClient", @@ -335,7 +342,7 @@ let package = Package( "NIOConcurrencyHelpers", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOHTTP1Server", @@ -346,7 +353,7 @@ let package = Package( "NIOConcurrencyHelpers", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOHTTP1Client", @@ -357,7 +364,7 @@ let package = Package( "NIOConcurrencyHelpers", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOChatServer", @@ -367,7 +374,7 @@ let package = Package( "NIOConcurrencyHelpers", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOChatClient", @@ -377,7 +384,7 @@ let package = Package( "NIOConcurrencyHelpers", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOWebSocketServer", @@ -388,7 +395,7 @@ let package = Package( "NIOWebSocket", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOWebSocketClient", @@ -399,7 +406,7 @@ let package = Package( "NIOWebSocket", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOMulticastChat", @@ -407,7 +414,7 @@ let package = Package( "NIOPosix", "NIOCore", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOUDPEchoServer", @@ -416,7 +423,7 @@ let package = Package( "NIOCore", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOUDPEchoClient", @@ -425,7 +432,7 @@ let package = Package( "NIOCore", ], exclude: ["README.md"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOAsyncAwaitDemo", @@ -434,7 +441,7 @@ let package = Package( "NIOCore", "NIOHTTP1", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), // MARK: - Tests @@ -449,7 +456,7 @@ let package = Package( "NIOFoundationCompat", "NIOWebSocket", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .executableTarget( name: "NIOCrashTester", @@ -461,7 +468,7 @@ let package = Package( "NIOWebSocket", "NIOFoundationCompat", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOCoreTests", @@ -473,7 +480,7 @@ let package = Package( "NIOTestUtils", swiftAtomics, ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOEmbeddedTests", @@ -482,7 +489,7 @@ let package = Package( "NIOCore", "NIOEmbedded", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOPosixTests", @@ -497,7 +504,7 @@ let package = Package( "CNIODarwin", "NIOTLS", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOConcurrencyHelpersTests", @@ -505,17 +512,17 @@ let package = Package( "NIOConcurrencyHelpers", "NIOCore", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIODataStructuresTests", dependencies: ["_NIODataStructures"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOBase64Tests", dependencies: ["_NIOBase64"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOHTTP1Tests", @@ -527,7 +534,7 @@ let package = Package( "NIOFoundationCompat", "NIOTestUtils", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOTLSTests", @@ -538,7 +545,7 @@ let package = Package( "NIOFoundationCompat", "NIOTestUtils", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOWebSocketTests", @@ -547,7 +554,7 @@ let package = Package( "NIOEmbedded", "NIOWebSocket", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOTestUtilsTests", @@ -557,7 +564,7 @@ let package = Package( "NIOEmbedded", "NIOPosix", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOFoundationCompatTests", @@ -565,17 +572,17 @@ let package = Package( "NIOCore", "NIOFoundationCompat", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOTests", dependencies: ["NIO"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOSingletonsTests", dependencies: ["NIOCore", "NIOPosix"], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOFileSystemTests", @@ -586,7 +593,7 @@ let package = Package( swiftCollections, swiftSystem, ], - swiftSettings: strictConcurrencySettings + [ + swiftSettings: standardSwiftSettings + [ .define("ENABLE_MOCKING", .when(configuration: .debug)) ] ), @@ -604,7 +611,7 @@ let package = Package( // the build. "Test Data" ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), .testTarget( name: "NIOFileSystemFoundationCompatTests", @@ -612,7 +619,7 @@ let package = Package( "NIOFileSystem", "NIOFileSystemFoundationCompat", ], - swiftSettings: strictConcurrencySettings + swiftSettings: standardSwiftSettings ), ] ) diff --git a/Sources/NIOCore/ByteBuffer-aux.swift b/Sources/NIOCore/ByteBuffer-aux.swift index e95ab8f37d..44d5ed940d 100644 --- a/Sources/NIOCore/ByteBuffer-aux.swift +++ b/Sources/NIOCore/ByteBuffer-aux.swift @@ -785,6 +785,36 @@ extension ByteBuffer { self = ByteBufferAllocator().buffer(dispatchData: dispatchData) } #endif + + #if compiler(>=6.2) + /// Create a fresh ``ByteBuffer`` with a minimum size, and initializing it safely via an `OutputSpan`. + /// + /// This will allocate a new ``ByteBuffer`` with at least `capacity` bytes of storage, and then calls + /// `initializer` with an `OutputSpan` over the entire allocated storage. This is a convenient method + /// to initialize a buffer directly and safely in a single allocation, including from C code. + /// + /// Once this call returns, the buffer will have its ``ByteBuffer/writerIndex`` appropriately advanced to encompass + /// any memory initialized by the `initializer`. Uninitialized memory will be after the ``ByteBuffer/writerIndex``, + /// available for subsequent use. + /// + /// - info: If you have access to a `Channel`, `ChannelHandlerContext`, or `ByteBufferAllocator` we + /// recommend using `channel.allocator.buffer(capacity:initializingWith:)`. Or if you want to write multiple items into + /// the buffer use `channel.allocator.buffer(capacity: ...)` to allocate a `ByteBuffer` of the right + /// size followed by a `write(minimumWritableBytes:initializingWith:)` instead of using this method. This allows SwiftNIO to do + /// accounting and optimisations of resources acquired for operations on a given `Channel` in the future. + /// + /// - parameters: + /// - capacity: The minimum initial space to allocate for the buffer. + /// - initializer: The initializer that will be invoked to initialize the allocated memory. + @inlinable + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + public init( + initialCapacity capacity: Int, + initializingWith initializer: (inout OutputRawSpan) throws -> Void + ) rethrows { + self = try ByteBufferAllocator().buffer(capacity: capacity, initializingWith: initializer) + } + #endif } extension ByteBuffer: Codable { @@ -924,6 +954,32 @@ extension ByteBufferAllocator { return buffer } #endif + + #if compiler(>=6.2) + /// Create a fresh ``ByteBuffer`` with a minimum size, and initializing it safely via an `OutputSpan`. + /// + /// This will allocate a new ``ByteBuffer`` with at least `capacity` bytes of storage, and then calls + /// `initializer` with an `OutputSpan` over the entire allocated storage. This is a convenient method + /// to initialize a buffer directly and safely in a single allocation, including from C code. + /// + /// Once this call returns, the buffer will have its ``ByteBuffer/writerIndex`` appropriately advanced to encompass + /// any memory initialized by the `initializer`. Uninitialized memory will be after the ``ByteBuffer/writerIndex``, + /// available for subsequent use. + /// + /// - parameters: + /// - capacity: The minimum initial space to allocate for the buffer. + /// - initializer: The initializer that will be invoked to initialize the allocated memory. + @inlinable + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + public func buffer( + capacity: Int, + initializingWith initializer: (inout OutputRawSpan) throws -> Void + ) rethrows -> ByteBuffer { + var buffer = self.buffer(capacity: capacity) + try buffer.write(minimumWritableBytes: capacity, initializingWith: initializer) + return buffer + } + #endif } extension Optional where Wrapped == ByteBuffer { diff --git a/Sources/NIOCore/ByteBuffer-core.swift b/Sources/NIOCore/ByteBuffer-core.swift index add0340b77..0e08c54616 100644 --- a/Sources/NIOCore/ByteBuffer-core.swift +++ b/Sources/NIOCore/ByteBuffer-core.swift @@ -691,6 +691,45 @@ public struct ByteBuffer { return try body(.init(rebasing: self._slicedStorageBuffer[range])) } + #if compiler(>=6.2) + /// Provides safe high-performance read-only access to the readable bytes of this buffer. + @inlinable + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + public var readableBytesSpan: RawSpan { + @_lifetime(borrow self) + borrowing get { + let range = Range(uncheckedBounds: (lower: self.readerIndex, upper: self.writerIndex)) + return _overrideLifetime(RawSpan(_unsafeBytes: self._slicedStorageBuffer[range]), borrowing: self) + } + } + + /// Provides mutable access to the readable bytes of this buffer. + @inlinable + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + public var mutableReadableBytesSpan: MutableRawSpan { + @_lifetime(&self) + mutating get { + self._copyStorageAndRebaseIfNeeded() + let range = Range(uncheckedBounds: (lower: self.readerIndex, upper: self.writerIndex)) + return _overrideLifetime(MutableRawSpan(_unsafeBytes: self._slicedStorageBuffer[range]), mutating: &self) + } + } + + /// Enables high-performance low-level appending into the writable section of this buffer. + @inlinable + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + public mutating func write( + minimumWritableBytes: Int, + initializingWith initializer: (inout OutputRawSpan) throws -> Void + ) rethrows { + try self.writeWithUnsafeMutableBytes(minimumWritableBytes: minimumWritableBytes) { ptr in + var span = OutputRawSpan(buffer: ptr, initializedCount: 0) + try initializer(&span) + return span.byteCount + } + } + #endif + /// Yields the bytes currently writable (`bytesWritable` = `capacity` - `writerIndex`). Before reading those bytes you must first /// write to them otherwise you will trigger undefined behaviour. The writer index will remain unchanged. /// diff --git a/Tests/NIOCoreTests/ByteBufferSpanTests.swift b/Tests/NIOCoreTests/ByteBufferSpanTests.swift new file mode 100644 index 0000000000..1fee4c8e56 --- /dev/null +++ b/Tests/NIOCoreTests/ByteBufferSpanTests.swift @@ -0,0 +1,250 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +import NIOCore + +@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) +final class ByteBufferSpanTests: XCTestCase { + #if compiler(>=6.2) + func testReadableBytesSpanOfEmptyByteBuffer() { + let bb = ByteBuffer() + XCTAssertEqual(bb.readableBytesSpan.byteCount, 0) + } + + func testReadableBytesSpanOfSimpleBuffer() { + let bb = ByteBuffer(string: "Hello, world!") + XCTAssertEqual(bb.readableBytesSpan.byteCount, 13) + XCTAssertTrue(bb.readableBytesSpan.elementsEqual("Hello, world!".utf8)) + } + + func testReadableBytesSpanNotAtTheStart() { + var bb = ByteBuffer(string: "Hello, world!") + bb.moveReaderIndex(forwardBy: 5) + XCTAssertEqual(bb.readableBytesSpan.byteCount, 8) + XCTAssertTrue(bb.readableBytesSpan.elementsEqual(", world!".utf8)) + } + + func testReadableBytesSpanOfSlice() { + let first = ByteBuffer(string: "Hello, world!") + let bb = first.getSlice(at: 5, length: 5)! + XCTAssertEqual(bb.readableBytesSpan.byteCount, 5) + XCTAssertTrue(bb.readableBytesSpan.elementsEqual(", wor".utf8)) + } + + func testMutableReadableBytesSpanOfEmptyByteBuffer() { + var bb = ByteBuffer() + XCTAssertEqual(bb.mutableReadableBytesSpan.byteCount, 0) + } + + func testMutableReadableBytesSpanOfSimpleBuffer() { + var bb = ByteBuffer(string: "Hello, world!") + XCTAssertEqual(bb.mutableReadableBytesSpan.byteCount, 13) + XCTAssertTrue(bb.mutableReadableBytesSpan.elementsEqual("Hello, world!".utf8)) + + var readableBytes = bb.mutableReadableBytesSpan + readableBytes.storeBytes(of: UInt8(ascii: "o"), toByteOffset: 5, as: UInt8.self) + + XCTAssertEqual(String(buffer: bb), "Helloo world!") + } + + func testMutableReadableBytesSpanNotAtTheStart() { + var bb = ByteBuffer(string: "Hello, world!") + bb.moveReaderIndex(forwardBy: 5) + XCTAssertEqual(bb.mutableReadableBytesSpan.byteCount, 8) + XCTAssertTrue(bb.mutableReadableBytesSpan.elementsEqual(", world!".utf8)) + + var readableBytes = bb.mutableReadableBytesSpan + readableBytes.storeBytes(of: UInt8(ascii: "o"), toByteOffset: 5, as: UInt8.self) + + XCTAssertEqual(String(buffer: bb), ", worod!") + } + + func testMutableReadableBytesSpanOfSlice() { + let first = ByteBuffer(string: "Hello, world!") + var bb = first.getSlice(at: 5, length: 5)! + XCTAssertEqual(bb.mutableReadableBytesSpan.byteCount, 5) + XCTAssertTrue(bb.mutableReadableBytesSpan.elementsEqual(", wor".utf8)) + + var readableBytes = bb.mutableReadableBytesSpan + readableBytes.storeBytes(of: UInt8(ascii: "o"), toByteOffset: 4, as: UInt8.self) + + XCTAssertEqual(String(buffer: bb), ", woo") + XCTAssertEqual(String(buffer: first), "Hello, world!") + } + + func testEvenCreatingMutableSpanTriggersCoW() { + let first = ByteBuffer(string: "Hello, world!") + var second = first + + let firstBackingPtr = first.withVeryUnsafeBytes { $0 }.baseAddress + let secondBackingPtr = second.withVeryUnsafeBytes { $0 }.baseAddress + XCTAssertEqual(firstBackingPtr, secondBackingPtr) + + let readableBytes = second.mutableReadableBytesSpan + _ = consume readableBytes + let firstNewBackingPtr = first.withVeryUnsafeBytes { $0 }.baseAddress + let secondNewBackingPtr = second.withVeryUnsafeBytes { $0 }.baseAddress + XCTAssertNotEqual(firstNewBackingPtr, secondNewBackingPtr) + XCTAssertEqual(firstBackingPtr, firstNewBackingPtr) + } + + func testAppendingToEmptyBufferViaOutputSpan() { + var bb = ByteBuffer() + bb.write(minimumWritableBytes: 15) { span in + XCTAssertEqual(span.byteCount, 0) + XCTAssertGreaterThanOrEqual(span.capacity, 15) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 15) + XCTAssertTrue(span.initializedElementsEqual([])) + + span.append(contentsOf: "Hello, world!".utf8) + + XCTAssertEqual(span.byteCount, 13) + XCTAssertGreaterThanOrEqual(span.capacity, 2) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 2) + XCTAssertTrue(span.initializedElementsEqual("Hello, world!".utf8)) + } + XCTAssertEqual(bb.readableBytes, 13) + XCTAssertEqual(String(buffer: bb), "Hello, world!") + } + + func testAppendingToNonEmptyBufferViaOutputSpanDoesNotExposeInitialBytes() { + var bb = ByteBuffer() + bb.writeString("Hello") + bb.write(minimumWritableBytes: 8) { span in + XCTAssertEqual(span.byteCount, 0) + XCTAssertGreaterThanOrEqual(span.capacity, 8) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 8) + XCTAssertTrue(span.initializedElementsEqual([])) + + span.append(contentsOf: ", world!".utf8) + + XCTAssertEqual(span.byteCount, 8) + XCTAssertGreaterThanOrEqual(span.capacity, 0) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 0) + XCTAssertTrue(span.initializedElementsEqual(", world!".utf8)) + } + XCTAssertEqual(bb.readableBytes, 13) + XCTAssertEqual(String(buffer: bb), "Hello, world!") + } + + func testAppendingToASliceViaOutputSpan() { + let first = ByteBuffer(string: "Hello, world!") + var bb = first.getSlice(at: 5, length: 5)! + XCTAssertEqual(bb.mutableReadableBytesSpan.byteCount, 5) + XCTAssertTrue(bb.mutableReadableBytesSpan.elementsEqual(", wor".utf8)) + + bb.write(minimumWritableBytes: 5) { span in + span.append(contentsOf: "olleh".utf8) + } + + XCTAssertEqual(String(buffer: bb), ", worolleh") + XCTAssertEqual(String(buffer: first), "Hello, world!") + } + + func testEvenCreatingAnOutputSpanTriggersCoW() { + let first = ByteBuffer(string: "Hello, world!") + var second = first + + let firstBackingPtr = first.withVeryUnsafeBytes { $0 }.baseAddress + let secondBackingPtr = second.withVeryUnsafeBytes { $0 }.baseAddress + XCTAssertEqual(firstBackingPtr, secondBackingPtr) + + second.write(minimumWritableBytes: 5) { _ in} + let firstNewBackingPtr = first.withVeryUnsafeBytes { $0 }.baseAddress + let secondNewBackingPtr = second.withVeryUnsafeBytes { $0 }.baseAddress + XCTAssertNotEqual(firstNewBackingPtr, secondNewBackingPtr) + XCTAssertEqual(firstBackingPtr, firstNewBackingPtr) + } + + func testCanCreateEmptyBufferDirectly() { + let bb = ByteBuffer(initialCapacity: 15) { span in + XCTAssertEqual(span.byteCount, 0) + XCTAssertGreaterThanOrEqual(span.capacity, 15) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 15) + XCTAssertTrue(span.initializedElementsEqual([])) + + span.append(contentsOf: "Hello, world!".utf8) + + XCTAssertEqual(span.byteCount, 13) + XCTAssertGreaterThanOrEqual(span.capacity, 2) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 2) + XCTAssertTrue(span.initializedElementsEqual("Hello, world!".utf8)) + } + XCTAssertEqual(bb.readableBytes, 13) + XCTAssertEqual(String(buffer: bb), "Hello, world!") + } + + func testCanCreateEmptyBufferDirectlyFromAllocator() { + let bb = ByteBufferAllocator().buffer(capacity: 15) { span in + XCTAssertEqual(span.byteCount, 0) + XCTAssertGreaterThanOrEqual(span.capacity, 15) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 15) + XCTAssertTrue(span.initializedElementsEqual([])) + + span.append(contentsOf: "Hello, world!".utf8) + + XCTAssertEqual(span.byteCount, 13) + XCTAssertGreaterThanOrEqual(span.capacity, 2) + XCTAssertGreaterThanOrEqual(span.freeCapacity, 2) + XCTAssertTrue(span.initializedElementsEqual("Hello, world!".utf8)) + } + XCTAssertEqual(bb.readableBytes, 13) + XCTAssertEqual(String(buffer: bb), "Hello, world!") + } + #endif +} + +#if compiler(>=6.2) +@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) +extension RawSpan { + func elementsEqual(_ other: Other) -> Bool where Other.Element == UInt8 { + guard other.count == self.byteCount else { return false } + + var index = other.startIndex + var offset = 0 + while index < other.endIndex { + guard other[index] == self.unsafeLoadUnaligned(fromByteOffset: offset, as: UInt8.self) else { + return false + } + other.formIndex(after: &index) + offset &+= 1 + } + + return true + } +} + +@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) +extension MutableRawSpan { + func elementsEqual(_ other: Other) -> Bool where Other.Element == UInt8 { + self.bytes.elementsEqual(other) + } +} + +@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) +extension OutputRawSpan { + func initializedElementsEqual(_ other: Other) -> Bool where Other.Element == UInt8 { + self.bytes.elementsEqual(other) + } + + @_lifetime(self: copy self) + mutating func append(contentsOf other: Other) where Other.Element == UInt8 { + for element in other { + self.append(element) + } + } +} +#endif From fffb0a0de64a87a5b68f34a6564fec7f0caab229 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Mon, 8 Sep 2025 18:15:51 +0100 Subject: [PATCH 2/3] Stop 5.10 finding the visionOS target --- Tests/NIOCoreTests/ByteBufferSpanTests.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Tests/NIOCoreTests/ByteBufferSpanTests.swift b/Tests/NIOCoreTests/ByteBufferSpanTests.swift index 1fee4c8e56..0bcad26092 100644 --- a/Tests/NIOCoreTests/ByteBufferSpanTests.swift +++ b/Tests/NIOCoreTests/ByteBufferSpanTests.swift @@ -16,9 +16,9 @@ import XCTest import NIOCore +#if compiler(>=6.2) @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) final class ByteBufferSpanTests: XCTestCase { - #if compiler(>=6.2) func testReadableBytesSpanOfEmptyByteBuffer() { let bb = ByteBuffer() XCTAssertEqual(bb.readableBytesSpan.byteCount, 0) @@ -204,10 +204,8 @@ final class ByteBufferSpanTests: XCTestCase { XCTAssertEqual(bb.readableBytes, 13) XCTAssertEqual(String(buffer: bb), "Hello, world!") } - #endif } -#if compiler(>=6.2) @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) extension RawSpan { func elementsEqual(_ other: Other) -> Bool where Other.Element == UInt8 { From 52712b1657281f20b41ab92ac62b605f87e76ae7 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Tue, 9 Sep 2025 10:04:00 +0100 Subject: [PATCH 3/3] Code review feedback --- Sources/NIOCore/ByteBuffer-aux.swift | 6 +++--- Sources/NIOCore/ByteBuffer-core.swift | 11 +++++++++-- Tests/NIOCoreTests/ByteBufferSpanTests.swift | 8 ++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/NIOCore/ByteBuffer-aux.swift b/Sources/NIOCore/ByteBuffer-aux.swift index 44d5ed940d..792b48a273 100644 --- a/Sources/NIOCore/ByteBuffer-aux.swift +++ b/Sources/NIOCore/ByteBuffer-aux.swift @@ -810,7 +810,7 @@ extension ByteBuffer { @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) public init( initialCapacity capacity: Int, - initializingWith initializer: (inout OutputRawSpan) throws -> Void + initializingWith initializer: (_ span: inout OutputRawSpan) throws -> Void ) rethrows { self = try ByteBufferAllocator().buffer(capacity: capacity, initializingWith: initializer) } @@ -973,10 +973,10 @@ extension ByteBufferAllocator { @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) public func buffer( capacity: Int, - initializingWith initializer: (inout OutputRawSpan) throws -> Void + initializingWith initializer: (_ span: inout OutputRawSpan) throws -> Void ) rethrows -> ByteBuffer { var buffer = self.buffer(capacity: capacity) - try buffer.write(minimumWritableBytes: capacity, initializingWith: initializer) + try buffer.writeWithOutputRawSpan(minimumWritableBytes: capacity, initializingWith: initializer) return buffer } #endif diff --git a/Sources/NIOCore/ByteBuffer-core.swift b/Sources/NIOCore/ByteBuffer-core.swift index 0e08c54616..e0287fb733 100644 --- a/Sources/NIOCore/ByteBuffer-core.swift +++ b/Sources/NIOCore/ByteBuffer-core.swift @@ -716,11 +716,18 @@ public struct ByteBuffer { } /// Enables high-performance low-level appending into the writable section of this buffer. + /// + /// The writer index will be advanced by the number of bytes written into the + /// `OutputRawSpan`. + /// + /// - parameters: + /// - minimumWritableBytes: The minimum initial space to allocate for the buffer. + /// - initializer: The initializer that will be invoked to initialize the allocated memory. @inlinable @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) - public mutating func write( + public mutating func writeWithOutputRawSpan( minimumWritableBytes: Int, - initializingWith initializer: (inout OutputRawSpan) throws -> Void + initializingWith initializer: (_ span: inout OutputRawSpan) throws -> Void ) rethrows { try self.writeWithUnsafeMutableBytes(minimumWritableBytes: minimumWritableBytes) { ptr in var span = OutputRawSpan(buffer: ptr, initializedCount: 0) diff --git a/Tests/NIOCoreTests/ByteBufferSpanTests.swift b/Tests/NIOCoreTests/ByteBufferSpanTests.swift index 0bcad26092..90a61c4cb3 100644 --- a/Tests/NIOCoreTests/ByteBufferSpanTests.swift +++ b/Tests/NIOCoreTests/ByteBufferSpanTests.swift @@ -103,7 +103,7 @@ final class ByteBufferSpanTests: XCTestCase { func testAppendingToEmptyBufferViaOutputSpan() { var bb = ByteBuffer() - bb.write(minimumWritableBytes: 15) { span in + bb.writeWithOutputRawSpan(minimumWritableBytes: 15) { span in XCTAssertEqual(span.byteCount, 0) XCTAssertGreaterThanOrEqual(span.capacity, 15) XCTAssertGreaterThanOrEqual(span.freeCapacity, 15) @@ -123,7 +123,7 @@ final class ByteBufferSpanTests: XCTestCase { func testAppendingToNonEmptyBufferViaOutputSpanDoesNotExposeInitialBytes() { var bb = ByteBuffer() bb.writeString("Hello") - bb.write(minimumWritableBytes: 8) { span in + bb.writeWithOutputRawSpan(minimumWritableBytes: 8) { span in XCTAssertEqual(span.byteCount, 0) XCTAssertGreaterThanOrEqual(span.capacity, 8) XCTAssertGreaterThanOrEqual(span.freeCapacity, 8) @@ -146,7 +146,7 @@ final class ByteBufferSpanTests: XCTestCase { XCTAssertEqual(bb.mutableReadableBytesSpan.byteCount, 5) XCTAssertTrue(bb.mutableReadableBytesSpan.elementsEqual(", wor".utf8)) - bb.write(minimumWritableBytes: 5) { span in + bb.writeWithOutputRawSpan(minimumWritableBytes: 5) { span in span.append(contentsOf: "olleh".utf8) } @@ -162,7 +162,7 @@ final class ByteBufferSpanTests: XCTestCase { let secondBackingPtr = second.withVeryUnsafeBytes { $0 }.baseAddress XCTAssertEqual(firstBackingPtr, secondBackingPtr) - second.write(minimumWritableBytes: 5) { _ in} + second.writeWithOutputRawSpan(minimumWritableBytes: 5) { _ in} let firstNewBackingPtr = first.withVeryUnsafeBytes { $0 }.baseAddress let secondNewBackingPtr = second.withVeryUnsafeBytes { $0 }.baseAddress XCTAssertNotEqual(firstNewBackingPtr, secondNewBackingPtr)