From 6dffa040c6d85522db6fbeff3af8b3af15ffd2f6 Mon Sep 17 00:00:00 2001 From: Mustafa Kuloglu Date: Sun, 18 Oct 2026 23:26:41 +0200 Subject: [PATCH 1/3] Fix Console.prompt crash on newer Linux kernels (epoll EPERM on /dev/null) NIOPipeBootstrap registers the stdin descriptor with epoll_ctl, which returns EPERM for /dev/null on recent kernels, triggering SwiftNIO's 'try!' fatal error. Use readLine() instead of async NIO I/O for prompts. Fixes: xtool auth login < /dev/null crashing with 'epoll_ctl(...): Operation not permitted (errno: 1)' --- Sources/XToolSupport/Console.swift | 42 +++++------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index ba6136e1..d3c85038 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XToolSupport/Console.swift @@ -1,45 +1,17 @@ import Foundation import XKit import XUtils -import NIOPosix -import NIOCore enum Console { - private static func withStdio( - _ body: ( - _ stdin: NIOAsyncChannelInboundStream, - _ stdout: NIOAsyncChannelOutboundWriter - ) async throws -> T - ) async throws -> T { - try await NIOPipeBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .takingOwnershipOfDescriptors( - input: FileDescriptor.standardInput.duplicate().rawValue, - output: FileDescriptor.standardOutput.duplicate().rawValue - ) - .flatMapThrowing { try NIOAsyncChannel(wrappingChannelSynchronously: $0) } - .get() - .executeThenClose { try await body($0, $1) } - } - static func prompt(_ message: String) async throws -> String { - try await withStdio { stdin, stdout in - try await stdout.write(ByteBuffer(bytes: message.utf8)) - - fflush(stdoutSafe) - - var data = Data() - for try await chunk in stdin { - let view = chunk.readableBytesView - if let endIndex = view.firstIndex(of: UInt8(ascii: "\n")) { - data += view[.. String { From 1f83e66b4c7522609e1bbea54072adbd94a1aff4 Mon Sep 17 00:00:00 2001 From: Mustafa Kuloglu Date: Sun, 18 Oct 2026 23:50:09 +0200 Subject: [PATCH 2/3] Make Console.prompt cancellation-aware readLine() is synchronous and doesn't observe task cancellation, so Ctrl-C (SIGINT -> task.cancel() in cancellableMain) left prompts stuck. Bridge the blocking read to async with a CheckedContinuation and run it on a thread, resuming with CancellationError on cancel. Also use FileHandle.read (raw read) instead of readLine(): C stdio holds the stdin lock while blocked, which deadlocks exit() after cancellation. --- Sources/XToolSupport/Console.swift | 72 +++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index d3c85038..80edc64d 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XToolSupport/Console.swift @@ -3,15 +3,77 @@ import XKit import XUtils enum Console { + /// Bridges the synchronous, blocking stdin read to a cancellable async + /// read. SIGINT is delivered as task cancellation by + /// XToolCommand.cancellableMain, so a prompt must observe cancellation. + private final class PromptRead: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + func install(_ continuation: CheckedContinuation) { + lock.lock() + defer { lock.unlock() } + self.continuation = continuation + } + + func finish(returning value: String?) { + lock.lock() + defer { lock.unlock() } + continuation?.resume(returning: value) + continuation = nil + } + + func finish(throwing error: any Swift.Error) { + lock.lock() + defer { lock.unlock() } + continuation?.resume(throwing: error) + continuation = nil + } + } + static func prompt(_ message: String) async throws -> String { print(message, terminator: "") fflush(stdoutSafe) - guard let line = readLine() else { - // EOF (e.g. stdin redirected from /dev/null): surface a clean - // error instead of trapping on NIO's epoll registration. - throw Error("Unexpected end of input.") + + let read = PromptRead() + return try await withTaskCancellationHandler { + let line = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + read.install(continuation) + let thread = Thread { + read.finish(returning: readLineFromStandardInput()) + } + thread.name = "xtool-console-prompt" + thread.start() + } + guard let line else { + // EOF (e.g. stdin redirected from /dev/null): surface a clean + // error instead of trapping on NIO's epoll registration. + throw Error("Unexpected end of input.") + } + return line.hasSuffix("\r") ? String(line.dropLast()) : line + } onCancel: { + // Unblock the suspended continuation; the read thread's blocking + // read is abandoned and dies when the process exits. + read.finish(throwing: CancellationError()) + } + } + + /// Reads a line from stdin using FileHandle (raw read(), not C stdio). + /// C stdio's readLine() holds the stdin lock while blocked, which + /// deadlocks exit() after SIGINT cancels a prompt. + private static func readLineFromStandardInput() -> String? { + var data = Data() + let handle = FileHandle.standardInput + while true { + guard let chunk = try? handle.read(upToCount: 1), !chunk.isEmpty else { + return data.isEmpty ? nil : String(decoding: data, as: UTF8.self) + } + if chunk.first == UInt8(ascii: "\n") { + break + } + data.append(contentsOf: chunk) } - return line.hasSuffix("\r") ? String(line.dropLast()) : line + return String(decoding: data, as: UTF8.self) } static func promptRequired(_ message: String, existing: String?) async throws -> String { From a32b3c9a2fe4b4cb4ac9410a8355306b7d7c2b22 Mon Sep 17 00:00:00 2001 From: Mustafa Kuloglu Date: Sun, 18 Oct 2026 23:59:42 +0200 Subject: [PATCH 3/3] Fix prompt cancellation race and propagate read errors - PromptRead now records cancellation even if it races ahead of install(), so a prompt cancelled before its continuation is installed resumes immediately instead of blocking forever. - readLineFromStandardInput() now throws read errors (instead of swallowing them as EOF); the read thread forwards them through the continuation. --- Sources/XToolSupport/Console.swift | 42 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index 80edc64d..1de8c3f1 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XToolSupport/Console.swift @@ -9,11 +9,21 @@ enum Console { private final class PromptRead: @unchecked Sendable { private let lock = NSLock() private var continuation: CheckedContinuation? + private var cancelled = false - func install(_ continuation: CheckedContinuation) { + /// Returns true when the continuation was installed and the caller + /// should start the blocking read. Returns false when cancellation + /// already happened: the continuation is resumed immediately and no + /// read thread should be started. + func install(_ continuation: CheckedContinuation) -> Bool { lock.lock() defer { lock.unlock() } + if cancelled { + continuation.resume(throwing: CancellationError()) + return false + } self.continuation = continuation + return true } func finish(returning value: String?) { @@ -29,6 +39,14 @@ enum Console { continuation?.resume(throwing: error) continuation = nil } + + func cancel() { + lock.lock() + defer { lock.unlock() } + cancelled = true + continuation?.resume(throwing: CancellationError()) + continuation = nil + } } static func prompt(_ message: String) async throws -> String { @@ -38,12 +56,17 @@ enum Console { let read = PromptRead() return try await withTaskCancellationHandler { let line = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - read.install(continuation) - let thread = Thread { - read.finish(returning: readLineFromStandardInput()) + if read.install(continuation) { + let thread = Thread { + do { + read.finish(returning: try readLineFromStandardInput()) + } catch { + read.finish(throwing: error) + } + } + thread.name = "xtool-console-prompt" + thread.start() } - thread.name = "xtool-console-prompt" - thread.start() } guard let line else { // EOF (e.g. stdin redirected from /dev/null): surface a clean @@ -54,18 +77,19 @@ enum Console { } onCancel: { // Unblock the suspended continuation; the read thread's blocking // read is abandoned and dies when the process exits. - read.finish(throwing: CancellationError()) + read.cancel() } } /// Reads a line from stdin using FileHandle (raw read(), not C stdio). /// C stdio's readLine() holds the stdin lock while blocked, which /// deadlocks exit() after SIGINT cancels a prompt. - private static func readLineFromStandardInput() -> String? { + private static func readLineFromStandardInput() throws -> String? { var data = Data() let handle = FileHandle.standardInput while true { - guard let chunk = try? handle.read(upToCount: 1), !chunk.isEmpty else { + guard let chunk = try handle.read(upToCount: 1), !chunk.isEmpty else { + // EOF (nil or empty read) return data.isEmpty ? nil : String(decoding: data, as: UTF8.self) } if chunk.first == UInt8(ascii: "\n") {