Skip to content

til::ThrottledFunc chains duplicate runs when events arrive during function execution (debounce does not cover in-flight state) #20103

Open

Description

@Skydev0h

Windows Terminal version

1.23.20211.0

Windows build number

10.0.26100.7171

Other Software

No response

Steps to reproduce

Note on framing: this is a til::ThrottledFunc semantics bug, not the same bug as #11522 (keyboard-layout switch triggers full settings reload), even though the easiest observation path goes through that code path. #11522's eventual fix — skipping ReloadSettingsThrottled() on layout change — would make this particular manifestation invisible while leaving the underlying ThrottledFunc misbehavior latent for any other caller whose function body outgrows its debounce delay. The two should be fixed independently.

The reload path is used here purely as a convenient amplifier: (a) the function body takes ~3.5 s at stressed state so the "events during execution" window is trivially achievable by hand, and (b) it's already a well-documented symptom on #11522. Any ThrottledFunc with delay < function_runtime and rapid callers will exhibit the same chaining regardless of what the function body does.

Reproducible on stock Stable Windows Terminal, no custom build required.

  1. Install (or already have) a current Stable Windows Terminal — tested on 1.23.20211.0.

  2. Save the following ~15 line PowerShell script as stress-tab.ps1 somewhere accessible:

    # Fills a tab with 10 000 lines of URL-ish scrollback content to exercise
    # ControlCore::UpdatePatternLocations under the same til::ticket_lock that
    # _RefreshUIForSettingsReload competes with, saturating the reload cost.
    $ErrorActionPreference = 'Continue'
    $tabId = [guid]::NewGuid().ToString().Substring(0, 8)
    1..10000 | ForEach-Object {
        "[$tabId][$_] https://example.com/resource/$_/edit?id=$_ file://C:/data/file$_.log text before text after"
    }
    Write-Host ""
    Write-Host "=== Tab $tabId filled with 10000 scrollback lines ===" -ForegroundColor Green
  3. From any shell, spin up ~40 tabs running the stress script to bring the WT host to ~1.1–1.5 GB private bytes (takes under a minute):

    1..40 | ForEach-Object {
        wt -w 0 nt powershell -NoExit -File <path>\stress-tab.ps1
        Start-Sleep -Milliseconds 150
    }
  4. Click into any tab to bring focus into WT.

  5. Rapidly spam Win+Space (or Ctrl+Shift — either way of switching OS keyboard layout works) — 5 to 10 switches within ~1 second. This requires at least two installed keyboard layouts; any combination works, the bug is layout-agnostic.

  6. Subjectively observe: the UI freeze is visibly longer than a single reload would produce. Typing a few characters right after the layout switches out-and-back shows them buffering and dumping in a burst after a multi-second delay (not the typical single-reload delay associated with the Switching OS input method resets Terminal colors and zoom #11522 symptom).

Optional: quantitative confirmation via instrumentation

For anyone wanting exact per-dispatch timings rather than subjective observation, Skydev0h/terminal@investigation/gh11522-layout-switch-lag adds OutputDebugString + TraceLogging around the _reloadSettings ThrottledFunc wiring in AppLogic.cpp. Build it as Dev SKU, run DebugView with Capture Global Win32 and filter *GH11522*, then repeat step 5 — each ThrottledFunc dispatch prints a reload-done elapsed=...ms line. The capture in Actual Behavior below was produced on that branch; the observable behaviour is the same on Stable, only the quantitative output isn't printed.

Full context, measurements, and call-chain walkthrough for the related #11522 lag live at wt-keyboard-layout-lag-investigation.md.

Expected Behavior

Given ThrottledFunc configured with { delay = 100ms, debounce = true, trailing = true }, rapid consecutive calls should coalesce into a single execution at the trailing edge of the quiet period. From the caller's perspective the contract reads: "call me as often as you want, my target function will be invoked at most once per ~100 ms of quiet time after the last call".

When the function body happens to take longer than the debounce delay (a legitimate real-world case as soon as the work scales with state), calls arriving during that in-flight execution should follow one of these reasonable semantics:

  • (a) Coalesce into the current in-flight run — if semantics are "the running execution is the one that observes the effects of those calls".
  • (b) Start a new debounce timer at completion of the current run — if semantics are "quiet period means quiet, including post-run settle".
  • (c) Drop entirely during in-flight execution — if semantics are "one run per burst".

What should not happen: events arriving during execution cause an immediate second run the instant the first one completes, with zero additional debounce settle time. This defeats the purpose of debounce for any caller whose work exceeds the delay — the more work you do, the more the debounce contract degrades into "run as fast as you can back-to-back".

Actual Behavior

Events arriving during the throttled function's execution get queued and cause an immediate second dispatch right after the first one completes, with no additional debounce settle time in between. For expensive function bodies like ReloadSettings in the #11522 path, this doubles the effective cost for a fast caller and visibly doubles the UI freeze duration as experienced by the user.

Video demonstration

2026-04-11_14-30-26.mp4

The recording (~50 seconds) shows the scenario in step 5 on a stressed Windows Terminal host: rapid Ctrl-Shift spam, followed by a visibly multi-second UI freeze. Several secondary observations visible in the recording:

  • The freeze is two chained freezes, not one: watch the system tray language indicator and the cursor behaviour — the layout indicator updates once, then the UI thaws briefly, then freezes again for another multi-second interval before finally responding. That second freeze is the back-to-back run of the throttled function that shouldn't be happening.
  • Hyperlink hover is frozen: the mouse cursor moves freely (Windows input delivery continues), and the text caret keeps its timer-driven blink, but the highlight over the last hovered URL stays stuck. This is the til::ticket_lock mechanism from #12607 — ControlCore::UpdatePatternLocations cannot acquire the buffer lock while UpdateSettings holds it during the chained reloads.
  • System tray language indicator also freezes: the "EN"/"UA" box near the taskbar clock does not update until WT finishes the second reload, not after the first one. This is a cross-process observation — explorer.exe / TextInputHost.exe should not be directly blocked by a WindowsTerminal.exe freeze. Possibly TSF / ctfmon.exe holds some process-level serialization waiting for the foreground app to acknowledge the layout change, and releases only after WT's reload completes. Worth noting as a side finding; may deserve separate investigation if the double-reload bug ends up being easier to fix than this cross-process symptom.
  • Input buffering: characters typed during the freeze are not lost — they are queued in the input message pump and dump at once when the final reload completes, producing the "machine gun catch-up" visible at the end of the recording.

Quantitative capture (from instrumented build)

Numbers below come from the instrumentation branch mentioned in Steps to reproduce — the Stable WT user only sees the subjective double-freeze in the video above; the DebugView log makes the two dispatches explicit:

[GH11522] layout-change tick=943590890 priv=1835MB ws=680MB skip=0
[GH11522] layout-change tick=943590953 priv=1835MB ws=680MB skip=0
[GH11522] layout-change tick=943591031 priv=1835MB ws=680MB skip=0
[GH11522] layout-change tick=943591093 priv=1835MB ws=680MB skip=0
[GH11522] layout-change tick=943591156 priv=1834MB ws=679MB skip=0
[GH11522] layout-change tick=943591234 priv=1834MB ws=679MB skip=0
[GH11522] layout-change tick=943591296 priv=1834MB ws=679MB skip=0
[GH11522] layout-change tick=943591375 priv=1834MB ws=679MB skip=0
[GH11522] layout-change tick=943591515 priv=1834MB ws=679MB skip=0
[GH11522] reload-done elapsed=3703ms priv=1834MB delta=-204800B
[GH11522] reload-done elapsed=3532ms priv=1835MB delta=+233472B

Nine OnActivated events within a 625 ms window, then two back-to-back reload-done events totaling 7235 ms of frozen UI for what contract-wise should have been a single coalesced run. The first run covers the events that arrived before the trailing-edge dispatcher fired; the second run is ThrottledFunc honoring a queued "pending call" from events that arrived during the first run's execution.

With the default delay=100ms, the effective observed behaviour for a 3.5-second function body becomes: "first burst of events coalesces into run #1, any events during run #1 get coalesced into run #2 which starts immediately after run #1 ends, events during run #2 feed run #3, and so on". The debounce has degraded into back-pressure-limited serial dispatch.

Related / context

Activity

  1. added
    Issue-BugIt either shouldn't be doing this or needs an investigation.
    Needs-TriageIt's a new issue that the core contributor team needs to triage at the next triage meeting
    on Apr 11, 2026
  2. added
    Area-CodeHealthIssues related to code cleanliness, linting, rules, warnings, errors, static analysis, etc.
    on Apr 29, 2026
  3. DHowett commented on Apr 29, 2026

    @DHowett
    Member

    It seems like this is the safer option, isn't it? ReloadSettings as in your example probably captured important data, and it needs to run to completion; however, the next request might also bear important data, and can't be ignored.

    Do you have a (brief) practical example of where this causes problems in Terminal/?

  4. added
    Needs-Author-FeedbackThe original author of the issue/PR needs to come back and respond to something
    No-Recent-ActivityThis issue/PR is going stale and may be auto-closed without further activity.
    on Apr 29, 2026
  5. Skydev0h commented on May 4, 2026

    @Skydev0h
    Author

    It seems like this is the safer option, isn't it? ReloadSettings as in your example probably captured important data, and it needs to run to completion; however, the next request might also bear important data, and can't be ignored.

    Do you have a (brief) practical example of where this causes problems in Terminal/?

    For me this amplified another bug report that was causing me significant lag issues (#11522) - the input freezes for twice the time it does usually.

    Hopefully it is one of few places where it could really manifestate but it makes things noticeably worse there.

  6. added
    Needs-AttentionThe core contributors need to come back around and look at this ASAP.
    and removed
    Needs-Author-FeedbackThe original author of the issue/PR needs to come back and respond to something
    No-Recent-ActivityThis issue/PR is going stale and may be auto-closed without further activity.
    on May 4, 2026
  7. PavelAmialiushka commented on Oct 14, 2026

    @PavelAmialiushka

    That bug makes it impossible to use windows-terminal to work with LLM. And it is open already 6 month.

  8. DHowett commented on Oct 14, 2026

    @DHowett
    Member

    Having 40 tabs simultaneously stressing terminal while rapidly spamming Win+Space has made it impossible for you to do your work? Have you considered, possibly, not running the original reporter's repro script while you work?

  9. locked and limited conversation to collaborators on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area-CodeHealthIssues related to code cleanliness, linting, rules, warnings, errors, static analysis, etc.Issue-BugIt either shouldn't be doing this or needs an investigation.Needs-AttentionThe core contributors need to come back around and look at this ASAP.Needs-TriageIt's a new issue that the core contributor team needs to triage at the next triage meeting

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions