Skip to content

fix(edit_block): run fuzzy search in a worker thread to keep event lo…#500

Merged
wonderwhy-er merged 5 commits into
mainfrom
fix/fuzzy-search-worker-thread
Jun 17, 2026
Merged

fix(edit_block): run fuzzy search in a worker thread to keep event lo…#500
wonderwhy-er merged 5 commits into
mainfrom
fix/fuzzy-search-worker-thread

Conversation

@edgarsskore

@edgarsskore edgarsskore commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

When edit_block found no exact match, the fuzzy-search fallback ran recursiveFuzzyIndexOf() synchronously on the main thread. On large files this pinned the event loop for seconds, and several parallel edit_block calls serialized their scans back-to-back, freezing pings and all other tool calls — the server appeared hung.

The scan now runs in a worker thread via runFuzzySearchInWorker():

  • inline eval worker that imports this module, so no separate worker file ships with the build
  • 30s timeout terminates runaway scans (errors surface via the existing handleEditBlock catch)
  • worker and timer are unref'd so a running scan never delays shutdown

Measured: 4 parallel 2MB scans drop from 16.0s (serialized) to 4.8s (concurrent), with max ping latency 1-14ms during scans vs ~3.5s before.

Adds a regression scenario to the edit-block performance integration test that pings every 200ms during a deliberately slow scan and fails if max latency exceeds 500ms (the existing 5s responsiveness probe was too loose to catch this).

Measured impact

Scenario Before (sync) After (worker)
Max ping latency during a 4s scan ~3,574ms 1–14ms
4 parallel 2MB scans (wall-clock) 16.0s 4.8s
Single 2MB scan 4.07s 4.20s (~3% overhead)
Small-file fuzzy fallback ~1ms ~275ms (worker spawn cost)

Summary by CodeRabbit

  • Performance Improvements

    • Fuzzy text search now runs in a background worker to keep the main event loop responsive, captures detailed match and timing metrics, and enforces a configurable timeout with explicit worker lifecycle handling to avoid lingering work.
  • Tests

    • New regression workflow verifies event-loop responsiveness during heavy fuzzy scans and reports ping counts, maximum latency, and total scan duration.

…op responsive

When edit_block found no exact match, the fuzzy-search fallback ran
recursiveFuzzyIndexOf() synchronously on the main thread. On large files
this pinned the event loop for seconds, and several parallel edit_block
calls serialized their scans back-to-back, freezing pings and all other
tool calls — the server appeared hung.

The scan now runs in a worker thread via runFuzzySearchInWorker():
- inline eval worker that imports this module, so no separate worker
  file ships with the build
- 30s timeout terminates runaway scans (errors surface via the existing
  handleEditBlock catch)
- worker and timer are unref'd so a running scan never delays shutdown

Measured: 4 parallel 2MB scans drop from 16.0s (serialized) to 4.8s
(concurrent), with max ping latency 1-14ms during scans vs ~3.5s before.

Adds a regression scenario to the edit-block performance integration
test that pings every 200ms during a deliberately slow scan and fails
if max latency exceeds 500ms (the existing 5s responsiveness probe was
too loose to catch this).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Fuzzy-search execution is moved from the main thread to an isolated worker thread with timeout enforcement; edit flow now calls the worker-based search, and an integration test measures event-loop responsiveness during large fuzzy scans.

Changes

Fuzzy Search Worker Implementation

