microsoft / microsoft/terminal

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

Open
#20,103 2 comments 0 reactions 0 assignees View on GitHub
Area-CodeHealth Issue-Bug Needs-Attention Needs-Triage
Dominant language
C++
Stars
105k
Forks
9.6k
Avg merge
3d 17h
Merged PRs (30d)
29

Description

### 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](https://github.com/microsoft/terminal/issues/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:

```powershell
# 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):

```powershell
1..40 | ForEach-Object {
wt -w 0 nt powershell -NoExit -File \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 #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`](https://github.com/Skydev0h/terminal/tree/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](https://github.com/Skydev0h/terminal/blob/investigation/gh11522-layout-switch-lag/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

https://github.com/user-attachments/assets/74612edc-1bfe-4310-b89b-326d1c1b509f

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](https://github.com/microsoft/terminal/issues/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

- Relevant code: the `_reloadSettings` ThrottledFunc construction at [`src/cascadia/TerminalApp/AppLogic.cpp:137-149`](https://github.com/microsoft/terminal/blob/main/src/cascadia/TerminalApp/AppLogic.cpp#L137-L149), implementation in [`src/inc/til/throttled_func.h`](https://github.com/microsoft/terminal/blob/main/src/inc/til/throttled_func.h).
- Full GH#11522 investigation with 40-tab stress reproducer, A/B measurements via `WT_GH11522_SKIP` env-var toggle, and code walkthrough: [Skydev0h/terminal/tree/investigation/gh11522-layout-switch-lag](https://github.com/Skydev0h/terminal/tree/investigation/gh11522-layout-switch-lag).
- **This is a distinct bug from [#11522](https://github.com/microsoft/terminal/issues/11522).** Fixing #11522 (skipping the reload entirely on layout change) would make this particular manifestation invisible, but the underlying ThrottledFunc semantic issue would remain latent for any other caller — the two should be fixed independently. Any user of `til::ThrottledFunc` whose function body takes longer than the configured debounce delay is potentially affected.

Contributor guide

Open the contributing guide

Research direction

Start with the _reloadSettings ThrottledFunc construction in src/cascadia/TerminalApp/AppLogic.cpp:137-149 and read its implementation in src/inc/til/throttled_func.h. Reproduce the in-flight event behavior with the documented stress scenario, then establish which debounce semantics maintainers want. Done means events received during execution no longer cause an immediate back-to-back dispatch without the intended settling behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
desktop-dev, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.