mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-14 16:24:09 +00:00
fix(image-analysis): handle installer lock contention
This commit is contained in:
@@ -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,52 +188,66 @@ function removeManagedServerConfig(configPath: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return withClaudeUserConfigLock(configPath, () => {
|
try {
|
||||||
const config = readClaudeUserConfig(configPath);
|
return withClaudeUserConfigLock(configPath, () => {
|
||||||
if (config === null) {
|
const config = readClaudeUserConfig(configPath);
|
||||||
if (process.env.CCS_DEBUG) {
|
if (config === null) {
|
||||||
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingServers =
|
const existingServers =
|
||||||
config.mcpServers &&
|
config.mcpServers &&
|
||||||
typeof config.mcpServers === 'object' &&
|
typeof config.mcpServers === 'object' &&
|
||||||
!Array.isArray(config.mcpServers)
|
!Array.isArray(config.mcpServers)
|
||||||
? { ...(config.mcpServers as Record<string, unknown>) }
|
? { ...(config.mcpServers as Record<string, unknown>) }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) {
|
if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
|
|
||||||
|
|
||||||
const nextConfig: ClaudeUserConfig = { ...config };
|
|
||||||
if (Object.keys(existingServers).length === 0) {
|
|
||||||
delete nextConfig.mcpServers;
|
|
||||||
} else {
|
|
||||||
nextConfig.mcpServers = existingServers;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeClaudeUserConfig(configPath, nextConfig);
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info(`Removed Image Analysis MCP config from ${configPath}`));
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
} catch (error) {
|
delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
|
||||||
|
|
||||||
|
const nextConfig: ClaudeUserConfig = { ...config };
|
||||||
|
if (Object.keys(existingServers).length === 0) {
|
||||||
|
delete nextConfig.mcpServers;
|
||||||
|
} else {
|
||||||
|
nextConfig.mcpServers = existingServers;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeClaudeUserConfig(configPath, nextConfig);
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(info(`Removed Image Analysis MCP config from ${configPath}`));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(
|
||||||
|
warn(
|
||||||
|
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isLockUnavailableError(error)) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error(
|
console.error(
|
||||||
warn(
|
warn(
|
||||||
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}`
|
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installImageAnalysisMcpServer(): boolean {
|
export function installImageAnalysisMcpServer(): boolean {
|
||||||
@@ -331,52 +350,66 @@ export function ensureImageAnalysisMcpConfig(): boolean {
|
|||||||
env: {},
|
env: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
|
try {
|
||||||
const config = readClaudeUserConfig(claudeUserConfigPath);
|
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
|
||||||
|
const config = readClaudeUserConfig(claudeUserConfigPath);
|
||||||
|
|
||||||
if (config === null) {
|
if (config === null) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingServers =
|
||||||
|
config.mcpServers &&
|
||||||
|
typeof config.mcpServers === 'object' &&
|
||||||
|
!Array.isArray(config.mcpServers)
|
||||||
|
? (config.mcpServers as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
|
||||||
|
if (
|
||||||
|
typeof currentConfig === 'object' &&
|
||||||
|
currentConfig !== null &&
|
||||||
|
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextConfig: ClaudeUserConfig = {
|
||||||
|
...config,
|
||||||
|
mcpServers: {
|
||||||
|
...existingServers,
|
||||||
|
[IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isLockUnavailableError(error)) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning'));
|
console.error(
|
||||||
|
warn(
|
||||||
|
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process`
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
const existingServers =
|
}
|
||||||
config.mcpServers &&
|
|
||||||
typeof config.mcpServers === 'object' &&
|
|
||||||
!Array.isArray(config.mcpServers)
|
|
||||||
? (config.mcpServers as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
|
|
||||||
if (
|
|
||||||
typeof currentConfig === 'object' &&
|
|
||||||
currentConfig !== null &&
|
|
||||||
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextConfig: ClaudeUserConfig = {
|
|
||||||
...config,
|
|
||||||
mcpServers: {
|
|
||||||
...existingServers,
|
|
||||||
[IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user