Skip to content

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

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.styleFull | 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?

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