Layer / File(s) Summary
Fuzzy search core (worker-target)
src/tools/fuzzySearchCore.ts
Implements FuzzyMatch and FuzzySearchMetrics, runFuzzySearch(text, query) wrapper, recursiveFuzzyIndexOf recursive matcher, iterativeReduction refinement, and getSimilarityRatio.
Worker infrastructure and timeout handling
src/tools/fuzzySearch.ts
Adds worker_threads import, FUZZY_SEARCH_TIMEOUT_MS = 30000 constant, inline WORKER_CODE, and runFuzzySearchInWorker(text, query, timeoutMs) that spawns an eval-based Worker, unref()s it, enforces timeout via setTimeout+terminate(), and resolves/rejects based on Worker message, error, or non-zero exit; returned metrics are routed to telemetry.
Integration into performSearchReplace
src/tools/edit.ts
Updates import to use runFuzzySearchInWorker and replaces the in-process recursiveFuzzyIndexOf(content, block.search) call with runFuzzySearchInWorker(content, block.search), while preserving similarity computation and downstream diff/log/threshold logic.
Event-loop responsiveness regression test
test/integration/edit-block-performance.js
Adds fuzzy-scan regression constants and runFuzzyEventLoopResponsivenessWorkflow(client) that writes a large fixture, triggers an edit_block to force a full fuzzy scan, repeatedly pings the client during the scan, asserts minimum ping count and max latency, and records metrics into the performance summary.

Sequence Diagram(s)

sequenceDiagram
  participant performSearchReplace
  participant runFuzzySearchInWorker as runFuzzySearchInWorker()
  participant Worker
  participant runFuzzySearch as runFuzzySearch()
  participant recursiveFuzzyIndexOf as recursiveFuzzyIndexOf()

  performSearchReplace->>runFuzzySearchInWorker: call(text, query)
  runFuzzySearchInWorker->>Worker: spawn with WORKER_CODE (moduleUrl, text, query)
  Worker->>runFuzzySearch: import module and call runFuzzySearch(text, query)
  runFuzzySearch->>recursiveFuzzyIndexOf: recursiveFuzzyIndexOf(text, query)
  recursiveFuzzyIndexOf-->>runFuzzySearch: final match + metrics
  runFuzzySearch-->>Worker: postMessage({ ok: true, result, metrics })
  Worker->>runFuzzySearchInWorker: postMessage(payload)
  runFuzzySearchInWorker-->>performSearchReplace: resolve(result) or reject on timeout/error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

size:XL

Poem

🐇 I hopped the search off to a worker den,
While pings kept ticking again and again,
Thirty seconds to finish the chase,
Event loop kept its steady pace,
A nimble search and a cheerful pen.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: running fuzzy search in a worker thread to improve event loop responsiveness during edit_block operations on large files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fuzzy-search-worker-thread

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/tools/fuzzySearch.ts (1)

164-170: ⚡ Quick win

Add a small-input fast path before worker spawn.

Line 164 currently pays worker startup + clone overhead for every fuzzy fallback. For small payloads, this is a noticeable regression; keep worker offload for large scans, but run recursiveFuzzyIndexOf(...) inline under a conservative size threshold.

