Skip to content

Commit b0e0247

Browse files
Add opt-in API for channels to expose their underlying transport (#3509)
## Motivation NIO channels abstract over an underlying transport mechanisms (sockets, pipes, etc.), which users typically need not interact with. However, there are scenarios where users need direct access to the underlying transport for low-level operations that work outside NIOs abstraction. One example is performing out out-of-band operations on the underlying file descriptor for a socket-based channel. This PR adds a structured way to access the underlying transport of a channel, for channels that choose to implement it. ## Modifications - Add a new `public protocol NIOTransportAccessibleChannelCore<Transport>` which provides a scoped `withUnsafeTransport(_:)`, used for channel implementations to opt-in. - Add conformance to `NIOTransportAccessibleChannel<NIOBSDSocket.Handle>` for `BaseSocketChannel`, to make this API available for all socket-based channels, including channels returned from the socket-based bootstrap public APIs. - Add public API `ChannelPipeline.SynchronousOperations.withUnsafeTransportIfAvailable(_:)` Note that not all channels need to or should their transport, which is why this was added as an additional protocol that refines `ChannelCore`, vs. extending `Channel` or `ChannelCore` with a default implementation. The protocol uses a primary associated type allowing channels to provide typed access to the transport. E.g. this could be used in NIO Transport Services to expose the underlying `NWConnection`, if desired. The method itself is spelled with "unsafe", uses scoped access, and has clear documentation that users must not violated any of NIOs assumptions about the state of the underlying transport. It's very much not intended for every day use. It being on `ChannelCore` should defer users of the low-level API, since `Channel._channelCore` is marked as for NIO internal use, and the public `withUnsafeTransportIfAvailable(_:)` will take care of the runtime checks for channels that have opted into this API. ## Result - New opt-in API for channel implementations to expose their underlying transport - All socket-based channels from NIOPosix now expose the underlying socket file descriptor - New API `ChannelPipeline.SynchronousOperations.withUnsafeTransportIfAvailable(_:)` for users --------- Co-authored-by: Agam Dua <agam_dua@apple.com>
1 parent 2fdda6c commit b0e0247

File tree

7 files changed

+297
-2
lines changed

7 files changed

+297
-2
lines changed

Sources/NIOCore/BSDSocketAPI.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@ public enum NIOBSDSocket: Sendable {
129129
#else
130130
public typealias Handle = CInt
131131
#endif
132+
public struct PipeHandle {
133+
public var input: Handle
134+
public var output: Handle
135+
136+
@inlinable
137+
public init(input: Handle, output: Handle) {
138+
self.input = input
139+
self.output = output
140+
}
141+
}
132142
}
133143

134144
extension NIOBSDSocket {

Sources/NIOCore/ChannelPipeline.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ public final class ChannelPipeline: ChannelInvoker {
154154
private var _channel: Optional<Channel>
155155

156156
/// The `Channel` that this `ChannelPipeline` belongs to.
157+
@usableFromInline
157158
internal var channel: Channel {
158159
self.eventLoop.assertInEventLoop()
159160
assert(self._channel != nil || self.destroyed)
@@ -1605,6 +1606,72 @@ extension ChannelPipeline {
16051606
self.eventLoop.assertInEventLoop()
16061607
self._pipeline.triggerUserOutboundEvent0(event, promise: promise)
16071608
}
1609+
1610+
/// Provides scoped access to the underlying transport, if the channel supports it.
1611+
///
1612+
/// This is an advanced API for reading or manipulating the underlying transport that backs a channel. Users
1613+
/// must not close the transport or invalidate any invariants that the channel relies upon for its operation.
1614+
///
1615+
/// Not all channels support access to the underlying channel. If the channel does not support this API, the
1616+
/// closure is not called and this function immediately returns `nil`.
1617+
///
1618+
/// Note that you must call this API with an appropriate closure, or otherwise explicitly specify the correct
1619+
/// transport type prarameter, in order for the closure to be run. Calling this function such that the compiler
1620+
/// infers a type for the transport closure parameter that differs from the channel implementation will result
1621+
/// in the closure not being run and this function will return `nil`.
1622+
///
1623+
/// For example, for socket-based channels, that expose the underlying socket handle:
1624+
///
1625+
/// ```swift
1626+
/// try channel.pipeline.syncOperations.withUnsafeTransportIfAvailable { transport in
1627+
/// // This closure is called.
1628+
/// transport == NIOBSDSocketHandle.invalid
1629+
/// }
1630+
///
1631+
/// try channel.pipeline.syncOperations.withUnsafeTransportIfAvailable { (_: NIOBSDSocket.Handle) in
1632+
/// // This closure is called.
1633+
/// return
1634+
/// }
1635+
///
1636+
/// try channel.pipeline.syncOperations.withUnsafeTransportIfAvailable(of: NIOBSDSocket.Handle.self) { _ in
1637+
/// // This closure is called.
1638+
/// return
1639+
/// }
1640+
///
1641+
/// try channel.pipeline.syncOperations.withUnsafeTransportIfAvailable {
1642+
/// // This closure is NOT called.
1643+
/// return
1644+
/// }
1645+
///
1646+
/// try channel.pipeline.syncOperations.withUnsafeTransportIfAvailable { (_: Any.self) in
1647+
/// // This closure is NOT called.
1648+
/// return
1649+
/// }
1650+
///
1651+
/// try channel.pipeline.syncOperations.withUnsafeTransportIfAvailable(of: Any.self) { _ in
1652+
/// // This closure is NOT called.
1653+
/// return
1654+
/// }
1655+
/// ```
1656+
///
1657+
/// - Parameters:
1658+
/// - type: The expected transport type the channel makes available.
1659+
/// - body: /// A closure that takes the underlying transport, if the channel supports this operation.
1660+
/// - Returns: The value returned by the closure, or `nil` if the channel does not expose its transport.
1661+
/// - Throws: If there was an error accessing the underlying transport, or an error was thrown by the closure.
1662+
@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
1663+
@inlinable
1664+
public func withUnsafeTransportIfAvailable<Transport, Result>(
1665+
of type: Transport.Type = Transport.self,
1666+
_ body: (_ transport: Transport) throws -> Result
1667+
) throws -> Result? {
1668+
self.eventLoop.assertInEventLoop()
1669+
guard let core = self._pipeline.channel._channelCore as? any NIOTransportAccessibleChannelCore<Transport>
1670+
else {
1671+
return nil
1672+
}
1673+
return try core.withUnsafeTransport(body)
1674+
}
16081675
}
16091676

16101677
/// Returns a view of operations which can be performed synchronously on this pipeline. All
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// A ``ChannelCore`` that provides access to its underlying transport.
16+
///
17+
/// This API is only used for ``Channel`` implementations: if you are not implementing a ``Channel``, do not use this
18+
/// protocol directly. Instead use ``ChannelPipeline/SynchronousOperations/withUnsafeTransportIfAvailable(of:_:)``.
19+
///
20+
/// Not all channels are expected to conform to ``NIOTransportAccessibleChannelCore``, but this is determined at runtime, by
21+
/// ``ChannelPipeline/SynchronousOperations/withUnsafeTransportIfAvailable(of:_:)``.
22+
public protocol NIOTransportAccessibleChannelCore<Transport>: ChannelCore {
23+
/// The type of the underlying transport.
24+
associatedtype Transport
25+
26+
/// Provides scoped access to the underlying transport.
27+
///
28+
/// This is an advanced API for reading or manipulating the underlying transport that backs a channel. Users must
29+
/// not close the transport or invalidate any invariants that NIO relies upon for the channel operation.
30+
///
31+
/// Users should not attempt to use this API direcly, but should instead use
32+
/// ``ChannelPipeline/SynchronousOperations/withUnsafeTransportIfAvailable(of:_:)``.
33+
///
34+
/// Not all channels are expected to conform to ``NIOTransportAccessibleChannelCore``. If your channel implementation
35+
/// does not support this protocol, do not provide a throwing implementation to indicate this. Instead, simply do
36+
/// not conform your channel core to this protocol. Availablity of this functionality is communicated to users by /// ///
37+
/// ``ChannelPipeline/SynchronousOperations/withUnsafeTransportIfAvailable(of:_:)``.
38+
///
39+
/// - Parameter body: A closure that takes the underlying transport.
40+
/// - Returns: The value returned by the closure.
41+
/// - Throws: If the underlying transport is unavailable, or rethrows any error thrown by the closure.
42+
func withUnsafeTransport<Result>(_ body: (_ transport: Transport) throws -> Result) throws -> Result
43+
}

Sources/NIOPosix/BSDSocketAPICommon.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ internal enum Shutdown: _SocketShutdownProtocol, Sendable {
4444

4545
extension NIOBSDSocket {
4646
#if os(Windows)
47-
internal static let invalidHandle: Handle = INVALID_SOCKET
47+
public static let invalidHandle: Handle = INVALID_SOCKET
4848
#else
49-
internal static let invalidHandle: Handle = -1
49+
public static let invalidHandle: Handle = -1
5050
#endif
5151
}
5252

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIOCore
16+
17+
extension BaseSocketChannel where SocketType: BaseSocket {
18+
/// The underlying transport type backing this channel.
19+
typealias Transport = NIOBSDSocket.Handle
20+
21+
/// Provides scoped access to the socket file handle.
22+
func withUnsafeTransport<Result>(_ body: (_ transport: NIOBSDSocket.Handle) throws -> Result) throws -> Result {
23+
try self.socket.withUnsafeHandle(body)
24+
}
25+
}
26+
27+
extension BaseSocketChannel where SocketType: PipePair {
28+
/// The underlying transport type backing this channel.
29+
typealias Transport = NIOBSDSocket.PipeHandle
30+
31+
/// Provides scoped access to the pipe file descriptors.
32+
func withUnsafeTransport<Result>(_ body: (_ transport: NIOBSDSocket.PipeHandle) throws -> Result) throws -> Result {
33+
try body(
34+
NIOBSDSocket.PipeHandle(
35+
input: self.socket.input?.fileDescriptor ?? NIOBSDSocket.invalidHandle,
36+
output: self.socket.output?.fileDescriptor ?? NIOBSDSocket.invalidHandle
37+
)
38+
)
39+
}
40+
}
41+
42+
extension SocketChannel: NIOTransportAccessibleChannelCore {}
43+
extension ServerSocketChannel: NIOTransportAccessibleChannelCore {}
44+
extension DatagramChannel: NIOTransportAccessibleChannelCore {}
45+
extension PipeChannel: NIOTransportAccessibleChannelCore {}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIOCore // NOTE: Not @testable import here -- testing public API surface.
16+
import NIOEmbedded
17+
import NIOPosix // NOTE: Not @testable import here -- testing public API surface.
18+
import Testing
19+
20+
@Suite struct NIOTransportAccessibleChannelCoreTests {
21+
@Test func testUnderlyingSocketAccessForSocketBasedChannel() throws {
22+
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
23+
defer { #expect(throws: Never.self) { try group.syncShutdownGracefully() } }
24+
let channel = try DatagramBootstrap(group: group).bind(host: "127.0.0.1", port: 0).wait()
25+
defer { #expect(throws: Never.self) { try channel.close().wait() } }
26+
27+
// We don't expect users to do this runtime check, but test the channel we got back from bootstrap conforms.
28+
#expect(channel is any NIOTransportAccessibleChannelCore)
29+
#expect(channel is any NIOTransportAccessibleChannelCore<NIOBSDSocket.Handle>)
30+
#expect(channel is any NIOTransportAccessibleChannelCore<Any> == false)
31+
32+
// Here we try the public API use, in various flavours.
33+
try channel.eventLoop.submit {
34+
let syncOps = channel.pipeline.syncOperations
35+
36+
// Calling without explicit transport type runs closure if body inefers correct transport type.
37+
try #expect(syncOps.withUnsafeTransportIfAvailable { fd in fd != NIOBSDSocket.invalidHandle } == true)
38+
try #expect(syncOps.withUnsafeTransportIfAvailable { $0 != NIOBSDSocket.invalidHandle } == true)
39+
40+
// Calling with explicit correct transport type runs closure.
41+
try #expect(syncOps.withUnsafeTransportIfAvailable(of: NIOBSDSocket.Handle.self) { _ in 42 } == 42)
42+
try #expect(syncOps.withUnsafeTransportIfAvailable { (_: NIOBSDSocket.Handle) in 42 } == 42)
43+
44+
// Calling with explicit incorrect transport type does not run closure.
45+
try #expect(syncOps.withUnsafeTransportIfAvailable(of: String.self) { _ in 42 } == nil)
46+
try #expect(syncOps.withUnsafeTransportIfAvailable { (_: String) in 42 } == nil)
47+
48+
// Calling with explicit Any transport type does not run closure.
49+
try #expect(syncOps.withUnsafeTransportIfAvailable(of: Any.self) { _ in 42 } == nil)
50+
try #expect(syncOps.withUnsafeTransportIfAvailable { (_: Any) in 42 } == nil)
51+
52+
// Calling without explicit transport type does not run closure, even if body doesn't use transport.
53+
try #expect(syncOps.withUnsafeTransportIfAvailable { 42 } == nil)
54+
55+
// Fun aside: What is the resolved type of the above function and why does it allow ignoring closure param?
56+
try #expect(syncOps.withUnsafeTransportIfAvailable { $0.self } == nil)
57+
// Answer: `$0: any (~Copyable & ~Escapable).Type`
58+
59+
// Calling without explicit transport type does not run closure, even if body uses compatible literal value.
60+
try #expect(syncOps.withUnsafeTransportIfAvailable { transport in transport != -1 } == nil)
61+
}.wait()
62+
}
63+
64+
@Test func testUnderlyingTransportForUnsupportedChannels() throws {
65+
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
66+
defer { #expect(throws: Never.self) { try group.syncShutdownGracefully() } }
67+
let channel = EmbeddedChannel()
68+
defer { #expect(throws: Never.self) { try channel.close().wait() } }
69+
70+
#expect(channel is any NIOTransportAccessibleChannelCore == false)
71+
72+
// Calling the public API will never run the closure -- we cannot specify a type to pass the runtime check.
73+
let syncOps = channel.pipeline.syncOperations
74+
try #expect(syncOps.withUnsafeTransportIfAvailable { 42 } == nil)
75+
try #expect(syncOps.withUnsafeTransportIfAvailable(of: Any.self) { _ in 42 } == nil)
76+
try #expect(syncOps.withUnsafeTransportIfAvailable(of: CInt.self) { _ in 42 } == nil)
77+
try #expect(syncOps.withUnsafeTransportIfAvailable(of: type(of: STDOUT_FILENO).self) { _ in 42 } == nil)
78+
}
79+
80+
@Test func testUnderlyingTransportConformanceForExpectedChannels() throws {
81+
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
82+
defer { #expect(throws: Never.self) { try group.syncShutdownGracefully() } }
83+
84+
// SeverSocketChannel -- yep.
85+
let serverChannel = try ServerBootstrap(group: group).bind(host: "127.0.0.1", port: 0).wait()
86+
defer { #expect(throws: Never.self) { try serverChannel.close().wait() } }
87+
#expect(serverChannel is any NIOTransportAccessibleChannelCore)
88+
#expect(serverChannel is any NIOTransportAccessibleChannelCore<NIOBSDSocket.Handle>)
89+
90+
// SocketChannel -- yep.
91+
let clientChannel = try ClientBootstrap(group: group).connect(to: serverChannel.localAddress!).wait()
92+
defer { #expect(throws: Never.self) { try clientChannel.close().wait() } }
93+
#expect(clientChannel is any NIOTransportAccessibleChannelCore)
94+
#expect(clientChannel is any NIOTransportAccessibleChannelCore<NIOBSDSocket.Handle>)
95+
96+
// DatagramChannel -- yep.
97+
let datagramChannel = try DatagramBootstrap(group: group).bind(host: "127.0.0.1", port: 0).wait()
98+
defer { #expect(throws: Never.self) { try datagramChannel.close().wait() } }
99+
#expect(datagramChannel is any NIOTransportAccessibleChannelCore)
100+
#expect(datagramChannel is any NIOTransportAccessibleChannelCore<NIOBSDSocket.Handle>)
101+
102+
// PipeChannel -- yep.
103+
let pipeChannel = try NIOPipeBootstrap(group: group).takingOwnershipOfDescriptor(output: STDOUT_FILENO).wait()
104+
defer { #expect(throws: Never.self) { try pipeChannel.close().wait() } }
105+
#expect(pipeChannel is any NIOTransportAccessibleChannelCore)
106+
#expect(pipeChannel is any NIOTransportAccessibleChannelCore<NIOBSDSocket.PipeHandle>)
107+
108+
// EmbeddedChannel -- nope.
109+
let embeddedChannel = EmbeddedChannel()
110+
defer { #expect(throws: Never.self) { try embeddedChannel.close().wait() } }
111+
#expect(embeddedChannel is any NIOTransportAccessibleChannelCore == false)
112+
}
113+
}

Tests/NIOPosixTests/SocketChannelTest.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,23 @@ final class SocketChannelTest: XCTestCase {
11261126
#endif
11271127
}
11281128

1129+
func testBaseSocketChannelWithUnderlyingTransport() throws {
1130+
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
1131+
defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) }
1132+
let socket = try Socket.SocketType(protocolFamily: .inet, type: .datagram)
1133+
let channel = try BaseSocketChannel(
1134+
socket: socket,
1135+
parent: nil,
1136+
eventLoop: group.next() as! SelectableEventLoop,
1137+
recvAllocator: FixedSizeRecvByteBufferAllocator(capacity: 1024),
1138+
supportReconnect: false
1139+
)
1140+
1141+
try channel.socket.withUnsafeHandle { fd in
1142+
XCTAssertNotEqual(fd, NIOBSDSocket.invalidHandle)
1143+
try channel.withUnsafeTransport { XCTAssertEqual($0, fd) }
1144+
}
1145+
}
11291146
}
11301147

11311148
final class DropAllReadsOnTheFloorHandler: ChannelDuplexHandler, Sendable {

0 commit comments

Comments
 (0)