Skip to content

Commit 8527db3

Browse files
authored
Fix ProgressManager Bugs (swiftlang#1907)
* addChild fired withMutation of completedCount bug + fix * fractionCompleted update in interop case with NSProgress parent and ProgressManager grandchild bug + fix * atomic handling of updates to NSProgress * add tests * cleanup * add #if FOUNDATION_FRAMEWORK check * cleanup * cleanup
1 parent 0c96197 commit 8527db3

4 files changed

Lines changed: 232 additions & 32 deletions

File tree

Sources/FoundationEssentials/ProgressManager/ProgressManager+Interop.swift

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,14 @@ internal final class SubprogressBridge: Sendable {
133133
return
134134
}
135135

136-
// This needs to change totalUnitCount before completedUnitCount otherwise progressBridge will finish and mess up the math
137-
self.progressBridge.totalUnitCount = Int64(observerState.totalCount)
138-
self.progressBridge.completedUnitCount = Int64(observerState.completedCount)
136+
// Use atomic update to avoid corrupting NSProgress's parent accounting.
137+
// overallFraction's denominator changes when children finish, and setting
138+
// totalUnitCount and completedUnitCount separately causes a momentary spike
139+
// that permanently corrupts the parent's bookkeeping.
140+
self.progressBridge._setCompletedUnitCount(
141+
Int64(observerState.completedCount),
142+
totalUnitCount: Int64(observerState.totalCount)
143+
)
139144
}
140145
}
141146
}
@@ -298,6 +303,44 @@ extension ProgressManager {
298303
}
299304
}
300305

306+
/// Notifies interop bridge observers so that grandchild progress propagates
307+
/// through the SubprogressBridge to the parent NSProgress.
308+
internal func notifyInteropObserversOfChildUpdate() {
309+
// Phase 1: Collect bridge manager and dirty children info
310+
let info: (bridgeManager: ProgressManager, updates: [PendingChildUpdateInfo]?)? = state.withLock { state in
311+
guard case .interopObservation(let observation) = state.interopType,
312+
let bridgeManager = observation.subprogressBridge?.manager else {
313+
return nil
314+
}
315+
return (bridgeManager, state.pendingChildrenUpdates())
316+
}
317+
318+
guard let info, let updates = info.updates else {
319+
return
320+
}
321+
322+
// Phase 2: Resolve each dirty child's fraction
323+
var childrenUpdates: [PendingChildUpdate] = []
324+
for update in updates {
325+
let updatedFraction = update.manager.updatedProgressFraction()
326+
childrenUpdates.append(PendingChildUpdate(
327+
index: update.index,
328+
updatedFraction: updatedFraction,
329+
assignedCount: update.assignedCount
330+
))
331+
}
332+
333+
// Phase 3: Apply updates and compute fraction
334+
let observerState = state.withLock { state in
335+
state.updateChildrenProgressFraction(updates: childrenUpdates)
336+
let fraction = state.overallFraction
337+
return ObserverState(totalCount: fraction.total ?? 0, completedCount: fraction.completed)
338+
}
339+
340+
// Notify bridge
341+
info.bridgeManager.notifyObservers(with: observerState)
342+
}
343+
301344
internal func addBridge(reporterBridge: ProgressReporterBridge? = nil, nsProgressBridge: Foundation.Progress? = nil) {
302345
state.withLock { state in
303346
var interopObservation = InteropObservation(subprogressBridge: nil)

Sources/FoundationEssentials/ProgressManager/ProgressManager.swift

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,12 @@ internal import _FoundationCollections
384384
state.markChildDirty(at: position)
385385
}
386386
if let parents = parents {
387-
// rdar://172950622 (Async stream observation of ProgressManager's fractionCompleted does not receive updates from subsubprogress and beyond.)
388387
markSelfDirty(parents: parents)
389-
self.withMutation(keyPath: \.completedCount) {}
390388
}
389+
self.withMutation(keyPath: \.completedCount) {}
390+
#if FOUNDATION_FRAMEWORK
391+
notifyInteropObserversOfChildUpdate()
392+
#endif
391393
}
392394

