feat(kebab-tui): p9-fb-08 async search worker + generation counter

도그푸딩 item 6 — TUI search 의 200ms debounce 후 동기 호출이 vector
/ hybrid 모드에서 50-200ms 동안 UI 를 freeze 시키던 문제 해소. 별
thread 에서 search 돌리고 결과 mpsc 로 받음. 사용자가 계속 타이핑하면
stale 결과 자동 폐기 (generation counter pattern, ask.rs 의 worker
패턴과 동일).

## 핵심 변경

- **`SearchState` 필드 3 개 신규**:
  - `generation: u64` — 각 spawn 마다 increment, worker 가 carry
  - `worker_thread: Option<JoinHandle<()>>`
  - `worker_rx: Option<Receiver<SearchWorkerMessage>>`
- **`SearchWorkerMessage`** (`pub enum`) — 단일 변종 `Done {
  generation, result }`. ask.rs 의 token stream 과 달리 search 는
  최종 결과만 한 번 send, 그래서 enum 으로 추후 확장 여지 둠.
- **`fire_search`** rewrite: generation+1 → debounce snapshot 갱신 →
  `std::thread::Builder::spawn` 으로 별 thread, `kebab_app::search_
  with_config(cfg, query)` 호출, channel 로 `(gen, result)` post.
  return 은 즉시 — event loop 안 막힘.
- **`poll_worker`** 신규 (`pub`, integration test 위해 노출): tick
  마다 try_recv. `gen != s.generation` 이면 stale → silently drop +
  `searching` 그대로 (newer worker 가 처리). 일치하면 hits 적용 +
  `searching=false`. Disconnect 면 worker 패닉 처리 — searching
  clear, 다음 tick 의 debounce_due 가 재 spawn.
- **`debounce_due`** 강화: `searching && last_query == 현 input/mode`
  케이스 skip — 같은 query 재 spawn 방지. 기존 dedupe 도 유지.
- **run loop** 의 `Pane::Search` 분기에 `poll_worker(app)` 한 줄
  추가 (debounce_due 호출 직전). 매 tick drain.

## 테스트 (tests/search.rs 신규 4 개)

- `poll_worker_applies_fresh_result_to_hits` — gen 일치 시 hits 적용
  + searching clear + rx drain
- `poll_worker_drops_stale_result` — gen 불일치 시 hits 비어 있음
  + searching 유지 (newer worker 기다림)
- `poll_worker_noop_when_no_rx` — 평상시 tick 에 noop, 기존 hits
  보존
- `poll_worker_handles_disconnected_channel` — 워커 panic (tx drop)
  복구 — searching clear, rx 비움

기존 17 search + 35 lib + 18 ask + 12 inspect + 10 library = 92
통과. clippy clean.

## 문서

- README `kebab tui` 행: "Search 패널은 200ms debounce 후 background
  worker, stale 결과 자동 폐기" 한 줄 추가
- HANDOFF: 2026-05-03 entry
- spec status planned → in_progress

## Out of scope

- 캐시 (p9-fb-19 별도)
- 동일 query 의 inflight worker 합치기 — 현재는 dedupe + 가장 최근
  spawn 만 살아남는 fire-and-forget. 합치는 건 mpsc multiplexing
  로직 필요해 P+ 로 미룸.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 03:50:56 +00:00
parent 29c9668f58
commit fd8597c696
8 changed files with 252 additions and 30 deletions

View File

@@ -7,7 +7,8 @@ use kebab_core::{
RetrievalDetail, SearchHit, SearchMode, WorkspacePath,
};
use kebab_tui::{
App, KeyOutcome, Pane, SearchState, build_jump_command, handle_key_search, render_search,
App, KeyOutcome, Pane, SearchState, SearchWorkerMessage, build_jump_command,
handle_key_search, poll_search_worker, render_search,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -334,6 +335,99 @@ fn g_key_with_no_hits_does_not_enqueue() {
);
}
// ── p9-fb-08: async search worker + generation counter ────────────
/// `poll_search_worker` applies a fresh result (matching generation)
/// to `state.search.hits` and clears `searching`.
#[test]
fn poll_worker_applies_fresh_result_to_hits() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel();
{
let s = app.search.as_mut().unwrap();
s.generation = 5;
s.searching = true;
s.worker_rx = Some(rx);
}
let hit = make_hit(1, "a.md", "snip", line_citation("a.md", 1));
tx.send(SearchWorkerMessage::Done {
generation: 5,
result: Ok(vec![hit]),
})
.unwrap();
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert_eq!(s.hits.len(), 1, "fresh result populates hits");
assert!(!s.searching, "searching cleared");
assert!(s.worker_rx.is_none(), "rx drained");
}
/// p9-fb-08 — a stale result (generation mismatch) is silently
/// dropped. `searching` remains true since a newer worker is
/// (presumed) still in flight.
#[test]
fn poll_worker_drops_stale_result() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel();
{
let s = app.search.as_mut().unwrap();
s.generation = 7;
s.searching = true;
s.worker_rx = Some(rx);
}
let hit = make_hit(1, "stale.md", "snip", line_citation("stale.md", 1));
// generation 3 < current 7 → stale.
tx.send(SearchWorkerMessage::Done {
generation: 3,
result: Ok(vec![hit]),
})
.unwrap();
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert!(s.hits.is_empty(), "stale result must not populate hits");
assert!(
s.searching,
"searching stays true so newer worker can resolve it"
);
assert!(
s.worker_rx.is_none(),
"stale message still drains the rx slot — worker is one-shot"
);
}
/// p9-fb-08 — `poll_search_worker` is a no-op when no worker is in
/// flight (no rx). Common case on every tick the user isn't typing.
#[test]
fn poll_worker_noop_when_no_rx() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "x.md", "snip", line_citation("x.md", 1))];
}
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert_eq!(s.hits.len(), 1, "existing hits preserved");
assert!(s.worker_rx.is_none());
}
/// p9-fb-08 — disconnected channel (worker panicked) clears the rx
/// + searching flag so the next debounce tick can re-fire cleanly.
#[test]
fn poll_worker_handles_disconnected_channel() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel::<SearchWorkerMessage>();
{
let s = app.search.as_mut().unwrap();
s.searching = true;
s.worker_rx = Some(rx);
}
drop(tx); // simulate worker panic before send
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert!(!s.searching, "searching cleared on disconnect");
assert!(s.worker_rx.is_none());
}
#[test]
fn no_search_state_returns_to_library() {
let mut config = Config::defaults();