[Pitch] Progress Reporting in Swift Concurrency

Hi all,

I have developed a pitch to introduce a new API in Foundation —— ProgressReporter —— to support progress reporting in Swift Concurrency.

Feel free to leave your feedback, thoughts, comments or questions here!


ProgressReporter: Progress Reporting in Swift Concurrency

  • Proposal: SF-NNNN
  • Author(s): Chloe Yeo
  • Review Manager: TBD
  • Status: Draft

Introduction

Progress reporting is a generally useful concept, and can be helpful in all kinds of applications: from high level UIs, to simple command line tools, and more.

Foundation offers a progress reporting mechanism that has been very popular with application developers on Apple platforms. The existing Progress class provides a self-contained, tree-based mechanism for progress reporting and is adopted in various APIs which are able to report progress. The functionality of the Progress class is two-fold –– it reports progress at the code level, and at the same time, displays progress at the User Interface level. While the recommended usage pattern of Progress works well with Cocoa's completion-handler-based async APIs, it does not fit well with Swift's concurrency support via async/await.

This proposal aims to introduce an efficient, easy-to-use, less error-prone Progress Reporting API —— ProgressReporter —— that is compatible with async/await style concurrency to Foundation. To further support the use of this Progress Reporting API with high-level UIs, this API is also Observable.

Proposed solution and example

Before proceeding further with this proposal, it is important to keep in mind the type aliases introduced with this API. The examples outlined in the following sections will utilize type aliases as follows:

public typealias BasicProgressReporter = ProgressReporter<BasicProgressProperties>
public typealias FileProgressReporter = ProgressReporter<FileProgressProperties>

public typealias FileProgress = ProgressReporter<FileProgressProperties>.Progress
public typealias BasicProgress = ProgressReporter<BasicProgressProperties>.Progress

Reporting Progress With Identical Properties

To begin, let's create a class called MakeSalad that reports progress made on a salad while it is being made.

struct Fruit {
    let name: String 
    
    init(_ fruit: String) {
        self.name = fruit
    }
    
    func chop() async {}
}

struct Dressing {
    let name: String 
    
    init (_ dressing: String) {
        self.name = dressing 
    }
    
    func pour() async {} 
}

public class MakeSalad {
    
    let overall: BasicProgressReporter
    let fruits: [Fruit]
    let dressings: [Dressing]
    
    public init() {
        overall = BasicProgressReporter(totalCount: 100)
        fruits = [Fruit("apple"), Fruit("banana"), Fruit("cherry")]
        dressings = [Dressing("mayo"), Dressing("mustard"), Dressing("ketchup")]
    }
}

In order to report progress on subparts of making a salad, such as chopFruits and mixDressings, we can instantitate subprogresses by passing an instance of ProgressReporter.Progress to each subpart. Each ProgressReporter.Progress passed into the subparts then have to be consumed to initialize an instance of ProgressReporter. This is done by calling reporter(totalCount:) on ProgressReporter.Progress. These child progresses will automatically contribute to the overall progress reporter within the class, due to established parent-children relationships between overall and the reporters of subparts. This can be done as follows:

extension MakeSalad {

    public func start() async -> String {
        // Gets a BasicProgress instance with 70 portioned count from `overall` 
        let fruitsProgress = overall.assign(count: 70)
        await chopFruits(progress: fruitsProgress)
        
        // Gets a BasicProgress instance with 30 portioned count from `overall`
        let dressingsProgress = overall.assign(count: 30)
        await mixDressings(progress: dressingsProgress)
        
        return "Salad is ready!"
    }
    
    private func chopFruits(progress: consuming BasicProgress?) async {
        // Initializes a progress reporter to report progress on chopping fruits 
        // with passed-in progress parameter 
        let choppingReporter = progress?.reporter(totalCount: fruits.count)
        for fruit in fruits {
            await fruit.chop()
            choppingReporter?.complete(count: 1)
        }
    }
    
    private func mixDressings(progress: consuming BasicProgress?) async {
        // Initializes a progress reporter to report progress on mixing dressing 
        // with passed-in progress parameter 
        let dressingReporter = progress?.reporter(totalCount: dressings.count)
        for dressing in dressings {
            await dressing.pour()
            dressingReporter?.complete(count: 1)
        }
    }
}

For the complete proposed solution, detailed design and more in-depth info, check out the full proposal on the PR to the swift-foundation repo.

17 Likes

I haven't fully read the proposal yet, however there are some instances where BasicProgress is passed in as optional. For instance:

In Detailed Design the assign function does not return an optional result:

public func assign(count: Int) -> ProgressReporter<Properties>.Progress

Would you mind clarifying, why it is optional in chopFruits and friends? Thank you.

Hi Philipp, thanks for the question! The assign function indeed does not return an optional result, but the parameter progress is optional in chopFruits and other progress reporting methods because I was trying to mirror the case in which a framework has a progress reporting method, but the caller of it may want to call that method without reporting progress.

For example:

// Framework Code 
func downloadImage(from imageURL: URL, progress: consuming BasicProgress?) async -> Image {
    let downloadedImage = // operation to download image using image URL 
    return downloadedImage
}

// Client Code 
let image = downloadImage(from: URL(string: "https://www.catsite.com/example-cat"), progress: nil) 

In this case, the client may want to download an image, but does not wish to report progress on it.

Let me know if I can help clarify anything further! :)

2 Likes

I'm happy to see this proposed. I've worked with Foundation.Progress quite a bit. The API design here regarding getting a child progress via .assign(count:) will hopefully clear up many misconceptions.

There is one use case that might be addressed here, but it's not clear to me. Sometimes I'd like to show the progress of a subset of the work. For example, an app may be syncing down 100s of photos across a variety of documents. If the user selects one particular document, though, we'd like to show the progress for syncing down just the photos in that document. But we also want to be able to show the overall progress for syncing everything.

Currently, the progress tree we use looks something like this:

              [Overall progress]
                      |
        ----------------------------
        |        |        |        |
    [photo1]  [photo2] [photo3] [photo4]

However, it's not easy to make a separate Progress parent that just tracks the progress of photo3 and photo4. It would be nice if I could do something like this:

// These are already part of an existing parent, 'overallProgress'
let existingPhoto3Progress = self.progress(forID: "photo3")
let existingPhoto4Progress = self.progress(forID: "photo4")

// Would this work?
let subsetProgress = BasicProgressReporter(totalCount: 2)
subsetProgress.assign(count: 1, to: existingPhoto3Progress)
subsetProgress.assign(count: 1, to: existingPhoto4Progress)

Would that work? Currently, Foundation.Progress triggers an assertion if a progress is added as a child when it is already part of an existing tree.

Aside about possible workarounds I could restructure the tree to have multiple levels: Overall Progress > Document Progress > Individual Photo Progress. But there can be *other* ways we want to slice the progress as well (e.g. by photo type, by date, etc.) and structuring the progress tree around documents can only serve _one_ of those use cases.
1 Like

To clarify, in this example, would existingPhoto3Progress and existingPhoto4Progress be of type Foundation.Progress?

Well, when I asked the question, I was thinking of reproducing this entire situation using the new proposed ProgressReporter mechanism, so no.

But I guess it would be interesting to know the answer to that question too. :smile:

Over a decade ago when NSProgress was first introduced, a friend and I found that it was fundamentally broken. We filed, I think, Radar 14462333 NSProgress - Fundamentally broken (child progress is over represented in parent). Neither of us seems to have access to this any more. We think it was never satisfactorily addressed, and neither of us have used NSProgress since its release because of it.

From what I can recall, the problem was related to the implict thread-local tree of NSProgress instances, and not knowing how many child tasks would be created — any API out of your control could be updated to report NSProgress, and that could throw off the counts at higher layers.

From the pitch, it looks like this probably doesn't suffer from the same problem, except where it's bridged to NSProgress. I find that bridge a bit concerning, and I think I'd prefer to make a clean cut.


    /// Initializes `self` with `totalCount` and `properties`.
    /// If `totalCount` is set to `nil`, `self` is indeterminate.
    /// 
    /// - Parameters:
    ///   - totalCount: Total count of work.
    ///   - properties: An instance of`ProgressProperties`.
    public convenience init(totalCount: Int?)