393395
internal func updatedProgressFraction() -> ProgressFraction {
@@ -425,32 +427,30 @@ internal import _FoundationCollections
425427

426428
//MARK: Parent - Child Relationship Methods
427429
internal func addChild(childManager: ProgressManager, assignedCount: Int, childFraction: ProgressFraction) -> Int {
428-
self.withMutation(keyPath: \.completedCount) {
429-
let (index, parents) = state.withLock { state in
430-
let child = Child(manager: childManager,
431-
assignedCount: assignedCount,
432-
fraction: childFraction,
433-
isFractionDirty: true,
434-
totalFileCountSummary: PropertyStateInt(value: ProgressManager.Properties.TotalFileCount.defaultSummary, isDirty: false),
435-
completedFileCountSummary: PropertyStateInt(value: ProgressManager.Properties.CompletedFileCount.defaultSummary, isDirty: false),
436-
totalByteCountSummary: PropertyStateUInt64(value: ProgressManager.Properties.TotalByteCount.defaultSummary, isDirty: false),
437-
completedByteCountSummary: PropertyStateUInt64(value: ProgressManager.Properties.CompletedByteCount.defaultSummary, isDirty: false),
438-
throughputSummary: PropertyStateUInt64Array(value: ProgressManager.Properties.Throughput.defaultSummary, isDirty: false),
439-
estimatedTimeRemainingSummary: PropertyStateDuration(value: ProgressManager.Properties.EstimatedTimeRemaining.defaultSummary, isDirty: false),
440-
customPropertiesIntSummary: [:],
441-
customPropertiesUInt64Summary: [:],
442-
customPropertiesDoubleSummary: [:],
443-
customPropertiesStringSummary: [:],
444-
customPropertiesURLSummary: [:],
445-
customPropertiesUInt64ArraySummary: [:],
446-
customPropertiesDurationSummary: [:])
447-
state.children.append(child)
448-
return (state.children.count - 1, state.parents)
449-
}
450-
// Mark dirty all the way up to the root so that if the branch was marked not dirty right before this it will be marked dirty again (for optimization to work)
451-
markSelfDirty(parents: parents)
452-
return index
453-
}
430+
let (index, parents) = state.withLock { state in
431+
let child = Child(manager: childManager,
432+
assignedCount: assignedCount,
433+
fraction: childFraction,
434+
isFractionDirty: true,
435+
totalFileCountSummary: PropertyStateInt(value: ProgressManager.Properties.TotalFileCount.defaultSummary, isDirty: false),
436+
completedFileCountSummary: PropertyStateInt(value: ProgressManager.Properties.CompletedFileCount.defaultSummary, isDirty: false),
437+
totalByteCountSummary: PropertyStateUInt64(value: ProgressManager.Properties.TotalByteCount.defaultSummary, isDirty: false),
438+
completedByteCountSummary: PropertyStateUInt64(value: ProgressManager.Properties.CompletedByteCount.defaultSummary, isDirty: false),
439+
throughputSummary: PropertyStateUInt64Array(value: ProgressManager.Properties.Throughput.defaultSummary, isDirty: false),
440+
estimatedTimeRemainingSummary: PropertyStateDuration(value: ProgressManager.Properties.EstimatedTimeRemaining.defaultSummary, isDirty: false),
441+
customPropertiesIntSummary: [:],
442+
customPropertiesUInt64Summary: [:],
443+
customPropertiesDoubleSummary: [:],
444+
customPropertiesStringSummary: [:],
445+
customPropertiesURLSummary: [:],
446+
customPropertiesUInt64ArraySummary: [:],
447+
customPropertiesDurationSummary: [:])
448+
state.children.append(child)
449+
return (state.children.count - 1, state.parents)
450+
}
451+
// Mark dirty all the way up to the root so that if the branch was marked not dirty right before this it will be marked dirty again (for optimization to work)
452+
markSelfDirty(parents: parents)
453+
return index
454454
}
455455

456456
internal func addParent(parentManager: ProgressManager, positionInParent: Int) {

Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerInteropTests.swift

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,136 @@ import Testing
303303
receiveProgress(progress: interopChild)
304304
#expect(overallProgress2.totalUnitCount == 0)
305305
}
306-
306+
307+
@Test func interopProgressParentProgressManagerChildAndGrandchildren() async throws {
308+
// Caller's NSProgress
309+
let overall = Progress.discreteProgress(totalUnitCount: 10)
310+
let subprogress = overall.subprogress(assigningCount: 10)
311+
312+
// Receiver calls start(), then assigns work to sub-tasks via ProgressReporter
313+
let child = subprogress.start(totalCount: 2)
314+
let task1 = ProgressManager(totalCount: 100)
315+
let task2 = ProgressManager(totalCount: 100)
316+
child.assign(count: 1, to: task1.reporter)
317+
child.assign(count: 1, to: task2.reporter)
318+
319+
// Task 1 reports partial progress
320+
task1.complete(count: 50)
321+
#expect(overall.fractionCompleted == 0.25, "Task 1 50% of half should be 25% overall: expected 0.25, got \(overall.fractionCompleted)")
322+
323+
// Task 1 completes
324+
task1.complete(count: 50)
325+
#expect(overall.fractionCompleted == 0.5, "Task 1 complete should be 50% overall: expected 0.5, got \(overall.fractionCompleted)")
326+
327+
// Task 2 completes
328+
task2.complete(count: 100)
329+
#expect(overall.fractionCompleted == 1.0, "Both tasks complete should be 100%: expected 1.0, got \(overall.fractionCompleted)")
330+
}
331+
332+
@Test func interopProgressParentProgressManagerGrandchildrenNonSequentialCompletion() async throws {
333+
let overall = Progress.discreteProgress(totalUnitCount: 10)
334+
let subprogress = overall.subprogress(assigningCount: 10)
335+
336+
let child = subprogress.start(totalCount: 2)
337+
let task1 = ProgressManager(totalCount: 100)
338+
let task2 = ProgressManager(totalCount: 100)
339+
child.assign(count: 1, to: task1.reporter)
340+
child.assign(count: 1, to: task2.reporter)
341+
342+
// Task 2 reports partial progress first
343+
task2.complete(count: 50)
344+
#expect(overall.fractionCompleted == 0.25, "Task 2 50% of half should be 25% overall: expected 0.25, got \(overall.fractionCompleted)")
345+
346+
// Task 2 completes before task 1
347+
task2.complete(count: 50)
348+
#expect(overall.fractionCompleted == 0.5, "Task 2 complete should be 50% overall: expected 0.5, got \(overall.fractionCompleted)")
349+
350+
// Task 1 completes
351+
task1.complete(count: 100)
352+
#expect(overall.fractionCompleted == 1.0, "Both tasks complete should be 100%: expected 1.0, got \(overall.fractionCompleted)")
353+
}
354+
355+
@Test func interopProgressParentProgressManagerManyGrandchildren() async throws {
356+
let overall = Progress.discreteProgress(totalUnitCount: 10)
357+
let subprogress = overall.subprogress(assigningCount: 10)
358+
359+
let child = subprogress.start(totalCount: 5)
360+
var tasks: [ProgressManager] = []
361+
for _ in 0..<5 {
362+
let task = ProgressManager(totalCount: 100)
363+
child.assign(count: 1, to: task.reporter)
364+
tasks.append(task)
365+
}
366+
367+
// Complete each task one by one
368+
for i in 0..<5 {
369+
tasks[i].complete(count: 100)
370+
let expected = Double(i + 1) / 5.0
371+
#expect(overall.fractionCompleted == expected, "After \(i + 1) of 5 tasks complete: expected \(expected), got \(overall.fractionCompleted)")
372+
}
373+
}
374+
375+
@Test func interopProgressParentProgressManagerGrandchildrenPartialProgress() async throws {
376+
let overall = Progress.discreteProgress(totalUnitCount: 10)
377+
let subprogress = overall.subprogress(assigningCount: 10)
378+
379+
let child = subprogress.start(totalCount: 2)
380+
let task1 = ProgressManager(totalCount: 100)
381+
let task2 = ProgressManager(totalCount: 100)
382+
child.assign(count: 1, to: task1.reporter)
383+
child.assign(count: 1, to: task2.reporter)
384+
385+
// Task 1 at 25%
386+
task1.complete(count: 25)
387+
#expect(overall.fractionCompleted == 0.125, "Task 1 25% of half: expected 0.125, got \(overall.fractionCompleted)")
388+
389+
// Task 1 at 50%
390+
task1.complete(count: 25)
391+
#expect(overall.fractionCompleted == 0.25, "Task 1 50% of half: expected 0.25, got \(overall.fractionCompleted)")
392+
393+
// Task 2 at 50%
394+
task2.complete(count: 50)
395+
#expect(overall.fractionCompleted == 0.5, "Both at 50%: expected 0.5, got \(overall.fractionCompleted)")
396+
397+
// Task 1 completes
398+
task1.complete(count: 50)
399+
#expect(overall.fractionCompleted == 0.75, "Task 1 done, task 2 at 50%: expected 0.75, got \(overall.fractionCompleted)")
400+
401+
// Task 2 completes
402+
task2.complete(count: 50)
403+
#expect(overall.fractionCompleted == 1.0, "Both complete: expected 1.0, got \(overall.fractionCompleted)")
404+
}
405+
406+
@Test func interopProgressParentProgressManagerIndeterminateGrandchild() async throws {
407+
let overall = Progress.discreteProgress(totalUnitCount: 10)
408+
let subprogress = overall.subprogress(assigningCount: 10)
409+
410+
let child = subprogress.start(totalCount: 2)
411+
let task1 = ProgressManager(totalCount: nil) // indeterminate
412+
let task2 = ProgressManager(totalCount: 100)
413+
child.assign(count: 1, to: task1.reporter)
414+
child.assign(count: 1, to: task2.reporter)
415+
416+
// Indeterminate task should not affect overall progress
417+
#expect(overall.fractionCompleted == 0.0, "No progress yet: expected 0.0, got \(overall.fractionCompleted)")
418+
419+
// Determinate task reports progress
420+
task2.complete(count: 50)
421+
#expect(overall.fractionCompleted == 0.25, "Task 2 50% of half: expected 0.25, got \(overall.fractionCompleted)")
422+
423+
// Indeterminate task becomes determinate
424+
task1.setCounts { completed, total in
425+
total = 100
426+
}
427+
task1.complete(count: 50)
428+
#expect(overall.fractionCompleted == 0.5, "Both at 50%: expected 0.5, got \(overall.fractionCompleted)")
429+
430+
// Both complete
431+
task1.complete(count: 50)
432+
task2.complete(count: 50)
433+
#expect(overall.fractionCompleted == 1.0, "Both complete: expected 1.0, got \(overall.fractionCompleted)")
434+
}
435+
307436
#if FOUNDATION_EXIT_TESTS
308437
@Test func indirectParticipationOfProgressInAcyclicGraph() async throws {
309438
await #expect(processExitsWith: .failure) {

Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,4 +846,32 @@ extension Tag {
846846
#expect(root.fractionCompleted == 1.0)
847847
#expect(root.isFinished)
848848
}
849+
850+
@Test func isFinishedObservationNotConsumedByAddChild() async throws {
851+
let parent = ProgressManager(totalCount: 1)
852+
let subprogress = parent.subprogress(assigningCount: 1)
853+
854+
// Track how many times onChange fires and what isFinished value it sees
855+
let onChangeCount = Mutex(0)
856+
let observedIsFinished = Mutex(false)
857+
withObservationTracking {
858+
_ = parent.isFinished
859+
} onChange: {
860+
onChangeCount.withLock { $0 += 1 }
861+
observedIsFinished.withLock { $0 = parent.isFinished }
862+
}
863+
864+
// start() calls addChild — addChild should NOT consume the observation
865+
let child = subprogress.start(totalCount: 10)
866+
867+
// Verify addChild did not fire the observation
868+
#expect(onChangeCount.withLock { $0 } == 0, "addChild should not fire observation — onChange count was \(onChangeCount.withLock { $0 })")
869+
870+
// Complete the child — this should fire the observation with isFinished == true
871+
child.complete(count: 10)
872+
873+
#expect(parent.isFinished, "Parent should be finished after child completes")
874+
#expect(onChangeCount.withLock { $0 } == 1, "onChange should fire exactly once (on child completion) — count was \(onChangeCount.withLock { $0 })")
875+
#expect(observedIsFinished.withLock { $0 }, "isFinished observation should fire with true when child completes, not be consumed earlier by addChild")
876+
}
849877
}

0 commit comments

Comments
 (0)