Skip to content

Commit 3c2ffc9

Browse files
committed
add unit tests for thread safety + fix for race condition in fractionCompleted
1 parent c71434f commit 3c2ffc9

File tree

2 files changed

+353
-2
lines changed

2 files changed

+353
-2
lines changed

Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,16 +191,27 @@ extension ProgressManager {
191191
guard !children.isEmpty else {
192192
return
193193
}
194+
194195
for (idx, childState) in children.enumerated() {
195196
if childState.isDirty {
196197
if let child = childState.child {
197198
let updatedProgressFraction = child.getUpdatedProgressFraction()
199+
let wasFinished = children[idx].childFraction.isFinished
198200
children[idx].childFraction = updatedProgressFraction
199-
if updatedProgressFraction.isFinished {
201+
// Only add to selfFraction if transitioning from unfinished to finished
202+
if updatedProgressFraction.isFinished && !wasFinished {
200203
selfFraction.completed += children[idx].portionOfTotal
201204
}
202205
} else {
203-
selfFraction.completed += children[idx].portionOfTotal
206+
let wasFinished = children[idx].childFraction.isFinished
207+
if !wasFinished {
208+
selfFraction.completed += children[idx].portionOfTotal
209+
// Mark nil child as finished to avoid any double counting
210+
children[idx].childFraction = ProgressFraction(
211+
completed: children[idx].portionOfTotal,
212+
total: children[idx].portionOfTotal
213+
)
214+
}
204215
}
205216
children[idx].isDirty = false
206217
}

Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,343 @@ extension Tag {
408408
#expect(manager.fractionCompleted == 1.0)
409409
}
410410
}
411+
412+
// MARK: - Thread Safety and Concurrent Access Tests
413+
@Suite("Progress Manager Thread Safety Tests", .tags(.progressManager)) struct ProgressManagerThreadSafetyTests {
414+
415+
@Test func concurrentBasicPropertiesAccess() async throws {
416+
let manager = ProgressManager(totalCount: 10)
417+
manager.complete(count: 5)
418+
419+
await withThrowingTaskGroup(of: Void.self) { group in
420+
421+
group.addTask {
422+
for _ in 1...10 {
423+
let fraction = manager.fractionCompleted
424+
#expect(fraction == 0.5)
425+
}
426+
}
427+
428+
group.addTask {
429+
for _ in 1...10 {
430+
let completed = manager.completedCount
431+
#expect(completed == 5)
432+
}
433+
}
434+
435+
group.addTask {
436+
for _ in 1...10 {
437+
let total = manager.totalCount
438+
#expect(total == 10)
439+
}
440+
}
441+
442+
group.addTask {
443+
for _ in 1...10 {
444+
let isFinished = manager.isFinished
445+
#expect(isFinished == false)
446+
}
447+
}
448+
449+
group.addTask {
450+
for _ in 1...10 {
451+
let isIndeterminate = manager.isIndeterminate
452+
#expect(isIndeterminate == false)
453+
}
454+
}
455+
}
456+
}
457+
458+
@Test func concurrentMultipleChildrenUpdatesAndParentReads() async throws {
459+
let manager = ProgressManager(totalCount: 100)
460+
let child1 = manager.subprogress(assigningCount: 30).start(totalCount: 10)
461+
let child2 = manager.subprogress(assigningCount: 40).start(totalCount: 8)
462+
let child3 = manager.subprogress(assigningCount: 30).start(totalCount: 6)
463+
464+
await withTaskGroup(of: Void.self) { group in
465+
group.addTask {
466+
for _ in 1...10 {
467+
child1.complete(count: 1)
468+
}
469+
}
470+
471+
group.addTask {
472+
for _ in 1...8 {
473+
child2.complete(count: 1)
474+
}
475+
}
476+
477+
group.addTask {
478+
for _ in 1...6 {
479+
child3.complete(count: 1)
480+
}
481+
}
482+
483+
group.addTask {
484+
for _ in 1...50 {
485+
let _ = manager.fractionCompleted
486+
let _ = manager.completedCount
487+
let _ = manager.isFinished
488+
}
489+
}
490+
491+
group.addTask {
492+
for _ in 1...30 {
493+
let _ = child1.fractionCompleted
494+
let _ = child2.completedCount
495+
let _ = child3.isFinished
496+
}
497+
}
498+
}
499+
500+
#expect(child1.isFinished == true)
501+
#expect(child2.isFinished == true)
502+
#expect(child3.isFinished == true)
503+
#expect(manager.fractionCompleted == 1.0)
504+
}
505+
506+
@Test func concurrentSingleChildUpdatesAndParentReads() async throws {
507+
let manager = ProgressManager(totalCount: 50)
508+
let child = manager.subprogress(assigningCount: 50).start(totalCount: 100)
509+
510+
await withThrowingTaskGroup(of: Void.self) { group in
511+
group.addTask {
512+
for i in 1...100 {
513+
child.complete(count: 1)
514+
if i % 10 == 0 {
515+
try? await Task.sleep(nanoseconds: 1_000_000)
516+
}
517+
}
518+
}
519+
520+
group.addTask {
521+
for _ in 1...200 {
522+
let _ = manager.fractionCompleted
523+
let _ = manager.completedCount
524+
let _ = manager.totalCount
525+
let _ = manager.isFinished
526+
let _ = manager.isIndeterminate
527+
}
528+
}
529+
530+
group.addTask {
531+
for _ in 1...150 {
532+
let _ = child.fractionCompleted
533+
let _ = child.completedCount
534+
let _ = child.isFinished
535+
}
536+
}
537+
}
538+
539+
#expect(child.isFinished == true)
540+
#expect(manager.fractionCompleted == 1.0)
541+
}
542+
543+
@Test func concurrentGrandchildrenUpdates() async throws {
544+
let parent = ProgressManager(totalCount: 60)
545+
let child1 = parent.subprogress(assigningCount: 20).start(totalCount: 10)
546+
let child2 = parent.subprogress(assigningCount: 20).start(totalCount: 8)
547+
let child3 = parent.subprogress(assigningCount: 20).start(totalCount: 6)
548+
549+
let grandchild1 = child1.subprogress(assigningCount: 5).start(totalCount: 4)
550+
let grandchild2 = child2.subprogress(assigningCount: 4).start(totalCount: 3)
551+
let grandchild3 = child3.subprogress(assigningCount: 3).start(totalCount: 2)
552+
553+
await withTaskGroup(of: Void.self) { group in
554+
group.addTask {
555+
for _ in 1...4 {
556+
grandchild1.complete(count: 1)
557+
}
558+
}
559+
560+
group.addTask {
561+
for _ in 1...3 {
562+
grandchild2.complete(count: 1)
563+
}
564+
}
565+
566+
group.addTask {
567+
for _ in 1...2 {
568+
grandchild3.complete(count: 1)
569+
}
570+
}
571+
572+
group.addTask {
573+
for _ in 1...5 {
574+
child1.complete(count: 1)
575+
}
576+
}
577+
578+
group.addTask {
579+
for _ in 1...4 {
580+
child2.complete(count: 1)
581+
}
582+
}
583+
584+
group.addTask {
585+
for _ in 1...3 {
586+
child3.complete(count: 1)
587+
}
588+
}
589+
590+
group.addTask {
591+
for _ in 1...100 {
592+
let _ = parent.fractionCompleted
593+
let _ = child1.fractionCompleted
594+
let _ = grandchild1.completedCount
595+
}
596+
}
597+
}
598+
599+
#expect(grandchild1.isFinished == true)
600+
#expect(grandchild2.isFinished == true)
601+
#expect(grandchild3.isFinished == true)
602+
#expect(parent.isFinished == true)
603+
}
604+
605+
@Test func concurrentReadDuringIndeterminateToDeterminateTransition() async throws {
606+
let manager = ProgressManager(totalCount: nil)
607+
608+
await withThrowingTaskGroup(of: Void.self) { group in
609+
group.addTask {
610+
for _ in 1...50 {
611+
let _ = manager.fractionCompleted
612+
let _ = manager.isIndeterminate
613+
}
614+
}
615+
616+
group.addTask {
617+
for _ in 1...10 {
618+
manager.complete(count: 1)
619+
}
620+
}
621+
622+
// Task 3: Change to determinate after a delay
623+
group.addTask {
624+
try? await Task.sleep(nanoseconds: 1_000_000)
625+
manager.withProperties { p in
626+
p.totalCount = 20
627+
}
628+
629+
for _ in 1...30 {
630+
let _ = manager.fractionCompleted
631+
let _ = manager.isIndeterminate
632+
}
633+
}
634+
}
635+
636+
#expect(manager.totalCount == 20)
637+
#expect(manager.completedCount == 10)
638+
#expect(manager.isIndeterminate == false)
639+
}
640+
641+
@Test func concurrentReadDuringExcessiveCompletion() async throws {
642+
let manager = ProgressManager(totalCount: 5)
643+
644+
await withThrowingTaskGroup(of: Void.self) { group in
645+
group.addTask {
646+
for _ in 1...20 {
647+
manager.complete(count: 1)
648+
try? await Task.sleep(nanoseconds: 100_000)
649+
}
650+
}
651+
652+
group.addTask {
653+
for _ in 1...100 {
654+
let fraction = manager.fractionCompleted
655+
let completed = manager.completedCount
656+
657+
#expect(completed >= 0 && completed <= 20)
658+
#expect(fraction >= 0.0 && fraction <= 4.0)
659+
}
660+
}
661+
}
662+
663+
#expect(manager.completedCount == 20)
664+
#expect(manager.fractionCompleted == 4.0)
665+
#expect(manager.isFinished == true)
666+
}
667+
668+
@Test func concurrentChildrenDeinitializationAndParentReads() async throws {
669+
let manager = ProgressManager(totalCount: 100)
670+
671+
await withThrowingTaskGroup(of: Void.self) { group in
672+
// Create and destroy children rapidly
673+
for batch in 1...10 {
674+
group.addTask {
675+
for i in 1...5 {
676+
func createAndDestroyChild() {
677+
let child = manager.subprogress(assigningCount: 2).start(totalCount: 3)
678+
child.complete(count: 2 + (i % 2)) // Complete 2 or 3
679+
// child deinits here
680+
}
681+
682+
createAndDestroyChild()
683+
try? await Task.sleep(nanoseconds: 200_000 * UInt64(batch))
684+
}
685+
}
686+
}
687+
688+
// Continuously read manager state during child lifecycle
689+
group.addTask {
690+
for _ in 1...300 {
691+
let fraction = manager.fractionCompleted
692+
let completed = manager.completedCount
693+
694+
// Properties should be stable and valid
695+
#expect(fraction >= 0.0)
696+
#expect(completed >= 0)
697+
698+
try? await Task.sleep(nanoseconds: 50_000)
699+
}
700+
}
701+
}
702+
703+
// Manager should reach completion
704+
#expect(manager.fractionCompleted == 1.0)
705+
#expect(manager.completedCount == 100)
706+
}
707+
708+
@Test func concurrentReadAndWriteAndCycleDetection() async throws {
709+
let manager1 = ProgressManager(totalCount: 10)
710+
let manager2 = ProgressManager(totalCount: 10)
711+
let manager3 = ProgressManager(totalCount: 10)
712+
713+
// Create initial chain: manager1 -> manager2 -> manager3
714+
manager1.assign(count: 5, to: manager2.reporter)
715+
manager2.assign(count: 5, to: manager3.reporter)
716+
717+
await withTaskGroup(of: Void.self) { group in
718+
// Task 1: Try to detect cycles continuously
719+
group.addTask {
720+
for _ in 1...50 {
721+
let wouldCycle1 = manager1.isCycle(reporter: manager3.reporter)
722+
let wouldCycle2 = manager2.isCycle(reporter: manager1.reporter)
723+
let wouldCycle3 = manager3.isCycle(reporter: manager2.reporter)
724+
725+
#expect(wouldCycle1 == false) // No cycle yet
726+
#expect(wouldCycle2 == true) // Would create cycle
727+
#expect(wouldCycle3 == true) // Would create cycle
728+
}
729+
}
730+
731+
// Task 2: Complete work in all managers
732+
group.addTask {
733+
for _ in 1...5 {
734+
manager1.complete(count: 1)
735+
manager2.complete(count: 1)
736+
manager3.complete(count: 1)
737+
}
738+
}
739+
740+
// Task 3: Access properties during cycle detection
741+
group.addTask {
742+
for _ in 1...100 {
743+
let _ = manager1.fractionCompleted
744+
let _ = manager2.completedCount
745+
let _ = manager3.isFinished
746+
}
747+
}
748+
}
749+
}
750+
}

0 commit comments

Comments
 (0)