Skip to content

Commit a0a5a40

Browse files
authored
feat: search pending on load-time indexing (#91)
1 parent 614275f commit a0a5a40

7 files changed

Lines changed: 184 additions & 32 deletions

File tree

src/cocoindex_code/cli.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,44 @@ def _on_progress(progress: IndexingProgress) -> None:
165165
raise _typer.Exit(code=1)
166166

167167

168+
def _search_with_wait_spinner(
169+
client: DaemonClient,
170+
project_root: str,
171+
query: str,
172+
languages: list[str] | None = None,
173+
paths: list[str] | None = None,
174+
limit: int = 10,
175+
offset: int = 0,
176+
) -> SearchResponse:
177+
"""Run search, showing a spinner if waiting for load-time indexing."""
178+
from rich.console import Console as _Console
179+
from rich.live import Live as _Live
180+
from rich.spinner import Spinner as _Spinner
181+
182+
err_console = _Console(stderr=True)
183+
waiting = False
184+
185+
# Use Live context so the spinner is cleaned up regardless of outcome
186+
with _Live(console=err_console, transient=True) as live:
187+
188+
def _on_waiting() -> None:
189+
nonlocal waiting
190+
waiting = True
191+
live.update(_Spinner("dots", "Waiting for indexing to complete..."))
192+
193+
resp = client.search(
194+
project_root=project_root,
195+
query=query,
196+
languages=languages,
197+
paths=paths,
198+
limit=limit,
199+
offset=offset,
200+
on_waiting=_on_waiting,
201+
)
202+
203+
return resp
204+
205+
168206
_GITIGNORE_COMMENT = "# CocoIndex Code (ccc)"
169207
_GITIGNORE_ENTRY = "/.cocoindex_code/"
170208

@@ -300,14 +338,14 @@ def search(
300338
if default is not None:
301339
paths = [default]
302340

303-
resp = client.search(
341+
resp = _search_with_wait_spinner(
342+
client,
304343
project_root=project_root,
305344
query=query_str,
306345
languages=lang or None,
307346
paths=paths,
308347
limit=limit,
309348
offset=offset,
310-
refresh=False,
311349
)
312350
print_search_results(resp)
313351

src/cocoindex_code/client.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,41 @@ def search(
101101
paths: list[str] | None = None,
102102
limit: int = 5,
103103
offset: int = 0,
104-
refresh: bool = False,
104+
on_waiting: Callable[[], None] | None = None,
105105
) -> SearchResponse:
106-
"""Search the codebase."""
107-
return self._send( # type: ignore[return-value]
108-
SearchRequest(
109-
project_root=project_root,
110-
query=query,
111-
languages=languages,
112-
paths=paths,
113-
limit=limit,
114-
offset=offset,
115-
refresh=refresh,
106+
"""Search the codebase.
107+
108+
If the daemon sends ``IndexWaitingNotice`` (load-time indexing in
109+
progress), calls *on_waiting* (if provided) then continues reading
110+
until the final ``SearchResponse``.
111+
"""
112+
self._conn.send_bytes(
113+
encode_request(
114+
SearchRequest(
115+
project_root=project_root,
116+
query=query,
117+
languages=languages,
118+
paths=paths,
119+
limit=limit,
120+
offset=offset,
121+
)
116122
)
117123
)
124+
while True:
125+
try:
126+
data = self._conn.recv_bytes()
127+
except EOFError:
128+
raise RuntimeError("Connection to daemon lost during search")
129+
resp = decode_response(data)
130+
if isinstance(resp, ErrorResponse):
131+
raise RuntimeError(f"Daemon error: {resp.message}")
132+
if isinstance(resp, IndexWaitingNotice):
133+
if on_waiting is not None:
134+
on_waiting()
135+
continue
136+
if isinstance(resp, SearchResponse):
137+
return resp
138+
raise RuntimeError(f"Unexpected response: {type(resp).__name__}")
118139

119140
def project_status(self, project_root: str) -> ProjectStatusResponse:
120141
return self._send( # type: ignore[return-value]

src/cocoindex_code/daemon.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
SearchRequest,
3939
SearchResponse,
4040
SearchResult,
41+
SearchStreamResponse,
4142
StopRequest,
4243
StopResponse,
4344
decode_request,
@@ -108,15 +109,17 @@ def __init__(self, embedder: Embedder) -> None:
108109
self._projects = {}
109110
self._index_locks = {}
110111
self._indexing = {}
112+
self._load_time_done: dict[str, asyncio.Event] = {}
111113
self._embedder = embedder
112114

113115
async def get_project(self, project_root: str, *, suppress_auto_index: bool = False) -> Project:
114116
"""Get or create a Project for the given root. Lazy initialization.
115117
116118
When a project is newly loaded and *suppress_auto_index* is False,
117-
a background indexing task is fired so the project is indexed
118-
immediately. Callers that will index right away (e.g. IndexRequest,
119-
SearchRequest with refresh) should pass ``suppress_auto_index=True``.
119+
a background indexing task (load-time indexing) is fired so the project
120+
is indexed immediately. Callers that will index right away (e.g.
121+
IndexRequest, SearchRequest with refresh) should pass
122+
``suppress_auto_index=True``.
120123
"""
121124
if project_root not in self._projects:
122125
root = Path(project_root)
@@ -126,17 +129,36 @@ async def get_project(self, project_root: str, *, suppress_auto_index: bool = Fa
126129
self._index_locks[project_root] = asyncio.Lock()
127130
self._indexing[project_root] = False
128131

129-
if not suppress_auto_index:
130-
asyncio.create_task(self._auto_index(project_root))
132+
event = asyncio.Event()
133+
self._load_time_done[project_root] = event
134+
if suppress_auto_index:
135+
event.set()
136+
else:
137+
asyncio.create_task(self._load_time_index(project_root))
131138
return self._projects[project_root]
132139

133-
async def _auto_index(self, project_root: str) -> None:
134-
"""Background auto-index, consuming the update_index stream."""
140+
def is_load_time_indexing(self, project_root: str) -> bool:
141+
"""Check if load-time indexing is in progress."""
142+
event = self._load_time_done.get(project_root)
143+
return event is not None and not event.is_set()
144+
145+
async def wait_for_load_time_indexing(self, project_root: str) -> None:
146+
"""Wait for load-time indexing to complete. Returns immediately if not in progress."""
147+
event = self._load_time_done.get(project_root)
148+
if event is not None:
149+
await event.wait()
150+
151+
async def _load_time_index(self, project_root: str) -> None:
152+
"""Background load-time indexing, consuming the update_index stream."""
135153
try:
136154
async for _ in self.update_index(project_root):
137155
pass
138156
except Exception:
139-
logger.exception("Auto-index failed for %s", project_root)
157+
logger.exception("Load-time indexing failed for %s", project_root)
158+
finally:
159+
event = self._load_time_done.get(project_root)
160+
if event is not None:
161+
event.set()
140162

141163
async def update_index(
142164
self, project_root: str, *, suppress_auto_index: bool = True
@@ -251,6 +273,7 @@ def remove_project(self, project_root: str) -> bool:
251273
project = self._projects.pop(project_root, None)
252274
self._index_locks.pop(project_root, None)
253275
self._indexing.pop(project_root, None)
276+
self._load_time_done.pop(project_root, None)
254277
if project is not None:
255278
project.close()
256279
del project
@@ -266,6 +289,7 @@ def close_all(self) -> None:
266289
self._projects.clear()
267290
self._index_locks.clear()
268291
self._indexing.clear()
292+
self._load_time_done.clear()
269293
gc.collect()
270294

271295
def list_projects(self) -> list[DaemonProjectInfo]:
@@ -357,26 +381,55 @@ def _recv() -> bytes:
357381
pass
358382

359383

384+
async def _search_with_wait(
385+
registry: ProjectRegistry, req: SearchRequest
386+
) -> AsyncIterator[SearchStreamResponse]:
387+
"""Stream search response, waiting for load-time indexing first."""
388+
yield IndexWaitingNotice()
389+
await registry.wait_for_load_time_indexing(req.project_root)
390+
try:
391+
results = await registry.search(
392+
project_root=req.project_root,
393+
query=req.query,
394+
languages=req.languages,
395+
paths=req.paths,
396+
limit=req.limit,
397+
offset=req.offset,
398+
)
399+
yield SearchResponse(
400+
success=True,
401+
results=results,
402+
total_returned=len(results),
403+
offset=req.offset,
404+
)
405+
except Exception as e:
406+
yield ErrorResponse(message=str(e))
407+
408+
360409
async def _dispatch(
361410
req: Request,
362411
registry: ProjectRegistry,
363412
start_time: float,
364413
shutdown_event: asyncio.Event,
365-
) -> Response | AsyncIterator[IndexStreamResponse]:
414+
) -> Response | AsyncIterator[IndexStreamResponse] | AsyncIterator[SearchStreamResponse]:
366415
"""Dispatch a request to the appropriate handler.
367416
368417
Returns a single Response for most requests, or an AsyncIterator for
369-
streaming requests (IndexRequest).
418+
streaming requests (IndexRequest, or SearchRequest when waiting for
419+
load-time indexing).
370420
"""
371421
try:
372422
if isinstance(req, IndexRequest):
373423
return registry.update_index(req.project_root)
374424

375425
if isinstance(req, SearchRequest):
376-
if req.refresh:
377-
# Consume the index stream silently for refresh
378-
async for _ in registry.update_index(req.project_root):
379-
pass
426+
# Ensure the project is loaded (may trigger load-time indexing)
427+
await registry.get_project(req.project_root)
428+
429+
# If load-time indexing is in progress, return a streaming response
430+
if registry.is_load_time_indexing(req.project_root):
431+
return _search_with_wait(registry, req)
432+
380433
results = await registry.search(
381434
project_root=req.project_root,
382435
query=req.query,

src/cocoindex_code/protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ class SearchRequest(_msgspec.Struct, tag="search"):
2424
paths: list[str] | None = None
2525
limit: int = 5
2626
offset: int = 0
27-
refresh: bool = False
2827

2928

3029
class ProjectStatusRequest(_msgspec.Struct, tag="project_status"):
@@ -154,6 +153,7 @@ class ErrorResponse(_msgspec.Struct, tag="error"):
154153
)
155154

156155
IndexStreamResponse = IndexProgressUpdate | IndexWaitingNotice | IndexResponse | ErrorResponse
156+
SearchStreamResponse = IndexWaitingNotice | SearchResponse | ErrorResponse
157157

158158
# ---------------------------------------------------------------------------
159159
# Encode / decode helpers (msgpack binary)

src/cocoindex_code/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ async def search(
127127
"""Query the codebase index via the daemon."""
128128
loop = asyncio.get_event_loop()
129129
try:
130+
if refresh_index:
131+
await loop.run_in_executor(None, lambda: client.index(project_root))
130132
resp = await loop.run_in_executor(
131133
None,
132134
lambda: client.search(
@@ -136,7 +138,6 @@ async def search(
136138
paths=paths,
137139
limit=limit,
138140
offset=offset,
139-
refresh=refresh_index,
140141
),
141142
)
142143
return SearchResultModel(

tests/test_daemon.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
RemoveProjectRequest,
3030
Response,
3131
SearchRequest,
32+
SearchResponse,
3233
StopRequest,
3334
decode_response,
3435
encode_request,
@@ -235,3 +236,44 @@ def test_daemon_remove_project_not_loaded(daemon_sock: str) -> None:
235236
resp = decode_response(conn.recv_bytes())
236237
assert resp.ok is True # type: ignore[union-attr]
237238
conn.close()
239+
240+
241+
def test_daemon_search_waits_for_load_time_indexing(daemon_sock: str) -> None:
242+
"""Search on a fresh project should wait for load-time indexing, sending IndexWaitingNotice."""
243+
# Create a new project that the daemon hasn't seen — its first load will
244+
# trigger load-time indexing in the background.
245+
project = Path(tempfile.mkdtemp(prefix="ccc_wait_"))
246+
save_project_settings(project, default_project_settings())
247+
(project / "main.py").write_text(SAMPLE_MAIN_PY)
248+
249+
conn, _ = _connect_and_handshake(daemon_sock)
250+
251+
# Send SearchRequest without prior explicit indexing.
252+
# The daemon should trigger load-time indexing, detect it's in progress,
253+
# and send IndexWaitingNotice before the final SearchResponse.
254+
conn.send_bytes(encode_request(SearchRequest(project_root=str(project), query="fibonacci")))
255+
256+
got_waiting = False
257+
final_resp: SearchResponse | None = None
258+
while True:
259+
resp = decode_response(conn.recv_bytes())
260+
if isinstance(resp, IndexWaitingNotice):
261+
got_waiting = True
262+
continue
263+
if isinstance(resp, SearchResponse):
264+
final_resp = resp
265+
break
266+
raise AssertionError(f"Unexpected response: {type(resp).__name__}")
267+
268+
assert got_waiting, "Expected IndexWaitingNotice before SearchResponse"
269+
assert final_resp is not None
270+
assert final_resp.success is True
271+
assert len(final_resp.results) > 0
272+
assert "main.py" in final_resp.results[0].file_path
273+
274+
# Second search — load-time indexing is done, no waiting expected
275+
conn.send_bytes(encode_request(SearchRequest(project_root=str(project), query="fibonacci")))
276+
resp2 = decode_response(conn.recv_bytes())
277+
assert isinstance(resp2, SearchResponse)
278+
assert resp2.success is True
279+
conn.close()

tests/test_protocol.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ def test_encode_decode_search_request_with_defaults() -> None:
4747
assert decoded.languages is None
4848
assert decoded.limit == 5
4949
assert decoded.offset == 0
50-
assert decoded.refresh is False
5150

5251

5352
def test_encode_decode_search_request_with_all_fields() -> None:
@@ -58,7 +57,6 @@ def test_encode_decode_search_request_with_all_fields() -> None:
5857
paths=["src/*"],
5958
limit=20,
6059
offset=5,
61-
refresh=True,
6260
)
6361
data = encode_request(req)
6462
decoded = decode_request(data)
@@ -69,7 +67,6 @@ def test_encode_decode_search_request_with_all_fields() -> None:
6967
assert decoded.paths == ["src/*"]
7068
assert decoded.limit == 20
7169
assert decoded.offset == 5
72-
assert decoded.refresh is True
7370

7471

7572
def test_encode_decode_search_response_with_results() -> None:

0 commit comments

Comments
 (0)