fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save backoff

This commit is contained in:
Mahima Shanware
2026-04-14 05:32:07 +00:00
parent a5f7b453ca
commit 2ffab0a554
3 changed files with 69 additions and 15 deletions
+2 -12
View File
@@ -3340,20 +3340,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.shellExecutionConfig;
}
setShellExecutionConfig(config: ShellExecutionConfig): void {
setShellExecutionConfig(config: Partial<ShellExecutionConfig>): void {
this.shellExecutionConfig = {
...this.shellExecutionConfig,
terminalWidth:
config.terminalWidth ?? this.shellExecutionConfig.terminalWidth,
terminalHeight:
config.terminalHeight ?? this.shellExecutionConfig.terminalHeight,
showColor: config.showColor ?? this.shellExecutionConfig.showColor,
pager: config.pager ?? this.shellExecutionConfig.pager,
sanitizationConfig:
config.sanitizationConfig ??
this.shellExecutionConfig.sanitizationConfig,
sandboxManager:
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
...config,
};
}
getScreenReader(): boolean {
@@ -300,4 +300,36 @@ describe('ProjectRegistry', () => {
'ProjectRegistry must be initialized before use',
);
});
it('retries on EBUSY during save', async () => {
const registry = new ProjectRegistry(registryPath);
await registry.initialize();
const renameSpy = vi.spyOn(fs.promises, 'rename');
let ebusyCount = 0;
renameSpy.mockImplementation(async (oldPath, newPath) => {
// Only throw for the specific temporary file generated by save()
if (oldPath.toString().includes('.tmp') && ebusyCount < 2) {
ebusyCount++;
const err = new Error('Resource busy or locked');
(err as { code?: string }).code = 'EBUSY';
throw err;
}
return fs.promises
.copyFile(oldPath, newPath)
.then(() => fs.promises.unlink(oldPath));
});
const projectPath = path.join(tempDir, 'ebusy-project');
const shortId = await registry.getShortId(projectPath);
expect(shortId).toBe('ebusy-project');
expect(ebusyCount).toBe(2);
// Verify it actually saved properly after retries
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects[normalizePath(projectPath)]).toBe('ebusy-project');
renameSpy.mockRestore();
});
});
+35 -3
View File
@@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { lock } from 'proper-lockfile';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError } from '../utils/errors.js';
export interface RegistryData {
projects: Record<string, string>;
@@ -83,17 +84,48 @@ export class ProjectRegistry {
await fs.promises.mkdir(dir, { recursive: true });
}
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
try {
const content = JSON.stringify(data, null, 2);
// Use a randomized tmp path to avoid ENOENT crashes when save() is called concurrently
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
await fs.promises.writeFile(tmpPath, content, 'utf8');
await fs.promises.rename(tmpPath, this.registryPath);
// Exponential backoff for OS-level file locks (EBUSY/EPERM) during rename
const maxRetries = 5;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await fs.promises.rename(tmpPath, this.registryPath);
break; // Success
} catch (error: unknown) {
const code = isNodeError(error) ? error.code : '';
if (
(code === 'EBUSY' || code === 'EPERM') &&
attempt < maxRetries - 1
) {
const delayMs = Math.pow(2, attempt) * 50;
debugLogger.debug(
`Rename failed with ${code}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})...`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
throw error;
}
}
} catch (error) {
debugLogger.error(
`Failed to save project registry to ${this.registryPath}:`,
error,
);
} finally {
// Clean up the temporary file if it was left behind
try {
if (fs.existsSync(tmpPath)) {
await fs.promises.unlink(tmpPath);
}
} catch {
// Ignore errors during cleanup
}
}
}