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()`.
This commit is contained in:
Dmitry Kovba
2025-06-06 11:48:22 -07:00
committed by GitHub
parent cd94337f27
commit 8ad99efdbc
4 changed files with 37 additions and 32 deletions
+4 -1
View File
@@ -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()
}
}
}
@@ -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..<lineCount {
lines += EscapeSequence.moveUp
}
}
text = "\(text)\(terminating)\(lines)"
ProgressBar.display(text)
display(text)
}
}
+17 -13
View File
@@ -22,19 +22,27 @@ public final class ProgressBar: Sendable {
let config: ProgressConfig
@SendableProperty
var state: State
static let term: FileHandle? = getTerminal()
static let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar")
@SendableProperty
var printedWidth = 0
let term: FileHandle?
let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar")
private let standardError = StandardError()
/// Returns `true` if the progress bar has finished.
public var isFinished: Bool {
state.finished
}
/// Creates a new progress bar.
/// - Parameter config: The configuration for the progress bar.
public init(config: ProgressConfig) {
self.config = config
term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil
state = State(
description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks,
totalItems: config.initialTotalItems,
totalSize: config.initialTotalSize)
ProgressBar.display(EscapeSequence.hideCursor)
display(EscapeSequence.hideCursor)
}
deinit {
@@ -48,15 +56,9 @@ public final class ProgressBar: Sendable {
/// Allows resetting the progress state of the current task.
public func resetCurrentTask() {
clear()
state = State(description: state.description, itemsName: state.itemsName, tasks: state.tasks, totalTasks: state.totalTasks, startTime: state.startTime)
}
/// Returns `true` if the progress bar has finished.
public func isFinished() -> 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()
@@ -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