Either this should remove the doc comment about properties (and add one about calling Properties(), or it should have a properties parameter (or maybe both? Seems like not having to start from the default for every subtask would potentially be useful?)

    /// Initializes `self` with `totalCount` and `properties`.
    /// If `totalCount` is set to `nil`, `self` is indeterminate.
    ///
    /// - Parameters:
    ///   - totalCount: Total count of work.
    ///   - properties: An instance of `BasicProgressProperties`.
    public convenience init(totalCount: Int?, properties: BasicProgressProperties = BasicProgressProperties())

These seem like vestiges of the same change? Should they still exist?


    /// Represents whether work is completed,
    /// returns `true` if completedCount >= totalCount.
    public var isFinished: Bool { get }

    /// Represents whether `totalCount` is initialized to an `Int`,
    /// returns `true` only if `totalCount == nil`.
    public var isIndeterminate: Bool { get }

It's not obvious to me how these work together; it seems to me that indeterminate progress should still be able to be finished?


@Observable public final class ProgressReporter<Properties: ProgressProperties> : Sendable, Hashable, Equatable {

    /// Access point to additional properties such as `fileTotalCount`
    /// declared within struct of custom type `ProgressProperties`.
    public var properties: Properties { get set }

}

I don't understand how this works — how can this class be Sendable, Observable, and have unlocked write access to Properties? I think it shouldn't be Sendable. That being the point of ProgressReporter.Progress?


Is it the intent that descriptions of what's currently happening bubble up through properties and reduce? Should BasicProgressProperties include such a facility?


    /// Returns a new `FileProgressProperties` instance that is a result of aggregating an array of children`FileProgressProperties` instances.
    /// - Parameter children: An Array of `FileProgressProperties` instances to be aggregated into a new `FileProgressProperties` instance.
    /// - Returns: A `FileProgressProperties` instance.
    public func reduce(children: [FileProgressProperties]) -> FileProgressProperties

It's not obvious how this will aggregate throughput or estimatedTimeRemaining; they should be documented.


The proposed API requires the root of the progress tree to have a finite integer representing the total unit count (or otherwise be indeterminate). This seems fairly inflexible — there are plenty of situations where one might not have a concrete number ahead of time, but can produce a result "better than indeterminate" — copying a filesystem hierarchy, for example. In that case there are many ways to slice the progress, none of which are supported by this API:

  • each time a directory is recursed into, adjust the total count (progress can go backward, but the "done" and "total" counts are accurate). Alternatively, adjust the total byte count instead of the total file count.
  • divide the root progress by the number of items in the root directory; divide each child progress by the number of items in its directory (progress isn't linear, but doesn't go backward). This requires arbitrary subdivision of units. This is kind of supported currently, but the granularity at the root affects the total granularity that can be reported to the user. Why use Int rather than Double 0.0...1.0 for everything?

This API seems designed to allow children to have different property types from parents:

    /// Returns a `ProgressReporter<Properties>.Progress` which can be passed to any method that reports progress.
    ///
    /// Delegates a portion of `self`'s `totalCount` to a to-be-initialized child `ProgressReporter` instance.
    ///
    /// - Parameter count: Count of units delegated to a child instance of `ProgressReporter`
    /// which may be instantiated by calling `reporter(totalCount:)`.
    /// - Parameter kind: `ProgressProperties` of child instance of `ProgressReporter`.
    /// - Returns: A `ProgressReporter<Properties>.Progress` instance.
    public func assign<AssignedProperties>(count: Int, kind: AssignedProperties.Type = AssignedProperties.self) -> ProgressReporter<AssignedProperties>.Progress

But I don't see how it integerates with reduce; It feels like this function should take map: @escaping @Sendable (AssignedProperties) -> Properties to allow the child's properties to be reduced into this parent's properties? Otherwise there's just a disconnect in the properties tree?


The examples given all use optional types for progress reporting. This makes sense, since not every consumer needs progress reported. But I wonder if it wouldn't be better to work on a "default argument" basis instead, rather than have optionals lying around everwhere?


I'm really struggling with ProgressReporter and ProgressReporter.Progress terminology, and with assign. None of them make intuitive sense to me, and each time I think I've understood the relationships, it slips through my brain again :confused:

6 Likes

Coming from the NSProgress world I had similar feeling initially, but this design is growing on me.

The way I see it is that a reporter (ProgressReporter) hands out (assigns) a progress report (Progress) for its subordinates to fill in,
like a supervisor assigns tasks to subordinates. The report is tied to the assignee, and can only be filled in once (i.e. the ~Copyable constraint).

Taking the other ideas from the Alternatives Considered section, at the risk of over personification, the other image I have is a Reporter Tree passes a Link to a function, and the function "grows"/"hydrates" the link into a progress subtree to which it reports progress to.

1 Like

I'm really excited to see this pitched!

It's a topic I was helping out with here and there for a very long time, including this latest incarnation and I'm very happy with seeing this come together and bring Progress into the world Swift Concurrency :slight_smile:

I'm very happy with the current proposed naming - the BasicProgress and BasicProgressReporter make sense and, and it's a nice way to establish creating child reporters if we want to actually start reporting some progress :+1: Might take some time getting used to for folks familiar to the old Progress API but I think these names are very clear in their intent and usage.

How is the cancellation implemented?

A ProgressReporter running in a Task can respond to the cancellation of the Task. In structured concurrency, cancellation of the parent task results in the cancellation of all child tasks. Mirroring this behavior, a ProgressReporter running in a parent Taskthat is cancelled will have its children instances of ProgressReporter cancelled as well.

Cancellation in the context of ProgressReporter means that any subsequent calls to complete(count:) after a ProgressReporter is cancelled results in a no-op.

To verify my understanding here: I think this means that reporting progress checks isCancelled, right? It is less so about "when the task is cancelled the progress reporter becomes cancelled" because in order to do this you would have to use a withTaskCancellation {} around the whole scope of using the reporter, which would be annoying and we don't really care about actively becoming cancelled: just about not emitting progress numbers when in a cancelled task.

I think that's the right approach anyway, checking isCancelled is cheap enough and will be fine like this :slight_smile:

Reporting from cancelled task

I am a little bit unsure if this not reporting progress from a cancelled task... but I don't have enough experience using Progress to know for sure...

Like this example:

        ... // expensive async work here
        progressReporter?.complete(count: 1) // This becomes a no-op if the Task is cancelled
        return "Chopped \(fruit)"

I mean: We did do the expensive work and returned the result anyway... so why do we not want to report this progress? Maybe that's something people expect? It felt a bit off to me to be honest reading these examples today.

If we did process the data, might as well report the progress: what if the entire processing did not stop because the cancellation and we return the final value anyway, as if cancellation didn't happen? Wouldn't it be weird that a progress bar is at 80% but we actually got the value successfully and "completely"?

I am wondering if the way developers would write such reacting to cancellation wouldn't be this?

class FoodProcessor {
    static func chopFruit(fruit: String, progress: consuming BasicProgress?) async throws -> String {
        let progressReporter = progress?.reporter(totalCount: 1)
      try Task.checkCancellation()
        ... // expensive async work here
        // could checkCancellation here as well, if we wanted to
        progressReporter?.complete(count: 1) // would not get here if cancelled before expensive work
        return "Chopped \(fruit)"
    }
}

and therefore if cancelled before "expensive work" happened, we would not report the progress, simply because we never got to the complete(count: 1) line.

Maybe I'm missing how developers use Progress in practice though.

We are eventually going to build some form of "even if cancelled" shields into the language which one could then use to "yeah report this progress anyway" if someone really wanted to :wink:

2 Likes

Currently, assign(count: to:) is introduced as a method to support interoperability between ProgressReporter and Foundation.Progress, so this method cannot be used to construct a progress reporting tree that is made up of only ProgressReporter.

In this new proposed mechanism of building a progress tree, the only methods that should be used to build a progress tree that has only ProgressReporter are assign(count:) and assign(count: kind:), which returns ProgressReporter.Progress of the same type as its parent and ProgressReporter.Progress of the specified kind (different from its parent) respectively.

In short, ProgressReporter has no mechanism to allow adding a progress that is already part of an existing tree as a child. While it may be nice to allow for other ways to slice the progress (by photo type, date, etc), allowing the freedom to add a ProgressReporter to more than one tree may compromise the safety guarantee we want to promise in this API. The main safety guarantee we provide via this API is that ProgressReporter will not be used more than once because it is always instantiated from calling reporter(totalCount:) on a ~Copyable ProgressReporter.Progress instance. This ProgressReporter.Progress is consumed the moment we call reporter(totalCount:) on it.

As you've correctly pointed out as a possible workaround, we would recommend restructuring the tree into multiple levels to keep track of ProgressReporter for specific subparts.

Hope this answers your question and feel free to let me know if you have any further questions / ideas! :))

