[2.x] fix: Stabilize progress indicator sort (#8984)

When multiple tasks run in parallel, the super shell progress lines reorder in a semi-random manner on every refresh because they are sorted by raw elapsed microseconds. Tasks that start at nearly the same time constantly swap positions due to sub-second timing jitter, making the display hard to read.

This fixes the sort key to use elapsed seconds (rounded down) with task name as a tiebreaker, matching the granularity already shown to the user (e.g. 3s). Tasks displaying the same elapsed second now stay alphabetically stable instead of flickering.
This commit is contained in:
BitToby 2026-03-27 00:17:30 +02:00 committed by GitHub
parent 99471da5f1
commit f81a1524bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 96 additions and 1 deletions

View File

@ -170,7 +170,12 @@ private[sbt] class TaskProgress(
val currentTasksCount = currentTasks.size
def event(tasks: Vector[(TaskId[?], Long)]): ProgressEvent = {
if (tasks.nonEmpty) nextReport.set(Deadline.now + sleepDuration)
val toWrite = tasks.sortBy(_._2)
// Sort by elapsed seconds (rounded down), then by name for stability.
// Without rounding, microsecond jitter causes tasks with similar start times
// to swap positions on every refresh, making the display hard to read. See #5466.
val toWrite = tasks.sortBy { (task, elapsed) =>
(elapsed / 1000000L, taskName(task))
}
val distinct = new java.util.LinkedHashMap[String, ProgressItem]
toWrite.foreach { (task, elapsed) =>
val name = taskName(task)

View File

@ -0,0 +1,90 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
import sbt.internal.util.ProgressItem
import verify.BasicTestSuite
object TaskProgressSpec extends BasicTestSuite {
// Reproduces the sorting logic from TaskProgress.report() so we can test it in isolation.
// The real code sorts Vector[(TaskId[?], Long)] then maps to ProgressItem, but the
// ordering key is (elapsedMicros / 1_000_000, name) -- we test that contract here.
private def sortItems(items: Vector[ProgressItem]): Vector[ProgressItem] =
items.sortBy(item => (item.elapsedMicros / 1000000L, item.name))
test("tasks with different elapsed seconds are sorted by elapsed time ascending") {
val items = Vector(
ProgressItem("compile", 5_000_000L), // 5s
ProgressItem("update", 2_000_000L), // 2s
ProgressItem("test", 8_000_000L), // 8s
)
val sorted = sortItems(items)
assert(sorted.map(_.name) == Vector("update", "compile", "test"))
}
test("tasks within the same elapsed second are sorted alphabetically by name") {
// All three tasks are within the same 1-second bucket (1s)
val items = Vector(
ProgressItem("zinc", 1_900_000L),
ProgressItem("compile", 1_100_000L),
ProgressItem("api", 1_500_000L),
)
val sorted = sortItems(items)
assert(sorted.map(_.name) == Vector("api", "compile", "zinc"))
}
test("sub-second microsecond jitter does not change ordering (#5466)") {
// Two tasks started at almost the same time. On successive refreshes, their
// microsecond elapsed times may flip relative order. With second-granularity
// rounding + alphabetical tiebreak, the order must stay stable.
val refresh1 = Vector(
ProgressItem("b / compile", 3_000_100L), // 3.000100s
ProgressItem("a / compile", 3_000_200L), // 3.000200s
)
val refresh2 = Vector(
ProgressItem("b / compile", 3_500_200L), // 3.500200s -- now b > a in micros
ProgressItem("a / compile", 3_500_100L), // 3.500100s
)
val sorted1 = sortItems(refresh1)
val sorted2 = sortItems(refresh2)
// Both refreshes should produce the same order: alphabetical within same second bucket
assert(sorted1.map(_.name) == Vector("a / compile", "b / compile"))
assert(sorted2.map(_.name) == Vector("a / compile", "b / compile"))
}
test("tasks crossing a second boundary do change position") {
val items = Vector(
ProgressItem("update", 2_999_999L), // still in 2s bucket
ProgressItem("compile", 3_000_001L), // in 3s bucket
)
val sorted = sortItems(items)
assert(sorted.map(_.name) == Vector("update", "compile"))
}
test("mixed buckets: elapsed seconds dominate, names break ties within") {
val items = Vector(
ProgressItem("z-task", 1_200_000L), // 1s bucket
ProgressItem("a-task", 1_800_000L), // 1s bucket
ProgressItem("m-task", 2_100_000L), // 2s bucket
ProgressItem("b-task", 500_000L), // 0s bucket
)
val sorted = sortItems(items)
assert(sorted.map(_.name) == Vector("b-task", "a-task", "z-task", "m-task"))
}
test("empty input produces empty output") {
assert(sortItems(Vector.empty) == Vector.empty)
}
test("single task is returned as-is") {
val items = Vector(ProgressItem("compile", 5_000_000L))
assert(sortItems(items) == items)
}
}