Skip to content

fix: bound the shell-integration launch fallback to one traversal per command - #41

Merged
HelloThisWorld merged 1 commit into
mainfrom
fix/visual-progress-shell-fallback-one-shot
Aug 5, 2026
Merged

fix: bound the shell-integration launch fallback to one traversal per command#41
HelloThisWorld merged 1 commit into
mainfrom
fix/visual-progress-shell-fallback-one-shot

Conversation

@HelloThisWorld

@HelloThisWorld HelloThisWorld commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Problem

Any command without a real progress source keeps the Visual Progress rainbow/comet animation looping until the command exits. For long-running processes that is effectively forever:

  • k9s, vim, less, top, htop (Alternate Screen TUIs)
  • FastAPI/uvicorn, Spring Boot, Node.js development servers
  • tail -f, kubectl port-forward, and anything else that intentionally stays running

Root cause

OSC 133 CommandExecuted makes ProgressStateMachine::ApplyShellLifecycle store a ShellIntegration / Indeterminate / Running fallback snapshot, and nothing bounds it: it stays the active presentation until CommandFinished. The renderer faithfully animates that snapshot with IterationBehavior::Forever, so the launch animation never stops while the command lives.

Implementation

The Shell Integration fallback becomes a bounded one-shot launch indication, implemented as command-generation-scoped state — no command names, process names, or regex allow/deny lists anywhere.

State machine (src/winterm/VisualProgress/VisualProgressModel.h, renderer-independent, fully unit-testable):

  • ProgressSnapshot gains launchGeneration, non-zero only for the launch fallback.
  • ProgressStateMachine tracks a monotonic shell launch generation, an expired flag, and the last observed lifecycle state. Every lifecycle transition (and Reset/Close/disable) advances the generation, which strands stale completion callbacks.
  • A repeated CommandExecuted observation of the same still-running command (alternate-screen churn, pane rehydration, reconnect re-broadcast) is idempotent: it neither re-arms a consumed launch nor opens a new generation.
  • New ExpireShellLaunch(generation) clears the stored fallback only when the captured generation is still current and the stored shell state is still the running launch. It clears the fallback even while a provider or explicit source owns the presentation, so a later ownership release cannot resurrect it. Expiration publishes Hidden with Running status — Hidden/Cancelled would raise a fake interruption announcement through the accessibility policy, and Success would be a lie.

Renderer (src/winterm/VisualProgress/RainbowArcRenderer.h):

  • A launch clock: one one-shot compositor animation on a private CompositionPropertySet, wrapped in a CompositionScopedBatch whose generation-guarded, weak-referenced completion invokes the new LaunchExpiredCallback. Duration is the existing IndeterminateCycleDuration (1,800 ms) — no duplicated raw duration, no timer, no polling loop, no CPU frame loop.
  • The clock is deliberately decoupled from the visible comet (whose animations stop/restart with focus, geometry, and performance transitions) and keeps running while a provider or explicit source owns the bar, so background expiry works.
  • The launch comet itself uses IterationCount(1): it makes exactly one traversal and parks off-track, transparent. Explicit OSC 9;4 and provider indeterminate presentations keep Forever.
  • Reduced Motion / static tiers show the static launch presentation bounded by the same clock (requires no continuous animation). Only the solid XAML tier, where the compositor is unavailable, keeps the pre-existing until-lifecycle-transition behavior.

Pane (src/cascadia/TerminalApp/Pane.cpp|h):

  • Passes a weak launch-expired callback into RainbowArcRenderer::TryCreate.
  • New _ExpireVisualProgressShellLaunch runs on the UI thread, asks the state machine to expire (its own lock, never held across UI calls), and queues the resulting snapshot through the existing one-element mailbox.

ControlCore: unchanged. Recognition, providers, and the ownership precedence (explicit OSC 9;4 → provider → shell fallback → hidden) are untouched.

Lifecycle / state machine

CommandExecuted (new command)   -> advance generation, expired=false,
                                   publish ShellIntegration/Indeterminate/Running{launchGeneration}
CommandExecuted (re-broadcast)  -> no-op (same command)
launch clock completes          -> ExpireShellLaunch(captured generation):
                                     valid only if generation current, not yet expired,
                                     stored state still the running launch
                                   -> clear stored fallback, publish Hidden(Running) unless a
                                      higher-priority owner is presenting
CommandFinished                 -> advance generation (strands pending completions),
                                   publish existing Success/Error terminal presentation
Prompt / CommandStart           -> clear fallback, advance generation
Reset / Close / disable         -> clear + advance generation, lifecycle back to None

Provider/explicit interplay: if a provider or OSC 9;4 takes over mid-launch and the clock later completes, the stored fallback is silently expired; when that owner clears, _fallbackSnapshot() resolves to Hidden(Running) instead of resurrecting the launch.

Test coverage

48/48 TAEF tests pass (SettingsModel.Unit.Tests.dll /name:*WinTermVisualProgressTests*), including 4 new methods:

  • ShellLaunchFallbackIsOneShotPerCommand — launch starts on CommandExecuted; re-broadcast idempotence; expiry publishes Hidden(Running), never Success; no replay after expiry; the later real result still presents.
  • ShellLaunchExpirationIgnoresStaleGenerations — short command finishing first is untouched by a late completion; a stale generation can never hide a newer command; generation 0 invalid; expiry is exactly-once.
  • ExpiredShellLaunchDoesNotResurrectAfterOwnershipClears — provider takeover, background expiry, provider clear → Hidden; same for explicit OSC 9;4; a provider can still start genuinely new work afterwards.
  • ShellLaunchInvalidationOnResetDisableAndCloseReset() (pane close/detach), SetEnabled(false) (settings disable, including across re-enable), and Close() all strand pending completions.