Hi Konrad, thanks so much for the questions!

Yes, you're right. The cancellation mechanism here involves checking isCancelled within the complete(count:) call instead of using a withTaskCancellation {}. Specifically, if isCancelled is true, calls to complete(count:) will become a no-op and not increase the completedCount of the reporter.

Yeah I see your point here, there's a conflict in the scope of responsibilities of the API vs the developer. The expensive work would have happened, since the call to do the expensive work will be done from the call site, which is within the developers' scope of responsibilities. At the same time, because the complete(count:) becomes a no-op once Task is cancelled, this is essentially handled by the API, within the API's scope of responsibilities.

I think having an "even if cancelled" language feature would be great for this use case in the future!

Back to the example here, I do think that this would also be a valid way to check for cancellation. This means that the cancellation check is done from the call site instead of the API, and this means it'll be up to the developer to check whether or not a Task is cancelled. The question here then would be whether it is better for us to have the check in the API, or expect the developer to check it themselves from the call site. I'd love to hear more opinions about this! :))

2 Likes

Hi Keith, thanks for the questions!

As you've rightfully pointed out, I think the prior concerns for NSProgress will not appear here in ProgressReporter since we expect developers to always explicitly pass ProgressReporter.Progress as a parameter into progress-reporting methods.

The bridge here to NSProgress only supports bridging to the explicit model of progress reporting for NSProgress. The assign(count: to:) adds an NSProgress as a child to a ProgressReporter, whereas makeChild(withPendingUnitCount:) called on a NSProgress returns a ProgressReporter.Progress that can be passed into progress-reporting methods. This way, the bridge to NSProgress should not resurface any concerns related to the implicit thread-local tree of NSProgress. We decided to introduce this bridge to support cases in which adopters of ProgressReporter need to call methods from other frameworks that return a NSProgress instance.

