perf: convert synchronous file reads to async in main process#111
perf: convert synchronous file reads to async in main process#111matt1398 merged 1 commit intomatt1398:mainfrom
Conversation
Replace readFileSync, statSync, realpathSync, existsSync, and readdirSync with their fs.promises equivalents to unblock the Electron main process event loop. - ConfigManager: make loadConfig() async, add initialize() + initializeInstance() pattern, make reload() async - GitIdentityResolver: convert all methods (resolveIdentity, isWorktree, getRemoteUrl, getBranch, detectWorktreeSource, getWorktreeDisplayName, getGitWorktreeName) to use fs.promises - SubagentLocator: remove hasSubagentsSync() (callers should use the existing async hasSubagents() instead) - ProjectScanner: remove hasSubagentsSync() wrapper - WorktreeGrouper: update call sites to await now-async methods using Promise.all for parallel worktree resolution - utility.ts: convert handleShellOpenPath and handleReadMentionedFile to use fs.promises.access, stat, and readFile Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request focuses on a significant performance improvement by migrating synchronous file system operations to asynchronous ones within the main process. This change ensures that file I/O no longer blocks the event loop, leading to a more responsive and fluid user experience. The refactoring touches several key areas, including configuration management, Git identity resolution, and inter-process communication handlers, all aimed at enhancing the application's overall performance characteristics. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
📝 WalkthroughWalkthroughSystematic migration of filesystem operations from synchronous to asynchronous patterns across discovery services, IPC utilities, configuration management, and git identity resolution. Removed synchronous method variants and introduced async initialization workflows with promise-based I/O. Changes
Suggested labels
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (5)
src/main/ipc/utility.ts (1)
205-218: Async conversion looks good; consider removing redundant access() check.The
fs.promises.access()call on lines 205-209 is redundant sincefs.promises.stat()on line 212 will throwENOENTif the file doesn't exist. The outer try/catch already handles this case.♻️ Optional: Simplify by removing the access() check
const safePath = validation.normalizedPath!; - // Check if file exists - try { - await fs.promises.access(safePath); - } catch { - return null; - } - // Check if it's a file (not directory) - const stats = await fs.promises.stat(safePath); + let stats: fs.Stats; + try { + stats = await fs.promises.stat(safePath); + } catch { + return null; + } if (!stats.isFile()) { return null; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/ipc/utility.ts` around lines 205 - 218, Remove the redundant fs.promises.access(safePath) check: in the function using safePath, delete the try/catch that calls fs.promises.access and rely on fs.promises.stat(safePath) to throw if the path doesn't exist; keep the subsequent isFile() check and the readFile(safePath, 'utf8') call so behavior and error handling remain the same, only eliminating the unnecessary access() invocation.src/main/services/parsing/GitIdentityResolver.ts (3)
55-64: Consider consolidating existence check with stat operation.The current pattern performs two async filesystem operations:
access()to check existence, thenstat(). You could simplify to a singlestat()call, catchingENOENTto handle the non-existence case.♻️ Optional: Single stat call with error handling
- let gitPathExists = false; - try { - await fs.promises.access(gitPath); - gitPathExists = true; - } catch { - // Path doesn't exist - } - - if (gitPathExists) { - const stats = await fs.promises.stat(gitPath); + let stats: fs.Stats | null = null; + try { + stats = await fs.promises.stat(gitPath); + } catch { + // Path doesn't exist or is inaccessible + } + + if (stats) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/services/parsing/GitIdentityResolver.ts` around lines 55 - 64, The code in GitIdentityResolver currently calls fs.promises.access(gitPath) and then fs.promises.stat(gitPath); consolidate these into a single fs.promises.stat(gitPath) call and catch errors: replace the two-step existence check with one try/catch around stat(gitPath), treat ENOENT as "path doesn't exist" (skip the stat result) and rethrow or handle other errors; update any logic that used gitPathExists to use the presence/absence of the stat result.
266-279: Redundant nested try/catch blocks.The nested try/catch structure at lines 268-273 inside 266-276 is redundant since both catch blocks handle errors the same way (ignore and continue). This can be simplified.
♻️ Optional: Simplify error handling
// Fallback: check filesystem if available try { const gitPath = path.join(projectPath, '.git'); - try { - const stats = await fs.promises.stat(gitPath); - return stats.isFile(); - } catch { - // Path doesn't exist - } + const stats = await fs.promises.stat(gitPath); + return stats.isFile(); } catch { - // Ignore errors - filesystem might not be available + // Path doesn't exist or filesystem not available }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/services/parsing/GitIdentityResolver.ts` around lines 266 - 279, The nested try/catch in GitIdentityResolver (the function that checks the .git path) is redundant; replace the two-level try/catch with a single try/catch: compute gitPath (path.join(projectPath, '.git')), await fs.promises.stat(gitPath), return stats.isFile() in the try block, and in the single catch block return false (or fall through to return false) so errors are handled once and the outer try/catch is removed.
537-552: Same redundant nested try/catch pattern.Similar to
isWorktree, the nested try/catch blocks here can be simplified.♻️ Optional: Simplify error handling
// Check if it's a standard git repo (only if filesystem exists) // For deleted repos, we'll return 'git' as fallback since we can't verify try { const gitPath = path.join(projectPath, '.git'); - try { - await fs.promises.access(gitPath); - return 'git'; - } catch { - // Path doesn't exist - } + await fs.promises.access(gitPath); + return 'git'; } catch { - // Ignore errors - filesystem might not be available + // Path doesn't exist or filesystem not available }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/services/parsing/GitIdentityResolver.ts` around lines 537 - 552, The nested try/catch around checking fs.promises.access(gitPath) is redundant; simplify by using a single try/catch that attempts await fs.promises.access(gitPath) and returns 'git' on success, and otherwise falls through to the final return 'git'. Modify the block in GitIdentityResolver that builds gitPath and calls fs.promises.access (remove the outer try/catch and keep a single try/catch around the access call), ensuring any filesystem errors are caught once and ignored so the function still ultimately returns 'git'.src/main/services/infrastructure/ConfigManager.ts (1)
414-422: Consider convertingpersistConfigto async for consistency.The PR converts reads to async but
persistConfigstill uses synchronousfs.existsSync,fs.mkdirSync, andfs.writeFileSync. These can block the event loop during config saves, which happen on every update.♻️ Suggested async conversion
- private persistConfig(config: AppConfig): void { + private async persistConfig(config: AppConfig): Promise<void> { const configDir = path.dirname(this.configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } + await fs.promises.mkdir(configDir, { recursive: true }); const content = JSON.stringify(config, null, 2); - fs.writeFileSync(this.configPath, content, 'utf8'); + await fs.promises.writeFile(this.configPath, content, 'utf8'); }Note: This would require updating
saveConfig()and all callers to handle the async nature, which may be out of scope for this PR.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/services/infrastructure/ConfigManager.ts` around lines 414 - 422, persistConfig currently uses blocking fs calls (fs.existsSync, fs.mkdirSync, fs.writeFileSync) which can block the event loop; convert persistConfig to an async function that returns Promise<void>, use fs.promises (or import { promises as fs } ) to await fs.mkdir(path.dirname(this.configPath), { recursive: true }) and await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8'), handling errors with try/catch and rethrow or log as appropriate; update saveConfig and any callers to await the new async persistConfig (or return the Promise) so callers handle async behavior consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/services/infrastructure/ConfigManager.ts`:
- Around line 353-362: The race occurs because ConfigManager.instance is
assigned after awaiting instance.initialize() in initializeInstance; change
initializeInstance (the static method) to set ConfigManager.instance = instance
before awaiting instance.initialize() so the same singleton is returned by
getInstance() and referenced by the exported configManager/configManagerPromise;
keep calling await instance.initialize() afterwards to perform async load (and
do not create another instance inside getInstance()).
- Around line 970-974: The exported promise configManagerPromise (from
ConfigManager.initializeInstance) is never awaited, so startup calls to
configManager.getConfig() read defaults instead of disk values; update startup
to await configManagerPromise before any synchronous access (i.e., before calls
to configManager.getConfig() in your app.whenReady handler) so initialization
completes, and keep the configManager export for convenience, and also update
the misleading comment for the configManager export to reflect that the instance
must be awaited (or alternatively remove the unused configManagerPromise export
if you prefer synchronous-only usage).
---
Nitpick comments:
In `@src/main/ipc/utility.ts`:
- Around line 205-218: Remove the redundant fs.promises.access(safePath) check:
in the function using safePath, delete the try/catch that calls
fs.promises.access and rely on fs.promises.stat(safePath) to throw if the path
doesn't exist; keep the subsequent isFile() check and the readFile(safePath,
'utf8') call so behavior and error handling remain the same, only eliminating
the unnecessary access() invocation.
In `@src/main/services/infrastructure/ConfigManager.ts`:
- Around line 414-422: persistConfig currently uses blocking fs calls
(fs.existsSync, fs.mkdirSync, fs.writeFileSync) which can block the event loop;
convert persistConfig to an async function that returns Promise<void>, use
fs.promises (or import { promises as fs } ) to await
fs.mkdir(path.dirname(this.configPath), { recursive: true }) and await
fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8'), handling
errors with try/catch and rethrow or log as appropriate; update saveConfig and
any callers to await the new async persistConfig (or return the Promise) so
callers handle async behavior consistently.
In `@src/main/services/parsing/GitIdentityResolver.ts`:
- Around line 55-64: The code in GitIdentityResolver currently calls
fs.promises.access(gitPath) and then fs.promises.stat(gitPath); consolidate
these into a single fs.promises.stat(gitPath) call and catch errors: replace the
two-step existence check with one try/catch around stat(gitPath), treat ENOENT
as "path doesn't exist" (skip the stat result) and rethrow or handle other
errors; update any logic that used gitPathExists to use the presence/absence of
the stat result.
- Around line 266-279: The nested try/catch in GitIdentityResolver (the function
that checks the .git path) is redundant; replace the two-level try/catch with a
single try/catch: compute gitPath (path.join(projectPath, '.git')), await
fs.promises.stat(gitPath), return stats.isFile() in the try block, and in the
single catch block return false (or fall through to return false) so errors are
handled once and the outer try/catch is removed.
- Around line 537-552: The nested try/catch around checking
fs.promises.access(gitPath) is redundant; simplify by using a single try/catch
that attempts await fs.promises.access(gitPath) and returns 'git' on success,
and otherwise falls through to the final return 'git'. Modify the block in
GitIdentityResolver that builds gitPath and calls fs.promises.access (remove the
outer try/catch and keep a single try/catch around the access call), ensuring
any filesystem errors are caught once and ignored so the function still
ultimately returns 'git'.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c7b952ff-8cb0-4b21-83e0-53a5d3be505b
📒 Files selected for processing (6)
src/main/ipc/utility.tssrc/main/services/discovery/ProjectScanner.tssrc/main/services/discovery/SubagentLocator.tssrc/main/services/discovery/WorktreeGrouper.tssrc/main/services/infrastructure/ConfigManager.tssrc/main/services/parsing/GitIdentityResolver.ts
💤 Files with no reviewable changes (2)
- src/main/services/discovery/SubagentLocator.ts
- src/main/services/discovery/ProjectScanner.ts
| /** | ||
| * Creates and initializes the singleton instance asynchronously. | ||
| * Loads configuration from disk. Call this at app startup. | ||
| */ | ||
| static async initializeInstance(configPath?: string): Promise<ConfigManager> { | ||
| const instance = new ConfigManager(configPath); | ||
| await instance.initialize(); | ||
| ConfigManager.instance = instance; | ||
| return instance; | ||
| } |
There was a problem hiding this comment.
Race condition: ConfigManager.instance is set after await, causing dual instances.
The assignment at line 360 happens after the await on line 359. When the module loads, both exports (lines 970-974) are evaluated synchronously. Since initializeInstance() returns a Promise immediately (before line 360 executes), the subsequent getInstance() call finds ConfigManager.instance still null and creates a second instance.
This means configManager and configManagerPromise resolve to different instances—the synchronous export will never be initialized from disk.
🐛 Proposed fix: set instance before await
static async initializeInstance(configPath?: string): Promise<ConfigManager> {
const instance = new ConfigManager(configPath);
+ ConfigManager.instance = instance;
await instance.initialize();
- ConfigManager.instance = instance;
return instance;
}This ensures both exports reference the same instance. The synchronous configManager will have defaults initially but will be updated in-place when initialize() completes.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** | |
| * Creates and initializes the singleton instance asynchronously. | |
| * Loads configuration from disk. Call this at app startup. | |
| */ | |
| static async initializeInstance(configPath?: string): Promise<ConfigManager> { | |
| const instance = new ConfigManager(configPath); | |
| await instance.initialize(); | |
| ConfigManager.instance = instance; | |
| return instance; | |
| } | |
| /** | |
| * Creates and initializes the singleton instance asynchronously. | |
| * Loads configuration from disk. Call this at app startup. | |
| */ | |
| static async initializeInstance(configPath?: string): Promise<ConfigManager> { | |
| const instance = new ConfigManager(configPath); | |
| ConfigManager.instance = instance; | |
| await instance.initialize(); | |
| return instance; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/services/infrastructure/ConfigManager.ts` around lines 353 - 362,
The race occurs because ConfigManager.instance is assigned after awaiting
instance.initialize() in initializeInstance; change initializeInstance (the
static method) to set ConfigManager.instance = instance before awaiting
instance.initialize() so the same singleton is returned by getInstance() and
referenced by the exported configManager/configManagerPromise; keep calling
await instance.initialize() afterwards to perform async load (and do not create
another instance inside getInstance()).
| /** Singleton instance promise - resolves to an initialized ConfigManager */ | ||
| export const configManagerPromise = ConfigManager.initializeInstance(); | ||
|
|
||
| /** Singleton instance for convenience (synchronous access, uses defaults until initialized) */ | ||
| export const configManager = ConfigManager.getInstance(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for configManager usage in main process startup code
rg -n -C3 'configManager\.(getConfig|getInstance)' src/main/Repository: matt1398/claude-devtools
Length of output: 7904
🏁 Script executed:
#!/bin/bash
# Check if configManagerPromise is awaited before app initialization
rg -n -C5 'configManagerPromise' src/main/Repository: matt1398/claude-devtools
Length of output: 1036
🏁 Script executed:
cat -n src/main/index.ts | head -600 | tail -300Repository: matt1398/claude-devtools
Length of output: 11810
🏁 Script executed:
# Search for app.on('ready') and related event handlers
rg -n "app\.on\(" src/main/index.ts -A 10Repository: matt1398/claude-devtools
Length of output: 514
🏁 Script executed:
# Check the full context around createWindow and initializeServices calls
rg -n "createWindow\(\)|initializeServices\(\)" src/main/index.ts -B 5 -A 5Repository: matt1398/claude-devtools
Length of output: 1798
Promise never awaited—startup uses uninitialized config with misleading comment.
The configManagerPromise is created but never awaited anywhere in the codebase. Startup code (lines 336, 428, 566 in src/main/index.ts) calls configManager.getConfig() synchronously during app.whenReady() without waiting for configManagerPromise to resolve, meaning disk configuration is never loaded. The comment on line 973 claiming the instance "uses defaults until initialized" is misleading—initialization never occurs since the promise is ignored.
Early startup (service initialization, window creation, and config application) all access the synchronous instance before any async initialization completes. Either await configManagerPromise at the start of the ready handler, or remove the unused promise export.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/services/infrastructure/ConfigManager.ts` around lines 970 - 974,
The exported promise configManagerPromise (from
ConfigManager.initializeInstance) is never awaited, so startup calls to
configManager.getConfig() read defaults instead of disk values; update startup
to await configManagerPromise before any synchronous access (i.e., before calls
to configManager.getConfig() in your app.whenReady handler) so initialization
completes, and keep the configManager export for convenience, and also update
the misleading comment for the configManager export to reflect that the instance
must be awaited (or alternatively remove the unused configManagerPromise export
if you prefer synchronous-only usage).
There was a problem hiding this comment.
Code Review
This pull request aims to significantly improve the application's performance and responsiveness by converting numerous synchronous file I/O operations to asynchronous ones using fs.promises. However, a critical security vulnerability was identified in the IPC handlers for utility operations, specifically shell:openPath and read-mentioned-file. These handlers trust the projectRoot parameter from the renderer process, allowing a compromised renderer to bypass path validation and access arbitrary files. It is recommended to resolve project paths using authoritative data in the main process. Beyond this, the changes are well-implemented, introducing initialize() and initializeInstance() in ConfigManager for asynchronous configuration loading, utilizing Promise.all() in WorktreeGrouper for parallel processing, and cleaning up dead code by removing unused hasSubagentsSync() methods.
| // Check if path exists | ||
| if (!fs.existsSync(safePath)) { | ||
| try { | ||
| await fs.promises.access(safePath); |
There was a problem hiding this comment.
The handleShellOpenPath IPC handler trusts the projectRoot parameter provided by the renderer process. This parameter is used in validateOpenPath to determine if the targetPath is within an allowed directory. A compromised renderer could provide a broad projectRoot (e.g., / on POSIX or C:\ on Windows) to bypass this restriction and open arbitrary files or directories on the system (excluding those explicitly blocked by SENSITIVE_PATTERNS).
To remediate this, the main process should maintain an authoritative mapping of project IDs to their respective root paths and use this mapping to resolve and validate paths, rather than trusting a path provided directly by the renderer.
| await fs.promises.access(safePath); | ||
| } catch { | ||
| return null; | ||
| } | ||
|
|
||
| // Check if it's a file (not directory) | ||
| const stats = fs.statSync(safePath); | ||
| const stats = await fs.promises.stat(safePath); | ||
| if (!stats.isFile()) { | ||
| return null; | ||
| } | ||
|
|
||
| // Read file content | ||
| const content = fs.readFileSync(safePath, 'utf8'); | ||
| const content = await fs.promises.readFile(safePath, 'utf8'); |
There was a problem hiding this comment.
The handleReadMentionedFile IPC handler trusts the projectRoot parameter provided by the renderer process. This parameter is passed to validateFilePath, which uses it to verify if the absolutePath is within an allowed directory. An attacker in the renderer process could provide a malicious projectRoot (e.g., /) to read arbitrary files on the system that are not covered by the SENSITIVE_PATTERNS list.
Remediation: Do not trust the projectRoot provided via IPC. Instead, resolve the project root path in the main process based on a trusted project identifier.
Summary
loadConfig()andreload()converted from sync to async; addsinitialize()andinitializeInstance()factory for async startupisWorktree,getRemoteUrl,detectWorktreeSource,getWorktreeDisplayName,getGitWorktreeName) converted fromfs.*Synctofs.promises.*awaitthe now-async GitIdentityResolver methods; usesPromise.all()for parallel worktree processinghandleShellOpenPathandhandleReadMentionedFileconverted from sync to async fs callshasSubagentsSync()from SubagentLocator and ProjectScanner (zero callers in codebase)Addresses #95
Test plan
hasSubagentsSynchas zero callers before removal🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes