diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index ba6136e1..1de8c3f1 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XToolSupport/Console.swift @@ -1,45 +1,103 @@ 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) } + /// 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? + private var cancelled = false + + /// 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?) { + 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 + } + + func cancel() { + lock.lock() + defer { lock.unlock() } + cancelled = true + continuation?.resume(throwing: CancellationError()) + continuation = nil + } } 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[..) in + 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() } } - let str = String(decoding: data, as: UTF8.self) - return str.hasSuffix("\r") ? String(str.dropLast()) : str + 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.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() throws -> String? { + var data = Data() + let handle = FileHandle.standardInput + while true { + 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") { + break + } + data.append(contentsOf: chunk) } + return String(decoding: data, as: UTF8.self) } static func promptRequired(_ message: String, existing: String?) async throws -> String {