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..f1328e9e 100644 --- a/src/tools/fuzzySearch.ts +++ b/src/tools/fuzzySearch.ts @@ -1,140 +1,88 @@ -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 + * 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. */ -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); - } -} +const WORKER_CODE = ` +const { workerData, parentPort } = require('worker_threads'); +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; /** - * 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 + * 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. */ -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 +export function runFuzzySearchInWorker( + text: string, + query: string, + timeoutMs: number = FUZZY_SEARCH_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + 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(); + + const timer = setTimeout(() => { + worker.terminate(); + reject(new Error(`Fuzzy search timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref(); + + worker.on('message', (msg: { ok: true; result: FuzzyMatch; metrics: FuzzySearchMetrics } | { ok: false; error: string }) => { + clearTimeout(timer); + 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(); + }); + + 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}`)); + } + }); }); - - 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); -} \ 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..22a6b541 --- /dev/null +++ b/src/tools/fuzzySearchCore.ts @@ -0,0 +1,170 @@ +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; + + // 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; + + // 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); +} diff --git a/test/integration/edit-block-performance.js b/test/integration/edit-block-performance.js index ee88eea8..7b6e817b 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,63 @@ 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, + }); + // 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 = []; + 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 +841,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 +853,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) {