diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 258e58ea35..6b3980eeea 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3340,20 +3340,10 @@ export class Config implements McpContext, AgentLoopContext { return this.shellExecutionConfig; } - setShellExecutionConfig(config: ShellExecutionConfig): void { + setShellExecutionConfig(config: Partial): 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 { diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index a441de8b3e..4777bcb203 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -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(); + }); }); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index c58fb55ce8..ec9bb92d05 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -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; @@ -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 + } } }