That being said, we also encourage adopters to use ProgressReporter over NSProgress, since ProgressReporter is inherently compatible with Swift's async/await style concurrency.

They are merely properties that allow developers to check whether or not a reporter has been finished or is indeterminate. It does not act as a restricting condition for whether or not developers can continue to increase completedCount of ProgressReporter.

That's right, descriptions bubble up that way. BasicProgressProperties doesn't need that, but because it is a conforming type of ProgressProperties it will need to have all properties or methods outlined in the ProgressProperties protocol.

I will add more documentation for this, thanks for pointing this out! This will aggregate throughput and estimatedTimeRemaining by returning an average for both.

Incrementing completedCount on an indeterminate ProgressReporter won't ever result in isFinished becoming true. How should one mark an indeterminate ProgressReporter as being finished?

1 Like

An indeterminate ProgressReporter cannot be marked as being finished because the condition for isFinished is when totalCount >= completedCount.

An indeterminate ProgressReporter, which has totalCount of nil will automatically become determinate when totalCount is set to an Int. Incrementing completedCount on an indeterminate ProgressReporter is fine, and if later the totalCount is changed from nil to an Int, and completedCount >= totalCount, then the ProgressReporter will be marked as finished.

1 Like

Thanks for the detailed feedback, I'll try to address them here one by one, and feel free to follow up with more clarifications!

Thanks for catching this! This was a typo, I should remove the doc comment that includes properties from the init that does not take the argument Properties.


The class is indeed Sendable and Observable. While the API surface may seem like it has unlocked write access to Properties, it actually does not. We use synchronization primitives in the implementation to make sure that the access is done safely via a lock. This is just like how totalCount can be set but also appears as:

  /// Represents total count of work to be done.
  /// Setting this to `nil` means that `self` is indeterminate,
  /// and developers should later set this value to an `Int` value before using `self` to report progress for `fractionCompleted` to be non-zero.
  public var totalCount: Int? { get set }

The method reduce is meant to help aggregate the information of children ProgressReporter that has the same ProgressProperties as parent ProgressReporter so that parent's properties reflects children's properties as well.

An example that I have in mind when considering reduce(children:) is this:

We have a FileProgressReporter parent, which have children that are also of FileProgressReporter. Both the parent and children have properties contained inside ProgressProperties such as totalFileCount and totalByteCount.

We have access to the parent FileProgressReporter, and we want its totalFileCount, completedFileCount to show the sum of its children's totalFileCount and completedFileCount. This is where reduce(children:)comes in.

This is a bad idea, as it encourages racy code; this code looks safe:

progressReporter.totalCount += 17

But if multiple threads do it simultaneously, it is not guaranteed to increment atomically, since the get and set take the lock independently:

Thread A                         Thread B
lock
read totalCount (0)
unlock
increment by 17                  lock
                                 read totalCount(0)
                                 unlock
lock                             increment by 17
write totalCount (17)
unlock                        
                                 lock
                                 write totalCount (17)
                                 unlock

Two threads have added 17 to totalCount, but totalCount is still only 17 — one thread's modification was lost entirely.

The same applies to properties, but potentially even more confusingly.