💡 Suggested refactor
 export function runFuzzySearchInWorker(
     text: string,
     query: string,
     timeoutMs: number = FUZZY_SEARCH_TIMEOUT_MS
 ): Promise<{ start: number; end: number; value: string; distance: number }> {
+    // Avoid worker startup overhead for tiny scans.
+    const SMALL_TEXT_THRESHOLD = 16 * 1024; // 16KB
+    const SMALL_QUERY_THRESHOLD = 512;      // 512B
+    if (text.length <= SMALL_TEXT_THRESHOLD && query.length <= SMALL_QUERY_THRESHOLD) {
+        return Promise.resolve(recursiveFuzzyIndexOf(text, query));
+    }
+
     return new Promise((resolve, reject) => {
         const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: import.meta.url, text, query } });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tools/fuzzySearch.ts` around lines 164 - 170, The current
runFuzzySearchInWorker always spawns a Worker (using WORKER_CODE and workerData)
which is costly for small inputs; add a fast-path at the top of
runFuzzySearchInWorker that checks the payload size (e.g. text.length and/or
query.length against a conservative threshold constant) and, if under the
threshold, calls recursiveFuzzyIndexOf(text, query) inline and returns a
resolved Promise with the same shape as the worker result (respecting the
timeoutMs parameter by returning immediately); otherwise keep the existing
Worker creation logic unchanged. Ensure you reference runFuzzySearchInWorker,
recursiveFuzzyIndexOf, WORKER_CODE and honor the timeoutMs argument.
test/integration/edit-block-performance.js (1)

693-699: ⚡ Quick win

Make the minimum-ping assertion duration-aware.

Lines 693-699 use a fixed >= 5 ping count, which can fail on faster machines if the scan completes quickly even when latency stays healthy. Derive the minimum from measured scan duration (with a floor of 1) to reduce flakiness.

💡 Suggested refactor
-  assert.ok(
-    pingLatencies.length >= FUZZY_SCAN_MIN_PING_COUNT,
-    `event loop blocked during fuzzy scan: only ${pingLatencies.length} ping(s) completed, expected >= ${FUZZY_SCAN_MIN_PING_COUNT}`
-  );
+  const durationBasedMinPingCount = Math.max(
+    1,
+    Math.floor(durationMs / FUZZY_SCAN_PING_INTERVAL_MS)
+  );
+  const minPingCount = Math.min(FUZZY_SCAN_MIN_PING_COUNT, durationBasedMinPingCount);
+  assert.ok(
+    pingLatencies.length >= minPingCount,
+    `event loop blocked during fuzzy scan: only ${pingLatencies.length} ping(s) completed, expected >= ${minPingCount}`
+  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integration/edit-block-performance.js` around lines 693 - 699, Replace
the fixed FUZZY_SCAN_MIN_PING_COUNT check with a duration-aware minimum: compute
the fuzzy scan duration (e.g., scanEnd - scanStart) and derive minPings =
Math.max(1, Math.floor(scanDuration / pingIntervalMs)) or another sensible
division that yields expected ping count; then assert pingLatencies.length >=
minPings and update the failure message to include minPings and the
scanDuration. Update the assertion that currently references
FUZZY_SCAN_MIN_PING_COUNT to use this computed minPings and ensure you have
timestamps (scan start/end) captured around the code that fills pingLatencies so
scanDuration and minPings are available for the assertion involving
pingLatencies, maxPingLatencyMs and FUZZY_SCAN_MAX_PING_LATENCY_MS.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/integration/edit-block-performance.js`:
- Line 673: The call editPromise.finally(() => { editDone = true; }) creates a
detached promise that can produce an unhandled rejection; update the code in
runFuzzyEventLoopResponsivenessWorkflow so the finally chain attaches a
rejection handler (e.g., append a .catch(...) to the finally chain) or otherwise
handle errors from editPromise so no detached promise can reject, while keeping
the editDone assignment inside the finally handler.

---

Nitpick comments:
In `@src/tools/fuzzySearch.ts`:
- Around line 164-170: The current runFuzzySearchInWorker always spawns a Worker
(using WORKER_CODE and workerData) which is costly for small inputs; add a
fast-path at the top of runFuzzySearchInWorker that checks the payload size
(e.g. text.length and/or query.length against a conservative threshold constant)
and, if under the threshold, calls recursiveFuzzyIndexOf(text, query) inline and
returns a resolved Promise with the same shape as the worker result (respecting
the timeoutMs parameter by returning immediately); otherwise keep the existing
Worker creation logic unchanged. Ensure you reference runFuzzySearchInWorker,
recursiveFuzzyIndexOf, WORKER_CODE and honor the timeoutMs argument.

In `@test/integration/edit-block-performance.js`:
- Around line 693-699: Replace the fixed FUZZY_SCAN_MIN_PING_COUNT check with a
duration-aware minimum: compute the fuzzy scan duration (e.g., scanEnd -
scanStart) and derive minPings = Math.max(1, Math.floor(scanDuration /
pingIntervalMs)) or another sensible division that yields expected ping count;
then assert pingLatencies.length >= minPings and update the failure message to
include minPings and the scanDuration. Update the assertion that currently
references FUZZY_SCAN_MIN_PING_COUNT to use this computed minPings and ensure
you have timestamps (scan start/end) captured around the code that fills
pingLatencies so scanDuration and minPings are available for the assertion
involving pingLatencies, maxPingLatencyMs and FUZZY_SCAN_MAX_PING_LATENCY_MS.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ecc4ad91-123b-4ee9-8e6f-d1a42de8ddda

📥 Commits

Reviewing files that changed from the base of the PR and between 7a9b2ff and 37305bf.

📒 Files selected for processing (3)
  • src/tools/edit.ts
  • src/tools/fuzzySearch.ts
  • test/integration/edit-block-performance.js

Comment thread test/integration/edit-block-performance.js Outdated
edgarsskore and others added 2 commits June 10, 2026 11:11
Without an explicit terminate the worker lingers until its module graph
drains (the in-worker telemetry HTTPS call alone holds it ~3s), keeping a
structured-clone copy of the full file content in memory per call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The worker previously imported fuzzySearch.ts, which pulls in capture.js
and through it server.ts — booting the entire app module graph per fuzzy
search. That cost ~275ms per call on small files and fired the search
telemetry from inside the worker, where the client identity is never
initialized.

fuzzySearchCore.ts now holds the pure search functions with
fastest-levenshtein as its only import; the worker loads just that and
returns timing metrics as data, which the main thread captures with the
real client id (same event names and payloads as before). Worker
round-trip drops to ~18ms. The worker snippet also gained a .catch so
import/search failures surface as a structured error instead of an
opaque worker crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tools/fuzzySearchCore.ts`:
- Around line 69-150: The recursiveFuzzyIndexOf flow seeds iterativeReduction
with an uninformed parentDistance (often Infinity) so iterativeReduction can
shrink a slice without that slice's actual measured distance; change the
recursion to propagate a full measured candidate or at minimum compute the
distance for the current slice (call distance(...) on text.substring(start,end)
or the current left/right slice) and pass that real distance into
iterativeReduction instead of parentDistance; update recursiveFuzzyIndexOf to
use that measured FuzzyMatch (start,end,value,distance) as the baseline when
recursing or calling iterativeReduction, and ensure iterativeReduction
accepts/uses the seeded distance (and ideally a seeded start/end/value) so its
returned value always reflects a distance actually computed for that slice
(refer to recursiveFuzzyIndexOf, iterativeReduction, parentDistance,
bestDistance, and distance).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e7145b6-80e2-4611-bd54-869efee3d93e

