Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion src/tools/fuzzySearch.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`));
}
});
});
}
73 changes: 73 additions & 0 deletions test/integration/edit-block-performance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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; });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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 };
Expand Down Expand Up @@ -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`);
Expand All @@ -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) {
Expand Down
Loading