Skip to content

feat: add Pane Search Phase 3 checkpoint (v1.3.3) - #46

Merged
HelloThisWorld merged 1 commit into
mainfrom
feature/v1.3.3-pane-search-phase3
Aug 12, 2026
Merged

feat: add Pane Search Phase 3 checkpoint (v1.3.3)#46
HelloThisWorld merged 1 commit into
mainfrom
feature/v1.3.3-pane-search-phase3

Conversation

@HelloThisWorld

Copy link
Copy Markdown
Owner

Pane Search Phase 3 — Performance & Edge-Case Hardening (v1.3.3)

Summary

Engineering checkpoint 1.3.3, Phase 3 of the Pane Search roadmap. No new
user-facing search feature and no visual redesign: this phase makes the
Phase 1/2 search reliable and responsive under real terminal workloads —
rapid typing, sustained output, large buffers and match counts, resize and
reflow, alternate-screen applications — and closes the edge cases found by
tracing the actual hot paths. The existing Microsoft Terminal search stack
(SearchBoxControlTermControlControlCore::SearchSearch
TextBuffer::SearchText, renderer highlights, scrollbar mark surface)
remains the only engine; no index, no mirror, no second search path.

Baseline / Phase 2 state

main at 97ecb7e85 (v1.3.2, PR #45): complete pane search UX with
compact SearchBox, current / total counter, responsive layout states,
scrollbar search overview with per-row dedup and current-match emphasis,
ShowMarks-independent rendering, pane-isolated state.

Performance investigation (before any change)

Traced, in the live code:

  1. Live typing is fully synchronous. TextBox.TextChanged
    SearchChangedTermControl::_SearchChangedControlCore::Search
    runs on the UI thread holding the terminal write lock; a stale query
    (Search::IsStale, needle/flags/mutation-id compare) triggers
    Search::ResetTextBuffer::SearchText — a full ICU scan of every
    committed row, once per keystroke, including every backspace step.
  2. Output-driven refresh cadence. Output arms
    SharedState::outputIdle (til::throttled_func, 100 ms, debounce +
    trailing) → OutputIdle event → TermControl::_refreshSearch()
    reset-only Search. Because the throttle is debounced, sustained
    output at intervals < 100 ms postpones it forever: with tail -f-style
    streams the counter froze and highlights/overview drifted misaligned
    (buffer rows shift under stored spans) until output paused. Spec §11/§12
    scenarios failed on the baseline.
  3. Search staleness tracking. Search::IsStale compares the buffer's
    GetLastMutationId(), which increments on every mutable row access —
    output, eviction (IncrementCircularBuffer), clears — and every new
    TextBuffer (resize/reflow, alt-screen entry) starts in a distinct
    id-space. Staleness detection is complete; refresh delivery was the gap.
  4. Search::Reset allocation. Each reset allocates a fresh result
    vector via SearchText; the previous vector is extracted for renderer
    invalidation and freed. Terminal::SetSearchHighlights however
    copy-assigned into its member, so clearing search retained the old
    capacity indefinitely (16 bytes × matches after Esc).
  5. Scrollbar overview. All mark drawing funnels through the throttled
    (8 ms) _updateScrollBar_throttledUpdateScrollbar, which fully
    repainted the mark bitmap on every tick while the surface renders —
    including pure thumb moves during scrolling — re-enumerating the entire
    occurrence list (ForEachDistinctSearchRow) and every mark row
    (GetMarkRows, O(buffer height)) to produce an identical bitmap. The
    bitmap's content does not depend on the scroll position.
  6. Resize/reflow. _refreshSizeUnderLockUserResize (main buffer
    replaced via TextBuffer::Reflow) → ClearSearch() → OutputIdle
    recomputes ≤ 100 ms later. Correct — except ClearSearch also performs
    the GH#19358 "select the focused result" conversion, feeding pre-reflow
    spans through the new buffer's scroll offset: every resize with an
    active search planted a stray selection at arbitrary coordinates, which
    Search::Reset then used as its current-match anchor.
  7. Alternate screen. Entering/leaving the alt buffer swaps the active
    TextBuffer (search follows the active buffer — native semantics,
    preserved) but left Terminal::_searchHighlights holding the other
    buffer's spans; the renderer could paint them at wrong positions for up
    to one refresh interval.
  8. Regex. til::ICU::CreateRegex already applies
    uregex_setTimeLimit(4096) and uregex_setStackLimit(4 MB): invalid
    patterns fail fast into the existing invalid-regex status with results
    cleared; pathological patterns abort bounded (silently truncating that
    scan's results) rather than hanging the UI. Verified, documented, no
    engine change.
  9. Close lifecycle. _refreshSearch re-checks IsOpen(); revokers +
    weak refs guard destruction; ClearSearch resets core state. Already
    sound; preserved under the new coalescing by re-reading live state.

Measurement: the in-tree TestSearchScanPerfSmoke bench (log-only) writes
representative log lines and times ControlCore::Search for e, er,
error, ERROR, a no-match literal, ERROR|WARN, and an invalid regex.
Release x64 on the development machine:

buffer e error no-match ERROR|WARN (regex) invalid [
2 000 lines (4 020 rows) 1.0 ms / 8 000 hits 0.8 ms / 2 000 0.5 ms 1.1 ms / 4 000 0.08 ms
9 001 lines (full 9 021-row buffer, wrapped) 2.0 ms / 18 040 1.8 ms / 4 510 1.2 ms 2.4 ms / 9 020 0.16 ms

A full scan of the complete default-size buffer costs ≈ 2 ms and scales
linearly, so single keystrokes were never the visible problem — redundant
scans during bursts, the starved mid-output refresh, and the per-tick
scrollbar re-enumeration were. That shaped the changes: cheap bounded
coalescing (not a rework), a bounded refresh cap, and a repaint signature.
Timings are logged, never asserted; WINTERM_SEARCH_BENCH_LINES scales the
bench locally (32 k-row extrapolation ≈ 7–8 ms per scan).

Changes (all measurement-justified; nothing else touched)

  • Typing coalescing, pane-local (TermControl): a
    ThrottledFunc<> (50 ms, leading + trailing) now backs
    _SearchChanged. The leading edge keeps a single keystroke as
    responsive as before; bursts collapse into one trailing search. The
    callback captures no query: it re-reads the search box at fire time, so
    the latest query always wins, a fire after Esc/close finds
    IsOpen() == false and does nothing, and TermControl teardown is guarded
    by the weak reference. Emptying the query bypasses the throttle and
    clears synchronously (spec §26). Navigation (Enter/Shift+Enter/
    buttons) keeps its direct synchronous path reading the box's current
    text — it cannot act on a stale query by construction (spec §9).
  • Mid-output convergence (ControlCore): a companion
    til::throttled_func (500 ms, trailing, no debounce) raises the new
    internal SearchRefreshNeeded event → _refreshSearch(). It is armed
    from the output handler only while _searchActive (atomic; set by
    non-empty Search, cleared by empty query and ClearSearch). Sustained
    output now updates counter/highlights/overview at most every 500 ms;
    quiet terminals keep the untouched 100 ms OutputIdle path. Search closed:
    the output path pays one relaxed load, schedules nothing (spec §5/§27).
  • Scrollbar repaint signature (TermControl + SearchUxHelpers):
    ScrollbarMarkPaintState captures everything the bitmap depends on —
    geometry (maximum, viewport, pixel size), category flags, the new
    ControlCore::SearchStateGeneration() (bumped on reset/navigate/clear),
    the pip color while search pips render, and BufferMutationId() while
    generic marks render. Ticks whose state equals the last painted state
    skip the repaint; collapsing the canvas invalidates the signature. Pure
    scrolling with tens of thousands of matches no longer re-enumerates
    anything. Search core remains the source of truth; the signature is an
    identity, not a cache of results.
  • Resize stray-selection fix (ControlCore): ClearSearch() split
    into the public close path (still performs the GH#19358 focused-result
    selection) and _clearSearchImpl(false) for the resize/reflow path,
    which now only invalidates. Regression-tested.
  • Alt-screen span hygiene (Terminal): UseAlternateScreenBuffer /
    UseMainScreenBuffer clear stored search highlights (they describe the
    other buffer); the next refresh recomputes against the active buffer.
    Search semantics unchanged and now documented: search targets the
    currently active buffer (alt screen while a TUI runs). No process-name
    special cases anywhere.
  • Memory release on clear (Terminal): SetSearchHighlights takes the
    vector by value and move-assigns, so clearing releases the old
    allocation instead of retaining capacity (spec §28).

Explicitly not done: no second engine, no index/mirror/database, no regex
replacement, no UI redesign, no per-frame work, no polling threads, no
Sleep loops (spec §8/§29/§35).

Continuous-output behavior

17 matches → new ERROR → 18 now converges ≤ 500 ms during sustained
output and ≤ 100 ms after idle; current match stays anchored to the focused
span (Search::Reset re-anchors via the previous focused highlight), so
appended matches update 4/20 → 4/25 instead of resetting to 1/25
(deterministically tested). Bursts converge after the existing idle
boundary; per-chunk work is unchanged (one throttle poke).

Resize/reflow behavior

Resize invalidates (hides) results immediately, recomputes after the
existing idle boundary against the reflowed buffer, never renders old spans
against the new geometry, and no longer creates a selection. Covered by a
shrink-then-grow reflow test asserting cleared results, no stray selection,
and post-reflow span validity in the narrowed geometry.

Alternate-screen behavior

Buffer switches drop the other buffer's highlight spans immediately;
search follows the active buffer (vim/less search the alt screen,
returning to the shell searches the main buffer again). Deterministically
tested through ?1049h/?1049l transitions, including span bounds inside
the alt viewport.

Unicode/regex behavior

Span widths verified through ControlCore for Traditional/Simplified
Chinese, Japanese, Korean (wide cells), accented Latin (narrow), and an
emoji surrogate pair; navigation across wide matches stays in range.
Case-insensitive default and case-sensitive mode regression-covered by the
existing Phase 1 tests plus ut_host SearchTests (unchanged). Invalid
regex keeps reporting the existing invalid status with zero retained
results; ICU's compiled-in time/stack limits bound pathological patterns
(risk documented above; no semantic change).

Tests

New in UnitTests_Control/ControlCoreTests.cpp (all deterministic, no
wall-clock assertions):

  • TestSearchBufferMutationRefreshesResults — mutation invalidation,
    post-output count refresh, focused-match anchoring, no-op refresh.
  • TestSearchScrollbackEvictionSafety — tiny history, evicted content
    reports zero (not stale), surviving spans/current match in bounds.
  • TestSearchReflowInvalidationAndNoStraySelection — wrapped lines,
    shrink/grow reflow, cleared results, no selection, valid new spans.
  • TestSearchAltBufferTransitions — highlight clearing on both switches,
    search follows the active buffer, alt spans within the viewport.
  • TestSearchStateGenerationSemantics — generation bump/no-op rules,
    BufferMutationId movement, _searchActive arm/disarm lifecycle.
  • TestSearchUnicodeWideSpans — span widths for 錯誤/错误/エラー/오류/café/👍.
  • TestScrollbarMarkPaintStateContracts — repaint-signature rules,
    including mutation-id and pip-color participation gating.
  • TestSearchScanPerfSmoke — log-only scan-cost bench + invalid-regex
    state assertions (WINTERM_SEARCH_BENCH_LINES scales it locally).

Manual performance checklist (for the 1.4.0-alpha validation pass)

Automated evidence in this PR: the TAEF suites above (mutation, eviction,
reflow, alt-screen, Unicode, generation/arming, repaint signature) and the
scan-cost bench at 2 000 and 9 001 lines. The interactive scenarios below
complement them and are the manual checklist for the upcoming alpha
validation; they were not run against a packaged build in this PR.

  • A — large static buffer:
    1..10000 | % { Write-Host "INFO request=$_ ERROR sample WARN payload" },
    then type eererrerror; typing must stay fluid.
  • B — no-match: search THIS_STRING_DOES_NOT_EXIST_123456; no freeze.
  • C — huge match count: search INFO; counter shows the bounded
    999+ form, terminal and overview stay usable.
  • D — continuous output:
    while ($true) { Write-Host "$(Get-Date -Format o) INFO request ERROR sample"; Start-Sleep -Milliseconds 50 }
    (stop with Ctrl+C), search ERROR; counter/highlights/overview must
    converge at least every ~500 ms while the loop runs.
  • E — burst: emit several thousand lines at once; search converges
    after output idles.
  • F — reflow: long wrapped lines containing the query; drag the pane
    border narrower and wider; no ghost or missing highlights, no stray
    selection.
  • G — split panes: four panes, different queries, simultaneous output;
    no cross-pane interference.
  • H — TUI: vim / less with a search open across alt-screen
    enter/exit; no stale highlights, Esc still owned by the search box.
  • I — Unicode: search 錯誤 / 错误 / エラー / 오류 / café /
    👍; counts, highlights, navigation correct.
  • J — rapid close: type a query and hit Esc immediately, repeatedly;
    nothing may resurrect afterwards.

Version changes

1.3.21.3.3 across: Branding version.json + ReleaseMetadata.h,
appx manifest, WindowsTerminal.rc / wt.rc / winterm-shim.rc (dotted +
comma forms), PowerShell module (psd1/psm1/shared version.json),
Workspace descriptor/serializer fallbacks, pinned literals in
package-shell-assets.ps1 / test.ps1 / verify-branding.ps1 /
test-visual-progress.ps1 / verify-version.ps1, README.md + README.ja.md
source-version references, CHANGELOG, current-progress. v1.3.3 appended
to the checkpoint-tag allowlists in release.yml,
test-release-workflow.ps1, and verify-version.ps1 (existing tags
retained). No build label, no artifacts, Latest/WinGet/prerelease metadata
untouched.

Deferred final integration

1.4.0-alpha (final integration + manual user validation) starts only on a
separate instruction. Remaining known risks deferred there: ICU's bounded
pathological-regex truncation is documented rather than surfaced in UI, and
mid-output refresh cost on maximum (32k-line) histories is bounded but
measurable (≤ 2 full scans/s while streaming with search open).

Performance and edge-case hardening for the pane search, keeping the
existing Terminal search stack as the only engine.

- Coalesce live-typing searches per pane (50 ms, leading + trailing); the
  callback re-reads the search box at fire time so the latest query always
  wins, navigation stays synchronous on current text, and fires after
  close are no-ops.
- Converge an open search during sustained output: a non-debounced 500 ms
  refresh cap (new internal SearchRefreshNeeded event) complements the
  debounced OutputIdle path and is armed only while a search is active;
  search closed keeps the output path at one relaxed atomic load.
- Repaint the scrollbar mark bitmap only when its content inputs change
  (geometry, categories, search generation, pip color, buffer mutation id
  while generic marks render); plain scrolling stops re-enumerating
  occurrences and mark rows.
- Stop the resize/reflow invalidation from converting pre-reflow spans
  into a stray selection; the GH#19358 select-on-close behavior now
  belongs to the close path only.
- Drop stored search highlights on main/alt screen-buffer switches; search
  keeps following the active buffer.
- Release the terminal-side highlight copy when search clears.
- Deterministic regression tests: mutation invalidation, focused-match
  anchoring, scrollback eviction, reflow + no-stray-selection, alt-screen
  transitions, generation/arming semantics, repaint-signature contracts,
  wide-character spans, and a log-only scan bench
  (WINTERM_SEARCH_BENCH_LINES scales it locally).
- Version 1.3.3 across branding, packaging, module, scripts, READMEs,
  CHANGELOG, and progress docs; v1.3.3 appended to the checkpoint-tag
  allowlists. Engineering checkpoint only: no build label, no artifacts,
  Latest/WinGet/prerelease metadata untouched.
@HelloThisWorld
HelloThisWorld merged commit e436fdf into main Aug 12, 2026
7 checks passed
@HelloThisWorld
HelloThisWorld deleted the feature/v1.3.3-pane-search-phase3 branch August 12, 2026 14:01
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