feat(kebab-tui): p9-fb-08 async search worker + generation counter #74
Reference in New Issue
Block a user
Delete Branch "feat/p9-fb-08-search-async"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
도그푸딩 item 6 — TUI search 동기 호출 (vector/hybrid 50-200ms) 가 UI freeze 시키던 문제. 별 thread + generation counter 로 stale 결과 자동 폐기.
변경
SearchState에generation: u64+worker_thread+worker_rx추가SearchWorkerMessage(pub enum) —Done { generation, result }fire_search— 동기 → spawn thread + channel send. event loop 안 막힘.poll_worker(pub) — 매 tick try_recv, gen 불일치 stale drop, 일치 hits 적용debounce_due— searching+동일 query 재 spawn skipPane::Search에poll_worker(app)한 줄테스트
cargo clippy -p kebab-tui --all-targets -- -D warningsclean문서
도그푸딩 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>회차 1 — async 전환 자체는 깔끔. generation counter 패턴이 ask.rs 와 정렬되고, fire_search 의 4 단계 (gen+1, snapshot, channel, spawn) 분명. poll_worker 의 stale guard 가 정확하고, disconnect/empty 처리도 견고.
actionable nit 2 건 — (a) 필드가 저장만 되고 join 안 되는데 의도가 불명확, (b) 새 debounce_due skip 분기 () 회귀 테스트 부재.
@@ -78,0 +83,4 @@/// (the user kept typing and a newer query is already in/// flight). Wraps at u64::MAX which is unreachable in practice.pub generation: u64,/// p9-fb-08: in-flight worker thread. Held so the worker can beworker_thread: Option<JoinHandle<()>>가 저장되지만 어디서도.join()안 되고,fire_search가 새 spawn 마다 덮어쓰면서 이전 handle 이 drop 되어 thread 가 detach 됩니다. 즉 사실상 fire-and-forget 이고 이 필드는 "마지막 spawn 의 thread 가 살아있다" 는 정보 외에 쓸모가 없습니다.2 가지 옵션:
worker_thread제거,fire_search의s.worker_thread = Some(handle)도 제거. handle 을 그대로 drop 시켜 detach. JoinHandle drop = 자동 detach 라 메모리/thread 누수 없음.AskState.threadand to keep the 'is a worker live' check ergonomic" 같은 doc.저는 (1) 이 YAGNI 원칙에 가깝다고 봅니다. ask.rs 의 thread 는
take().join()패턴으로 실제 사용되지만 (Ctrl-L cancel), search 는 그런 수요 없음.@@ -453,0 +463,4 @@/// Run-loop hook: spawn an asynchronous search worker. Returns/// immediately so the event loop keeps polling — the result lands in/// `state.search.worker_rx` and is applied by `poll_worker` on a/// later tick. p9-fb-08 deviation from the original synchronousp9-fb-08 의 새 skip 분기 (
searching && last_query == 현 input/mode) 에 대한 회귀 테스트가 없습니다. fast typing 시 in-flight worker 결과를 기다리는 동안 동일 query 가 재 spawn 되지 않는 invariant 를 pin 해야 합니다 — 이 invariant 가 깨지면 worker 가 무한 누적되는 회귀가 됩니다.제안:
tests/search.rs에 unit 추가:물론
debounce_due도pub노출 필요 (현재pub(crate)).- `SearchState.worker_thread` 필드 제거 — `JoinHandle` 을 저장만 하고 어디서도 join 안 했음. fire_search 가 spawn 후 handle 을 즉시 drop 하면 OS 가 thread 를 detach (search 는 pure read 라 cleanup 의무 없음). YAGNI — ask.rs 의 thread 와 달리 cancel/observe 수요가 없는 fire-and-forget. doc 으로 의도 명시. - `debounce_due` 가 `pub` 으로 노출 — 새 skip 분기 (`searching && 같은 query`) 회귀 테스트 추가: - `debounce_due_skips_when_in_flight_for_same_query`: 같은 input/mode 재입력 시 spawn 안 함 (worker 누적 방지) - `debounce_due_fires_when_in_flight_for_different_query`: 사용자가 in-flight 보다 빠르게 새 query 입력하면 정상 spawn (poll_worker 의 stale guard 가 이전 결과 처리) - `search_state_with` 헬퍼: `SearchState::default()` + field 재할당 패턴이 clippy `field_reassign_with_default` 위반 → `#[allow(...)]` 로 lint 무시 (테스트 helper 의 가독성 우선). 23 tests/search.rs + 35 lib + 18 ask + 12 inspect + 10 library = 98 통과. clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 2 — nit 2 건 깔끔히 반영.
추가 지적 없음. 머지 OK.