Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 89 additions & 31 deletions Sources/XToolSupport/Console.swift
Original file line number Diff line number Diff line change
@@ -1,45 +1,103 @@
import Foundation
import XKit
import XUtils
import NIOPosix
import NIOCore

enum Console {
private static func withStdio<T>(
_ 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
)
.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<String?, any Swift.Error>?
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<String?, any Swift.Error>) -> 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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[..<endIndex]
break
} else {
data += view
print(message, terminator: "")
fflush(stdoutSafe)

let read = PromptRead()
return try await withTaskCancellationHandler {
let line = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String?, any Swift.Error>) 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 {
Expand Down