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

Merged
altair823 merged 2 commits from feat/p9-fb-08-search-async into main 2026-05-03 03:55:44 +00:00
Owner

요약

도그푸딩 item 6 — TUI search 동기 호출 (vector/hybrid 50-200ms) 가 UI freeze 시키던 문제. 별 thread + generation counter 로 stale 결과 자동 폐기.

변경

  • SearchStategeneration: 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 skip
  • run loop Pane::Searchpoll_worker(app) 한 줄

테스트

  • 신규 4 unit (fresh apply / stale drop / noop / disconnect 복구)
  • 기존 92 TUI 테스트 모두 통과
  • cargo clippy -p kebab-tui --all-targets -- -D warnings clean

문서

  • README: "200ms debounce 후 background worker, stale 자동 폐기"
  • HANDOFF entry
  • spec status planned → in_progress
## 요약 도그푸딩 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 skip - run loop `Pane::Search` 에 `poll_worker(app)` 한 줄 ## 테스트 - 신규 4 unit (fresh apply / stale drop / noop / disconnect 복구) - 기존 92 TUI 테스트 모두 통과 - `cargo clippy -p kebab-tui --all-targets -- -D warnings` clean ## 문서 - README: "200ms debounce 후 background worker, stale 자동 폐기" - HANDOFF entry - spec status planned → in_progress
altair823 added 1 commit 2026-05-03 03:51:15 +00:00
도그푸딩 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>
claude-reviewer-01 requested changes 2026-05-03 03:52:22 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

회차 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 분기 () 회귀 테스트 부재.

회차 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 be

worker_thread: Option<JoinHandle<()>> 가 저장되지만 어디서도 .join() 안 되고, fire_search 가 새 spawn 마다 덮어쓰면서 이전 handle 이 drop 되어 thread 가 detach 됩니다. 즉 사실상 fire-and-forget 이고 이 필드는 "마지막 spawn 의 thread 가 살아있다" 는 정보 외에 쓸모가 없습니다.

2 가지 옵션:

  1. 필드 삭제worker_thread 제거, fire_searchs.worker_thread = Some(handle) 도 제거. handle 을 그대로 drop 시켜 detach. JoinHandle drop = 자동 detach 라 메모리/thread 누수 없음.
  2. 유지하되 의도 주석 — "fire-and-forget; never joined; stored only for symmetry with AskState.thread and to keep the 'is a worker live' check ergonomic" 같은 doc.

저는 (1) 이 YAGNI 원칙에 가깝다고 봅니다. ask.rs 의 thread 는 take().join() 패턴으로 실제 사용되지만 (Ctrl-L cancel), search 는 그런 수요 없음.

`worker_thread: Option<JoinHandle<()>>` 가 저장되지만 어디서도 `.join()` 안 되고, `fire_search` 가 새 spawn 마다 덮어쓰면서 이전 handle 이 drop 되어 thread 가 detach 됩니다. 즉 사실상 fire-and-forget 이고 이 필드는 "마지막 spawn 의 thread 가 살아있다" 는 정보 외에 쓸모가 없습니다. 2 가지 옵션: 1. **필드 삭제** — `worker_thread` 제거, `fire_search` 의 `s.worker_thread = Some(handle)` 도 제거. handle 을 그대로 drop 시켜 detach. JoinHandle drop = 자동 detach 라 메모리/thread 누수 없음. 2. **유지하되 의도 주석** — "fire-and-forget; never joined; stored only for symmetry with `AskState.thread` and 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 synchronous

p9-fb-08 의 새 skip 분기 (searching && last_query == 현 input/mode) 에 대한 회귀 테스트가 없습니다. fast typing 시 in-flight worker 결과를 기다리는 동안 동일 query 가 재 spawn 되지 않는 invariant 를 pin 해야 합니다 — 이 invariant 가 깨지면 worker 가 무한 누적되는 회귀가 됩니다.

제안: tests/search.rs 에 unit 추가:

#[test]
fn debounce_due_skips_when_in_flight_for_same_query() {
    let mut s = SearchState::default();
    s.input = "hello".into();
    s.mode = SearchMode::Hybrid;
    s.searching = true;
    s.last_query = Some((s.input.clone(), s.mode));
    s.input_dirty_at = Some(time::OffsetDateTime::now_utc() - time::Duration::seconds(1));
    // 사용자가 동일 query 로 dirty 다시 마킹 (e.g. 같은 글자 재타이핑)
    // → debounce 시간 지났어도 in-flight 와 같은 query 면 skip.
    assert!(!debounce_due(&s));
}

물론 debounce_duepub 노출 필요 (현재 pub(crate)).

p9-fb-08 의 새 skip 분기 (`searching && last_query == 현 input/mode`) 에 대한 회귀 테스트가 없습니다. fast typing 시 in-flight worker 결과를 기다리는 동안 동일 query 가 재 spawn 되지 않는 invariant 를 pin 해야 합니다 — 이 invariant 가 깨지면 worker 가 무한 누적되는 회귀가 됩니다. 제안: `tests/search.rs` 에 unit 추가: ```rust #[test] fn debounce_due_skips_when_in_flight_for_same_query() { let mut s = SearchState::default(); s.input = "hello".into(); s.mode = SearchMode::Hybrid; s.searching = true; s.last_query = Some((s.input.clone(), s.mode)); s.input_dirty_at = Some(time::OffsetDateTime::now_utc() - time::Duration::seconds(1)); // 사용자가 동일 query 로 dirty 다시 마킹 (e.g. 같은 글자 재타이핑) // → debounce 시간 지났어도 in-flight 와 같은 query 면 skip. assert!(!debounce_due(&s)); } ``` 물론 `debounce_due` 도 `pub` 노출 필요 (현재 `pub(crate)`).
altair823 added 1 commit 2026-05-03 03:55:31 +00:00
- `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>
claude-reviewer-01 approved these changes 2026-05-03 03:55:39 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — nit 2 건 깔끔히 반영.

  • worker_thread 필드 제거 + fire-and-forget doc 명시
  • debounce_due 두 회귀 테스트 (same query skip, different query fires) 추가
  • helper 의 clippy field_reassign_with_default lint 은 #[allow] 로 처리 (테스트 helper 가독성 우선)

추가 지적 없음. 머지 OK.

회차 2 — nit 2 건 깔끔히 반영. - worker_thread 필드 제거 + fire-and-forget doc 명시 - debounce_due 두 회귀 테스트 (same query skip, different query fires) 추가 - helper 의 clippy field_reassign_with_default lint 은 #[allow] 로 처리 (테스트 helper 가독성 우선) 추가 지적 없음. 머지 OK.
altair823 merged commit be0a96e10f into main 2026-05-03 03:55:44 +00:00
altair823 deleted branch feat/p9-fb-08-search-async 2026-05-03 03:55:45 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#74