20 KiB
Async Find — Tech Spec
Companion to specs/async-find/PRODUCT.md. This spec is a semantic walkthrough of what changed in the david/async-find branch; it should be enough to read the diff in pieces without having to load all ~3k lines into your head at once.
Context
Sync terminal find lives in app/src/terminal/find/model/block_list.rs. run_find_on_block_list walks the block height sumtree under BlockList, runs RegexDFAs against each grid via GridHandler::find_in_range, and packs everything into a BlockListFindRun (see app/src/terminal/find/model/block_list.rs (32-114)). TerminalFindModel (in app/src/terminal/find/model.rs) is a ViewHandle-owned model that holds the latest BlockListFindRun, exposes the FindModel trait used by view_components/find.rs, and is invoked from TerminalView::run_find / rerun_find_on_active_grid / focus_next_find_match / clear_matches. Rendering reads matches off the BlockListFindRun from block_list_element.rs and from view.rs::scroll_to_match.
The model is shared across threads as Arc<FairMutex<TerminalModel>>, so any background scanner has to coexist with the main thread's writes (ANSI parser appending rows, blocks completing, etc.). Existing AbsolutePoint (app/src/terminal/model/grid/grid_handler.rs (172-233)) already encodes scrollback-stable row coordinates by adding num_lines_truncated() — async find leans on this for incremental updates.
The change introduces an async path that runs alongside the sync one, gated on the AsyncFind feature flag. The sync path is left intact and continues to be the only path used when the flag is off; the rendering layer is taught to consume either path through a new render-data abstraction.
Most relevant entry points before this branch:
app/src/terminal/find/model.rs—TerminalFindModel, the only thing UI code touchesapp/src/terminal/find/model/block_list.rs— sync scannerapp/src/terminal/block_list_element.rs— readsBlockListFindRunto draw highlightsapp/src/terminal/view.rs—scroll_to_match,block_completed_eventhandlingapp/src/view_components/find.rs—FindModeltrait + find bar renderapp/src/terminal/model/grid/grid_handler.rs—find_in_range,AbsolutePointcrates/warp_features/src/lib.rs— feature flag registry
Proposed changes
Module layout
A new submodule app/src/terminal/find/model/async_find/ is added with three files:
async_find.rs— the public API (AsyncFindController,AsyncFindStatus,AsyncFindConfig,AbsoluteMatch,BlockFindResults,BlockInfo).async_find/work_queue.rs—FindWorkQueue, anArc<Mutex<…>>+event_listener::Eventqueue shared between the controller and the background task.async_find/background_task.rs— the async function spawned viactx.spawn(...)that pulls items off the queue and streams results back over anasync_channel.
async_find_tests.rs (sibling, included via the standard #[path = "async_find_tests.rs"] mod tests; pattern) holds 18 unit / integration tests.
Two find paths in one model
TerminalFindModel gains an async_find_controller: Option<AsyncFindController> field, populated in new() iff FeatureFlag::AsyncFind.is_enabled() (app/src/terminal/find/model.rs:243). Every existing TerminalFindModel method now branches:
if model.is_alt_screen_active() { /* alt-screen path, unchanged */ }
else if let Some(controller) = &mut self.async_find_controller { /* async path */ }
else { /* legacy sync path, unchanged */ }
This keeps the alt-screen and sync paths byte-for-byte identical when the flag is off, and lets the async path be tested independently. Methods updated in this pattern: match_count, active_find_options, run_find, rerun_find_on_active_grid, focus_next_find_match, clear_matches, update_matches_for_filtered_block (no-ops on async since invalidation runs through invalidate_block). Three new helpers are added: is_async_find_scanning, invalidate_async_find_block, notify_block_completed, plus a focused_block_list_match() accessor that abstracts both paths for view.rs::scroll_to_match.
Render data abstraction
block_list_element.rs previously reached into BlockListFindRun directly. The branch introduces BlockFindRenderData<'a> (app/src/terminal/find/model.rs (44-178)), an enum with Sync { run, block_index } and Async { command_matches, output_matches, focused_command_range, focused_output_range } variants. Both expose the same triple of methods (command_grid_matches, output_grid_matches, focused_range_for_grid) so the renderer is path-agnostic. TerminalFindModel::find_render_data_for_block produces the right variant based on which controller is present, and pre-converts AbsoluteMatch → RangeInclusive<Point> at construction time so the renderer doesn't need a GridHandler reference.
This is the only diff in block_list_element.rs (besides GridType gaining Hash for use in HashMap keys): replace direct BlockListFindRun/BlockListMatch usage with BlockFindRenderData accessors.
Controller, queue, background task
AsyncFindController owns:
terminal_model: Arc<FairMutex<TerminalModel>>— shared with the background task.block_results: BlockFindResults—HashMap<(BlockIndex, GridType), Vec<AbsoluteMatch>>for terminal matches plusHashMap<EntityId, Vec<RichContentMatchId>>for AI matches. Per-blockTotalIndexmaps live alongside so focus traversal can sort by display position.current_config,current_find_options,block_sort_direction,focused_match_index,cached_focused_match,status.result_tx,throttle_tx,task_handle,work_queue,generation.
start_find (async_find.rs (607-702)) is the heart of the controller. It:
- Cancels any in-flight scan (
cancel_current_findcloses the queue, aborts the future, drops senders). - Runs the query refinement check (
is_query_refinement—new.starts_with(old)and longer; only triggers when regex is off and case-sensitivity matches). Today this just routes throughfilter_results_for_refinement, which is essentially a clean restart with the new query but is structured so a future commit can swap in true in-place filtering. - Builds an
AsyncFindConfig, takes a brief lock on the terminal model to callcollect_block_info(newest-first traversal of the block-height sumtree) and to clear stalefind_dirty_rows_rangeon the active block's output grid (otherwise the first incremental update would re-scan rows the full scan already covers). - Populates the
terminal_total_indices/ai_total_indicesmaps socompute_focused_terminal_matchcan re-sort results into display order. - Creates an unbounded
async_channelforFindTaskMessageresults, bumpsgeneration, and spawns two streams viactx.spawn_stream_local:- The result stream calls
process_messagefor every message that arrives, but only if its capturedgenerationstill matches the controller's currentgeneration(this is the deduplication mechanism for staleDonemessages — see Risks). - A throttle stream wraps an
async_channel<()>incrate::throttle::throttle(50ms)so rapid result deliveries coalesce into at most oneFindEvent::RanFindemit every 50 ms.
- The result stream calls
- Spawns the background future via
background_task::spawn_find_task.
FindWorkQueue (work_queue.rs) is a deliberately small primitive:
enqueue_full_scan(blocks)pushesFullBlock/AIBlockitems at the back, in the newest-first order produced bycollect_block_info.invalidate_block(idx, dirty_range)pushes aDirtyRange(orFullBlockif no range) at the front, so reactive work jumps the queue ahead of the initial scan. It is also a no-op if aFullBlockfor the same block is already pending.pop()is async and usesevent_listener::Eventto block when empty without busy-waiting. It returns(item, queue_drained: bool)so the consumer can decide atomically when to emitFindTaskMessage::Done— no separateis_empty()call, no TOCTOU race.close()flips a flag and notifies all listeners so a parkedpop()returnsErr(QueueClosed).
background_task::run_find_task_loop (background_task.rs (56-128)) builds RegexDFAs once from the config, then:
- For
FullBlock { block_index }: callsscan_terminal_block_chunked, which iterates the grid in the block-sort-direction-aware order and delegates each grid toscan_grid_chunked. - For
DirtyRange { … }: callsscan_grid_chunkedwith the dirty bounds andScanResultMode::DirtyRange { num_lines_truncated }so the controller knows to merge rather than extend. - For
AIBlock { … }: forwards the work back to the main thread as aScanAIBlockmessage — rich content scanning still has to run on the UI thread because it touchesViewHandles.
scan_grid_chunked is the only place that holds the terminal-model lock. It scans ROWS_PER_CHUNK = 1000 rows per pass, converts the resulting RangeInclusive<Point>s into AbsoluteMatches while still under the lock (because conversion needs the grid's num_lines_truncated), drops the lock, sends a BlockGridMatches or DirtyRangeMatches message, and yields back to the executor (futures_lite::yield_now) if the held lock duration exceeded MAX_LOCK_DURATION_MS / 2 = 2.5ms. This is what keeps the main thread responsive — the lock is never held for more than a few hundred microseconds at a time on real workloads.
process_message on the main thread
AsyncFindController::process_message (async_find.rs (708-808)) handles four message types:
BlockGridMatches: extends the per-grid match vec, lazily fills interminal_total_indicesfor blocks that arrived afterstart_find(e.g. created mid-scan), auto-focuses index 0 on first arrival, and clamps focus.DirtyRangeMatches: routes throughBlockFindResults::update_dirty_matches, which does positional splicing inside the existing match vec (assumes ascending order by end-row, which is guaranteed byfind_in_rangereturning non-overlapping descending matches that the task reverses). Then prunes truncated matches and clamps focus.ScanAIBlock: invokesFindableRichContentHandle::run_findon the registered view, stashes results inai_matches+ai_total_indices, clamps focus.Done: flipsstatustoComplete.
After each message, a () is try_send-ed into the throttle channel so the find bar gets at most one FindEvent::RanFind every 50 ms. The throttle is what produces the streaming-but-not-saturated UI updates.
Focused match ordering
compute_focused_terminal_match (async_find.rs (506-586)) is the bit that makes async match traversal match sync match traversal. It builds a unified (TotalIndex, BlockInfo) list of every block that has results, sorts descending by TotalIndex (newest blocks first), then for each block iterates grids in the order dictated by block_sort_direction, reversing within-grid iteration on MostRecentLast. The result is cached in cached_focused_match and only recomputed when focus or the result set changes — without the cache, focused_terminal_match() would re-sort and re-iterate on every render frame. (See commit 32eef7ef31 perf: cache focused_terminal_match() to avoid per-call sorting.)
Dirty range plumbing
GridHandler gains a find_dirty_rows_range: Option<RangeInclusive<usize>> field that accumulates rows touched by ANSI writes between explicit consumes (grid_handler.rs:1568+). Existing dirty_cells_range is reset every byte-processing pass, so the new field is needed to bridge across many processing passes — find consumes it on its own cadence. take_find_dirty_rows_range() is the destructive read used by both start_find (to reset stale state) and notify_block_completed / rerun_find_on_active_grid (to drive incremental updates).
BlockList::block_at_mut (app/src/terminal/model/blocks.rs:1773) is added to allow notify_block_completed to reach into the completed block's grids to consume the dirty range.
View + UI wiring
Three small changes outside the find module:
app/src/terminal/view.rs—block_completed_eventnow also callsfind_model.notify_block_completed(idx, ctx)so async find rescans the freshly-finalized block.scroll_to_matchswitches fromblock_list_find_run().focused_match()to the path-agnosticfocused_block_list_match()accessor.app/src/terminal/block_list_element.rs— switches highlight rendering toBlockFindRenderData.GridTypegainsHashso it can keyBlockFindResults'sHashMap.app/src/view_components/find.rs—FindModeltrait gainsis_scanning() -> bool(defaultfalse).render_match_indexchecks it and rendersScanning...or<count>+ ...in place of thecurrent/totallabel.
Feature flag, build setup
crates/warp_features/src/lib.rsaddsFeatureFlag::AsyncFindand lists it inDOGFOOD_FLAGS.app/Cargo.tomladds the correspondingasync_find = []cargo feature, andapp/src/lib.rs::enabled_features()maps the cargo feature to the runtime flag.- Top-level
Cargo.tomlbumpswarp_terminal.opt-level = 3in thedevprofile because the background task's hot loop (DFA matching against thousands of rows) is intolerably slow underopt-level = 0.
End-to-end flow
sequenceDiagram
participant UI as Find bar (UI thread)
participant TFM as TerminalFindModel
participant AFC as AsyncFindController
participant BG as Background task
participant TM as TerminalModel
UI->>TFM: run_find(options)
TFM->>AFC: start_find(options, sort_dir, ctx)
AFC->>AFC: cancel old run + bump generation
AFC->>TM: lock briefly: collect_block_info, clear dirty range
AFC->>BG: spawn_find_task(config, queue, result_tx)
BG->>BG: build RegexDFAs
loop until queue closed
BG->>BG: queue.pop().await
BG->>TM: lock for ≤5ms, scan ROWS_PER_CHUNK rows
BG-->>AFC: FindTaskMessage (via async_channel)
AFC->>AFC: process_message: update results, clamp focus
AFC-->>UI: throttle (50ms): FindEvent::RanFind
end
BG-->>AFC: FindTaskMessage::Done
AFC->>AFC: status = Complete
Note over UI,AFC: User edits query → start_find again → cancel cascade
Note over TM,AFC: New output → notify_block_completed → invalidate_block → queue.invalidate_block (front of queue)
Testing and validation
Tests live in app/src/terminal/find/model/async_find_tests.rs and run via cargo nextest run -p warp_terminal (or the workspace defaults). Each spec invariant from PRODUCT.md is covered:
- PRODUCT.md (1, 18) — parity with sync find.
test_async_find_produces_same_results_as_sync_findruns sync and async over the same mocked block list and asserts identical match counts and ranges.test_async_focused_order_matches_sync_most_recent_lastandtest_async_focused_order_matches_sync_most_recent_firstlock down focus traversal order against sync for both sort directions. - PRODUCT.md (2, 3) — scanning indicator.
test_async_find_status_displaycovers theAsyncFindStatusDisplay impl.is_scanning()andmatch_count()thread through the existingview_components/find.rsrender tests via theFindModeltrait. - PRODUCT.md (5, 6, 7) — focus and wrap.
test_focus_next_match_wraps_aroundcovers wrap behavior.test_message_processing_updates_statecovers auto-focus on first match arrival. - PRODUCT.md (8) — cancellation.
test_async_find_cancellationstarts a scan, cancels it, and assertsstatus == Idleplus that no further messages are processed. - PRODUCT.md (9) — query refinement.
test_is_query_refinementcovers the predicate. End-to-end refinement is exercised implicitly bystart_findtaking the refinement branch. - PRODUCT.md (11, 12) — dirty range / block completion.
test_block_invalidation_with_dirty_rangeenqueues an invalidation and asserts the queue receives aDirtyRangework item at the front.test_update_dirty_matches_*(5 tests: empty / prepend / append / replace_middle / clear_range) lock downBlockFindResults::update_dirty_matchessince it owns the splicing math. - PRODUCT.md (13) — truncation.
test_absolute_match_is_truncatedcoversAbsoluteMatch::is_truncated.prune_truncated_matchesis exercised inside the dirty-range tests. - PRODUCT.md (14, 15) — find-in-block + AI blocks.
test_async_find_config_from_optionscovers the option threading; AI block routing is covered by the existing rich-content tests via theFindableRichContentHandletrait. - PRODUCT.md (16) — alt screen. Sync alt-screen path is unchanged, covered by existing alt-screen tests.
- PRODUCT.md (17) — clear / close.
test_block_find_results_remove_block,test_block_find_results_total_countcover the clearing primitives; the controller-level path is covered intest_async_find_cancellation.
Manual validation:
- Build via
cargo run --features async_find(or rely on the dogfood-flag default-on) on a session with thousands of blocks. Type a query that has matches in old blocks; confirm the input box and block list stay responsive while the count climbs. - Run a long
find / -name foowhile the find bar is open with an active query; confirm new matches appear in the active block as output streams in. - Toggle the feature flag off and confirm sync find behavior is bit-identical.
Lint/format:
cargo fmtcargo clippy --workspace --all-targets --all-features --tests -- -D warnings./script/presubmit
Risks and mitigations
-
Stale
Donemessages from a cancelled scan ending a new one prematurely. The result stream is created perstart_findcall, butasync_channels in flight can outlive the cancel. Each spawn captures the controller'sgenerationcounter;process_messageis only called whencontroller.generation == captured_generation. (See commitb4e69a5390 Avoid explicit polling.) -
Lock contention with the ANSI parser writing to the active block's grid. Mitigated by the
ROWS_PER_CHUNK = 1000chunking +MAX_LOCK_DURATION_MS = 5budget +yield_now()inscan_grid_chunked. The terminal model lock is aFairMutexso the writer cannot starve. PerWARP.md, locking discipline matters: the background task only acquires the lock insidescan_grid_chunkedand immediately drops it before any await point. -
Queue/result race on completion. The
Donemessage is emitted by the background task only whenpop()returned a drained queue (queue_drained == true), checked atomically inside the queue's mutex. This avoids the TOCTOU race of a separateis_empty()check. -
Match ordering invariants.
update_dirty_matchesrelies on the per-grid match vec being sorted ascending by end-row with non-overlapping ranges. This is asserted withdebug_assert!after every update. If a future change introduces overlapping matches, the assert fires in dev/test before reaching production. -
Truncation during dirty-range scans. Whenever the active block's output is updated,
prune_truncated_matchesruns on everyDirtyRangeMatchesto drop matches whose start row has been truncated; the focused index is clamped. Without this,focused_match_indexcould point past the end of the result set andfocused_terminal_match()would returnNoneeven though there are matches. -
Rollout blast radius. Hidden behind
FeatureFlag::AsyncFind. The cargo featureasync_finddefaults off; the runtime flag is on for dogfood only. Disabling is a one-line revert (remove fromDOGFOOD_FLAGS). The sync path is unchanged so the worst-case fallback is "behavior identical to today".
Follow-ups
- True in-place query refinement (today
filter_results_for_refinementrescans). The plumbing —is_query_refinement,current_find_options, the queue'sinvalidate_block— is already in place. - Move AI block scanning off the main thread (currently still synchronous via
ScanAIBlockround-trip). - Promote the flag to
RELEASE_FLAGSonce it has baked in dogfood for a release cycle.