📥 Commits

Reviewing files that changed from the base of the PR and between 4a015d1 and 88902d4.

📒 Files selected for processing (2)
  • src/tools/fuzzySearch.ts
  • src/tools/fuzzySearchCore.ts

Comment thread src/tools/fuzzySearchCore.ts
The .finally() call created a derived promise with no rejection handler;
if edit_block failed it raised unhandledRejection even though editPromise
itself is awaited later. Swallow rejections on the detached chain only —
errors still surface via the awaited editPromise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@edgarsskore edgarsskore force-pushed the fix/fuzzy-search-worker-thread branch from 091cb7f to 357b9d8 Compare June 10, 2026 11:12
In all recursive paths parentDistance is exact — the parent passes the
distance it measured on precisely the child's slice, so the original
branch-and-bound seeding was sound there. The one broken path is a
top-level call where the whole text fits the small-segment branch
(text <= 2x query length): parentDistance defaults to Infinity, the
first shrink check always passes, and a match at position 0 can never
be returned at position 0 — e.g. matching 'the quick brown fox' in
'the quick brwn fox jumps' returned start 1 / distance 2 instead of
start 0 / distance 1.

Measuring the slice distance on entry fixes that case and is a no-op
for recursive calls (the computed seed equals the value the parent
passed; scan timings are unchanged). Costs one extra distance() call
per search, the same size as a single shrink-loop iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@edgarsskore edgarsskore force-pushed the fix/fuzzy-search-worker-thread branch from 357b9d8 to 4110710 Compare June 10, 2026 11:12
@wonderwhy-er wonderwhy-er merged commit fea0681 into main Jun 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants