Repository navigation
tests: xtool prompt crashes when stdout is /dev/null #304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,27 +3,89 @@ 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<T>( | ||
| input: CInt, | ||
| output: CInt, | ||
| _ body: ( | ||
| _ stdin: NIOAsyncChannelInboundStream<ByteBuffer>, | ||
| _ stdout: NIOAsyncChannelOutboundWriter<ByteBuffer> | ||
| ) async throws -> T | ||
| ) 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() | ||
| .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)) | ||
| 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 `<sys/epoll.h>`, 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[..<newline]) | ||
| break | ||
|
Comment on lines
+130
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Preserve input after the first newline. If one 256-byte read returns 🤖 Prompt for AI Agents |
||
| } | ||
| data.append(contentsOf: chunk) | ||
| } | ||
| return String(decoding: data, as: UTF8.self) | ||
| } | ||
|
|
||
| static func promptRequired(_ message: String, existing: String?) async throws -> String { | ||
| let value: String | ||
| if let existing { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Close duplicated descriptors when setup fails.
If the output duplication throws, the input duplicate remains open. If
NIOPipeBootstrap.takingOwnershipOfDescriptorsfails, SwiftNIO leaves ownership of both duplicates with this function, but this function does not close them. Keep the duplicates asFileDescriptorvalues and close any descriptors that NIO did not take. (raw.githubusercontent.com)Based on learnings, bare duplicated descriptor integers need explicit cleanup on failure paths.
🤖 Prompt for AI Agents
Source: Learnings