Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 22 additions & 0 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Threadpool bootstrap. MUST be the first import in index.ts.
*
* Every fs operation (read_file, write_file, edit_block, config/history/log
* persistence) runs on libuv's threadpool, which defaults to only 4 threads.
* Under heavy parallel load — e.g. several agents reading/writing files on a
* slow or cloud-synced filesystem — 4 stalled operations exhaust the pool, and
* because a stalled syscall keeps its thread until the OS returns (a JS-level
* timeout does not cancel it), every subsequent fs op queues for minutes. That
* surfaced as multi-minute tool-call hangs under parallel `claude -p` load.
*
* Raising the pool size gives enough headroom that a burst of slow reads no
* longer starves the rest. libuv reads UV_THREADPOOL_SIZE only when the pool is
* first initialized (on first submitted work), so this assignment has to happen
* before ANY threadpool work — hence "first import". A user-provided value is
* always respected.
*/
const DEFAULT_THREADPOOL_SIZE = 16;

if (!process.env.UV_THREADPOOL_SIZE) {
process.env.UV_THREADPOOL_SIZE = String(DEFAULT_THREADPOOL_SIZE);
}
65 changes: 57 additions & 8 deletions src/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ class ConfigManager {
private config: ServerConfig = {};
private initialized = false;
private _isFirstRun = false; // Track if this is the first run (config was just created)
// Serializes all disk writes so concurrent saves can't corrupt config.json.
private writeChain: Promise<void> = Promise.resolve();
// True while a coalesced background write is already queued (see scheduleSave).
private saveScheduled = false;

constructor() {
// Get user's home directory
Expand Down Expand Up @@ -174,15 +178,45 @@ class ConfigManager {
}

/**
* Save config to disk
* Write the current in-memory config to disk. All writes funnel through
* writeChain (see saveConfig / scheduleSave) so overlapping saves can never
* interleave and corrupt the file. Previously every tool call could fire its
* own independent fs.writeFile of the same path.
*/
private async saveConfig() {
try {
await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
} catch (error) {
console.error('Failed to save config:', error);
throw error;
}
private async writeConfigToDisk(): Promise<void> {
await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
}

/**
* Awaitable save, serialized on writeChain. Use for explicit, user-driven
* config changes where the caller wants on-disk confirmation.
*/
private async saveConfig(): Promise<void> {
const write = this.writeChain.then(() => this.writeConfigToDisk());
// Keep the chain alive even if this write rejects, so later writes still run.
this.writeChain = write.catch(() => {});
return write;
}

/**
* Non-blocking, coalesced save. Returns immediately; the write runs in the
* background. A burst of calls collapses to at most one queued write behind
* the in-flight one, so a storm of tool calls can't storm the disk — and,
* critically, can't pile up behind a saturated libuv threadpool and gate the
* tool-call response path. Used for high-frequency, non-critical persistence
* such as usage stats.
*/
scheduleSave(): void {
if (this.saveScheduled) return; // a queued write will capture the latest config
this.saveScheduled = true;
this.writeChain = this.writeChain.then(async () => {
this.saveScheduled = false; // let the next burst queue a fresh write
try {
await this.writeConfigToDisk();
} catch (error) {
console.error('Failed to save config (background):', error);
}
});
}

/**
Expand Down Expand Up @@ -236,6 +270,21 @@ class ConfigManager {
await this.saveConfig();
}

/**
* Update a value in memory and persist it WITHOUT blocking the caller.
* The tool-call response path must never wait on a disk write: when the libuv
* threadpool is saturated (e.g. many parallel reads stalled on a slow/cloud
* filesystem) an awaited write can't get a thread and would hang the response
* of even pure-memory tools. The in-memory value is updated synchronously so
* subsequent reads see it immediately; the write is coalesced in the
* background. Callers needing on-disk confirmation should use setValue.
*/
async setValueNonBlocking(key: string, value: any): Promise<void> {
await this.init();
this.config[key] = value;
this.scheduleSave();
}

/**
* Update multiple configuration values at once
*/
Expand Down
7 changes: 6 additions & 1 deletion src/handlers/filesystem-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ function getErrorFromPath(path: string): string {
* Handle read_file command
*/
export async function handleReadFile(args: unknown): Promise<ServerResult> {
const HANDLER_TIMEOUT = 60000; // 60 seconds total operation timeout
// Backstop for the whole handler operation. The real control is the
// 3-minute cancellable read timeout inside readFileFromDisk; this sits just
// above it (so that one fires first, with cleanup + a useful error) but
// still below the MCP client's ~4-minute hard cap, so we never leave the
// client hanging on an opaque timeout.
const HANDLER_TIMEOUT = 3.5 * 60 * 1000; // 3m30s
// Add input validation
if (args === null || args === undefined) {
return createErrorResponse('No arguments provided for read_file command');
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#!/usr/bin/env node

// MUST be first: raises the libuv threadpool size before any fs work is
// submitted. See src/bootstrap.ts for why import order matters.
import './bootstrap.js';
import { FilteredStdioServerTransport } from './custom-stdio.js';
import { server, flushDeferredMessages } from './server.js';
import { commandManager } from './command-manager.js';
Expand Down
48 changes: 31 additions & 17 deletions src/tools/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fetch from 'cross-fetch';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { capture } from '../utils/capture.js';
import { withTimeout } from '../utils/withTimeout.js';
import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
import { configManager } from '../config-manager.js';
import { getFileHandler, TextFileHandler } from '../utils/files/index.js';
import type { ReadOptions, FileResult, PdfPageItem } from '../utils/files/base.js';
Expand All @@ -20,6 +20,14 @@ const FILE_OPERATION_TIMEOUTS = {
FILE_READ: 30000, // 30 seconds
} as const;

// Cap file read operations at 3 minutes. The MCP client's hard per-call limit
// is ~4 minutes; timing out at 3m lets us abort the underlying fs op and return
// a useful error (e.g. the cloud-storage guidance in buildPermissionError)
// BEFORE the client gives up with an opaque "No result received after 4
// minutes". Paired with runWithAbortableTimeout so the read is actually
// cancelled (fd/thread released), not just abandoned.
export const READ_OPERATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes

const FILE_SIZE_LIMITS = {
LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
} as const;
Expand Down Expand Up @@ -495,8 +503,9 @@ export async function readFileFromDisk(
// If we can't stat the file, continue anyway and let the read operation handle errors
}

// Use withTimeout to handle potential hangs
const readOperation = async () => {
// Read under an abortable timeout so a hung/stalled read is cancelled
// (fd/thread freed) rather than leaked until the OS call returns.
const readOperation = async (signal: AbortSignal) => {
// Get appropriate handler for this file type (async - includes binary detection)
const handler = await getFileHandler(validPath);

Expand All @@ -506,7 +515,8 @@ export async function readFileFromDisk(
length,
sheet,
range,
includeStatusMessage: true
includeStatusMessage: true,
signal
});

// Return with content as string
Expand All @@ -529,22 +539,20 @@ export async function readFileFromDisk(
};
};

// Execute with timeout
// Execute with a 3-minute, cancellable timeout
let result;
try {
result = await withTimeout(
readOperation(),
FILE_OPERATION_TIMEOUTS.FILE_READ,
`Read file operation for ${filePath}`,
null
result = await runWithAbortableTimeout(
(signal) => readOperation(signal),
READ_OPERATION_TIMEOUT_MS,
`Read file operation for ${filePath}`
);
} catch (error) {
const err = error as NodeJS.ErrnoException;
// withTimeout rejects with a plain string "__ERROR__: ... timed out after N seconds"
// when defaultValue is null — it has no .code property, so check for that too.
const isWithTimeoutString = typeof error === 'string' && (error as string).startsWith('__ERROR__:');
if (isWithTimeoutString || err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
throw buildPermissionError(filePath, isWithTimeoutString ? 'ETIMEDOUT' : err.code);
// runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
// on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
throw buildPermissionError(filePath, err.code);
}
throw error;
}
Expand Down Expand Up @@ -602,8 +610,14 @@ export async function readFileInternal(filePath: string, offset: number = 0, len
// preserve exact file content including original line endings.
// We cannot use readline-based reading as it strips line endings.

// Read entire file content preserving line endings
const content = await fs.readFile(validPath, 'utf8');
// Read entire file content preserving line endings, under a 3-minute,
// cancellable timeout so an edit on a stalled/cloud path can't hang forever
// (previously this read had no timeout at all).
const content = await runWithAbortableTimeout(
(signal) => fs.readFile(validPath, { encoding: 'utf8', signal }),
READ_OPERATION_TIMEOUT_MS,
`Internal read for ${filePath}`
);

// If we need to apply offset/length, do it while preserving line endings
if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
Expand Down
3 changes: 3 additions & 0 deletions src/utils/files/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export interface ReadOptions {

/** Whether to include status messages (default: true) */
includeStatusMessage?: boolean;

/** Optional AbortSignal to cancel an in-flight read (frees fd/thread on timeout). */
signal?: AbortSignal;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/utils/files/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class ImageFileHandler implements FileHandler {

async read(path: string, options?: ReadOptions): Promise<FileResult> {
// Images are always read in full, ignoring offset and length
const buffer = await fs.readFile(path);
const buffer = await fs.readFile(path, { signal: options?.signal });
const content = buffer.toString('base64');
const mimeType = this.getMimeType(path);

Expand Down
48 changes: 29 additions & 19 deletions src/utils/files/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class TextFileHandler implements FileHandler {
const includeStatusMessage = options?.includeStatusMessage ?? true;

// Binary detection is done at factory level - just read as text
return this.readFileWithSmartPositioning(filePath, offset, length, 'text/plain', includeStatusMessage);
return this.readFileWithSmartPositioning(filePath, offset, length, 'text/plain', includeStatusMessage, options?.signal);
}

async write(path: string, content: string, mode: 'rewrite' | 'append' = 'rewrite'): Promise<void> {
Expand Down Expand Up @@ -118,11 +118,11 @@ export class TextFileHandler implements FileHandler {
/**
* Get file line count (for files under size limit)
*/
private async getFileLineCount(filePath: string): Promise<number | undefined> {
private async getFileLineCount(filePath: string, signal?: AbortSignal): Promise<number | undefined> {
try {
const stats = await fs.stat(filePath);
if (stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) {
const content = await fs.readFile(filePath, 'utf8');
const content = await fs.readFile(filePath, { encoding: 'utf8', signal });
return TextFileHandler.countLines(content);
}
} catch (error) {
Expand Down Expand Up @@ -208,33 +208,34 @@ export class TextFileHandler implements FileHandler {
offset: number,
length: number,
mimeType: string,
includeStatusMessage: boolean = true
includeStatusMessage: boolean = true,
signal?: AbortSignal
): Promise<FileResult> {
const stats = await fs.stat(filePath);
const fileSize = stats.size;

const totalLines = await this.getFileLineCount(filePath);
const totalLines = await this.getFileLineCount(filePath, signal);

// For negative offsets (tail behavior), use reverse reading
if (offset < 0) {
const requestedLines = Math.abs(offset);

if (fileSize > FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD &&
requestedLines <= READ_PERFORMANCE_THRESHOLDS.SMALL_READ_THRESHOLD) {
return await this.readLastNLinesReverse(filePath, requestedLines, mimeType, includeStatusMessage, totalLines);
return await this.readLastNLinesReverse(filePath, requestedLines, mimeType, includeStatusMessage, totalLines, signal);
} else {
return await this.readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage, totalLines);
return await this.readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage, totalLines, signal);
}
}
// For positive offsets
else {
if (fileSize < FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD || offset === 0) {
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines);
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
} else {
if (offset > READ_PERFORMANCE_THRESHOLDS.DEEP_OFFSET_THRESHOLD) {
return await this.readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage, totalLines);
return await this.readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
} else {
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines);
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
}
}
}
Expand All @@ -248,7 +249,8 @@ export class TextFileHandler implements FileHandler {
n: number,
mimeType: string,
includeStatusMessage: boolean = true,
fileTotalLines?: number
fileTotalLines?: number,
signal?: AbortSignal
): Promise<FileResult> {
const fd = await fs.open(filePath, 'r');
try {
Expand All @@ -260,6 +262,11 @@ export class TextFileHandler implements FileHandler {
let partialLine = '';

while (position > 0 && lines.length < n) {
if (signal?.aborted) {
const err = new Error('Read aborted') as NodeJS.ErrnoException;
err.code = 'ABORT_ERR';
throw err;
}
const readSize = Math.min(READ_PERFORMANCE_THRESHOLDS.CHUNK_SIZE, position);
position -= readSize;

Expand Down Expand Up @@ -297,10 +304,11 @@ export class TextFileHandler implements FileHandler {
requestedLines: number,
mimeType: string,
includeStatusMessage: boolean = true,
fileTotalLines?: number
fileTotalLines?: number,
signal?: AbortSignal
): Promise<FileResult> {
const rl = createInterface({
input: createReadStream(filePath),
input: createReadStream(filePath, { signal }),
crlfDelay: Infinity
});

Expand Down Expand Up @@ -342,10 +350,11 @@ export class TextFileHandler implements FileHandler {
length: number,
mimeType: string,
includeStatusMessage: boolean = true,
fileTotalLines?: number
fileTotalLines?: number,
signal?: AbortSignal
): Promise<FileResult> {
const rl = createInterface({
input: createReadStream(filePath),
input: createReadStream(filePath, { signal }),
crlfDelay: Infinity
});

Expand Down Expand Up @@ -381,11 +390,12 @@ export class TextFileHandler implements FileHandler {
length: number,
mimeType: string,
includeStatusMessage: boolean = true,
fileTotalLines?: number
fileTotalLines?: number,
signal?: AbortSignal
): Promise<FileResult> {
// First, do a quick scan to estimate lines per byte
const rl = createInterface({
input: createReadStream(filePath),
input: createReadStream(filePath, { signal }),
crlfDelay: Infinity
});

Expand All @@ -401,7 +411,7 @@ export class TextFileHandler implements FileHandler {
rl.close();

if (sampleLines === 0) {
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines);
return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines, signal);
}

// Estimate position
Expand All @@ -413,7 +423,7 @@ export class TextFileHandler implements FileHandler {
const stats = await fd.stat();
const startPosition = Math.min(estimatedBytePosition, stats.size);

const stream = createReadStream(filePath, { start: startPosition });
const stream = createReadStream(filePath, { start: startPosition, signal });
const rl2 = createInterface({
input: stream,
crlfDelay: Infinity
Expand Down
Loading
Loading