From a75118fd474577b880709d7c698975a631c5ed43 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:21:32 -0700 Subject: [PATCH 1/2] tests: xtool prompt crashes when stdout is /dev/null prompt takes explicit input and output descriptors and uses the NIO bootstrap only when isEpollRegisterable returns true for both. On Linux that function creates an epoll fd, attempts EPOLL_CTL_ADD with EPOLLERR|EPOLLHUP (Glibc enum raw values, Musl integer constants), and takes the direct path only for EPERM; a failed epoll_create1 stays on the NIO path, and non-Linux builds always do. The direct path writes the message with FileDescriptor.writeAll and reads the reply on a detached task, appending 256-byte reads until a newline or EOF. A NIO write that throws ChannelError.ioOnClosedChannel writes the prompt on the raw output descriptor instead, and the new ConsoleTests cover a normal line, EOF with and without a partial line, a line longer than the 256-byte buffer, pipes and /dev/null, and the Linux epoll preflight. Fixes #268 --- Sources/XToolSupport/Console.swift | 105 ++++++++++++++++++++++- Tests/XToolTests/ConsoleTests.swift | 124 ++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 Tests/XToolTests/ConsoleTests.swift diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index 8dc420c1..f20b10b5 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XToolSupport/Console.swift @@ -3,9 +3,20 @@ import XKit import XUtils import NIOPosix import NIOCore +#if os(Linux) +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +#endif enum Console { + private static let fallbackReadBufferSize = 256 + private static func withStdio( + input: CInt, + output: CInt, _ body: ( _ stdin: NIOAsyncChannelInboundStream, _ stdout: NIOAsyncChannelOutboundWriter @@ -13,8 +24,8 @@ enum Console { ) async throws -> T { try await NIOPipeBootstrap(group: .singletonMultiThreadedEventLoopGroup) .takingOwnershipOfDescriptors( - input: FileDescriptor.standardInput.duplicate().rawValue, - output: FileDescriptor.standardOutput.duplicate().rawValue + input: FileDescriptor(rawValue: input).duplicate().rawValue, + output: FileDescriptor(rawValue: output).duplicate().rawValue ) .flatMapThrowing { try NIOAsyncChannel(wrappingChannelSynchronously: $0) } .get() @@ -22,8 +33,61 @@ enum Console { } static func prompt(_ message: String) async throws -> String { - try await withStdio { stdin, stdout in - try await stdout.write(ByteBuffer(bytes: message.utf8)) + try await prompt( + message, + input: FileDescriptor.standardInput.rawValue, + output: FileDescriptor.standardOutput.rawValue + ) + } + + static func prompt(_ message: String, input: CInt, output: CInt) async throws -> String { + if isEpollRegisterable(input), isEpollRegisterable(output) { + return try await promptUsingNIO(message, input: input, output: output) + } + return try await promptUsingFileDescriptors(message, input: input, output: output) + } + + /// Linux `epoll_ctl` returns `EPERM` for `/dev/null`, and SwiftNIO turns that into a fatal error. + /// `isatty` and `poll` miss it: non-ttys already work, and `poll` reports `/dev/null` as ready. + #if os(Linux) + static func isEpollRegisterable(_ descriptor: CInt) -> Bool { + let epollFD = epoll_create1(numericCast(EPOLL_CLOEXEC)) + guard epollFD >= 0 else { + return true + } + defer { try? FileDescriptor(rawValue: epollFD).close() } + + var event = epoll_event() + // Glibc imports these as an enum; Musl imports them as integers. + #if canImport(Musl) + event.events = numericCast(EPOLLERR) | numericCast(EPOLLHUP) + #else + event.events = numericCast(EPOLLERR.rawValue) | numericCast(EPOLLHUP.rawValue) + #endif + guard epoll_ctl(epollFD, numericCast(EPOLL_CTL_ADD), descriptor, &event) == 0 else { + return errno != EPERM + } + return true + } + #else + static func isEpollRegisterable(_: CInt) -> Bool { + true + } + #endif + + private static func promptUsingNIO( + _ message: String, + input: CInt, + output: CInt + ) async throws -> String { + try await withStdio(input: input, output: output) { stdin, stdout in + do { + try await stdout.write(ByteBuffer(bytes: message.utf8)) + } catch ChannelError.ioOnClosedChannel { + // An already-closed pipe is drained and the channel closed before this write. + // Those bytes are buffered on `stdin`; the caller's output descriptor is still open. + try FileDescriptor(rawValue: output).writeAll(message.utf8) + } fflush(stdoutSafe) @@ -41,6 +105,39 @@ enum Console { } } + private static func promptUsingFileDescriptors( + _ message: String, + input: CInt, + output: CInt + ) async throws -> String { + try FileDescriptor(rawValue: output).writeAll(message.utf8) + fflush(stdoutSafe) + return try await Task.detached { + try Self.readLine(from: input) + }.value + } + + private static func readLine(from descriptor: CInt) throws -> String { + let input = FileDescriptor(rawValue: descriptor) + var data = Data() + var buffer = [UInt8](repeating: 0, count: fallbackReadBufferSize) + while true { + let bytesRead = try buffer.withUnsafeMutableBytes { rawBuffer in + try input.read(into: rawBuffer) + } + if bytesRead == 0 { + break + } + let chunk = buffer.prefix(bytesRead) + if let newline = chunk.firstIndex(of: UInt8(ascii: "\n")) { + data.append(contentsOf: chunk[.. String { let value: String if let existing { diff --git a/Tests/XToolTests/ConsoleTests.swift b/Tests/XToolTests/ConsoleTests.swift new file mode 100644 index 00000000..211aa441 --- /dev/null +++ b/Tests/XToolTests/ConsoleTests.swift @@ -0,0 +1,124 @@ +import Testing +import XUtils +@testable import XToolSupport + +@Test func testPromptReadsLineWhenOutputIsDevNull() async throws { + let input = try FileDescriptor.pipe() + defer { + try? input.readEnd.close() + try? input.writeEnd.close() + } + try input.writeEnd.writeAll("hello\n".utf8) + + let output = try FileDescriptor.open("/dev/null", .writeOnly) + defer { try? output.close() } + + let reply = try await Console.prompt( + "Path to Xcode.xip: ", + input: input.readEnd.rawValue, + output: output.rawValue + ) + #expect(reply == "hello") +} + +@Test func testPromptReadsLineFromPipes() async throws { + let input = try FileDescriptor.pipe() + let output = try FileDescriptor.pipe() + defer { + try? input.readEnd.close() + try? input.writeEnd.close() + try? output.readEnd.close() + try? output.writeEnd.close() + } + try input.writeEnd.writeAll("hello\n".utf8) + + let reply = try await Console.prompt( + "prompt: ", + input: input.readEnd.rawValue, + output: output.writeEnd.rawValue + ) + #expect(reply == "hello") +} + +@Test func testPromptReturnsPartialLineAtEOF() async throws { + let input = try FileDescriptor.pipe() + defer { try? input.readEnd.close() } + try input.writeEnd.writeAll("hello".utf8) + try input.writeEnd.close() + + let output = try FileDescriptor.pipe() + defer { + try? output.readEnd.close() + try? output.writeEnd.close() + } + + let reply = try await Console.prompt( + "prompt: ", + input: input.readEnd.rawValue, + output: output.writeEnd.rawValue + ) + #expect(reply == "hello") +} + +@Test func testPromptReturnsEmptyStringAtEOF() async throws { + let input = try FileDescriptor.pipe() + defer { try? input.readEnd.close() } + try input.writeEnd.close() + + let output = try FileDescriptor.pipe() + defer { + try? output.readEnd.close() + try? output.writeEnd.close() + } + + let closedPipe = try await Console.prompt( + "prompt: ", + input: input.readEnd.rawValue, + output: output.writeEnd.rawValue + ) + #expect(closedPipe == "") + + let devNull = try FileDescriptor.open("/dev/null", .readOnly) + defer { try? devNull.close() } + let discarded = try await Console.prompt( + "prompt: ", + input: devNull.rawValue, + output: output.writeEnd.rawValue + ) + #expect(discarded == "") +} + +@Test func testPromptReadsLineLongerThanFallbackBuffer() async throws { + let line = String(repeating: "a", count: 1000) + let input = try FileDescriptor.pipe() + defer { + try? input.readEnd.close() + try? input.writeEnd.close() + } + try input.writeEnd.writeAll((line + "\n").utf8) + + let output = try FileDescriptor.open("/dev/null", .writeOnly) + defer { try? output.close() } + + let reply = try await Console.prompt( + "prompt: ", + input: input.readEnd.rawValue, + output: output.rawValue + ) + #expect(reply == line) +} + +#if os(Linux) +@Test func testEpollPreflightRejectsDevNullAndAcceptsPipe() throws { + let devNull = try FileDescriptor.open("/dev/null", .writeOnly) + defer { try? devNull.close() } + let pipe = try FileDescriptor.pipe() + defer { + try? pipe.readEnd.close() + try? pipe.writeEnd.close() + } + + #expect(!Console.isEpollRegisterable(devNull.rawValue)) + #expect(Console.isEpollRegisterable(pipe.readEnd.rawValue)) +} +#endif From 4f9671cd7a7281b65a3866f1242b9fb2ca2caa45 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:13:18 -0700 Subject: [PATCH 2/2] fix: classify stdio with fstat instead of epoll on Linux The Glibc and Musl modules do not export , so epoll_create1, epoll_event and EPOLL_* were out of scope and build-linux failed to compile Console.swift. isEpollRegisterable now uses fstat: regular files, directories and non-tty character devices such as /dev/null report false (epoll_ctl returns EPERM for them); pipes, sockets and ttys report true. --- Sources/XToolSupport/Console.swift | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index f20b10b5..a9c994fa 100644 --- a/Sources/XToolSupport/Console.swift +++ b/Sources/XToolSupport/Console.swift @@ -47,27 +47,25 @@ enum Console { return try await promptUsingFileDescriptors(message, input: input, output: output) } - /// Linux `epoll_ctl` returns `EPERM` for `/dev/null`, and SwiftNIO turns that into a fatal error. - /// `isatty` and `poll` miss it: non-ttys already work, and `poll` reports `/dev/null` as ready. + /// Linux `epoll_ctl` returns `EPERM` for `/dev/null` and regular files, and SwiftNIO turns that + /// into a fatal error. `isatty` and `poll` miss it: non-ttys already work, and `poll` reports + /// `/dev/null` as ready. The Glibc and Musl modules do not export ``, so classify + /// the descriptor with `fstat`: regular files, directories and non-tty character devices + /// (such as `/dev/null`) cannot be registered; pipes, sockets and ttys can. #if os(Linux) static func isEpollRegisterable(_ descriptor: CInt) -> Bool { - let epollFD = epoll_create1(numericCast(EPOLL_CLOEXEC)) - guard epollFD >= 0 else { + var info = stat() + guard fstat(descriptor, &info) == 0 else { return true } - defer { try? FileDescriptor(rawValue: epollFD).close() } - - var event = epoll_event() - // Glibc imports these as an enum; Musl imports them as integers. - #if canImport(Musl) - event.events = numericCast(EPOLLERR) | numericCast(EPOLLHUP) - #else - event.events = numericCast(EPOLLERR.rawValue) | numericCast(EPOLLHUP.rawValue) - #endif - guard epoll_ctl(epollFD, numericCast(EPOLL_CTL_ADD), descriptor, &event) == 0 else { - return errno != EPERM + switch info.st_mode & mode_t(0o170000) { + case mode_t(0o100000), mode_t(0o040000): + return false + case mode_t(0o020000): + return isatty(descriptor) == 1 + default: + return true } - return true } #else static func isEpollRegisterable(_: CInt) -> Bool {