From 8ad99efdbc0cff89b1217c36d2012b0fe22dac80 Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Fri, 6 Jun 2025 11:48:22 -0700 Subject: [PATCH] Allow printed output to flush to prevent a race between progress updates and other output (#23) This PR also improves clearing the last progress update after moving to a new task using `setDescription()`. --- Sources/CLI/Application.swift | 5 +++- .../ProgressBar+Terminal.swift | 29 +++++++----------- Sources/TerminalProgress/ProgressBar.swift | 30 +++++++++++-------- Sources/TerminalProgress/ProgressConfig.swift | 5 ++++ 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index 1f012b41..3d029bcf 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -140,7 +140,10 @@ struct Application: AsyncParsableCommand { signal(SIGTERM, signalHandler) // Normal and explicit exit. atexit { - ProgressBar.resetCursor() + if let progressConfig = try? ProgressConfig() { + let progressBar = ProgressBar(config: progressConfig) + progressBar.resetCursor() + } } } diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift index 99c92129..e7598f5d 100644 --- a/Sources/TerminalProgress/ProgressBar+Terminal.swift +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -24,9 +24,9 @@ enum EscapeSequence { } extension ProgressBar { - static var terminalWidth: Int { + private var terminalWidth: Int { guard - let termimalHandle = ProgressBar.term, + let termimalHandle = term, let terminal = try? Terminal(descriptor: termimalHandle.fileDescriptor) else { return 0 @@ -39,28 +39,20 @@ extension ProgressBar { /// Clears the progress bar and resets the cursor. public func clearAndResetCursor() { clear() - ProgressBar.resetCursor() + resetCursor() } /// Clears the progress bar. public func clear() { - // We can't use "\u{001B}[2K" for clearing the line because this may lead to a race with `stdout` when using `stderr` for progress updates. displayText("") } /// Resets the cursor. - static public func resetCursor() { - ProgressBar.display(EscapeSequence.showCursor) + public func resetCursor() { + display(EscapeSequence.showCursor) } - static func getTerminal() -> FileHandle? { - let standardError = FileHandle.standardError - let fd = standardError.fileDescriptor - let isATTY = isatty(fd) - return isATTY == 1 ? standardError : nil - } - - static func display(_ text: String) { + func display(_ text: String) { guard let term else { return } @@ -74,19 +66,20 @@ extension ProgressBar { var text = text // Clears previously printed characters if the new string is shorter. - text += String(repeating: " ", count: max(state.output.count - text.count, 0)) + text += String(repeating: " ", count: max(printedWidth - text.count, 0)) + printedWidth = text.count state.output = text // Clears previously printed lines. var lines = "" - if ProgressBar.terminalWidth > 0 { - let lineCount = (text.count - 1) / ProgressBar.terminalWidth + if terminating.hasSuffix("\r") && terminalWidth > 0 { + let lineCount = (text.count - 1) / terminalWidth for _ in 0.. Bool { - state.finished - } - private func printFullDescription() { if state.subDescription != "" { standardError.write("\(state.description) \(state.subDescription)") @@ -95,7 +97,7 @@ public final class ProgressBar: Sendable { printFullDescription() } - while !self.isFinished() { + while !isFinished { let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) render() state.iteration += 1 @@ -115,7 +117,7 @@ public final class ProgressBar: Sendable { /// Finishes the progress bar. public func finish() { - guard !self.isFinished() else { + guard !isFinished else { return } @@ -127,8 +129,10 @@ public final class ProgressBar: Sendable { if config.clearOnFinish { clearAndResetCursor() } else { - ProgressBar.resetCursor() + resetCursor() } + // Allow printed output to flush. + usleep(100_000) } } @@ -140,7 +144,7 @@ extension ProgressBar { } func render() { - guard ProgressBar.term != nil && !config.disableProgressUpdates else { + guard term != nil && !config.disableProgressUpdates && !isFinished else { return } let output = draw() diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift index 86825b58..544604f1 100644 --- a/Sources/TerminalProgress/ProgressConfig.swift +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -18,6 +18,8 @@ import Foundation /// A configuration for displaying a progress bar. public struct ProgressConfig: Sendable { + /// The file handle for progress updates. + let terminal: FileHandle /// The initial description of the progress bar. let initialDescription: String /// The initial additional description of the progress bar. @@ -65,6 +67,7 @@ public struct ProgressConfig: Sendable { public let disableProgressUpdates: Bool /// Creates a new instance of `ProgressConfig`. /// - Parameters: + /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. /// - description: The initial description of the progress bar. The default value is `""`. /// - subDescription: The initial additional description of the progress bar. The default value is `""`. /// - itemsName: The initial items name. The default value is `"it"`. @@ -86,6 +89,7 @@ public struct ProgressConfig: Sendable { /// - clearOnFinish: The flag indicating whether to clear the progress bar before reseting the cursor. The default is `true`. /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. public init( + terminal: FileHandle = .standardError, description: String = "", subDescription: String = "", itemsName: String = "it", @@ -123,6 +127,7 @@ public struct ProgressConfig: Sendable { } } + self.terminal = terminal self.initialDescription = description self.initialSubDescription = subDescription self.initialItemsName = itemsName