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:
- 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 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
- 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?
- 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.
- Should
commands.progress.style = None also set $ProgressPreference, or only silence our own helper?
- Is
Write-DbaProgress the right name, or should we keep and extend Write-ProgressHelper in place?
- Anything that would break for users who currently rely on specific
-Id values, for example when scripting around our bars?
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-DbaDatabaseis the clearest example: it contains about 20 separateWrite-Progress -Completedcalls, one per way out of the command. Miss one path — or hit athrow— 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
Write-ProgresscallsWrite-ProgressHelpercallsBranch 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
returnwill leak again.Why the helper is not used much
This is a capability problem, not a discipline problem.
private/functions/Write-ProgressHelper.ps1has no-Idor-ParentIdsupport, 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:
switchon the caller name contains two dead duplicate branches (Invoke-DbaDbLogShipRecovery,Sync-DbaAvailabilityGroup).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
SourceIddefaults to the top-level pipeline. The host removes a record only when a matching-Completedarrives carrying the same SourceId.Two consequences follow:
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-Functionthe 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-DbaProgressthat supports-Idand-ParentId, and records every(Id, Activity)it writes into a module-scope registry keyed by runspace.Keep
Write-ProgressHelperas a thin shim over it. All 182 existing call sites then get registered cleanup without a single call-site change.2. Central cleanup
Complete-DbaProgressclears 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.endblock of commands, for normal completion.Individual
catchblocks 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-Functionwill still leak, because PowerShell v3 compatibility rules outclean {}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
-ParentIdfrom the call stack, so nested bars nest correctly without any command hardcoding id numbers.Today
Start-DbaMigration->Copy-DbaDatabase->Backup-DbaDatabaseeach pick ids by hand and can collide. This is something rawWrite-Progresscannot offer, and it is probably the strongest argument for commands to adopt the helper voluntarily.4. Configuration
Following the
message.*pattern inprivate/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
Write-Progressoutside the helper, with an allowlist that shrinks as the migration proceeds.Suggested phasing
Stop-Functionhook, old helper as shimBackup-DbaDatabase's ~20-Completedcalls collapse to nonePhase 1 is the high-leverage step — most of the leak surface disappears without touching a single command.
Open questions
SourceIdbehaviour exactly as described above on Windows PowerShell 5.1 and PowerShell 7, and in the ISE / VS Code hosts?Start-DbaDbEncryptionand friends need a look.commands.progress.style = Nonealso set$ProgressPreference, or only silence our own helper?Write-DbaProgressthe right name, or should we keep and extendWrite-ProgressHelperin place?-Idvalues, for example when scripting around our bars?