If the intent of these is to be locked, they should only be mutable via a withLock closure, just like Mutex.

And I'm not an expert on Observable, but I'm still concerned by the idea that a locked property could be observable, it seems like a conceptual conflict? At the very least, the @Observable macro wouldn't work.

1 Like

The method I pointed to is one where the AssignedProperties of the child don't match the parent, and that's the case I'm interested in:

For example, I'm writing a bulk image format converter. I integrate hypothetical OSS library DirectoryTraversal, which adopts ProgressReporter with its own Properties type which counts files discovered. I integrate hypothetical OSS library ImageTranscoder, which adopts ProgressReporter with its own Properties type which gives progress for an individual format conversion.

Now I want to create my own ProgressReporter for a bulk conversion operation, and I want to show users how many files are discovered and what percentage of the total conversion is complete — but how do I do that? This method allows me to have children with different Properties types (good!) but doesn't allow me to aggregate their properties into my own ProgressReporter for the overall operation to present to my users.

1 Like

To clarify further, the synchronization primitive we are using here is Mutex, and in the implementation, and thus both properties and totalCount are only mutable via a withLock closure.

The totalCount and properties are properties that call into internal properties that are mutable only via withLock closures.


This API can still be made Observable because we can have an ObservationRegistrar that we use to make our properties in this API Observable, and the way to use ObservationRegistrar to do so is documented here: ObservationRegistrar | Apple Developer Documentation


In this case, we'd expect you to have your own ProgressReporter at the top of tree, and hold references to DirectoryTraversal's ProgressReporter and ImageTranscoder's ProgressReporter to display information from the respective ProgressReporter.

Hope this helps clarify things!

What @KeithBauerANZ is driving at here is a common problem when backing settable properties with a lock. While there is no underlying data race the fact that the property is backed by a lock allows for logical races that are impossible to see from the outside without understanding the underlying implementation.

In Swift, we can mutate properties easily by mutating them in place which under the hood results in a pair of calls to get and then set. Since the lock is only held during each call but not across both calls other calls can interleave and cause unexpected results. I agree with @KeithBauerANZ here that this is problematic and can cause unexpected behaviour when the ProgressReporter is used from multiple threads at the same time.

6 Likes

Sorry, maybe I'm just being dense, but how do I get this information? It doesn't bubble up through reduce because the properties have mismatched types, and I can't access their ProgressReporter instances, I just handed them a ProgressReporter.Progress instance to get them started in my tree.

Based on my reading of the proposal, I think this code looks something like:

// DirectoryTraversal library
struct DirectoryTraversalProperties: ProgressProperties { ... }
enum DirectoryTraversalDecision { ... }
func traverse(
    _ root: FilePath,
    glob: String,
    progress: consuming ProgressReporter<DirectoryTraversalProperties>.Progress? = nil,
    eachFile: @Sendable (FilePath) -> DirectoryTraversalDecision
) async throws { ... }

// ImageTranscoder library
struct ImageTranscoderProperties: ProgressProperties { ... }
enum ImageFormat { ... }
func transcode(
    _ file: FilePath,
    toFormat: ImageFormat,
    dest: FilePath,
    progress: consuming ProgressReporter<ImageTranscoderProperties>.Progress? = nil
) async throws { ... }

// Me:
struct MyProperties: ProgressProperties { ... }
let myReporter = ProgressReporter<MyProperties>(totalCount: 100)
try await traverse(
    "path",
    glob: "*.png",
    progress: myReporter.assign(100, kind: DirectoryTraversalProperties.self)
) { path in
    myReporter.totalCount += 100
    try await transcode(
        path,
        toFormat: .jpeg,
        dest: path.replacing(".png", with: ".jpeg"),
        progress: myReporter.assign(100, kind: ImageTranscoderProperties.self)
    )
}

(side note: all these "100" everywhere are arbitrary and unhelpful; in general I don't think the "tree of integer counts" maps well onto this common problem domain)

I don't see a mechanism to move the child properties to the parent, since I can't access their ProgressReporter instances. Nor do I think that accessing those instances would make sense given the design of the API; I think instead I should call:

myReporter.assign(kind: DirectoryTraversalProperties.self) { directoryProps, myProps in
    myProps.directoriesTraversed += directoryProps.directoriesTraversed
}

// and
myReporter.assign(kind: ImageTranscoderProperties.self) { imageProps, myProps in
    myProps.imageBytesTranscoded += imageProps.imageBytesTranscoded
}