From baf6f8a8731207f4bb24229c8af9653417b3618a Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Wed, 5 Aug 2026 20:20:08 +0800 Subject: [PATCH] fix: bound the shell-integration launch fallback to one traversal per 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. --- CHANGELOG.md | 23 +++ docs/development/visual-progress-phase2.md | 53 ++++- .../winterm/invoke-visual-progress-smoke.ps1 | 47 +++++ scripts/winterm/test-visual-progress.ps1 | 53 +++++ src/cascadia/TerminalApp/Pane.cpp | 30 +++ src/cascadia/TerminalApp/Pane.h | 1 + .../WinTermVisualProgressTests.cpp | 167 ++++++++++++++++ .../VisualProgress/RainbowArcRenderer.h | 183 +++++++++++++++++- .../VisualProgress/VisualProgressModel.h | 94 ++++++++- 9 files changed, 639 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 258a2fc3c..763e1f8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased + +### Fixed + +- Long-running commands no longer animate the Visual Progress rainbow bar + forever. The OSC 133 Shell Integration fallback published at + `CommandExecuted` used to stay in an indeterminate Running state until + `CommandFinished`, so development servers (FastAPI/uvicorn, Spring Boot, + Node.js), Alternate Screen applications (k9s, vim, top, htop), `tail -f`, + `kubectl port-forward`, and similar intentionally long-running processes + looped the comet animation for their whole lifetime. The fallback is now a + bounded one-shot launch indication: the comet makes one traversal (the + existing 1,800 ms cycle, driven by a one-shot compositor batch rather than + a timer or polling loop) and then the overlay hides and releases its + animation resources. The expiration is command-generation scoped, so stale + completions cannot affect a newer command, re-entering the Alternate Screen + or rehydrating a pane cannot replay a consumed launch, and an expired + fallback cannot resurrect after a CLI provider or explicit OSC 9;4 owner + clears. Explicit progress, recognized providers, short-lived commands, and + the success/error/cancelled result presentations keep their existing + behavior. + + ## 1.3.0-beta2 - 2026-08-04 Second beta of the Command Timeline release, fixing a failure-reporting diff --git a/docs/development/visual-progress-phase2.md b/docs/development/visual-progress-phase2.md index ab8ddd2ab..b6f992aef 100644 --- a/docs/development/visual-progress-phase2.md +++ b/docs/development/visual-progress-phase2.md @@ -32,6 +32,55 @@ Progress ownership follows this order: Generic heuristic recognition cannot override explicit progress or an owned built-in provider. Clearing explicit progress reveals the current valid provider or shell fallback. A new semantic prompt resets provider ownership and bounded parser state for that pane. +## Bounded shell launch fallback + +The generic OSC 133 running state is a bounded launch indication, not a report +of the whole execution. When `CommandExecuted` starts a command that has no +explicit progress and no recognized provider, the indeterminate comet makes one +traversal; the overlay then hides and releases its continuous animation +resources. The fallback is not intended to represent the entire execution +duration: a development server (FastAPI/uvicorn, Spring Boot, Node.js), a TUI +such as k9s, vim, or top, `tail -f`, `kubectl port-forward`, and every other +intentionally long-running process keeps printing output with no permanent +decorative animation. Explicit OSC 9;4 progress and recognized CLI providers +are unaffected: they remain active for their real progress lifecycle, and +determinate values, provider stages, success, error, and cancelled +presentations keep their existing semantics. + +The one-shot is command-generation-scoped state in the progress state machine, +never a command-name or process-name heuristic: + +- Every shell lifecycle transition advances a monotonic launch generation. + `CommandExecuted` opens a new generation and publishes the launch fallback + carrying it. A repeated `CommandExecuted` observation of the same + still-running command (alternate-screen churn, pane rehydration, reconnect + re-broadcast) neither restarts a consumed launch nor opens a new generation, + so entering or leaving the Alternate Screen, resizing, focus and visibility + changes, tab switches, zoom, and renderer recreation cannot replay it. +- The renderer bounds the launch with a launch clock: one one-shot compositor + animation on a private property set whose duration is the existing + 1,800-millisecond indeterminate traversal. There is no timer, no polling + loop, and no CPU frame loop. The clock is decoupled from the visible comet, + so focus, geometry, and performance transitions cannot cut it short, and it + keeps running while a provider or explicit source owns the bar. +- When the clock completes, the state machine expires the fallback only if the + captured generation is still current and the stored shell state is still the + running launch; stale completions from an earlier command are inert. The + expiration publishes a silent Hidden snapshot with Running status, so + accessibility announces neither a fake success nor a fake cancellation, and + a later provider or explicit-progress clear cannot resurrect the expired + fallback. +- `CommandFinished` supersedes the launch immediately. Short commands keep + their existing completion, error, and cancellation presentations, and a + long-running command may still present its real result long after the + launch expired. + +Reduced Motion, High Contrast, and degraded static tiers keep the same logical +one-shot lifecycle: the static launch presentation is bounded by the same +clock without requiring continuous animation. Only the solid XAML fallback +tier, where the compositor is unavailable, retains the pre-existing behavior +of ending the fallback at the next shell lifecycle transition. + ## Rainbow Arc Weld renderer The WinTerm-owned renderer is under `src/winterm/VisualProgress/`. Its renderer-independent state and constants are separated from the small Pane integration boundary so timing, status transitions, degradation, and resource budgets can be tested without XAML. @@ -54,7 +103,7 @@ The centralized geometry uses a 10-DIP horizontal inset, an 8-DIP bottom inset, The rainbow is a coherent red-to-orange-to-yellow-to-green-to-cyan-to-blue-to-violet-to-magenta gradient. Its cached brush moves on a 2,000-millisecond cycle and is never rebuilt per frame. Determinate updates normally interpolate for 220 milliseconds. A real regression uses an intentional 240-millisecond phase-reset transition; the renderer does not invent a monotonic value. Zero percent keeps the welding head inside the track, while the fill remains clipped and the bloom drawing space remains available at 100 percent. -Indeterminate progress uses a welding-head comet with a continuous tail covering 25 percent of the track. It traverses the track in 1,800 milliseconds, fades cleanly at the right edge, and reappears at the left without showing a fabricated percentage. +Indeterminate progress uses a welding-head comet with a continuous tail covering 25 percent of the track. It traverses the track in 1,800 milliseconds, fades cleanly at the right edge, and reappears at the left without showing a fabricated percentage. Explicit and provider indeterminate progress repeat the traversal continuously; the Shell Integration launch fallback runs it once and parks off-track, transparent, until the launch clock publishes the Hidden snapshot that ends the launch. ### Status presentations @@ -213,7 +262,7 @@ winTerm pane: .\scripts\winterm\invoke-visual-progress-smoke.ps1 ``` -The script uses only PowerShell output and OSC sequences. It requires no Docker, Node, Python, Maven, Gradle, provider CLI, network access, or download. It exercises determinate values at 0, 1, 50, 99, and 100 percent; a real regression; indeterminate, waiting, success, error, cancellation, and clear; and sanitized carriage-return samples for every built-in provider and the generic fallback. +The script uses only PowerShell output and OSC sequences. It requires no Docker, Node, Python, Maven, Gradle, provider CLI, network access, or download. It exercises determinate values at 0, 1, 50, 99, and 100 percent; a real regression; indeterminate, waiting, success, error, cancellation, and clear; the one-shot launch fallback for a long-running command, including alternate-screen entry and re-entry that must not replay it; and sanitized carriage-return samples for every built-in provider and the generic fallback. For replacement testing, first run with `visualProgress.replaceRecognizedOutput` false and confirm every synthetic line diff --git a/scripts/winterm/invoke-visual-progress-smoke.ps1 b/scripts/winterm/invoke-visual-progress-smoke.ps1 index 0404b757a..4cd4cd2a3 100644 --- a/scripts/winterm/invoke-visual-progress-smoke.ps1 +++ b/scripts/winterm/invoke-visual-progress-smoke.ps1 @@ -34,6 +34,17 @@ function Send-Osc Wait-DemoStep } +function Send-ControlSequence +{ + param( + [Parameter(Mandatory)] + [string]$Payload + ) + + [Console]::Write("$([char]27)$Payload") + Wait-DemoStep +} + function Write-TransientFrame { param( @@ -180,6 +191,16 @@ Visual Progress manual checks: - Minimize, switch tabs, or deactivate the window to verify animation pauses or simplifies. - Rerun after disabling Windows animations to verify the static Reduced Motion fallback. - Rerun with a Windows contrast theme to verify the solid High Contrast fallback. +- For real long-running processes (a FastAPI/uvicorn dev server, a Spring Boot service, a Node.js dev + server, `tail -f`, or `kubectl port-forward`), the launch animation plays one traversal and then + disappears; later log output must not bring it back, and stopping the process still shows the + normal terminal result presentation. +- Real Maven/Gradle progress (for example a Spring Boot build) stays owned by the provider; once only + the long-running application remains, the expired launch fallback must not reappear. +- For Alternate Screen applications (k9s, vim, top, htop), the launch animation appears at most once; + entering, leaving, and re-entering the Alternate Screen, resizing, or switching tabs must not + replay it, and exiting still produces the normal final state when available. +- A short command keeps its current behavior: completion supersedes the launch animation immediately. '@ foreach ($payload in @('9;4;1;0', '9;4;1;1', '9;4;1;50', '9;4;1;99', '9;4;1;100')) @@ -228,6 +249,32 @@ Visual Progress manual checks: Send-Osc '133;A' Send-Osc '9;4;0' + Write-Host 'Visual Progress fixture: long-running command one-shot launch' + Write-Host ' Expected: the launch animation plays one traversal, then the overlay disappears while output continues.' + Send-Osc '133;B' + Send-Osc '133;C' + for ($tick = 1; $tick -le 4; $tick++) + { + Write-Host "synthetic long-running service output tick $tick (no progress overlay expected after the first traversal)" + Wait-DemoStep + } + Write-Host 'Visual Progress fixture: the long-running command still reports its real result' + Send-Osc '133;D;0' + Send-Osc '133;A' + + Write-Host 'Visual Progress fixture: alternate screen during a long-running command' + Write-Host ' Expected: entering, leaving, and re-entering the alternate screen does not replay the launch animation.' + Send-Osc '133;B' + Send-Osc '133;C' + Send-ControlSequence '[?1049h' + Write-Host 'synthetic alternate-screen application frame' + Send-ControlSequence '[?1049l' + Send-ControlSequence '[?1049h' + Write-Host 'synthetic alternate-screen application frame after re-entry' + Send-ControlSequence '[?1049l' + Send-Osc '133;D;0' + Send-Osc '133;A' + Invoke-ProviderFixtures Invoke-BoundedSoak diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index e806449ef..5145a6ba3 100644 --- a/scripts/winterm/test-visual-progress.ps1 +++ b/scripts/winterm/test-visual-progress.ps1 @@ -248,6 +248,20 @@ try } Assert-NotMatches $model 'std::wstring(?!_view)|std::string(?!_view)|winrt::hstring' 'Normalized progress state text-retention boundary' + foreach ($required in @( + 'uint64_t launchGeneration{}', + 'launchGeneration == other.launchGeneration', + 'std::optional ExpireShellLaunch(const uint64_t generation) noexcept', + 'generation != _shellLaunchGeneration || _shellLaunchExpired', + '_beginShellLaunchScope', + '_resetShellLaunchScope', + 'HiddenSnapshot(ProgressStatus::Running)' + )) + { + Assert-Contains $model $required 'Bounded one-shot shell launch fallback policy' + } + Assert-Matches $model '(?s)case ShellLifecycleState::CommandExecuted:.*?if \(_shellLifecycle == ShellLifecycleState::CommandExecuted\)\s*\{\s*break;\s*\}' 'Idempotent CommandExecuted re-broadcast boundary' + $providers = @( 'DockerPull', 'DockerBuildKit', @@ -477,6 +491,24 @@ try } Assert-NotMatches $renderer '_coreWindow\s*\.\s*(?:VisibilityChanged|Activated)\s*\(' 'Authoritative XAML-Islands host-window lifecycle boundary' + foreach ($required in @( + 'using LaunchExpiredCallback = std::function', + '_synchronizeLaunchClock', + '_startLaunchClock', + '_completeLaunchClock', + '_clearLaunchClock', + '_launchClockProperties.StartAnimation(L"Progress", _launchClockAnimation)', + '_launchClockAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration))', + '_cometTailAnimation.IterationCount(1)', + '_cometHeadAnimation.IterationCount(1)', + '_cometHeadOpacityAnimation.IterationCount(1)' + )) + { + Assert-Contains $renderer $required 'Bounded one-shot shell launch clock' + } + Assert-Matches $renderer '(?s)const auto oneShotLaunch = _snapshot\.source == ProgressSource::ShellIntegration &&\s*_snapshot\.launchGeneration != 0;' 'One-shot launch comet source discrimination' + Assert-NotContains $renderer '1800' 'Launch timeout shares the indeterminate traversal constant instead of a raw duration' + $paneVisualStart = $source.PaneCpp.IndexOf('void Pane::_SetVisualProgressEnabled', [System.StringComparison]::Ordinal) $paneVisualEnd = $source.PaneCpp.IndexOf('void Pane::_UpdatePaneHeader', $paneVisualStart, [System.StringComparison]::Ordinal) if ($paneVisualStart -lt 0 -or $paneVisualEnd -le $paneVisualStart) @@ -750,6 +782,9 @@ try Assert-Contains $taskbarProgress 'ApplyTaskbar' 'OSC 9;4 taskbar-state progress remains active' Assert-NotContains $taskbarProgress '_visualProgressRecognizeCliProgress' 'OSC 9;4 independence from CLI recognition setting' + Assert-Matches $pane '(?s)RainbowArcRenderer::TryCreate\(.*?_OnVisualProgressRendererFault\(\);.*?\[weakThis\]\(const uint64_t launchGeneration\).*?_ExpireVisualProgressShellLaunch\(launchGeneration\);' 'Weak one-shot launch expiration callback registration' + Assert-Matches $pane '(?s)void Pane::_ExpireVisualProgressShellLaunch.*?ExpireShellLaunch\(launchGeneration\).*?_QueueVisualProgressUpdate' 'Launch expiration routed through the state machine and UI mailbox' + $terminalPage = $source.TerminalPageCpp $registerStart = $terminalPage.IndexOf('void TerminalPage::_RegisterTabEvents', [System.StringComparison]::Ordinal) $registerEnd = $terminalPage.IndexOf('void TerminalPage::_UnZoomIfNeeded', $registerStart, [System.StringComparison]::Ordinal) @@ -1162,6 +1197,10 @@ try 'SuppressDuplicateState', 'ProviderProgressPrecedesShellLifecycle', 'StandardProgressPrecedesProviderAndFallsBack', + 'ShellLaunchFallbackIsOneShotPerCommand', + 'ShellLaunchExpirationIgnoresStaleGenerations', + 'ExpiredShellLaunchDoesNotResurrectAfterOwnershipClears', + 'ShellLaunchInvalidationOnResetDisableAndClose', 'ProviderStatePackingContainsOnlyStructuralFields', 'RecognitionClassifiesProvidersAndGenericFallback', 'RecognitionHandlesFragmentationAndMalformedInput', @@ -1278,6 +1317,14 @@ try '[ValidateRange(0, 10000)]', 'SoakIterations', 'synthetic summary; must remain visible', + 'long-running command one-shot launch', + 'alternate screen during a long-running command', + "Send-ControlSequence '[?1049h'", + "Send-ControlSequence '[?1049l'", + 'launch animation plays one traversal', + 'expired launch fallback must not reappear', + 'appears at most once', + 'completion supersedes the launch animation immediately', 'No files or external commands were used.' )) { @@ -1317,6 +1364,12 @@ try 'generic output is never suppressible', 'alternate-screen output', 'lifecycle-generation changes', + '## Bounded shell launch fallback', + 'bounded launch indication', + 'not intended to represent the entire execution', + 'launch clock', + 'command-generation-scoped state', + 'cannot resurrect the expired', 'does not upload or persist terminal content', 'WINTERM_DISABLE_VISUAL_PROGRESS=1', 'invoke-visual-progress-smoke.ps1', diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index 26a425a2d..13d413c3f 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -2572,6 +2572,12 @@ void Pane::_CreateVisualProgressOverlay() { pane->_OnVisualProgressRendererFault(); } + }, + [weakThis](const uint64_t launchGeneration) { + if (const auto pane = weakThis.lock()) + { + pane->_ExpireVisualProgressShellLaunch(launchGeneration); + } }); const auto rendererReady = _visualProgressRenderer && !_visualProgressRenderer->Faulted(); _visualProgressRendererReady.store(rendererReady, std::memory_order_release); @@ -2687,6 +2693,30 @@ void Pane::_UpdateVisualProgressFromProvider() } } +// Invoked on the UI thread when the renderer's one-shot launch clock +// completes. The state machine decides under its own lock whether the +// captured generation still identifies the current command; the resulting +// snapshot is queued through the mailbox like every other progress update, +// so no state-machine lock is ever held while renderer or XAML code runs. +void Pane::_ExpireVisualProgressShellLaunch(const uint64_t launchGeneration) noexcept +{ + if (!_visualProgressEnabled.load(std::memory_order_acquire)) + { + return; + } + try + { + if (const auto snapshot = _visualProgressState.ExpireShellLaunch(launchGeneration)) + { + _QueueVisualProgressUpdate(*snapshot); + } + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } +} + void Pane::_ConfigureVisualProgressRecognition() noexcept { try diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index 50a97f6a5..6a93d86e1 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -370,6 +370,7 @@ class Pane : public std::enable_shared_from_this void _UpdateVisualProgressFromTaskbar(); void _UpdateVisualProgressFromShellIntegration(); void _UpdateVisualProgressFromProvider(); + void _ExpireVisualProgressShellLaunch(uint64_t launchGeneration) noexcept; void _ConfigureVisualProgressRecognition() noexcept; void _QueueVisualProgressUpdate(const winTerm::VisualProgress::ProgressSnapshot& snapshot); void _ScheduleVisualProgressUpdate(); diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp index 45c5c8d3b..b7baab637 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp @@ -54,6 +54,10 @@ namespace SettingsModelUnitTests TEST_METHOD(SparkPoolsEnforcePaneAndGlobalCaps); TEST_METHOD(BackgroundAndHiddenPanesDoNotRequestSparkWork); TEST_METHOD(CommandCompletionClearsAtNextPrompt); + TEST_METHOD(ShellLaunchFallbackIsOneShotPerCommand); + TEST_METHOD(ShellLaunchExpirationIgnoresStaleGenerations); + TEST_METHOD(ExpiredShellLaunchDoesNotResurrectAfterOwnershipClears); + TEST_METHOD(ShellLaunchInvalidationOnResetDisableAndClose); TEST_METHOD(EmergencyOverridePrecedesSetting); TEST_METHOD(DisabledFeatureIgnoresEvents); TEST_METHOD(MultiplePanesRemainIndependent); @@ -1888,6 +1892,169 @@ namespace SettingsModelUnitTests VERIFY_IS_FALSE(state.Current().visible); } + void WinTermVisualProgressTests::ShellLaunchFallbackIsOneShotPerCommand() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + + const auto launch = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + VERIFY_IS_TRUE(launch.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::ShellIntegration), static_cast(launch->source)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(launch->mode)); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(launch->status)); + VERIFY_IS_TRUE(launch->launchGeneration != 0); + + // Alternate-screen churn, rehydration, and reconnect re-broadcasts + // re-observe the same still-running command. The launch generation + // must not advance, so the one-shot cannot be re-armed. + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1).has_value()); + VERIFY_ARE_EQUAL(launch->launchGeneration, state.Current().launchGeneration); + + // One traversal completed while the command keeps running: the + // fallback expires to Hidden -- never to Success -- and the Running + // status keeps the hidden snapshot free of cancellation semantics. + const auto expired = state.ExpireShellLaunch(launch->launchGeneration); + VERIFY_IS_TRUE(expired.has_value()); + VERIFY_IS_FALSE(expired->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(expired->mode)); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(expired->status)); + + // Long-running output and alternate-screen transitions after the + // expiration re-observe the same command and must not replay it, and + // a duplicate completion is inert. + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1).has_value()); + VERIFY_IS_FALSE(state.Current().visible); + VERIFY_IS_FALSE(state.ExpireShellLaunch(launch->launchGeneration).has_value()); + + // The eventual real result still presents with unchanged semantics. + const auto finished = state.ApplyShellLifecycle(ShellLifecycleState::CommandFinished, 0); + VERIFY_IS_TRUE(finished.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Success), static_cast(finished->status)); + VERIFY_ARE_EQUAL(uint8_t{ 100 }, finished->value); + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::Prompt, -1)->visible); + } + + void WinTermVisualProgressTests::ShellLaunchExpirationIgnoresStaleGenerations() + { + ProgressStateMachine state; + state.SetEnabled(true); + + const auto first = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + const auto firstGeneration = first->launchGeneration; + + // A short command finishes before the traversal ends: completion + // supersedes the launch immediately and the late expiration is inert. + const auto finished = state.ApplyShellLifecycle(ShellLifecycleState::CommandFinished, 1); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(finished->status)); + VERIFY_IS_FALSE(state.ExpireShellLaunch(firstGeneration).has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(state.Current().status)); + + // The next command opens a new generation and allows one new launch. + state.ApplyShellLifecycle(ShellLifecycleState::Prompt, -1); + state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + const auto second = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + VERIFY_IS_TRUE(second.has_value()); + VERIFY_IS_TRUE(second->launchGeneration != 0); + VERIFY_ARE_NOT_EQUAL(firstGeneration, second->launchGeneration); + + // A stale completion captured for the earlier command can never hide + // the newer command's launch, and zero is never a valid generation. + VERIFY_IS_FALSE(state.ExpireShellLaunch(firstGeneration).has_value()); + VERIFY_IS_FALSE(state.ExpireShellLaunch(0).has_value()); + VERIFY_IS_TRUE(state.Current().visible); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(state.Current().status)); + + // The current generation expires exactly once. + VERIFY_IS_TRUE(state.ExpireShellLaunch(second->launchGeneration).has_value()); + VERIFY_IS_FALSE(state.ExpireShellLaunch(second->launchGeneration).has_value()); + } + + void WinTermVisualProgressTests::ExpiredShellLaunchDoesNotResurrectAfterOwnershipClears() + { + ProgressStateMachine state; + state.SetEnabled(true); + const auto launch = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + + // A recognized provider takes ownership while the launch is active. + const ProviderProgress provider{ + ProgressProvider::Maven, + ProgressMode::Determinate, + ProgressStatus::Running, + 40, + ProviderConfidence::High, + true, + false, + false, + 2, + 1, + }; + const auto owned = state.ApplyProvider(provider); + VERIFY_IS_TRUE(owned.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Provider), static_cast(owned->source)); + + // The one-shot duration elapses in the background. Nothing visible + // changes, but the stored fallback must be expired -- not merely left + // unpainted -- so the later ownership release cannot resurrect it. + VERIFY_IS_FALSE(state.ExpireShellLaunch(launch->launchGeneration).has_value()); + const auto afterProvider = state.ResetProvider(); + VERIFY_IS_TRUE(afterProvider.has_value()); + VERIFY_IS_FALSE(afterProvider->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(afterProvider->mode)); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(afterProvider->status)); + + // The same rule holds when explicit OSC 9;4 progress owned the bar + // while the launch expired underneath it. + state.ApplyShellLifecycle(ShellLifecycleState::Prompt, -1); + state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + const auto second = state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + const auto explicitProgress = state.ApplyTaskbar(3, 0); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Taskbar), static_cast(explicitProgress->source)); + VERIFY_IS_FALSE(state.ExpireShellLaunch(second->launchGeneration).has_value()); + const auto afterExplicit = state.ApplyTaskbar(0, 0); + VERIFY_IS_TRUE(afterExplicit.has_value()); + VERIFY_IS_FALSE(afterExplicit->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(afterExplicit->status)); + + // A provider may still begin genuinely new work after the launch + // expired; only the expired shell fallback stays retired. + const auto reowned = state.ApplyProvider(provider); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Provider), static_cast(reowned->source)); + const auto cleared = state.ResetProvider(); + VERIFY_IS_FALSE(cleared->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(cleared->status)); + } + + void WinTermVisualProgressTests::ShellLaunchInvalidationOnResetDisableAndClose() + { + // Pane close and content detach reset the state machine; a pending + // completion captured before the reset must be stranded. + ProgressStateMachine detached; + detached.SetEnabled(true); + const auto detachedLaunch = detached.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + detached.Reset(); + VERIFY_IS_FALSE(detached.ExpireShellLaunch(detachedLaunch->launchGeneration).has_value()); + VERIFY_IS_FALSE(detached.Current().visible); + + // Disabling Visual Progress strands pending completions, including + // across a later re-enable. + ProgressStateMachine disabled; + disabled.SetEnabled(true); + const auto disabledLaunch = disabled.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + disabled.SetEnabled(false); + VERIFY_IS_FALSE(disabled.ExpireShellLaunch(disabledLaunch->launchGeneration).has_value()); + disabled.SetEnabled(true); + VERIFY_IS_FALSE(disabled.ExpireShellLaunch(disabledLaunch->launchGeneration).has_value()); + VERIFY_IS_FALSE(disabled.Current().visible); + + // Close strands pending completions permanently. + ProgressStateMachine closed; + closed.SetEnabled(true); + const auto closedLaunch = closed.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1); + closed.Close(); + VERIFY_IS_FALSE(closed.ExpireShellLaunch(closedLaunch->launchGeneration).has_value()); + } + void WinTermVisualProgressTests::EmergencyOverridePrecedesSetting() { VERIFY_IS_TRUE(IsFeatureEnabled(true, L"")); diff --git a/src/winterm/VisualProgress/RainbowArcRenderer.h b/src/winterm/VisualProgress/RainbowArcRenderer.h index 0830c6fb2..59675f0a3 100644 --- a/src/winterm/VisualProgress/RainbowArcRenderer.h +++ b/src/winterm/VisualProgress/RainbowArcRenderer.h @@ -52,10 +52,16 @@ namespace winTerm::VisualProgress { public: using FaultCallback = std::function; + // Reports that the one-shot Shell Integration launch presentation has + // run for one full traversal. The argument is the launch generation + // captured when that presentation started; the progress state machine + // uses it to discard stale completions. + using LaunchExpiredCallback = std::function; static std::shared_ptr TryCreate( const winrt::Windows::UI::Xaml::Controls::Grid& host, - FaultCallback faultCallback = {}) noexcept + FaultCallback faultCallback = {}, + LaunchExpiredCallback launchExpiredCallback = {}) noexcept { if (!host) { @@ -64,7 +70,7 @@ namespace winTerm::VisualProgress try { - auto renderer = std::shared_ptr{ new RainbowArcRenderer{ std::move(faultCallback) } }; + auto renderer = std::shared_ptr{ new RainbowArcRenderer{ std::move(faultCallback), std::move(launchExpiredCallback) } }; if (!renderer->_initialize(host)) { return nullptr; @@ -106,6 +112,7 @@ namespace winTerm::VisualProgress _applyPerformanceDecision(_evaluatePerformancePolicy()); auto plan = _renderState.Apply(snapshot, _environment, now); _applyWithDegradation(plan, true); + _synchronizeLaunchClock(); } void SetPaneActive(const bool active) noexcept @@ -220,6 +227,7 @@ namespace winTerm::VisualProgress } _closed = true; _faultCallback = {}; + _launchExpiredCallback = {}; try { @@ -227,6 +235,7 @@ namespace winTerm::VisualProgress _stopAllAnimations(); _releaseAllSparks(); _clearTerminalBatch(); + _clearLaunchClock(); _renderState.Close(); if (_host) @@ -306,9 +315,10 @@ namespace winTerm::VisualProgress bool ambient{}; }; - explicit RainbowArcRenderer(FaultCallback faultCallback) noexcept : + RainbowArcRenderer(FaultCallback faultCallback, LaunchExpiredCallback launchExpiredCallback) noexcept : _sparkPool{ _sharedSparkBudget }, - _faultCallback{ std::move(faultCallback) } + _faultCallback{ std::move(faultCallback) }, + _launchExpiredCallback{ std::move(launchExpiredCallback) } { // Visible is a safe presentation default. Focus deliberately // starts false so no continuous work or sparks begin before the @@ -1410,22 +1420,40 @@ namespace winTerm::VisualProgress _cometTail.Brush(_cometBrush); _cometTail.Offset({ startX, 0.0f, 0.0f }); + // The Shell Integration launch fallback is a bounded one-shot: its + // comet makes a single traversal and parks off-track, transparent. + // Every other indeterminate owner (explicit OSC 9;4, providers) + // keeps the continuous traversal. The launch clock, not this + // visual, publishes the Hidden snapshot that ends the launch. + const auto oneShotLaunch = _snapshot.source == ProgressSource::ShellIntegration && + _snapshot.launchGeneration != 0; + const auto iterationBehavior = oneShotLaunch ? + WUC::AnimationIterationBehavior::Count : + WUC::AnimationIterationBehavior::Forever; + _cometTailAnimation.InsertKeyFrame(0.0f, { startX, 0.0f, 0.0f }); _cometTailAnimation.InsertKeyFrame(1.0f, { endX, 0.0f, 0.0f }); _cometTailAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); - _cometTailAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + _cometTailAnimation.IterationBehavior(iterationBehavior); _cometHeadAnimation.InsertKeyFrame(0.0f, { RainbowArcVisualConstants::HorizontalInset, _headY, 0.0f }); _cometHeadAnimation.InsertKeyFrame(1.0f, { RainbowArcVisualConstants::HorizontalInset + _trackWidth + tailWidth, _headY, 0.0f }); _cometHeadAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); - _cometHeadAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + _cometHeadAnimation.IterationBehavior(iterationBehavior); _cometHeadOpacityAnimation.InsertKeyFrame(0.0f, 0.0f); _cometHeadOpacityAnimation.InsertKeyFrame(0.06f, 1.0f); _cometHeadOpacityAnimation.InsertKeyFrame(0.91f, 1.0f); _cometHeadOpacityAnimation.InsertKeyFrame(1.0f, 0.0f); _cometHeadOpacityAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); - _cometHeadOpacityAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + _cometHeadOpacityAnimation.IterationBehavior(iterationBehavior); + + if (oneShotLaunch) + { + _cometTailAnimation.IterationCount(1); + _cometHeadAnimation.IterationCount(1); + _cometHeadOpacityAnimation.IterationCount(1); + } if (!_indeterminateRunning) { @@ -1982,6 +2010,136 @@ namespace winTerm::VisualProgress _terminalBatch = nullptr; } + // The launch clock bounds the Shell Integration launch fallback to one + // comet traversal of wall time. It is a single one-shot composition + // animation on a private property set: no timer, no polling loop, and + // no coupling to the visible comet, whose animations stop and restart + // with focus, geometry, and performance changes. It keeps running when + // a provider or explicit source takes the presentation over, so the + // stored fallback still expires in the background; the state machine's + // generation checks decide whether a completion still matters. + void _synchronizeLaunchClock() noexcept + { + const auto launch = _snapshot.visible && + _snapshot.source == ProgressSource::ShellIntegration && + _snapshot.mode == ProgressMode::Indeterminate && + _snapshot.status == ProgressStatus::Running && + _snapshot.launchGeneration != 0; + if (launch) + { + if (_activeLaunchGeneration != _snapshot.launchGeneration) + { + _startLaunchClock(_snapshot.launchGeneration); + } + return; + } + + // Shell terminal presentations and hidden snapshots end the launch + // scope; any queued completion is already stale by generation. + if (_snapshot.source == ProgressSource::ShellIntegration || _snapshot.mode == ProgressMode::Hidden) + { + _clearLaunchClock(); + } + } + + void _startLaunchClock(const uint64_t launchGeneration) noexcept + { + _clearLaunchClock(); + if (_closed || _faulted || !_compositor || !_launchExpiredCallback) + { + return; + } + + try + { + if (!_launchClockProperties) + { + _launchClockProperties = _compositor.CreatePropertySet(); + _launchClockProperties.InsertScalar(L"Progress", 0.0f); + } + if (!_launchClockAnimation) + { + _launchClockAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _launchClockAnimation.InsertKeyFrame(1.0f, 1.0f); + _launchClockAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); + _launchClockAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Count); + _launchClockAnimation.IterationCount(1); + } + + _launchBatch = _compositor.CreateScopedBatch(WUC::CompositionBatchTypes::Animation); + _launchClockProperties.StartAnimation(L"Progress", _launchClockAnimation); + _launchBatch.End(); + _activeLaunchGeneration = launchGeneration; + + const auto weak = weak_from_this(); + _launchBatchToken = _launchBatch.Completed([weak, launchGeneration](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->_completeLaunchClock(launchGeneration); + } + }); + _launchBatchSubscribed = true; + } + catch (...) + { + // The clock only bounds a decorative fallback. Without it the + // fallback keeps its pre-existing lifetime, ending at the next + // shell lifecycle transition. + _clearLaunchClock(); + } + } + + void _completeLaunchClock(const uint64_t launchGeneration) noexcept + { + if (_closed || !_launchBatchSubscribed || _activeLaunchGeneration != launchGeneration) + { + return; + } + _clearLaunchClock(); + + const auto callback = _launchExpiredCallback; + if (callback) + { + try + { + callback(launchGeneration); + } + catch (...) + { + // Expiration is advisory and must remain fail-open. + } + } + } + + void _clearLaunchClock() noexcept + { + // Invalidate first so a queued completion from a replaced batch is + // stale even when its replacement carries the same generation. + _activeLaunchGeneration = 0; + try + { + if (_launchBatchSubscribed && _launchBatch) + { + _launchBatch.Completed(_launchBatchToken); + } + } + catch (...) + { + } + _launchBatchSubscribed = false; + _launchBatch = nullptr; + try + { + if (_launchClockProperties) + { + _launchClockProperties.StopAnimation(L"Progress"); + } + } + catch (...) + { + } + } + void _stopStatusAnimations() noexcept { try @@ -2209,6 +2367,9 @@ namespace winTerm::VisualProgress void _releaseCompositionHandles() noexcept { _clearTerminalBatch(); + _clearLaunchClock(); + _launchClockProperties = nullptr; + _launchClockAnimation = nullptr; _root = nullptr; _trackVisual = nullptr; _trackGeometry = nullptr; @@ -2347,6 +2508,14 @@ namespace winTerm::VisualProgress bool _terminalBatchSubscribed{}; bool _terminalPresentationCompleted{}; + LaunchExpiredCallback _launchExpiredCallback; + WUC::CompositionPropertySet _launchClockProperties{ nullptr }; + WUC::ScalarKeyFrameAnimation _launchClockAnimation{ nullptr }; + WUC::CompositionScopedBatch _launchBatch{ nullptr }; + winrt::event_token _launchBatchToken{}; + uint64_t _activeLaunchGeneration{}; + bool _launchBatchSubscribed{}; + WUVM::UISettings _uiSettings{ nullptr }; WUVM::AccessibilitySettings _accessibilitySettings{ nullptr }; winrt::event_token _loadedToken{}; diff --git a/src/winterm/VisualProgress/VisualProgressModel.h b/src/winterm/VisualProgress/VisualProgressModel.h index 0079ad430..20eaec6fb 100644 --- a/src/winterm/VisualProgress/VisualProgressModel.h +++ b/src/winterm/VisualProgress/VisualProgressModel.h @@ -134,6 +134,12 @@ namespace winTerm::VisualProgress bool transient{}; bool suppressible{}; uint16_t stage{}; + // Identifies the OSC 133 command a Shell Integration launch fallback + // belongs to. Zero for every other source and shell state. The + // renderer captures it when the launch presentation starts and hands + // it back through ExpireShellLaunch, which makes stale one-shot + // completions inert. + uint64_t launchGeneration{}; bool SamePresentation(const ProgressSnapshot& other) const noexcept { @@ -146,7 +152,8 @@ namespace winTerm::VisualProgress confidence == other.confidence && transient == other.transient && suppressible == other.suppressible && - stage == other.stage; + stage == other.stage && + launchGeneration == other.launchGeneration; } }; @@ -173,6 +180,7 @@ namespace winTerm::VisualProgress _explicitSnapshot = {}; _providerSnapshot.reset(); _shellSnapshot.reset(); + _resetShellLaunchScope(); return _emit(HiddenSnapshot(ProgressStatus::Cancelled)); } return std::nullopt; @@ -254,7 +262,7 @@ namespace winTerm::VisualProgress } _providerSnapshot.reset(); - return _explicitActive ? std::nullopt : _emit(_shellSnapshot.value_or(HiddenSnapshot())); + return _explicitActive ? std::nullopt : _emit(_fallbackSnapshot()); } std::optional ApplyShellLifecycle(const ShellLifecycleState state, const int64_t exitCode) noexcept @@ -270,18 +278,35 @@ namespace winTerm::VisualProgress case ShellLifecycleState::Prompt: _providerSnapshot.reset(); _shellSnapshot.reset(); + _beginShellLaunchScope(); break; case ShellLifecycleState::CommandStart: // OSC 133;B: the user is composing input at an interactive // prompt. Nothing is executing, so an idle prompt must never // animate a bar; only CommandExecuted starts one. _shellSnapshot.reset(); + _beginShellLaunchScope(); break; case ShellLifecycleState::CommandExecuted: + // The launch fallback is a bounded one-shot per command. A + // repeated CommandExecuted observation (pane rehydration, + // alternate-screen churn, reconnect re-broadcast) belongs to + // the same still-running command, so it must neither restart + // a consumed launch nor open a new launch generation. + if (_shellLifecycle == ShellLifecycleState::CommandExecuted) + { + break; + } + _beginShellLaunchScope(); _shellSnapshot = ProgressSnapshot{ ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::ShellIntegration, 0 }; + _shellSnapshot->launchGeneration = _shellLaunchGeneration; break; case ShellLifecycleState::CommandFinished: _providerSnapshot.reset(); + // Completion supersedes the launch immediately and advances + // the launch scope, so a one-shot completion still in flight + // can never disturb this terminal presentation. + _beginShellLaunchScope(); _shellSnapshot = ProgressSnapshot{ ProgressMode::Determinate, exitCode > 0 ? ProgressStatus::Error : ProgressStatus::Success, @@ -296,6 +321,36 @@ namespace winTerm::VisualProgress return std::nullopt; } + _shellLifecycle = state; + return _explicitActive ? std::nullopt : _emit(_fallbackSnapshot()); + } + + // Called when the renderer's one-shot launch presentation has run for + // one full traversal. The captured generation makes stale completions + // inert: a callback from an earlier command can never hide progress + // belonging to a newer command. Expiration clears the stored fallback + // even while a provider or explicit source owns the presentation, so + // a later ownership release cannot resurrect an expired launch. + std::optional ExpireShellLaunch(const uint64_t generation) noexcept + { + std::scoped_lock lock{ _mutex }; + if (!_enabled || _closed || generation == 0 || + generation != _shellLaunchGeneration || _shellLaunchExpired) + { + return std::nullopt; + } + if (!_shellSnapshot || + _shellSnapshot->source != ProgressSource::ShellIntegration || + _shellSnapshot->mode != ProgressMode::Indeterminate || + _shellSnapshot->status != ProgressStatus::Running) + { + // The launch fallback was already replaced; success, error, + // and cancelled presentations are never expired. + return std::nullopt; + } + + _shellLaunchExpired = true; + _shellSnapshot.reset(); return _explicitActive ? std::nullopt : _emit(_fallbackSnapshot()); } @@ -306,6 +361,7 @@ namespace winTerm::VisualProgress _explicitSnapshot = {}; _providerSnapshot.reset(); _shellSnapshot.reset(); + _resetShellLaunchScope(); return _emit(HiddenSnapshot()); } @@ -322,6 +378,7 @@ namespace winTerm::VisualProgress _explicitSnapshot = {}; _providerSnapshot.reset(); _shellSnapshot.reset(); + _resetShellLaunchScope(); return _emit(HiddenSnapshot(ProgressStatus::Cancelled)); } @@ -343,7 +400,35 @@ namespace winTerm::VisualProgress { return *_providerSnapshot; } - return _shellSnapshot.value_or(HiddenSnapshot()); + if (_shellSnapshot) + { + return *_shellSnapshot; + } + // An expired launch hides the bar while its command keeps + // running. Running status keeps that hidden snapshot silent; + // Cancelled would raise a fake interruption announcement through + // the accessibility policy even though nothing was interrupted. + if (_shellLifecycle == ShellLifecycleState::CommandExecuted && _shellLaunchExpired) + { + return HiddenSnapshot(ProgressStatus::Running); + } + return HiddenSnapshot(); + } + + // Every shell lifecycle transition opens a new launch scope: the + // generation advance strands completion callbacks captured for an + // earlier scope, and the cleared flag re-arms the one-shot for the + // next CommandExecuted. + void _beginShellLaunchScope() noexcept + { + ++_shellLaunchGeneration; + _shellLaunchExpired = false; + } + + void _resetShellLaunchScope() noexcept + { + _beginShellLaunchScope(); + _shellLifecycle = ShellLifecycleState::None; } uint8_t _meaningfulValue(const uint8_t value) const noexcept @@ -370,7 +455,10 @@ namespace winTerm::VisualProgress bool _enabled{}; bool _closed{}; bool _explicitActive{}; + bool _shellLaunchExpired{}; + ShellLifecycleState _shellLifecycle{ ShellLifecycleState::None }; uint64_t _sequence{}; + uint64_t _shellLaunchGeneration{}; ProgressSnapshot _current{}; ProgressSnapshot _explicitSnapshot{}; std::optional _providerSnapshot;