fix(mcp): retry Claude user config locks

This commit is contained in:
walker1211 authored and GitHub committed 2026-06-10 13:03:25 -04:00
1 parent f59536a81f
commit cf90f48240
6 files changed
+220 -83

No files matched your search

+4 -33
View File
@@ -4,10 +4,13 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { InstanceManager } from '../../management/instance-manager'; import { InstanceManager } from '../../management/instance-manager';
import { getClaudeUserConfigPath } from '../claude-config-path'; import { getClaudeUserConfigPath } from '../claude-config-path';
import { getCcsDir } from '../config-manager'; import { getCcsDir } from '../config-manager';
import {
isClaudeUserConfigLockUnavailableError as isLockUnavailableError,
withClaudeUserConfigLock,
} from '../claude-user-config-lock';
const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs'; const BROWSER_MCP_SERVER = 'ccs-browser-server.cjs';
const BROWSER_MCP_SERVER_NAME = 'ccs-browser'; const BROWSER_MCP_SERVER_NAME = 'ccs-browser';
@@ -133,38 +136,6 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo
} }
} }
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release.
}
}
}
}
function isLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
function removeManagedServerConfig(configPath: string): boolean { function removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
return false; return false;
+102
View File
@@ -0,0 +1,102 @@
import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
const DEFAULT_LOCK_STALE_MS = 10000;
const DEFAULT_LOCK_RETRY_DELAY_MS = 200;
const DEFAULT_LOCK_RETRY_TIMEOUT_MS = 10000;
interface LockOptions {
staleMs?: number;
retryDelayMs?: number;
retryTimeoutMs?: number;
}
export function withClaudeUserConfigLock<T>(
configPath: string,
callback: () => T,
options: LockOptions = {}
): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
const retryDelayMs = options.retryDelayMs ?? DEFAULT_LOCK_RETRY_DELAY_MS;
const retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_LOCK_RETRY_TIMEOUT_MS;
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = acquireClaudeUserConfigLock(
lockTarget,
configPath,
staleMs,
retryDelayMs,
retryTimeoutMs
);
return callback();
} finally {
if (release) {
try {
release();
} catch {}
}
}
}
export function isClaudeUserConfigLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
function acquireClaudeUserConfigLock(
lockTarget: string,
configPath: string,
staleMs: number,
retryDelayMs: number,
retryTimeoutMs: number
): () => void {
const startedAt = Date.now();
let lastError: unknown;
for (;;) {
try {
return lockfile.lockSync(lockTarget, { stale: staleMs }) as () => void;
} catch (error) {
if (!isClaudeUserConfigLockUnavailableError(error)) {
throw error;
}
lastError = error;
if (Date.now() - startedAt >= retryTimeoutMs) {
throw buildLockTimeoutError(configPath, retryTimeoutMs, lastError);
}
sleepSync(retryDelayMs);
}
}
}
function sleepSync(ms: number): void {
if (ms <= 0) {
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function buildLockTimeoutError(
configPath: string,
timeoutMs: number,
cause: unknown
): NodeJS.ErrnoException {
const causeCode = (cause as NodeJS.ErrnoException | undefined)?.code;
const error = new Error(
`Failed to acquire Claude user config lock for ${configPath} after ${timeoutMs}ms; another CCS process may be starting CLIProxy`
) as NodeJS.ErrnoException;
error.code = causeCode === 'ENOTACQUIRED' ? 'ENOTACQUIRED' : 'ELOCKED';
return error;
}
+4 -33
View File
@@ -4,7 +4,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getCcsDir } from '../config-manager'; import { getCcsDir } from '../config-manager';
import { getClaudeUserConfigPath } from '../claude-config-path'; import { getClaudeUserConfigPath } from '../claude-config-path';
@@ -12,6 +11,10 @@ import { info, warn } from '../ui';
import { InstanceManager } from '../../management/instance-manager'; import { InstanceManager } from '../../management/instance-manager';
import { installImageAnalysisPrompts } from './hook-installer'; import { installImageAnalysisPrompts } from './hook-installer';
import { getImageAnalysisConfig } from '../../config/config-loader-facade'; import { getImageAnalysisConfig } from '../../config/config-loader-facade';
import {
isClaudeUserConfigLockUnavailableError as isLockUnavailableError,
withClaudeUserConfigLock,
} from '../claude-user-config-lock';
const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs'; const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs';
const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs'; const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs';
@@ -121,38 +124,6 @@ function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): bo
} }
} }
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release.
}
}
}
}
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()) &&
+46 -7
View File
@@ -12,6 +12,10 @@ import { InstanceManager } from '../../management/instance-manager';
import { installWebSearchHook } from './hook-installer'; import { installWebSearchHook } from './hook-installer';
import { appendWebSearchTrace } from './trace'; import { appendWebSearchTrace } from './trace';
import { getWebSearchConfig } from '../../config/config-loader-facade'; import { getWebSearchConfig } from '../../config/config-loader-facade';
import {
isClaudeUserConfigLockUnavailableError as isLockUnavailableError,
withClaudeUserConfigLock,
} from '../claude-user-config-lock';
const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs';
const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch';
@@ -118,6 +122,8 @@ function removeManagedServerConfig(configPath: string): boolean {
return false; return false;
} }
try {
return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath); const config = readClaudeUserConfig(configPath);
if (config === null) { if (config === null) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
@@ -127,7 +133,9 @@ function removeManagedServerConfig(configPath: string): boolean {
} }
const existingServers = const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) } ? { ...(config.mcpServers as Record<string, unknown>) }
: {}; : {};
@@ -160,6 +168,18 @@ function removeManagedServerConfig(configPath: string): boolean {
} }
return false; return false;
} }
});
} catch (error) {
if (isLockUnavailableError(error)) {
appendWebSearchTrace('websearch_mcp_config_remove_skipped', {
reason: 'user_config_locked',
configPath,
error: (error as Error).message,
});
return false;
}
throw error;
}
} }
export function installWebSearchMcpServer(): boolean { export function installWebSearchMcpServer(): boolean {
@@ -243,7 +263,9 @@ export function ensureWebSearchMcpConfig(): boolean {
} }
const claudeUserConfigPath = getClaudeUserConfigPath(); const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
try {
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
const config = readClaudeUserConfig(claudeUserConfigPath); const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) { if (config === null) {
@@ -254,12 +276,10 @@ export function ensureWebSearchMcpConfig(): boolean {
return false; return false;
} }
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const existingServers = const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>) ? (config.mcpServers as Record<string, unknown>)
: {}; : {};
const desiredServerConfig: ManagedWebSearchMcpConfig = { const desiredServerConfig: ManagedWebSearchMcpConfig = {
@@ -305,6 +325,25 @@ export function ensureWebSearchMcpConfig(): boolean {
} }
return false; return false;
} }
});
} catch (error) {
if (isLockUnavailableError(error)) {
appendWebSearchTrace('websearch_mcp_config_failed', {
reason: 'user_config_locked',
configPath: claudeUserConfigPath,
error: (error as Error).message,
});
if (process.env.CCS_DEBUG) {
console.error(
warn(
`WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process`
)
);
}
return false;
}
throw error;
}
} }
export function ensureWebSearchMcp(): boolean { export function ensureWebSearchMcp(): boolean {
@@ -194,26 +194,54 @@ describe('ensureImageAnalysisMcp', () => {
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock')); expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
}); });
it('returns false instead of throwing when ~/.claude.json is already locked', () => { it('retries ~/.claude.json lock acquisition before provisioning', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
let calls = 0;
spyOn(lockfile, 'lockSync').mockImplementation(() => {
calls++;
if (calls === 1) {
const error = new Error('Lock file is already being held') as NodeJS.ErrnoException;
error.code = 'ELOCKED';
throw error;
}
return (() => undefined) as ReturnType<typeof lockfile.lockSync>;
});
expect(ensureImageAnalysisMcp()).toBe(true);
expect(calls).toBe(2);
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, unknown>;
};
expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig());
});
it('returns false instead of throwing when ~/.claude.json stays locked', () => {
setupTempHome(); setupTempHome();
writeEnabledConfig(); writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8'); fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
fs.writeFileSync(path.join(tempHome as string, '.claude.json.ccs-lock'), '', 'utf8');
const release = lockfile.lockSync(path.join(tempHome as string, '.claude.json.ccs-lock'), { let now = 0;
stale: 10000, spyOn(Date, 'now').mockImplementation(() => {
}) as () => void; now += 10001;
return now;
});
spyOn(lockfile, 'lockSync').mockImplementation(() => {
const error = new Error('Lock file is already being held') as NodeJS.ErrnoException;
error.code = 'ELOCKED';
throw error;
});
try {
let result: boolean | undefined; let result: boolean | undefined;
expect(() => { expect(() => {
result = ensureImageAnalysisMcp(); result = ensureImageAnalysisMcp();
}).not.toThrow(); }).not.toThrow();
expect(result).toBe(false); expect(result).toBe(false);
} finally {
release();
}
}); });
}); });
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getHookPath } from '../../../../src/utils/websearch/hook-config'; import { getHookPath } from '../../../../src/utils/websearch/hook-config';
import { import {
ensureWebSearchMcp, ensureWebSearchMcp,
@@ -33,7 +34,15 @@ describe('ensureWebSearchMcp', () => {
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(ccsDir, 'config.yaml'), path.join(ccsDir, 'config.yaml'),
['version: 12', 'websearch:', ' enabled: true', ' providers:', ' duckduckgo:', ' enabled: true', ''].join('\n'), [
'version: 12',
'websearch:',
' enabled: true',
' providers:',
' duckduckgo:',
' enabled: true',
'',
].join('\n'),
'utf8' 'utf8'
); );
} }
@@ -95,6 +104,19 @@ describe('ensureWebSearchMcp', () => {
expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig()); expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual(getManagedConfig());
}); });
it('serializes ~/.claude.json updates with a file lock', () => {
setupTempHome();
writeEnabledConfig();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
const lockSpy = spyOn(lockfile, 'lockSync');
expect(ensureWebSearchMcp()).toBe(true);
expect(lockSpy).toHaveBeenCalled();
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
});
it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => { it('preserves the existing ~/.claude.json permissions when provisioning WebSearch MCP', () => {
setupTempHome(); setupTempHome();
writeEnabledConfig(); writeEnabledConfig();
@@ -170,7 +192,11 @@ describe('ensureWebSearchMcp', () => {
'utf8' 'utf8'
); );
const lockSpy = spyOn(lockfile, 'lockSync');
expect(uninstallWebSearchMcp()).toBe(true); expect(uninstallWebSearchMcp()).toBe(true);
expect(lockSpy).toHaveBeenCalled();
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(false); expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(false);
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {