diff --git a/Sources/XToolSupport/Console.swift b/Sources/XToolSupport/Console.swift index 8dc420c1..a9c994fa 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,59 @@ 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 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 { + var info = stat() + guard fstat(descriptor, &info) == 0 else { + return true + } + 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 + } + } + #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 +103,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