Skip to content

Request for Comment: Centralize Write-Progress handling so progress bars cannot leak #10548

Open

Description

@andreasjordan

Problem

When a command ends, it sometimes leaves a progress bar behind. The stale bar keeps being displayed while later, unrelated commands run, and the user has no way to get rid of it short of restarting the session.

The root cause is that cleanup is currently the responsibility of every individual exit path. Backup-DbaDatabase is the clearest example: it contains about 20 separate Write-Progress -Completed calls, one per way out of the command. Miss one path — or hit a throw — and the bar leaks.

We already have Write-ProgressHelper, but it is not used consistently, and it does not help with cleanup at all.

Current state

count
raw Write-Progress calls 334 across 26 files
Write-ProgressHelper calls 182 across 30 files

Branch work in progress already completes the bars that 22 commands left behind on their happy path. That is a worthwhile fix on its own, but it is symptomatic — the next command that gains an early return will leak again.

Why the helper is not used much

This is a capability problem, not a discipline problem. private/functions/Write-ProgressHelper.ps1 has no -Id or -ParentId support, so every command that needs nested or multiple concurrent bars has to bypass it. That is precisely the set of 26 files with raw calls.

It also has a few defects worth noting, since any rewrite should not carry them forward:

  • The switch on the caller name contains two dead duplicate branches (Invoke-DbaDbLogShipRecovery, Sync-DbaAvailabilityGroup).
  • The default activity for Export-DbaInstance interpolates $instance, which is not defined in the helper — it is picked up from the caller's scope by dynamic scoping.
  • TotalSteps is derived by regex-counting occurrences of the string Write-ProgressHelper in the caller's entire .Definition, on every call. That is expensive on large functions, and it is simply wrong whenever the calls sit inside loops or conditional branches.

The constraint that shapes any solution

Progress records are keyed by (SourceId, ActivityId), where SourceId defaults to the top-level pipeline. The host removes a record only when a matching -Completed arrives carrying the same SourceId.

Two consequences follow:

  1. A later command cannot clear an earlier command's stale bar. Different pipeline, different SourceId. A global janitor, or a "sweep leftovers when the next dbatools command starts" approach, cannot work across command boundaries.
  2. Within a single pipeline it does work, including for nested calls (Start-DbaMigration -> Copy-DbaLogin), which is where a sweep remains useful.

So cleanup has to happen inside the pipeline that wrote the bar. That is what makes Stop-Function the natural hook.

This should be confirmed by a small host-level test before we build on it, since the whole design depends on it being accurate.

Proposal

1. One write path

A new internal Write-DbaProgress that supports -Id and -ParentId, and records every (Id, Activity) it writes into a module-scope registry keyed by runspace.

Keep Write-ProgressHelper as a thin shim over it. All 182 existing call sites then get registered cleanup without a single call-site change.

2. Central cleanup

Complete-DbaProgress clears everything the current pipeline registered. It gets called from:

  • Stop-Function, before it throws (EnableException) and before it returns (warning path) — both branches at the end of private/functions/flowcontrol/Stop-Function.ps1.
  • the end block of commands, for normal completion.

Individual catch blocks then no longer need to think about progress at all.

Known gap, stated openly: Ctrl+C and terminating errors that do not route through Stop-Function will still leak, because PowerShell v3 compatibility rules out clean {} blocks. The registry should be reset when a top-level dbatools command starts, so it cannot grow unbounded.

3. Automatic Id allocation

The helper hands out ids from the registry and derives -ParentId from the call stack, so nested bars nest correctly without any command hardcoding id numbers.

Today Start-DbaMigration -> Copy-DbaDatabase -> Backup-DbaDatabase each pick ids by hand and can collide. This is something raw Write-Progress cannot offer, and it is probably the strongest argument for commands to adopt the helper voluntarily.

4. Configuration

Following the message.* pattern in private/configurations/settings/userinteraction.ps1, with handlers setting static library properties so the hot-path check stays cheap:

  • commands.progress.style — Full | NoPercent | Verbose | None. The Verbose style routes progress through Write-Message instead of drawing bars, which is much friendlier for CI and log capture.
  • commands.progress.mininterval — milliseconds, default around 200. Throttles host writes. This is a real performance win: Write-Progress inside the tight loops of Import-DbaCsv and Copy-DbaDbTableData is not cheap.

5. Guardrails

  • A test that fails on new raw Write-Progress outside the helper, with an allowlist that shrinks as the migration proceeds.
  • A test asserting that a command completes every bar it opens.

Suggested phasing

