From 37305bfdab8b73bff3b9af9a974572616129c7ae Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Tue, 9 Jun 2026 22:22:54 +0300 Subject: [PATCH 1/5] fix(edit_block): run fuzzy search in a worker thread to keep event loop responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/tools/edit.ts | 9 +-- src/tools/fuzzySearch.ts | 59 ++++++++++++++++- test/integration/edit-block-performance.js | 73 ++++++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 9ad08085..c830c634 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -18,7 +18,7 @@ import { getDefaultEditorMetadata, readFile, writeFile, readFileInternal, validatePath } from './filesystem.js'; import fs from 'fs/promises'; import { ServerResult } from '../types.js'; -import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js'; +import { runFuzzySearchInWorker, getSimilarityRatio } from './fuzzySearch.js'; import { capture } from '../utils/capture.js'; import { createErrorResponse } from '../error-handlers.js'; import { EditBlockArgsSchema } from "./schemas.js"; @@ -251,9 +251,10 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into if (count === 0) { // Track fuzzy search time const startTime = performance.now(); - - // Perform fuzzy search - const fuzzyResult = recursiveFuzzyIndexOf(content, block.search); + + // Perform fuzzy search in a worker thread so the main event loop stays + // responsive to pings and parallel tool calls during the scan + const fuzzyResult = await runFuzzySearchInWorker(content, block.search); const similarity = getSimilarityRatio(block.search, fuzzyResult.value); // Calculate execution time in milliseconds diff --git a/src/tools/fuzzySearch.ts b/src/tools/fuzzySearch.ts index e39d3f2e..ada947cf 100644 --- a/src/tools/fuzzySearch.ts +++ b/src/tools/fuzzySearch.ts @@ -1,5 +1,9 @@ import { distance } from 'fastest-levenshtein'; import { capture } from '../utils/capture.js'; +import { Worker } from 'worker_threads'; + +/** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */ +export const FUZZY_SEARCH_TIMEOUT_MS = 30000; /** * Recursively finds the closest match to a query string within text using fuzzy matching @@ -134,7 +138,60 @@ function iterativeReduction(text: string, query: string, start: number, end: num export function getSimilarityRatio(a: string, b: string): number { const maxLength = Math.max(a.length, b.length); if (maxLength === 0) return 1; // Both strings are empty - + const levenshteinDistance = distance(a, b); return 1 - (levenshteinDistance / maxLength); +} + +/** + * Inline worker entry: imports this very module (passed as moduleUrl) and runs + * recursiveFuzzyIndexOf off the main thread. Kept as an eval'd snippet so the + * worker needs no separate file to ship alongside the compiled output. + */ +const WORKER_CODE = ` +const { workerData, parentPort } = require('worker_threads'); +import(workerData.moduleUrl).then((m) => { + parentPort.postMessage(m.recursiveFuzzyIndexOf(workerData.text, workerData.query)); +}); +`; + +/** + * Runs recursiveFuzzyIndexOf in a Worker thread so the main MCP event loop + * stays responsive to pings and other tool calls during heavy fuzzy scans. + * Rejects if the scan exceeds timeoutMs, terminating the worker so it + * doesn't linger in the background. + */ +export function runFuzzySearchInWorker( + text: string, + query: string, + timeoutMs: number = FUZZY_SEARCH_TIMEOUT_MS +): Promise<{ start: number; end: number; value: string; distance: number }> { + return new Promise((resolve, reject) => { + const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: import.meta.url, text, query } }); + // Never let a scan keep the server process alive during shutdown. + worker.unref(); + + const timer = setTimeout(() => { + worker.terminate(); + reject(new Error(`Fuzzy search timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref(); + + worker.on('message', (result) => { + clearTimeout(timer); + resolve(result); + }); + + worker.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + + worker.on('exit', (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`Fuzzy search worker exited with code ${code}`)); + } + }); + }); } \ No newline at end of file diff --git a/test/integration/edit-block-performance.js b/test/integration/edit-block-performance.js index ee88eea8..0d9db85d 100644 --- a/test/integration/edit-block-performance.js +++ b/test/integration/edit-block-performance.js @@ -41,6 +41,17 @@ const PERFORMANCE_LIMITS_MS = { const RESPONSIVENESS_INTERVAL_MS = 1000; const RESPONSIVENESS_MAX_LATENCY_MS = 5000; +// Fuzzy-scan event-loop regression: a deliberately slow fuzzy fallback (large +// file, large absent old_string) must not block concurrent pings. The general +// responsiveness probe above is too loose for this (5s limit); a synchronous +// scan blocks for ~3-4s and would slip under it, so this scenario pings on a +// tight interval with a strict latency ceiling. +const FUZZY_SCAN_FILE_MB = 2; +const FUZZY_SCAN_QUERY_KB = 8; +const FUZZY_SCAN_PING_INTERVAL_MS = 200; +const FUZZY_SCAN_MIN_PING_COUNT = 5; +const FUZZY_SCAN_MAX_PING_LATENCY_MS = 500; + function assertToolSuccess(result, message) { assert.strictEqual(result.content?.[0]?.type, 'text', `${message}: expected text response`); assert.ok(!result.isError, `${message}: should not be marked as an error`); @@ -636,6 +647,61 @@ async function runPythonFuzzyFallbackWorkflow(client, attemptCount) { }; } +// Regression test for the edit_block fuzzy-fallback hang: performSearchReplace() +// used to run recursiveFuzzyIndexOf() synchronously on the main thread, freezing +// every concurrent tool call and ping for the duration of the scan (seconds). +// The scan now runs in a worker thread (runFuzzySearchInWorker); this asserts +// the event loop stays responsive while a slow scan is in progress. +async function runFuzzyEventLoopResponsivenessWorkflow(client) { + const filePath = path.join(TEST_DIR, 'fuzzy-event-loop-regression.txt'); + const line = 'the quick brown fox jumps over the lazy dog and then keeps on running\n'; + const startedAt = performance.now(); + + // Written directly (not via write_file) — the fixture exceeds fileWriteLineLimit. + await fs.writeFile(filePath, line.repeat(Math.ceil((FUZZY_SCAN_FILE_MB * 1024 * 1024) / line.length)), 'utf8'); + + // old_string deliberately absent from the file -> forces a full fuzzy scan. + const oldString = 'NO_SUCH_MARKER_' + 'z'.repeat(FUZZY_SCAN_QUERY_KB * 1024); + + let editDone = false; + const editPromise = callTool(client, 'edit_block', { + file_path: filePath, + old_string: oldString, + new_string: 'replacement', + expected_replacements: 1, + }); + editPromise.finally(() => { editDone = true; }); + + // Ping on a tight interval while the fuzzy scan is running. + const pingLatencies = []; + while (!editDone) { + const pingStartedAt = performance.now(); + await client.ping({ timeout: 30000 }); + pingLatencies.push(performance.now() - pingStartedAt); + if (!editDone) await sleep(FUZZY_SCAN_PING_INTERVAL_MS); + } + + const editResult = await editPromise; + assertToolSuccess(editResult, 'fuzzy event-loop regression edit_block'); + + const durationMs = performance.now() - startedAt; + const maxPingLatencyMs = pingLatencies.length > 0 ? Math.max(...pingLatencies) : Infinity; + console.log( + `PASS fuzzy event-loop regression: ${pingLatencies.length} pings during scan, max latency ${maxPingLatencyMs.toFixed(0)}ms (scan ${durationMs.toFixed(0)}ms)` + ); + + 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}` + ); + assert.ok( + maxPingLatencyMs < FUZZY_SCAN_MAX_PING_LATENCY_MS, + `event loop blocked during fuzzy scan: max ping latency ${maxPingLatencyMs.toFixed(0)}ms, expected under ${FUZZY_SCAN_MAX_PING_LATENCY_MS}ms` + ); + + return { pingCount: pingLatencies.length, maxPingLatencyMs, durationMs }; +} + async function runParallelWorkflows(client, editCounts) { const startedAt = performance.now(); const stopProbe = { value: false }; @@ -773,6 +839,10 @@ async function main() { try { const results = await runParallelWorkflows(mcp.client, [1, 10, 100, 150]); + // Run sequentially: its strict ping-latency ceiling would be flaky under + // the parallel workflows' load. + const fuzzyResponsiveness = await runFuzzyEventLoopResponsivenessWorkflow(mcp.client); + console.log('\nPerformance summary:'); for (const result of results.workflowResults) { console.log(` ${result.label}: ${result.durationMs.toFixed(0)}ms`); @@ -781,6 +851,9 @@ async function main() { console.log( ` responsiveness pings: ${results.responsiveness.count}, max ${results.responsiveness.maxLatencyMs.toFixed(0)}ms, avg ${results.responsiveness.averageLatencyMs.toFixed(0)}ms` ); + console.log( + ` fuzzy-scan responsiveness: ${fuzzyResponsiveness.pingCount} pings, max ${fuzzyResponsiveness.maxPingLatencyMs.toFixed(0)}ms over a ${fuzzyResponsiveness.durationMs.toFixed(0)}ms scan` + ); console.log('\nEdit verification summary:'); for (const result of results.workflowResults) { From 4a015d167fda9af3f2c86633d7a09a1f04abf82a Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Wed, 10 Jun 2026 11:11:58 +0300 Subject: [PATCH 2/5] fix: terminate fuzzy search worker on success 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 --- src/tools/fuzzySearch.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tools/fuzzySearch.ts b/src/tools/fuzzySearch.ts index ada947cf..0e92ede2 100644 --- a/src/tools/fuzzySearch.ts +++ b/src/tools/fuzzySearch.ts @@ -180,6 +180,11 @@ export function runFuzzySearchInWorker( worker.on('message', (result) => { clearTimeout(timer); resolve(result); + // Don't let the worker wind down on its own — in-worker telemetry + // can hold it (and its copy of the file text) open for seconds. + // The promise is already resolved, so the exit-code rejection + // below is a no-op. + worker.terminate(); }); worker.on('error', (err) => { From 88902d4aac4277c183bab42ae02423a9e6b5747d Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Wed, 10 Jun 2026 12:56:29 +0300 Subject: [PATCH 3/5] refactor: extract fuzzy search into a dependency-free core module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/tools/fuzzySearch.ts | 202 ++++++++--------------------------- src/tools/fuzzySearchCore.ts | 166 ++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 158 deletions(-) create mode 100644 src/tools/fuzzySearchCore.ts diff --git a/src/tools/fuzzySearch.ts b/src/tools/fuzzySearch.ts index 0e92ede2..f1328e9e 100644 --- a/src/tools/fuzzySearch.ts +++ b/src/tools/fuzzySearch.ts @@ -1,173 +1,47 @@ -import { distance } from 'fastest-levenshtein'; import { capture } from '../utils/capture.js'; import { Worker } from 'worker_threads'; +import type { FuzzyMatch, FuzzySearchMetrics } from './fuzzySearchCore.js'; + +// Re-export so existing callers keep importing from this module. +export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js'; /** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */ export const FUZZY_SEARCH_TIMEOUT_MS = 30000; /** - * Recursively finds the closest match to a query string within text using fuzzy matching - * @param text The text to search within - * @param query The query string to find - * @param start Start index in the text (default: 0) - * @param end End index in the text (default: text.length) - * @param parentDistance Best distance found so far (default: Infinity) - * @returns Object with start and end indices, matched value, and Levenshtein distance - */ -export function recursiveFuzzyIndexOf(text: string, query: string, start: number = 0, end: number | null = null, parentDistance: number = Infinity, depth: number = 0): { - start: number; - end: number; - value: string; - distance: number; -} { - // For debugging and performance tracking purposes - if (depth === 0) { - const startTime = performance.now(); - const result = recursiveFuzzyIndexOf(text, query, start, end, parentDistance, depth + 1); - const executionTime = performance.now() - startTime; - - // Capture detailed metrics for the recursive search for in-depth analysis - capture('fuzzy_search_recursive_metrics', { - execution_time_ms: executionTime, - text_length: text.length, - query_length: query.length, - result_distance: result.distance - }); - - return result; - } - - if (end === null) end = text.length; - - // For small text segments, use iterative approach - if (end - start <= 2 * query.length) { - return iterativeReduction(text, query, start, end, parentDistance); - } - - let midPoint = start + Math.floor((end - start) / 2); - let leftEnd = Math.min(end, midPoint + query.length); // Include query length to cover overlaps - let rightStart = Math.max(start, midPoint - query.length); // Include query length to cover overlaps - - // Calculate distance for current segments - let leftDistance = distance(text.substring(start, leftEnd), query); - let rightDistance = distance(text.substring(rightStart, end), query); - let bestDistance = Math.min(leftDistance, parentDistance, rightDistance); - - // If parent distance is already the best, use iterative approach - if (parentDistance === bestDistance) { - return iterativeReduction(text, query, start, end, parentDistance); - } - - // Recursively search the better half - if (leftDistance < rightDistance) { - return recursiveFuzzyIndexOf(text, query, start, leftEnd, bestDistance, depth + 1); - } else { - return recursiveFuzzyIndexOf(text, query, rightStart, end, bestDistance, depth + 1); - } -} - -/** - * Iteratively refines the best match by reducing the search area - * @param text The text to search within - * @param query The query string to find - * @param start Start index in the text - * @param end End index in the text - * @param parentDistance Best distance found so far - * @returns Object with start and end indices, matched value, and Levenshtein distance - */ -function iterativeReduction(text: string, query: string, start: number, end: number, parentDistance: number): { - start: number; - end: number; - value: string; - distance: number; -} { - const startTime = performance.now(); - let iterations = 0; - - let bestDistance = parentDistance; - let bestStart = start; - let bestEnd = end; - - // Improve start position - let nextDistance = distance(text.substring(bestStart + 1, bestEnd), query); - - while (nextDistance < bestDistance) { - bestDistance = nextDistance; - bestStart++; - const smallerString = text.substring(bestStart + 1, bestEnd); - nextDistance = distance(smallerString, query); - iterations++; - } - - // Improve end position - nextDistance = distance(text.substring(bestStart, bestEnd - 1), query); - - while (nextDistance < bestDistance) { - bestDistance = nextDistance; - bestEnd--; - const smallerString = text.substring(bestStart, bestEnd - 1); - nextDistance = distance(smallerString, query); - iterations++; - } - - const executionTime = performance.now() - startTime; - - // Capture metrics for the iterative refinement phase - capture('fuzzy_search_iterative_metrics', { - execution_time_ms: executionTime, - iterations: iterations, - segment_length: end - start, - query_length: query.length, - final_distance: bestDistance - }); - - return { - start: bestStart, - end: bestEnd, - value: text.substring(bestStart, bestEnd), - distance: bestDistance - }; -} - -/** - * Calculates the similarity ratio between two strings - * @param a First string - * @param b Second string - * @returns Similarity ratio (0-1) - */ -export function getSimilarityRatio(a: string, b: string): number { - const maxLength = Math.max(a.length, b.length); - if (maxLength === 0) return 1; // Both strings are empty - - const levenshteinDistance = distance(a, b); - return 1 - (levenshteinDistance / maxLength); -} - -/** - * Inline worker entry: imports this very module (passed as moduleUrl) and runs - * recursiveFuzzyIndexOf off the main thread. Kept as an eval'd snippet so the - * worker needs no separate file to ship alongside the compiled output. + * Inline worker entry: imports the dependency-free core module (passed as + * moduleUrl) and runs the search off the main thread. The core module is + * deliberately a leaf — importing this module (or anything app-level) from the + * worker would boot the whole server per search. Kept as an eval'd snippet so + * the worker needs no separate file to ship alongside the compiled output. */ const WORKER_CODE = ` const { workerData, parentPort } = require('worker_threads'); -import(workerData.moduleUrl).then((m) => { - parentPort.postMessage(m.recursiveFuzzyIndexOf(workerData.text, workerData.query)); -}); +import(workerData.moduleUrl) + .then((m) => { + parentPort.postMessage({ ok: true, ...m.runFuzzySearch(workerData.text, workerData.query) }); + }) + .catch((err) => { + parentPort.postMessage({ ok: false, error: String(err && err.stack || err) }); + }); `; +const CORE_MODULE_URL = new URL('./fuzzySearchCore.js', import.meta.url).href; + /** - * Runs recursiveFuzzyIndexOf in a Worker thread so the main MCP event loop - * stays responsive to pings and other tool calls during heavy fuzzy scans. - * Rejects if the scan exceeds timeoutMs, terminating the worker so it - * doesn't linger in the background. + * Runs the fuzzy search in a Worker thread so the main MCP event loop stays + * responsive to pings and other tool calls during heavy scans. Rejects if the + * scan exceeds timeoutMs, terminating the worker so it doesn't linger in the + * background. Search metrics come back with the result and are captured here, + * on the main thread, where the client identity is initialized. */ export function runFuzzySearchInWorker( text: string, query: string, timeoutMs: number = FUZZY_SEARCH_TIMEOUT_MS -): Promise<{ start: number; end: number; value: string; distance: number }> { +): Promise { return new Promise((resolve, reject) => { - const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: import.meta.url, text, query } }); + const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: CORE_MODULE_URL, text, query } }); // Never let a scan keep the server process alive during shutdown. worker.unref(); @@ -177,13 +51,17 @@ export function runFuzzySearchInWorker( }, timeoutMs); timer.unref(); - worker.on('message', (result) => { + worker.on('message', (msg: { ok: true; result: FuzzyMatch; metrics: FuzzySearchMetrics } | { ok: false; error: string }) => { clearTimeout(timer); - resolve(result); - // Don't let the worker wind down on its own — in-worker telemetry - // can hold it (and its copy of the file text) open for seconds. - // The promise is already resolved, so the exit-code rejection - // below is a no-op. + if (msg.ok) { + captureFuzzySearchMetrics(msg.metrics); + resolve(msg.result); + } else { + reject(new Error(`Fuzzy search worker failed: ${msg.error}`)); + } + // Don't let the worker wind down on its own; the answer is already + // here, and a lingering worker holds its copy of the file text. + // The promise is settled, so the exit-code rejection below is a no-op. worker.terminate(); }); @@ -199,4 +77,12 @@ export function runFuzzySearchInWorker( } }); }); -} \ No newline at end of file +} + +/** Same telemetry events the search used to emit inline, now sent from the main thread. */ +function captureFuzzySearchMetrics(metrics: FuzzySearchMetrics): void { + capture('fuzzy_search_recursive_metrics', metrics.recursive); + if (metrics.iterative) { + capture('fuzzy_search_iterative_metrics', metrics.iterative); + } +} diff --git a/src/tools/fuzzySearchCore.ts b/src/tools/fuzzySearchCore.ts new file mode 100644 index 00000000..e145bf4e --- /dev/null +++ b/src/tools/fuzzySearchCore.ts @@ -0,0 +1,166 @@ +import { distance } from 'fastest-levenshtein'; + +/** + * Pure fuzzy-search core, kept free of app imports on purpose: it runs inside + * the worker thread spawned by runFuzzySearchInWorker (fuzzySearch.ts), and + * anything imported here is loaded per worker. Telemetry is returned as data + * and captured on the main thread, which has the real client identity. + */ + +export interface FuzzyMatch { + start: number; + end: number; + value: string; + distance: number; +} + +export interface FuzzySearchMetrics { + recursive: { + execution_time_ms: number; + text_length: number; + query_length: number; + result_distance: number; + }; + iterative: { + execution_time_ms: number; + iterations: number; + segment_length: number; + query_length: number; + final_distance: number; + } | null; +} + +// Set by iterativeReduction during a search (exactly one terminal call per +// search) and collected by runFuzzySearch. Single-threaded per context, so a +// module-level slot is safe. +let lastIterativeMetrics: FuzzySearchMetrics['iterative'] = null; + +/** + * Runs a full fuzzy search and returns the match together with the timing + * metrics that used to be captured inline. + */ +export function runFuzzySearch(text: string, query: string): { result: FuzzyMatch; metrics: FuzzySearchMetrics } { + const startTime = performance.now(); + lastIterativeMetrics = null; + const result = recursiveFuzzyIndexOf(text, query); + return { + result, + metrics: { + recursive: { + execution_time_ms: performance.now() - startTime, + text_length: text.length, + query_length: query.length, + result_distance: result.distance + }, + iterative: lastIterativeMetrics + } + }; +} + +/** + * Recursively finds the closest match to a query string within text using fuzzy matching + * @param text The text to search within + * @param query The query string to find + * @param start Start index in the text (default: 0) + * @param end End index in the text (default: text.length) + * @param parentDistance Best distance found so far (default: Infinity) + * @returns Object with start and end indices, matched value, and Levenshtein distance + */ +export function recursiveFuzzyIndexOf(text: string, query: string, start: number = 0, end: number | null = null, parentDistance: number = Infinity): FuzzyMatch { + if (end === null) end = text.length; + + // For small text segments, use iterative approach + if (end - start <= 2 * query.length) { + return iterativeReduction(text, query, start, end, parentDistance); + } + + let midPoint = start + Math.floor((end - start) / 2); + let leftEnd = Math.min(end, midPoint + query.length); // Include query length to cover overlaps + let rightStart = Math.max(start, midPoint - query.length); // Include query length to cover overlaps + + // Calculate distance for current segments + let leftDistance = distance(text.substring(start, leftEnd), query); + let rightDistance = distance(text.substring(rightStart, end), query); + let bestDistance = Math.min(leftDistance, parentDistance, rightDistance); + + // If parent distance is already the best, use iterative approach + if (parentDistance === bestDistance) { + return iterativeReduction(text, query, start, end, parentDistance); + } + + // Recursively search the better half + if (leftDistance < rightDistance) { + return recursiveFuzzyIndexOf(text, query, start, leftEnd, bestDistance); + } else { + return recursiveFuzzyIndexOf(text, query, rightStart, end, bestDistance); + } +} + +/** + * Iteratively refines the best match by reducing the search area + * @param text The text to search within + * @param query The query string to find + * @param start Start index in the text + * @param end End index in the text + * @param parentDistance Best distance found so far + * @returns Object with start and end indices, matched value, and Levenshtein distance + */ +function iterativeReduction(text: string, query: string, start: number, end: number, parentDistance: number): FuzzyMatch { + const startTime = performance.now(); + let iterations = 0; + + let bestDistance = parentDistance; + let bestStart = start; + let bestEnd = end; + + // Improve start position + let nextDistance = distance(text.substring(bestStart + 1, bestEnd), query); + + while (nextDistance < bestDistance) { + bestDistance = nextDistance; + bestStart++; + const smallerString = text.substring(bestStart + 1, bestEnd); + nextDistance = distance(smallerString, query); + iterations++; + } + + // Improve end position + nextDistance = distance(text.substring(bestStart, bestEnd - 1), query); + + while (nextDistance < bestDistance) { + bestDistance = nextDistance; + bestEnd--; + const smallerString = text.substring(bestStart, bestEnd - 1); + nextDistance = distance(smallerString, query); + iterations++; + } + + lastIterativeMetrics = { + execution_time_ms: performance.now() - startTime, + iterations: iterations, + segment_length: end - start, + query_length: query.length, + final_distance: bestDistance + }; + + return { + start: bestStart, + end: bestEnd, + value: text.substring(bestStart, bestEnd), + distance: bestDistance + }; +} + +/** + * Calculates the similarity ratio between two strings + * @param a First string + * @param b Second string + * @returns Similarity ratio (0-1) + */ +export function getSimilarityRatio(a: string, b: string): number { + const maxLength = Math.max(a.length, b.length); + if (maxLength === 0) return 1; // Both strings are empty + + const levenshteinDistance = distance(a, b); + return 1 - (levenshteinDistance / maxLength); +} From 9f5d3e0e91cb4c1e4fa886a66dc1a99bce033bc1 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Wed, 10 Jun 2026 13:51:03 +0300 Subject: [PATCH 4/5] test: handle rejection on the detached editPromise.finally chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/integration/edit-block-performance.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/integration/edit-block-performance.js b/test/integration/edit-block-performance.js index 0d9db85d..7b6e817b 100644 --- a/test/integration/edit-block-performance.js +++ b/test/integration/edit-block-performance.js @@ -670,7 +670,9 @@ async function runFuzzyEventLoopResponsivenessWorkflow(client) { new_string: 'replacement', expected_replacements: 1, }); - editPromise.finally(() => { editDone = true; }); + // Swallow rejections on this detached chain only; errors still surface via + // the awaited editPromise below. + editPromise.finally(() => { editDone = true; }).catch(() => {}); // Ping on a tight interval while the fuzzy scan is running. const pingLatencies = []; From 4110710b98f058534cb9b345f1260351bd2d462a Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Wed, 10 Jun 2026 13:55:41 +0300 Subject: [PATCH 5/5] fix: seed iterativeReduction with the measured distance of its slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/tools/fuzzySearchCore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tools/fuzzySearchCore.ts b/src/tools/fuzzySearchCore.ts index e145bf4e..22a6b541 100644 --- a/src/tools/fuzzySearchCore.ts +++ b/src/tools/fuzzySearchCore.ts @@ -109,7 +109,11 @@ function iterativeReduction(text: string, query: string, start: number, end: num const startTime = performance.now(); let iterations = 0; - let bestDistance = parentDistance; + // Seed with the measured distance of this slice. For recursive callers + // this equals parentDistance (the parent measured exactly this slice), but + // a top-level call on text <= 2x query length arrives with Infinity, which + // made the first shrink unconditional and a position-0 match unreachable. + let bestDistance = distance(text.substring(start, end), query); let bestStart = start; let bestEnd = end;