fix(image-analysis): handle installer lock contention

This commit is contained in:
Tam Nhu Tran committed 2026-04-02 18:53:11 -04:00
1 parent 77b488f8b1
commit afc8b0bb6e
2 files changed
+57 -1

No files matched your search

+34 -1
View File
@@ -121,7 +121,7 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo
} }
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T { function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const lockTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath); const lockTarget = path.dirname(configPath);
let release: (() => void) | undefined; let release: (() => void) | undefined;
if (!fs.existsSync(path.dirname(configPath))) { if (!fs.existsSync(path.dirname(configPath))) {
@@ -142,6 +142,11 @@ function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
} }
} }
function isLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
export function hasImageAnalysisMcpServerInstalled(): boolean { export function hasImageAnalysisMcpServerInstalled(): boolean {
return ( return (
fs.existsSync(getImageAnalysisMcpServerPath()) && fs.existsSync(getImageAnalysisMcpServerPath()) &&
@@ -183,6 +188,7 @@ function removeManagedServerConfig(configPath: string): boolean {
return false; return false;
} }
try {
return withClaudeUserConfigLock(configPath, () => { return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath); const config = readClaudeUserConfig(configPath);
if (config === null) { if (config === null) {
@@ -229,6 +235,19 @@ function removeManagedServerConfig(configPath: string): boolean {
return false; return false;
} }
}); });
} catch (error) {
if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process`
)
);
}
return false;
}
throw error;
}
} }
export function installImageAnalysisMcpServer(): boolean { export function installImageAnalysisMcpServer(): boolean {
@@ -331,6 +350,7 @@ export function ensureImageAnalysisMcpConfig(): boolean {
env: {}, env: {},
}; };
try {
return withClaudeUserConfigLock(claudeUserConfigPath, () => { return withClaudeUserConfigLock(claudeUserConfigPath, () => {
const config = readClaudeUserConfig(claudeUserConfigPath); const config = readClaudeUserConfig(claudeUserConfigPath);
@@ -377,6 +397,19 @@ export function ensureImageAnalysisMcpConfig(): boolean {
return false; return false;
} }
}); });
} catch (error) {
if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process`
)
);
}
return false;
}
throw error;
}
} }
export function ensureImageAnalysisMcp(): boolean { export function ensureImageAnalysisMcp(): boolean {
@@ -184,10 +184,33 @@ describe('ensureImageAnalysisMcp', () => {
it('serializes ~/.claude.json updates with a file lock', () => { it('serializes ~/.claude.json updates with a file lock', () => {
setupTempHome(); setupTempHome();
writeEnabledConfig(); writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
const lockSpy = spyOn(lockfile, 'lockSync'); const lockSpy = spyOn(lockfile, 'lockSync');
expect(ensureImageAnalysisMcp()).toBe(true); expect(ensureImageAnalysisMcp()).toBe(true);
expect(lockSpy).toHaveBeenCalled(); expect(lockSpy).toHaveBeenCalled();
expect(lockSpy.mock.calls[0]?.[0]).toBe(tempHome as string);
});
it('returns false instead of throwing when ~/.claude.json is already locked', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
const release = lockfile.lockSync(tempHome as string, { stale: 10000 }) as () => void;
try {
let result: boolean | undefined;
expect(() => {
result = ensureImageAnalysisMcp();
}).not.toThrow();
expect(result).toBe(false);
} finally {
release();
}
}); });
}); });