Phase Work Payoff
0 Land the in-progress branch completing 22 leaked bars Immediate fix for the happy path
1 Helper + registry + Stop-Function hook, old helper as shim 182 call sites get cleanup for free
2 Migrate the 26 raw-call files, biggest first Backup-DbaDatabase's ~20 -Completed calls collapse to none
3 Configuration, throttling, guardrail tests Configurable, and protected against regression

Phase 1 is the high-leverage step — most of the leak surface disappears without touching a single command.

Open questions

  1. Is the per-pipeline SourceId behaviour exactly as described above on Windows PowerShell 5.1 and PowerShell 7, and in the ISE / VS Code hosts?
  2. What is the right registry key? Runspace instance id is the obvious candidate, but nested runspaces and the parallel paths in Start-DbaDbEncryption and friends need a look.
  3. Should commands.progress.style = None also set $ProgressPreference, or only silence our own helper?
  4. Is Write-DbaProgress the right name, or should we keep and extend Write-ProgressHelper in place?
  5. Anything that would break for users who currently rely on specific -Id values, for example when scripting around our bars?

Activity

  1. andreasjordan commented on Aug 10, 2026

    @andreasjordan
    CollaboratorAuthor

    During my tests and the usage of dbatools I often see left over prgress bars, so I asked Claude to come up with some ideas. I don't now if this is generally important, so I have not started implementing it. I first want to get some feedback...

  2. potatoqualitee commented on Sep 26, 2026

    @potatoqualitee
    Member

    yes, good stuff. lets see what codex says.

  3. potatoqualitee commented on Sep 26, 2026

    @potatoqualitee
    Member

    Thanks Andreas, I’m in favor of centralizing this. Supporting nested bars, fixing the existing helper, and adding regression tests would all be useful. I would make a few changes to the proposed approach, though.

    First, the SourceId premise needs correcting. PowerShell’s source shows that Write-Progress defaults SourceId to 0 and passes that value through to the host. The separate WriteProgress(ProgressRecord) overload allocates a source ID per cmdlet instance, not per top-level pipeline. Matching (SourceId, ActivityId) matters, but “a later command cannot clear an earlier command’s bar because it is a different pipeline” is not a valid blanket rule. Let’s run the proposed host-level tests before building around that restriction. Sources: WriteProgressCmdlet.cs, MshCommandRuntime.cs.

    Second, Stop-Function should not complete everything registered in the pipeline. It supports continuing loops, and in warning mode its return exits Stop-Function, not the calling command. Even its throwing path can be caught by a parent command that continues working. A child failure must not clear the progress bars of an active migration, parent operation, or sibling operation. Any cleanup hook there needs explicit ownership, rather than a sweep of the whole registry. Source: Stop-Function.ps1.

    Third, registering progress is not the same as guaranteeing cleanup. The compatibility shim can register existing writes without changing those call sites, but it does not establish a reliable completion boundary for each command. An end block is normal post-processing, not a finally block. Where a bar’s lifetime fits within a try/finally, that is a better place for its completion. Bars spanning begin/process/end still need a deliberate lifetime strategy for older PowerShell versions. Cancellation and downstream early termination, such as Select-Object -First, should be tested explicitly. I would describe Phase 1 as centralized tracking and partial cleanup coverage, not automatic cleanup for all existing call sites. Source: about_Functions_Advanced_Methods.

    For the registry, I would use runspace isolation plus an operation/invocation token, with each operation owning its activities and referring to its parent explicitly. Keep display text separate from identity: the host matches source and activity IDs, not the activity’s text. Automatic ID allocation is useful, but it should allocate once per activity and reuse that ID for updates and completion. We also need to handle collisions with raw calls during the transition. Source: PendingProgress.cs.

    For the other open questions, my preferences are:

    • Extend Write-ProgressHelper in place initially. A new internal name is fine, but it is not necessary to get the first benefits.
    • Keep commands.progress.style = None limited to our progress reporting. Do not change the caller’s $ProgressPreference; continue respecting PowerShell’s existing preference behavior. Source: Write-Progress documentation.
    • Add throttling after the lifecycle rules are working. Completion must bypass throttling, and changing styles must not strand bars that were already displayed.

    I would start with targeted leak fixes and the helper’s existing defects, then prove the ownership model with a few representative commands before migrating everything or changing Stop-Function. The tests should check that child cleanup preserves active parents and that every owned activity is eventually completed, not just count calls to Write-Progress.

    One small correction to the existing-helper section: the duplicate switch branches are not dead code. PowerShell executes both matching branches unless control flow stops it, so those duplicates can actually duplicate the activity text. They are worth fixing independently. Sources: Write-ProgressHelper.ps1, about_Switch.

    Overall, yes to the direction. I would make explicit progress ownership and reliable completion boundaries the foundation, with Stop-Function integration an optional addition once those are established.

  4. andreasjordan commented on Sep 27, 2026

    @andreasjordan
    CollaboratorAuthor

    Thanks for the corrections. Before building anything I ran the host-level tests you asked for.

    Method: a script started in a real console window (Start-Process, not redirected) that reads the ConsoleHost's pending-progress list by reflection after each scenario, then resets it. Windows PowerShell 5.1.26100 and PowerShell 7.6.3 gave identical results. Notation: SourceId/ActivityId/Activity of the records the host still holds.

    # Scenario Left in the host
    S1 Function calls Write-Progress -Id 1 0/1/A
    S2 A later, different function sends -Id 1 -Completed with another Activity text nothing
    S3 $PSCmdlet.WriteProgress([ProgressRecord]) twice, then plain Write-Progress -Id 5 -Completed 1/5/C before, 2/5/C after (7.6; 5.1: 2 and 3)
    S4 Parent -Id 10 completed while child -Id 11 -ParentId 10 is active 0/11/child (orphan)
    S5 Child completed while parent active 0/10/parent
    S6 No -Id anywhere: an inner command completes its own bar nothing - the outer command's Id 0 bar is gone too
    S7 Select-Object -First 1 downstream; completion in end / in finally in process 0/20/end block / nothing
    S8 throw before the completion line 0/30/throw
    S9 PowerShell.Stop() (what Ctrl+C does), second runspace sharing the host; with / without finally nothing / 0/41/stopped
    S10 Second runspace on the same host sends -Id 50 -Completed for a bar of the first runspace nothing

    What that means for the design:

    • You were right about SourceId: plain Write-Progress always arrives with SourceId 0, so any later command - even from another runspace on the same host - clears a bar just by using the same Id. The "other pipeline cannot clear it" rule is dropped. Only $PSCmdlet.WriteProgress(record) gets a fresh SourceId per call, and then no later completion can reach it, so that overload is not usable for bars that are updated and completed across calls.
    • Completing a parent does not remove its children (S4), so ownership has to complete children before their parent.
    • The Id-0 collision (S6) is exactly what Write-ProgressHelper does today: it has no -Id, so a nested caller's -Completed clears the outer command's bar.
    • try/finally is the only shape that covered throw, pipeline stop and Select-Object -First (S7b, S9). The targeted leak fixes will use it where the bar's lifetime fits one block.

    Write-ProgressHelper defects found on the way (both editions):

    1. No -Id/-ParentId - see S6.
    2. PercentComplete above 100 throws (ValidateRange). Latent today, every caller resets its step counter per unit, but a clamp is cheap.
    3. An empty -Message throws (-Status rejects empty strings).
    4. The duplicate switch branches do run and produce "Performing log shipping recovery Performing log shipping recovery". No caller reaches them today, because Invoke-DbaDbLogShipRecovery and Sync-DbaAvailabilityGroup always pass -Activity.
    5. The automatic TotalSteps counts every occurrence of the text Write-ProgressHelper in the caller, including -Completed lines, and runs Get-Command on every call.

    Next: one PR "Write-ProgressHelper - Fix its defects and add -Id/-ParentId" (defaults unchanged, tests check that owned activities are completed and the parent's are not, no caller changed), then the targeted try/finally leak fixes, then the ownership pilot on a few commands.


    This text was created by Claude and reviewed by Andreas Jordan.

  5. andreasjordan commented on Oct 5, 2026

    @andreasjordan
    CollaboratorAuthor

    The targeted try/finally fixes from the 2026-09-26 plan are up as three draft PRs, one per tier, with one commit per command and its test:

    Each finally sits around its own bar's lifetime. There is no registry and no Stop-Function integration yet. Each test runs the real command in a runspace without a host. It ends the command with a throw, a pipeline stop or Select-Object -First, and asserts that every progress Id the command drew ends with a completed record. All of them were red on the old code and are green on the new code, on 5.1 and 7.6.

    Three findings for the ownership step:

    • A runspace pool created on $Host (Read-DbaBackupHeader, Start/Stop-DbaDbEncryption -Parallel) hides the caller's warning, verbose and progress records from API hosts once a pool thread has run. I checked those bars in a real console instead. This is a defect of its own and could get its own issue.
    • Stop-DbaDbEncryption calls Disable-DbaDbEncryption, and both draw Id 0 through Write-ProgressHelper, so the inner completion clears the outer bar. This is the collision from the 09-27 probe, now seen in real code.
    • Install-DbaMaintenanceSolution -ReplaceExisting draws its bar in begin and completes it in process. That is the lifetime that spans begin and process.

    This text was created by Claude and reviewed by Andreas Jordan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions