App-server fuzzy file search floods remote clients with intermediate snapshots

Open 💬 1 comment Opened Jul 17, 2026 by yiteng-guo

What version of the Codex App are you using (From “About Codex” dialog)?

26.707.91948

What subscription do you have?

API

What platform is your computer?

linux

What issue are you seeing?

Summary

@ file search can be much slower through a remote Codex app connection than through the local CLI, even when both search the same checkout with the same codex-file-search backend.

The app-server streaming API sends every intermediate top-N snapshot to the client. During a large walk, the matcher can publish a changed snapshot approximately every 10 ms. App-server turns each snapshot into a separate JSON notification and async send task, with no rate limit, coalescing, or latest-only backpressure.

Observed behavior

I traced a running app-server while typing:

@path/to/my_file.py

The search used one stable fuzzyFileSearch session ID. Query updates were sent to the existing session.

For that single interaction, app-server emitted:

  • 763 fuzzyFileSearch/sessionUpdated notifications
  • 2.83 MiB of notification payloads
  • 483 snapshots for one intermediate query prefix
  • 219 snapshots for an earlier intermediate query prefix

The same query run directly with codex-file-search completed in approximately 1.4 seconds. This isolates the remaining remote-app slowdown from the underlying repository traversal.

Why this happens

The shared file-search matcher ticks every 10 ms while Nucleo reports work in progress. Whenever status.changed is true, it calls SessionReporter::on_update with a new snapshot.

The app-server reporter currently:

  1. Converts every snapshot into up to 50 full result objects, including match indices.
  2. Spawns a Tokio task for every snapshot.
  3. Serializes and broadcasts every notification.

This behavior is tolerable for the in-process TUI, but it is expensive across the app-server boundary. The remote client must receive, parse, reconcile, and render hundreds of mostly superseded snapshots. A slower connection or client event loop makes the backlog more visible.

Minimal reproduction

The following script creates a large temporary tree, starts the selected codex app-server binary over stdio, uses the public experimental session API, and counts update notifications until the search completes. Pass the path to the Codex binary as the optional positional argument; when omitted, the script uses codex from PATH.

#!/usr/bin/env python3
import argparse
import json
import pathlib
import subprocess
import tempfile
import time


def send(proc, message):
    proc.stdin.write(json.dumps(message) + "\n")
    proc.stdin.flush()


parser = argparse.ArgumentParser()
parser.add_argument(
    "codex_binary",
    nargs="?",
    default="codex",
    help="path to the codex binary (default: codex from PATH)",
)
args = parser.parse_args()


with tempfile.TemporaryDirectory() as tmp:
    root = pathlib.Path(tmp)
    # Increase this count if the local filesystem completes the walk too quickly.
    for i in range(50_000):
        directory = root / f"dir-{i // 1_000:03d}"
        directory.mkdir(exist_ok=True)
        (directory / f"needle-{i:05d}.txt").touch()

    proc = subprocess.Popen(
        [args.codex_binary, "app-server"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
        bufsize=1,
    )

    send(
        proc,
        {
            "id": 0,
            "method": "initialize",
            "params": {
                "clientInfo": {
                    "name": "fuzzy-search-repro",
                    "title": "Fuzzy Search Repro",
                    "version": "0.1.0",
                },
                "capabilities": {"experimentalApi": True},
            },
        },
    )

    while True:
        message = json.loads(proc.stdout.readline())
        if message.get("id") == 0:
            break

    send(proc, {"method": "initialized"})
    send(
        proc,
        {
            "id": 1,
            "method": "fuzzyFileSearch/sessionStart",
            "params": {"sessionId": "repro", "roots": [str(root)]},
        },
    )
    # Requests for one session are serialized, so this can be pipelined behind start.
    send(
        proc,
        {
            "id": 2,
            "method": "fuzzyFileSearch/sessionUpdate",
            "params": {"sessionId": "repro", "query": "needle"},
        },
    )

    started = time.monotonic()
    update_count = 0
    update_bytes = 0
    saw_query_update = False

    while True:
        line = proc.stdout.readline()
        if not line:
            raise RuntimeError("app-server exited before search completed")
        message = json.loads(line)
        method = message.get("method")
        params = message.get("params", {})

        if (
            method == "fuzzyFileSearch/sessionUpdated"
            and params.get("sessionId") == "repro"
            and params.get("query") == "needle"
        ):
            saw_query_update = True
            update_count += 1
            update_bytes += len(line.encode())
        elif (
            method == "fuzzyFileSearch/sessionCompleted"
            and params.get("sessionId") == "repro"
            and saw_query_update
        ):
            break

    elapsed = time.monotonic() - started
    print(f"updates={update_count} bytes={update_bytes} elapsed={elapsed:.3f}s")

    send(
        proc,
        {
            "id": 3,
            "method": "fuzzyFileSearch/sessionStop",
            "params": {"sessionId": "repro"},
        },
    )
    proc.terminate()

Run the repro against the currently installed Codex binary with:

python repro.py /path/to/codex

On a fast local filesystem, increasing the file count or pointing one child symlink at a larger tree makes the notification flood easier to observe.

Potential fix

Coalesce notifications at the app-server boundary while leaving the shared matcher and wire protocol unchanged:

  1. Create one notification dispatcher task per fuzzy-search session instead of spawning one task per snapshot.
  2. Store only the latest pending snapshot. A newer snapshot replaces an older unsent snapshot.
  3. Send the first snapshot promptly, then rate-limit intermediate snapshots to approximately 10 updates per second.
  4. When search becomes idle, immediately flush the latest snapshot before sending fuzzyFileSearch/sessionCompleted.
  5. Tie completion to the query represented by the last matcher snapshot so a stale completion cannot be attributed to a newer query.
  6. Wake and terminate the dispatcher when the session is stopped.

This preserves incremental results and final ordering while bounding transport, serialization, task creation, and client rendering work. The rate limit should live in app-server rather than the shared matcher so the local in-process TUI can retain its current responsiveness.

After applying the fix, the repro goes from updates=31 bytes=238663 elapsed=0.433s to updates=2 bytes=8055 elapsed=0.060s.

What steps can reproduce the bug?

See above

What is the expected behavior?

_No response_

Additional information

codex-cli 0.144.4

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