scripts/winterm/test-visual-progress.ps1 -SourceOnly passes with new assertions locking in: the model policy (ExpireShellLaunch, generation guard, Hidden(Running), idempotent re-broadcast), the renderer clock (shared IndeterminateCycleDuration, no raw 1800 literal, one-shot comet iteration), the Pane wiring, the new smoke fixtures, and the new documentation section.

scripts/winterm/invoke-visual-progress-smoke.ps1 gains dependency-free fixtures: a long-running command one-shot launch (synthetic output ticks with no 133;D for several traversal periods) and an alternate-screen enter/leave/re-enter sequence during a running command, plus manual-check guidance for FastAPI/uvicorn, Spring Boot (Maven/Gradle provider handoff), k9s/vim/top/htop, and short commands.

Also run: the full ut_app TAEF suite (51/51 passed) after the TerminalAppLib build compile-verified the Pane.cpp and RainbowArcRenderer.h changes, and the smoke fixture script end-to-end with -DelayMilliseconds 0.

Not run, and why: UnitTests_Control was neither rebuilt nor run — no TerminalControl source changed (ControlCore is untouched by this fix). Live-app animation timing (the actual 1.8 s compositor batch completion and the unparented property-set clock) cannot be asserted from unit tests because the compositor is not exercisable headlessly — which is exactly why the generation/expiration policy is isolated in the renderer-independent state machine and tested there directly; the compositor-side behavior is covered by the manual steps below.

Manual verification steps

  1. Build and launch winTerm, open a PowerShell pane with shell integration.
  2. .\scripts\winterm\invoke-visual-progress-smoke.ps1 — watch the "long-running command one-shot launch" fixture: one traversal, overlay disappears, output continues, and the result presentation still runs at 133;D.
  3. Run a real long-running process (python -m http.server, uvicorn, a Node dev server, or Get-Content -Wait on a growing file): one launch traversal, then no overlay while output continues; Ctrl+C still shows the normal cancelled/error/success presentation.
  4. Run k9s or vim: launch plays at most once; entering/leaving the alternate screen, resizing, and switching tabs do not replay it.
  5. Run mvn spring-boot:run (or any Maven/Gradle build): real provider progress presents as before; after the app becomes a plain long-running server, no fallback animation returns.
  6. Short commands (dir) behave exactly as before; completion supersedes the launch immediately.
  7. Reduced Motion / High Contrast: static launch presentation appears and disappears after the same bounded interval, without animation.

Risks and rollback

  • Scope: decorative-progress-only. Terminal I/O, selection, alternate-screen rendering, and accessibility semantics are untouched; the state machine change is additive and the emission dedupe (SamePresentation) is extended, not altered, for existing sources.
  • Behavioral risk: the launch overlay now intentionally disappears after ~1.8 s for commands with no other progress source. That is the fix, but it is a visible behavior change users may notice.
  • Compositor dependence: the clock is a compositor animation; if composition is unavailable (solid XAML degradation tier) the pre-existing unbounded-until-lifecycle behavior remains, static and rare by construction. A clock start failure fails open to the previous behavior.
  • Rollback: revert this single commit. No settings, schema, ABI, or persisted-state changes are involved (launchGeneration lives only in the in-process snapshot struct).

… command

OSC 133 CommandExecuted published a ShellIntegration/Indeterminate/Running
fallback that stayed active until CommandFinished, so any long-running
command (k9s, vim, top, FastAPI/uvicorn, Spring Boot, Node dev servers,
tail -f, kubectl port-forward) looped the rainbow comet animation for its
whole lifetime.

The fallback is now a bounded one-shot launch indication scoped by a shell
command generation, never by command or process names. CommandExecuted
opens a new launch generation and publishes the launch snapshot carrying
it; a repeated observation of the same still-running command (alternate
screen churn, pane rehydration, reconnect re-broadcast) is idempotent. The
renderer bounds the launch with a launch clock: one one-shot compositor
animation on a private property set using the existing 1,800 ms
IndeterminateCycleDuration (no timer, no polling loop, no CPU frame loop)
whose generation-guarded completion asks the state machine to expire the
fallback. Expiration clears the stored shell snapshot even while a
provider or explicit OSC 9;4 source owns the bar, so a later ownership
release cannot resurrect it, and publishes Hidden with Running status so
accessibility announces neither a fake success nor a fake cancellation.
The launch comet itself plays a single traversal and parks off-track;
explicit and provider indeterminate presentations keep the continuous
traversal. CommandFinished supersedes the launch immediately and strands
in-flight completions, preserving every terminal presentation.

Four new TAEF tests cover the one-shot, stale-generation, ownership
resurrection, and reset/disable/close invalidation semantics; the source
suite locks in the policy, the clock sharing the traversal constant, and
the Pane wiring; the smoke fixture gains a long-running one-shot launch
and an alternate-screen sequence plus manual guidance for FastAPI, Spring
Boot, and k9s. Phase 2 documentation now describes the fallback as a
bounded launch indication, and the changelog records the fix under
Unreleased.
@HelloThisWorld
HelloThisWorld merged commit b372e0e into main Aug 5, 2026
5 checks passed
@HelloThisWorld
HelloThisWorld deleted the fix/visual-progress-shell-fallback-one-shot branch August 5, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant