From 2ca07e8917aee37a0a8432c21ef5b513f69f50f7 Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Fri, 10 Apr 2026 00:12:24 +0000 Subject: [PATCH] fix(core): prevent infinite loop in project registry short ID generation Adds a hard limit to the while(true) loop in claimNewSlug to prevent tests from hanging indefinitely when fs.existsSync is mocked improperly. Refines error typing in the rename retry block to strictly use isNodeError. --- packages/core/src/config/projectRegistry.ts | 28 +++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index e8f9cc8e55..1785c3dc1a 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, getErrorMessage } from '../utils/errors.js'; export interface RegistryData { projects: Record; @@ -62,7 +63,7 @@ export class ProjectRegistry { const content = await fs.promises.readFile(this.registryPath, 'utf8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); - } catch (e) { + } catch (e: unknown) { debugLogger.debug('Failed to load registry: ', e); // If the registry is corrupted, we'll start fresh to avoid blocking the CLI return { projects: {} }; @@ -95,13 +96,20 @@ export class ProjectRegistry { await fs.promises.writeFile(tmpPath, content, 'utf8'); await fs.promises.rename(tmpPath, this.registryPath); return; // Success - } catch (error) { + } catch (error: unknown) { attempt++; - if (attempt >= maxAttempts) { + const isRetryable = + isNodeError(error) && + (error.code === 'EPERM' || + error.code === 'EBUSY' || + error.code === 'EACCES'); + + if (attempt >= maxAttempts || !isRetryable) { debugLogger.error( - `Failed to save project registry to ${this.registryPath} after ${maxAttempts} attempts:`, - error, + `Failed to save project registry to ${this.registryPath} after ${attempt} attempts:`, + getErrorMessage(error), ); + return; // Stop trying } else { // Wait before retrying (exponential backoff could be used here too) await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -190,7 +198,7 @@ export class ProjectRegistry { if (this.normalizePath(owner) !== this.normalizePath(projectPath)) { return false; } - } catch (e) { + } catch (e: unknown) { debugLogger.debug( `Failed to read ownership marker ${markerPath}:`, e, @@ -233,7 +241,7 @@ export class ProjectRegistry { } } } - } catch (e) { + } catch (e: unknown) { debugLogger.debug(`Failed to scan base dir ${baseDir}:`, e); } } @@ -252,6 +260,12 @@ export class ProjectRegistry { const existingIds = new Set(Object.values(existingMappings)); while (true) { + if (counter > 1000) { + throw new Error( + 'Failed to generate a unique project short ID after 1000 attempts. Check for fs mocks or filesystem permission issues.', + ); + } + const candidate = counter === 0 ? slug : `${slug}-${counter}`; counter++;