diff --git a/Sources/System/CMakeLists.txt b/Sources/System/CMakeLists.txt index a904eb1a..d41e7aca 100644 --- a/Sources/System/CMakeLists.txt +++ b/Sources/System/CMakeLists.txt @@ -49,6 +49,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL Linux) IORing/IORequest.swift IORing/IORing.swift IORing/IORing+Util.swift + IORing/PollEvents.swift IORing/RawIORequest.swift) endif() target_sources(SystemPackage PRIVATE diff --git a/Sources/System/IORing/IOCompletion.swift b/Sources/System/IORing/IOCompletion.swift index d9e69050..8a1bc71b 100644 --- a/Sources/System/IORing/IOCompletion.swift +++ b/Sources/System/IORing/IOCompletion.swift @@ -1,3 +1,12 @@ +/* + This source file is part of the Swift System open source project + + Copyright (c) 2023 - 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 +*/ + #if compiler(>=6.2) && $Lifetimes #if os(Linux) @@ -45,6 +54,13 @@ public extension IORing.Completion { } } + /// The result of the completed operation. + /// + /// A non-negative value is the operation's success result: a byte count + /// for a read or a write, an event mask for a poll, and so on. + /// + /// A negative value is an `errno` code multiplied by -1. Recover the error + /// by negating it again: `Errno(rawValue: -completion.result)`. @inlinable var result: Int32 { get { rawValue.res diff --git a/Sources/System/IORing/IORequest.swift b/Sources/System/IORing/IORequest.swift index 4a388c49..8cec4e1c 100644 --- a/Sources/System/IORing/IORequest.swift +++ b/Sources/System/IORing/IORequest.swift @@ -1,3 +1,12 @@ +/* + This source file is part of the Swift System open source project + + Copyright (c) 2023 - 2026 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 +*/ + #if compiler(>=6.2) && $Lifetimes #if os(Linux) @@ -23,6 +32,12 @@ internal enum IORequestCore { intoSlot: IORing.RegisteredFile, context: UInt64 = 0 ) + case pollAdd( + file: FileDescriptor, + pollEvents: IORing.Request.PollEvents, + isMultiShot: Bool = true, + context: UInt64 = 0 + ) case read( file: FileDescriptor, buffer: IORing.RegisteredBuffer, @@ -187,6 +202,100 @@ extension IORing.Request { .init(core: .nop) } + // Poll + + /// Multishot poll: the poll handler continues to report CQEs on behalf + /// of the same SQE, each flagged with + /// ``IORing/Completion/Flags/moreCompletions``. + /// + /// Corresponds to `IORING_POLL_ADD_MULTI`. Note that since + /// `sqe->poll_events` is the event space, the command flags for + /// `POLL_ADD` are stored in `sqe->len`. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_POLL_ADD_MULTI: UInt32 { 1 << 0 } + + /// Adds a poll operation to monitor a file descriptor for specific I/O + /// events. + /// + /// This method creates an io_uring poll operation that monitors the + /// specified file descriptor for I/O readiness events. The operation + /// completes when any of the requested events occur on the file + /// descriptor, such as data becoming available for reading or the + /// descriptor becoming ready for writing. + /// + /// Poll operations are useful for implementing efficient I/O + /// multiplexing, allowing you to monitor multiple file descriptors + /// concurrently within a single io_uring instance. When used with + /// multishot mode, a single poll operation can deliver multiple + /// completion events without needing to be resubmitted. + /// + /// ## Multishot Behavior + /// + /// When `isMultiShot` is `true`, the poll operation automatically rearms + /// after each completion event, continuing to monitor the file descriptor + /// for subsequent events. This reduces submission overhead for long-lived + /// monitoring operations. The operation continues until explicitly + /// cancelled or the file descriptor is closed. + /// + /// When `isMultiShot` is `false`, the poll operation completes once after + /// the first matching event occurs, requiring resubmission to continue + /// monitoring. + /// + /// ## Example Usage + /// + /// ```swift + /// // Monitor a socket for incoming connections + /// var ring = try IORing(queueDepth: 32) + /// let pollRequest = IORing.Request.pollAdd( + /// listenSocket, + /// pollEvents: .pollIn, + /// isMultiShot: true, + /// context: 1 + /// ) + /// guard try ring.submit(linkedRequests: pollRequest) else { + /// // The submission queue was full; retry or drain completions first. + /// return + /// } + /// + /// // Process completions. A multishot poll stays armed while its + /// // completions contain `.moreCompletions`. + /// var armed = true + /// while armed { + /// let completion = try ring.blockingConsumeCompletion() + /// armed = completion.flags.contains(.moreCompletions) + /// if completion.context == 1 { + /// // Handle incoming connection + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - file: The file descriptor to monitor for I/O events. + /// - pollEvents: The I/O events to monitor on the file descriptor. + /// - isMultiShot: If `true`, the poll operation automatically rearms + /// after each event, continuing to monitor the file descriptor. If + /// `false`, the operation completes after the first matching event. + /// Defaults to `false`. + /// - context: An application-specific value passed through to the + /// completion event, allowing you to identify which operation + /// completed. Defaults to `0`. + /// + /// - Returns: An I/O ring request that monitors the file descriptor for + /// the specified events. + /// + /// ## See Also + /// + /// - ``PollEvents``: The events that can be monitored. + /// - ``IORing/Request/cancel(_:matching:)``: Cancelling poll operations. + @inlinable public static func pollAdd( + _ file: FileDescriptor, + pollEvents: PollEvents, + isMultiShot: Bool = false, + context: UInt64 = 0 + ) -> IORing.Request { + .init(core: .pollAdd(file: file, pollEvents: pollEvents, isMultiShot: isMultiShot, context: context)) + } + @inlinable public static func read( _ file: IORing.RegisteredFile, into buffer: IORing.RegisteredBuffer, @@ -316,24 +425,49 @@ extension IORing.Request { // Cancel - /* - * ASYNC_CANCEL flags. - * - * IORING_ASYNC_CANCEL_ALL Cancel all requests that match the given key - * IORING_ASYNC_CANCEL_FD Key off 'fd' for cancellation rather than the - * request 'user_data' - * IORING_ASYNC_CANCEL_ANY Match any request - * IORING_ASYNC_CANCEL_FD_FIXED 'fd' passed in is a fixed descriptor - * IORING_ASYNC_CANCEL_USERDATA Match on user_data, default for no other key - * IORING_ASYNC_CANCEL_OP Match request based on opcode - */ - -@inlinable internal static var SWIFT_IORING_ASYNC_CANCEL_ALL: UInt32 { 1 << 0 } -@inlinable internal static var SWIFT_IORING_ASYNC_CANCEL_FD: UInt32 { 1 << 1 } -@inlinable internal static var SWIFT_IORING_ASYNC_CANCEL_ANY: UInt32 { 1 << 2 } -@inlinable internal static var SWIFT_IORING_ASYNC_CANCEL_FD_FIXED: UInt32 { 1 << 3 } -@inlinable internal static var SWIFT_IORING_ASYNC_CANCEL_USERDATA: UInt32 { 1 << 4 } -@inlinable internal static var SWIFT_IORING_ASYNC_CANCEL_OP: UInt32 { 1 << 5 } + /// Cancel every request matching the given key, rather than only the + /// first one found. + /// + /// Corresponds to `IORING_ASYNC_CANCEL_ALL`. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_ASYNC_CANCEL_ALL: UInt32 { 1 << 0 } + + /// Match requests on `sqe->fd`, rather than on their `user_data`. + /// + /// Corresponds to `IORING_ASYNC_CANCEL_FD`. Cannot be combined with + /// ``SWIFT_IORING_ASYNC_CANCEL_ANY``. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_ASYNC_CANCEL_FD: UInt32 { 1 << 1 } + + /// Match any request, disregarding every other key. + /// + /// Corresponds to `IORING_ASYNC_CANCEL_ANY`. Cannot be combined with + /// ``SWIFT_IORING_ASYNC_CANCEL_FD`` or ``SWIFT_IORING_ASYNC_CANCEL_OP``. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_ASYNC_CANCEL_ANY: UInt32 { 1 << 2 } + + /// The descriptor to match against is a registered file, so `sqe->fd` + /// carries a slot index rather than a file descriptor. + /// + /// Corresponds to `IORING_ASYNC_CANCEL_FD_FIXED`, and accompanies + /// ``SWIFT_IORING_ASYNC_CANCEL_FD``. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_ASYNC_CANCEL_FD_FIXED: UInt32 { 1 << 3 } + + /// Match requests on their `user_data`. This is the default when no other + /// key is given. + /// + /// Corresponds to `IORING_ASYNC_CANCEL_USERDATA`. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_ASYNC_CANCEL_USERDATA: UInt32 { 1 << 4 } + + /// Match requests by operation. Note that the opcode to match is read + /// from `sqe->len`. + /// + /// Corresponds to `IORING_ASYNC_CANCEL_OP`. Cannot be combined with + /// ``SWIFT_IORING_ASYNC_CANCEL_ANY``. + @_alwaysEmitIntoClient + internal static var SWIFT_IORING_ASYNC_CANCEL_OP: UInt32 { 1 << 5 } public enum CancellationMatch { case all @@ -477,6 +611,14 @@ extension IORing.Request { case .cancel(let flags): request.operation = .asyncCancel request.cancel_flags = flags + case .pollAdd(let file, let pollEvents, let isMultiShot, let context): + request.operation = .pollAdd + request.fileDescriptor = file + request.rawValue.user_data = context + if isMultiShot { + request.rawValue.len = Self.SWIFT_IORING_POLL_ADD_MULTI + } + request.pollEvents = pollEvents } return request diff --git a/Sources/System/IORing/IORing.swift b/Sources/System/IORing/IORing.swift index 27d27a0e..bd6087dd 100644 --- a/Sources/System/IORing/IORing.swift +++ b/Sources/System/IORing/IORing.swift @@ -1,3 +1,12 @@ +/* + This source file is part of the Swift System open source project + + Copyright (c) 2023 - 2026 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 +*/ + #if compiler(>=6.2) && $Lifetimes #if os(Linux) diff --git a/Sources/System/IORing/PollEvents.swift b/Sources/System/IORing/PollEvents.swift new file mode 100644 index 00000000..b9b77e30 --- /dev/null +++ b/Sources/System/IORing/PollEvents.swift @@ -0,0 +1,118 @@ +/* + This source file is part of the Swift System open source project + + Copyright (c) 2026 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 + */ + +#if compiler(>=6.2) && $Lifetimes +#if os(Linux) +extension IORing.Request { + /// A set of I/O events that can be monitored on a file descriptor. + /// + /// `PollEvents` represents the event mask used with io_uring poll + /// operations to specify which I/O conditions to monitor on a file + /// descriptor. These events correspond to the standard POSIX poll events + /// defined in the kernel's `poll.h` header. + /// + /// Use `PollEvents` with + /// ``IORing/Request/pollAdd(_:pollEvents:isMultiShot:context:)`` to + /// register interest in specific I/O events. The poll operation completes + /// when any of the specified events become active on the file descriptor. + /// + /// ## Usage + /// + /// ```swift + /// // Monitor a socket for incoming data + /// let request = IORing.Request.pollAdd( + /// socketFD, + /// pollEvents: .pollIn, + /// isMultiShot: true + /// ) + /// ``` + public struct PollEvents: OptionSet, Hashable, Codable, CaseIterable { + public var rawValue: UInt32 + + @inlinable + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + @usableFromInline + init(_ event: Event) { + self.rawValue = event.rawValue + } + + @usableFromInline + enum Event: UInt32, RawRepresentable, Hashable, CaseIterable { + case pollIn = 0x0001 + case pollOut = 0x0004 + case pollErr = 0x0008 + case pollHup = 0x0010 + case pollNval = 0x0020 + } + + public static var allCases: [PollEvents] { + Event.allCases.map(PollEvents.init(_:)) + } + + /// An event indicating data is available for reading. + /// + /// This event becomes active when data arrives on the file descriptor + /// and can be read without blocking. For sockets, this includes when + /// a new connection is available on a listening socket. Corresponds + /// to the POSIX `POLLIN` event flag. + @inlinable + public static var pollIn: PollEvents { PollEvents(.pollIn) } + + /// An event indicating the file descriptor is ready for writing. + /// + /// This event becomes active when writing to the file descriptor will + /// not block. For sockets, this indicates that send buffer space is + /// available. Corresponds to the POSIX `POLLOUT` event flag. + @inlinable + public static var pollOut: PollEvents { PollEvents(.pollOut) } + + /// An event indicating an error condition on the file descriptor. + /// + /// The kernel reports this event whether or not it was requested, so + /// it can appear in a completion's result mask even when the poll + /// asked only for ``pollIn`` or ``pollOut``. Requesting it explicitly + /// has no effect. Corresponds to the POSIX `POLLERR` event flag. + @_alwaysEmitIntoClient + public static var pollErr: PollEvents { PollEvents(.pollErr) } + + /// An event indicating the peer closed its end of the channel. + /// + /// For a pipe this means the writing end was closed; for a socket, that + /// the connection was shut down. A descriptor reporting this event will + /// never become readable again, so treating it as "not ready yet" and + /// polling again will not make progress. + /// + /// The kernel reports this event whether or not it was requested, and + /// requesting it explicitly has no effect. Corresponds to the POSIX + /// `POLLHUP` event flag. + @_alwaysEmitIntoClient + public static var pollHup: PollEvents { PollEvents(.pollHup) } + + /// An event indicating that the object a descriptor refers to is no + /// longer valid. + /// + /// This arises when the descriptor itself resolves, but the thing it + /// refers to has since become invalid. For example, the disconnection + /// of a sound device could cause this event. + /// + /// Note that a descriptor which simply does not resolve would + /// return the EBADF error code (Errno.badFileDescriptor). + /// + /// The kernel reports this event whether or not it was requested, and + /// requesting it explicitly has no effect. Corresponds to the POSIX + /// `POLLNVAL` event flag. + @_alwaysEmitIntoClient + public static var pollNval: PollEvents { PollEvents(.pollNval) } + } +} +#endif +#endif diff --git a/Sources/System/IORing/RawIORequest.swift b/Sources/System/IORing/RawIORequest.swift index 1de60136..ab4eb564 100644 --- a/Sources/System/IORing/RawIORequest.swift +++ b/Sources/System/IORing/RawIORequest.swift @@ -1,3 +1,12 @@ +/* + This source file is part of the Swift System open source project + + Copyright (c) 2023 - 2026 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 +*/ + #if compiler(>=6.2) && $Lifetimes #if os(Linux) @@ -75,6 +84,27 @@ extension RawIORequest { set { rawValue.addr = newValue } } + /// The poll event mask, stored in `sqe->poll32_events`. + /// + /// Big-endian kernels swap the halfwords of this field before reading it. + /// Equivalent to liburing's `__io_uring_prep_poll_mask`. + @_alwaysEmitIntoClient var pollEvents: IORing.Request.PollEvents { + get { .init(rawValue: _applyPollMask(rawValue.poll32_events)) } + set { rawValue.poll32_events = _applyPollMask(newValue.rawValue) } + } + + /// Converts a poll event mask between its in-memory and `sqe` encodings. + /// + /// This is a halfword rotate (`swahw32` from ``), which keeps + /// the byte order within each half. + @_alwaysEmitIntoClient func _applyPollMask(_ mask: UInt32) -> UInt32 { + #if _endian(big) + return (mask &<< 16) | (mask &>> 16) + #else + return mask + #endif + } + @inlinable public var flags: Flags { get { Flags(rawValue: rawValue.flags) } set { rawValue.flags = newValue.rawValue } diff --git a/Tests/SystemTests/IORingTests.swift b/Tests/SystemTests/IORingTests.swift index 97bf86c7..e3b45b5f 100644 --- a/Tests/SystemTests/IORingTests.swift +++ b/Tests/SystemTests/IORingTests.swift @@ -25,6 +25,8 @@ let uringEnabled: Bool = { } }() +let failureMessage = "Runtime environment does not support IORing." + func isUringEnabled() throws -> Bool { // Even if the kernel supports io_uring, the SystemPackage build may have // been compiled against older kernel headers that lack features it needs @@ -83,12 +85,12 @@ final class IORingTests: XCTestCase { } func testInit() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) _ = try IORing(queueDepth: 32, flags: []) } func testNop() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) var ring = try IORing(queueDepth: 32, flags: []) _ = try ring.submit(linkedRequests: .nop()) let completion = try ring.blockingConsumeCompletion() @@ -124,7 +126,7 @@ final class IORingTests: XCTestCase { } func testUndersizedSubmissionQueue() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) var ring: IORing = try IORing(queueDepth: 1) let enqueued = ring.prepare(linkedRequests: .nop(), .nop()) XCTAssertFalse(enqueued) @@ -132,7 +134,7 @@ final class IORingTests: XCTestCase { // Exercises opening, reading, closing, registered files, registered buffers, and eventfd func testOpenReadAndWriteFixedFile() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) let (parent, path) = try makeHelloWorldFile() let rawBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 13, alignment: 16) var ring = try setupTestRing(depth: 6, fileSlots: 1, buffers: [rawBuffer]) @@ -177,7 +179,7 @@ final class IORingTests: XCTestCase { let bytesRead = try nonRingFD.read(into: rawBuffer) XCTAssert(bytesRead == 13) let result2 = String(cString: rawBuffer.assumingMemoryBound(to: CChar.self).baseAddress!) - XCTAssertEqual(result2, "Hello, World!") + XCTAssertEqual(result2, "Hello, World!") try cleanUpHelloWorldFile(parent) efdBuf.deallocate() rawBuffer.deallocate() @@ -189,7 +191,7 @@ final class IORingTests: XCTestCase { // dangling pointer. Here we deliberately let the FilePath go out of scope // between prepare and submit, then churn the heap to make UAFs observable. func testPathBufferLifetimeAcrossPrepareSubmit() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) let (parent, _) = try makeHelloWorldFile() var ring = try IORing(queueDepth: 6) @@ -223,7 +225,7 @@ final class IORingTests: XCTestCase { } func testPathBufferLifetimeAcrossLinkedRequests() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) let (parent, _) = try makeHelloWorldFile() let rawBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 13, alignment: 16) var ring = try setupTestRing(depth: 6, fileSlots: 1, buffers: [rawBuffer]) @@ -260,12 +262,12 @@ final class IORingTests: XCTestCase { // Timeout test for `blockingConsumeCompletion(timeout:)`: func testBlockingConsumeCompletionWithTimeoutOnIdleRing() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) let ring = try IORing(queueDepth: 4, flags: []) - guard ring.supportedFeatures.contains(.extendedArguments) else { - // Kernel < 5.11: timeouts in io_uring_enter aren't supported. - return - } + try XCTSkipIf( + !ring.supportedFeatures.contains(.extendedArguments), + "Kernel < 5.11: timeouts in io_uring_enter aren't supported." + ) let clock = ContinuousClock() let start = clock.now @@ -285,7 +287,7 @@ final class IORingTests: XCTestCase { } func testRegisterEventFDTwiceThrows() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) var ring = try IORing(queueDepth: 4) let efd = FileDescriptor(rawValue: eventfd(0, Int32(EFD_SEMAPHORE))) defer { try? efd.close() } @@ -300,16 +302,314 @@ final class IORingTests: XCTestCase { } func testSubmitOnDisabledRingThrows() throws { - guard uringEnabled else { return } + try XCTSkipIf(!uringEnabled, failureMessage) var ring = try IORing(queueDepth: 4, flags: [.startDisabled]) - do throws(Errno) { + do throws(Errno) { _ = try ring.submit(linkedRequests: .nop()) XCTFail("expected submit on a disabled ring to throw") } catch { XCTAssertEqual(error, Errno(rawValue: EBADFD)) } } + + func testPollAddPollIn() throws { + try XCTSkipIf(!uringEnabled, failureMessage) + var ring = try IORing(queueDepth: 32, flags: []) + + // This test case requires timeout support + try XCTSkipIf( + !ring.supportedFeatures.contains(.extendedArguments), + "Kernel < 5.11: timeouts in io_uring_enter aren't supported." + ) + + // Test POLLIN: Create an eventfd to monitor for read readiness + let testEventFD = FileDescriptor(rawValue: eventfd(0, 0)) + defer { + try? testEventFD.close() + } + let pollInContext: UInt64 = 42 + + // Submit a pollAdd request to monitor for POLLIN events (data available for reading) + let enqueued = try ring.submit(linkedRequests: + .pollAdd(testEventFD, pollEvents: .pollIn, isMultiShot: false, context: pollInContext)) + XCTAssert(enqueued) + + // Write to the eventfd to trigger the POLLIN event + var value: UInt64 = 1 + withUnsafeBytes(of: &value) { bufferPtr in + _ = try? testEventFD.write(bufferPtr) + } + + // Consume the completion from the poll operation + let completion = try ring.blockingConsumeCompletion( + timeout: .seconds(1) + ) + XCTAssertEqual(completion.context, pollInContext) + let pollIn = Int32(IORing.Request.PollEvents.pollIn.rawValue) + XCTAssertNotEqual( + completion.result & pollIn, 0, "expected POLLIN in the result mask" + ) + } + + func testPollAddPollOut() throws { + try XCTSkipIf(!uringEnabled, failureMessage) + var ring = try IORing(queueDepth: 32, flags: []) + + // This test case requires timeout support + try XCTSkipIf( + !ring.supportedFeatures.contains(.extendedArguments), + "Kernel < 5.11: timeouts in io_uring_enter aren't supported." + ) + + // Test POLLOUT: Create a pipe to monitor for write readiness + var pipeFDs: [Int32] = [0, 0] + let pipeResult = pipe(&pipeFDs) + XCTAssertEqual(pipeResult, 0) + let writeFD = FileDescriptor(rawValue: pipeFDs[1]) + let readFD = FileDescriptor(rawValue: pipeFDs[0]) + defer { + try? writeFD.close() + try? readFD.close() + } + let pollOutContext: UInt64 = 43 + + // Submit a pollAdd request to monitor for POLLOUT events (ready for writing) + // Pipes are typically ready for writing when empty + let enqueuedOut = try ring.submit(linkedRequests: + .pollAdd(writeFD, pollEvents: .pollOut, isMultiShot: false, context: pollOutContext)) + XCTAssert(enqueuedOut) + + // Consume the completion from the poll operation + let completionOut = try ring.blockingConsumeCompletion( + timeout: .seconds(1) + ) + XCTAssertEqual(completionOut.context, pollOutContext) + let pollOut = Int32(IORing.Request.PollEvents.pollOut.rawValue) + XCTAssertNotEqual( + completionOut.result & pollOut, 0, + "expected POLLOUT in the result mask" + ) + } + + // Similar to the multishot example in the documentation for + // `pollAdd(_:pollEvents:isMultiShot:context:)`: arm a multishot poll, + // then consume completions for as long as they carry `.moreCompletions`. + func testPollAddMultiShotRearmsAcrossEvents() throws { + try XCTSkipIf(!uringEnabled, failureMessage) + var ring = try IORing(queueDepth: 32, flags: []) + + // This test case requires timeout support + try XCTSkipIf( + !ring.supportedFeatures.contains(.extendedArguments), + "Kernel < 5.11: timeouts in io_uring_enter aren't supported." + ) + + var pipeFDs: [Int32] = [0, 0] + XCTAssertEqual(pipe(&pipeFDs), 0) + let readFD = FileDescriptor(rawValue: pipeFDs[0]) + let writeFD = FileDescriptor(rawValue: pipeFDs[1]) + defer { + try? readFD.close() + try? writeFD.close() + } + + func writeByte() throws { + var byte: UInt8 = 1 + try withUnsafeBytes(of: &byte) { + try XCTAssertEqual(writeFD.write($0), 1) + } + } + + func readByte() throws { + var scratch: UInt8 = 0 + try withUnsafeMutableBytes(of: &scratch) { + try XCTAssertEqual(readFD.read(into: $0), 1) + } + } + + let context: UInt64 = 44 + let pollIn = Int32(IORing.Request.PollEvents.pollIn.rawValue) + + let pollRequest = IORing.Request.pollAdd( + readFD, pollEvents: .pollIn, isMultiShot: true, context: context + ) + let enqueued = try ring.submit(linkedRequests: pollRequest) + XCTAssert(enqueued) + + try writeByte() + let first = try ring.blockingConsumeCompletion(timeout: .seconds(1)) + // Kernels before 5.13 reject IORING_POLL_ADD_MULTI + try XCTSkipIf( + first.result == -EINVAL, + "Kernel < 5.13: multishot poll is unsupported." + ) + XCTAssertEqual(first.context, context) + XCTAssertNotEqual( + first.result & pollIn, 0, "expected POLLIN in the result mask" + ) + XCTAssert(first.flags.contains(.moreCompletions)) + + // Drain the pipe, then any extra completions. + try readByte() + while ring.tryConsumeCompletion() != nil {} + + try writeByte() + // This written byte will only be reported by a re-armed poll. + let second = try ring.blockingConsumeCompletion(timeout: .seconds(1)) + XCTAssertEqual(second.context, context) + XCTAssertNotEqual( + second.result & pollIn, 0, "expected POLLIN in the result mask" + ) + + // Drain again + try readByte() + while ring.tryConsumeCompletion() != nil {} + + // Cancel to end the multishot poll. + try XCTAssert( + ring.submit(linkedRequests: .cancel(.all, matchingContext: context)) + ) + var terminal: (result: Int32, flags: IORing.Completion.Flags)? = nil + var observed: [(context: UInt64, result: Int32, flags: UInt32)] = [] + // The cancel posts a completion of its own under a different context, + // and the two may land a moment apart, so retry briefly rather than + // draining exactly once. + for _ in 0..<100 where terminal == nil { + while let completion = ring.tryConsumeCompletion() { + observed.append( + ( + completion.context, + completion.result, + completion.flags.rawValue + ) + ) + if completion.context == context { + terminal = (completion.result, completion.flags) + } + } + if terminal == nil { usleep(1000) } + } + XCTAssertNotNil( + terminal, + "no terminal completion for the cancelled poll; " + + "completions seen: \(observed)" + ) + if let terminal { + XCTAssertEqual(terminal.result, -ECANCELED) + XCTAssertFalse( + terminal.flags.contains(.moreCompletions), + "poll kept its armed state after being cancelled" + ) + } + } + + func testPollHangup() throws { + try XCTSkipIf(!uringEnabled, failureMessage) + var ring = try IORing(queueDepth: 8) + let (readFD, writeFD) = try FileDescriptor.pipe() + defer { + try? readFD.close() + try? writeFD.close() + } + + // This test case requires timeout support + try XCTSkipIf( + !ring.supportedFeatures.contains(.extendedArguments), + "Kernel < 5.11: timeouts in io_uring_enter aren't supported." + ) + + let request = IORing.Request.pollAdd( + readFD, pollEvents: .pollIn, isMultiShot: false, context: 97 + ) + let success = try ring.submit(linkedRequests: request) + XCTAssertEqual(success, true) + try writeFD.close() + + let dt = Duration.seconds(1) + let completion = try ring.blockingConsumeCompletion(timeout: dt) + + for event in IORing.Request.PollEvents.allCases { + if completion.result & Int32(event.rawValue) != 0 { + XCTAssertEqual(event, .pollHup) + return + } + } + + let unexpected = completion.result + XCTFail("Unexpected poll event: 0x\(String(unexpected, radix: 16))") + } + + func testPollError() throws { + try XCTSkipIf(!uringEnabled, failureMessage) + var ring = try IORing(queueDepth: 8) + let (readFD, writeFD) = try FileDescriptor.pipe(options: .nonBlocking) + defer { + try? readFD.close() + try? writeFD.close() + } + + // This test case requires timeout support + try XCTSkipIf( + !ring.supportedFeatures.contains(.extendedArguments), + "Kernel < 5.11: timeouts in io_uring_enter aren't supported." + ) + + let chunk = [UInt8](repeating: 0, count: 4096) + chunk.withUnsafeBytes { + // Fill the pipe by writing until an operation fails + while let written = try? writeFD.write($0), written > 0 {} + } + + let request = IORing.Request.pollAdd( + writeFD, pollEvents: .pollOut, isMultiShot: false, context: 98 + ) + let success = try ring.submit(linkedRequests: request) + XCTAssertEqual(success, true) + try readFD.close() + + let dt = Duration.seconds(1) + let completion = try ring.blockingConsumeCompletion(timeout: dt) + XCTAssertEqual(completion.context, 98) + + let pollErr = IORing.Request.PollEvents.pollErr.rawValue + let result = completion.result & Int32(pollErr) + if result != pollErr { + XCTFail("expected POLLERR, got 0x\(String(result, radix: 16))") + } + } + + // A completion's `result` is two things in one field: a non-negative + // value is an event mask, and a negative value is a negated errno. The + // sign is the only thing distinguishing them, so it has to be checked + // before the value is treated as anything else. + func testPollAddOnInvalidDescriptor() throws { + try XCTSkipIf(!uringEnabled, failureMessage) + var ring = try IORing(queueDepth: 8) + + let request = IORing.Request.pollAdd( + FileDescriptor(rawValue: -1), pollEvents: .pollIn, + isMultiShot: false, context: 99 + ) + let success = try ring.submit(linkedRequests: request) + XCTAssertEqual(success, true) + + guard let completion = ring.tryConsumeCompletion() else { + XCTFail("expected a completion for the failed poll") + return + } + XCTAssertEqual(completion.context, 99) + + // A negative value for `result` marks the completion a a failure. + XCTAssertLessThan(completion.result, 0, "expected a failure") + // The negative value is the error code multiplied by -1. + XCTAssertEqual(Errno(rawValue: -completion.result), .badFileDescriptor) + + // A negative result may look like another result code. + // Checking for the error must happen first. + let pollNval = Int32(IORing.Request.PollEvents.pollNval.rawValue) + XCTAssertNotEqual(completion.result & pollNval, 0) + } } #endif // os(Linux) #endif // compiler(>=6.2) && $Lifetimes