Repository navigation
Request for Comment: Centralize Write-Progress handling so progress bars cannot leak #10548
Description
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
switchon the caller name contains two dead duplicate branches (Invoke-DbaDbLogShipRecovery,Sync-DbaAvailabilityGroup). - The default activity for
Export-DbaInstanceinterpolates$instance, which is not defined in the helper — it is picked up from the caller's scope by dynamic scoping. TotalStepsis derived by regex-counting occurrences of the stringWrite-ProgressHelperin 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:
- 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.
- 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 ofprivate/functions/flowcontrol/Stop-Function.ps1.- the
endblock 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. TheVerbosestyle routes progress throughWrite-Messageinstead 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-Progressinside the tight loops ofImport-DbaCsvandCopy-DbaDbTableDatais not cheap.
5. Guardrails
- A test that fails on new raw
Write-Progressoutside 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
- Is the per-pipeline
SourceIdbehaviour exactly as described above on Windows PowerShell 5.1 and PowerShell 7, and in the ISE / VS Code hosts? - What is the right registry key? Runspace instance id is the obvious candidate, but nested runspaces and the parallel paths in
Start-DbaDbEncryptionand friends need a look. - Should
commands.progress.style = Nonealso set$ProgressPreference, or only silence our own helper? - Is
Write-DbaProgressthe right name, or should we keep and extendWrite-ProgressHelperin place? - Anything that would break for users who currently rely on specific
-Idvalues, for example when scripting around our bars?
Activity
andreasjordan commentedon Aug 10, 2026
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...
potatoqualitee commentedon Sep 26, 2026
yes, good stuff. lets see what codex says.
potatoqualitee commentedon Sep 26, 2026
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
SourceIdpremise needs correcting. PowerShell’s source shows thatWrite-ProgressdefaultsSourceIdto0and passes that value through to the host. The separateWriteProgress(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-Functionshould not complete everything registered in the pipeline. It supports continuing loops, and in warning mode itsreturnexitsStop-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
endblock is normal post-processing, not afinallyblock. Where a bar’s lifetime fits within atry/finally, that is a better place for its completion. Bars spanningbegin/process/endstill need a deliberate lifetime strategy for older PowerShell versions. Cancellation and downstream early termination, such asSelect-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:
Write-ProgressHelperin place initially. A new internal name is fine, but it is not necessary to get the first benefits.commands.progress.style = Nonelimited to our progress reporting. Do not change the caller’s$ProgressPreference; continue respecting PowerShell’s existing preference behavior. Source: Write-Progress documentation.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 toWrite-Progress.One small correction to the existing-helper section: the duplicate
switchbranches 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-Functionintegration an optional addition once those are established.andreasjordan commentedon Sep 27, 2026
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/Activityof the records the host still holds.Write-Progress -Id 10/1/A-Id 1 -Completedwith another Activity text$PSCmdlet.WriteProgress([ProgressRecord])twice, then plainWrite-Progress -Id 5 -Completed1/5/Cbefore,2/5/Cafter (7.6; 5.1: 2 and 3)-Id 10completed while child-Id 11 -ParentId 10is active0/11/child(orphan)0/10/parent-Idanywhere: an inner command completes its own barSelect-Object -First 1downstream; completion inend/ infinallyinprocess0/20/end block/ nothingthrowbefore the completion line0/30/throwPowerShell.Stop()(what Ctrl+C does), second runspace sharing the host; with / withoutfinally0/41/stopped-Id 50 -Completedfor a bar of the first runspaceWhat that means for the design:
Write-Progressalways 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.Write-ProgressHelperdoes today: it has no-Id, so a nested caller's-Completedclears the outer command's bar.try/finallyis the only shape that covered throw, pipeline stop andSelect-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):
-Id/-ParentId- see S6.PercentCompleteabove 100 throws (ValidateRange). Latent today, every caller resets its step counter per unit, but a clamp is cheap.-Messagethrows (-Statusrejects empty strings).-Activity.Write-ProgressHelperin the caller, including-Completedlines, and runsGet-Commandon 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/finallyleak fixes, then the ownership pilot on a few commands.This text was created by Claude and reviewed by Andreas Jordan.
andreasjordan commentedon Oct 5, 2026
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
finallysits 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 orSelect-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:
$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.-ReplaceExistingdraws its bar inbeginand completes it inprocess. That is the lifetime that spansbeginandprocess.This text was created by Claude and reviewed by Andreas Jordan.