Skip to content

fix: use async settings read with timeout in preSpawn path#100

Open
Spirotot wants to merge 1 commit intohappier-dev:devfrom
Spirotot:fix/async-settings-read
Open

fix: use async settings read with timeout in preSpawn path#100
Spirotot wants to merge 1 commit intohappier-dev:devfrom
Spirotot:fix/async-settings-read

Conversation

@Spirotot
Copy link

@Spirotot Spirotot commented Feb 28, 2026

Summary

  • readClaudeSettings() uses synchronous readFileSync to read settings.json, called during the preSpawn startup task chain via generateHookSettingsFile()
  • On macOS, if settings.json is a symlink to iCloud Drive (or any network/cloud filesystem), the openat() syscall can block indefinitely when the process lacks TCC entitlements — a common situation for LaunchAgent-spawned daemon processes
  • This causes sessions to hang for minutes or never start

Changes

  1. claudeSettings.ts: Add readClaudeSettingsAsync() using fs/promises.readFile with AbortSignal.timeout(5000). The sync readClaudeSettings() is preserved for backward compatibility.
  2. generateHookSettings.ts: Convert generateHookSettingsFile() to async, using the new readClaudeSettingsAsync().
  3. startHookServerTask.ts: Update generateHookSettingsFile type from (port: number) => string to (port: number) => Promise<string> and await the call. The task runner is already async.

Context

Discovered while debugging daemon-spawned sessions on macOS. The settings.json file was a symlink resolving through iCloud Drive (~/Library/Mobile Documents/...). macOS blocks openat() from LaunchAgent-spawned processes to iCloud Drive paths without TCC entitlements, causing the synchronous readFileSync to hang indefinitely and block the entire session startup.

Test plan

  • Verify sessions start normally when settings.json is a regular local file
  • Verify sessions start gracefully (with defaults) when settings.json is on a slow/inaccessible filesystem (times out after 5s instead of hanging)
  • Verify shouldIncludeCoAuthoredBy() still works (uses sync version, unaffected)
  • Verify existing tests pass with the async interface change

Summary by CodeRabbit

Release Notes

  • Refactor
    • Improved asynchronous operations in settings reading for better CLI responsiveness and reliability.
    • Enhanced timeout handling during configuration loading to ensure more robust hook server initialization.

readClaudeSettings() uses synchronous readFileSync, which blocks the
entire preSpawn startup task chain. On macOS, if settings.json is a
symlink to iCloud Drive (or any network/cloud filesystem), the openat()
syscall can block indefinitely when the process lacks TCC entitlements —
a common situation for LaunchAgent-spawned daemon processes.

Add readClaudeSettingsAsync() using fs/promises.readFile with a 5-second
AbortSignal.timeout(). Convert generateHookSettingsFile() and its caller
in startHookServerTask to async. The sync version is preserved for
backward compatibility with non-startup callers.
@coderabbitai
Copy link

coderabbitai bot commented Feb 28, 2026

Walkthrough

Three files across Claude settings utilities are updated to convert synchronous file I/O operations to asynchronous patterns. A new async function readClaudeSettingsAsync is added with timeout handling, generateHookSettingsFile is made async, and the startup task is updated to properly await the result.

Changes

Cohort / File(s) Summary
Async Claude Settings Utilities
apps/cli/src/backends/claude/utils/claudeSettings.ts, apps/cli/src/backends/claude/utils/generateHookSettings.ts
Added new readClaudeSettingsAsync() function with timeout handling and error logging. Updated generateHookSettingsFile() to async, now using the new async read variant instead of synchronous readClaudeSettings().
Startup Task Integration
apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts
Updated task parameters to expect generateHookSettingsFile returning Promise<string>. Call site updated to await the async generation result.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: use async settings read with timeout in preSpawn path' accurately describes the main change: converting synchronous settings reads to asynchronous with a 5-second timeout in the startup initialization path.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/cli/src/backends/claude/utils/claudeSettings.ts (1)

78-81: Consider if existsSync can also block on slow filesystems.

The synchronous existsSync call could theoretically also hang on the same slow/inaccessible filesystem scenario this PR aims to fix. However, existence checks via stat are typically much faster than open + read, so this is likely acceptable in practice.

If you want full consistency, you could use fs/promises.access() with a timeout, but the current approach is pragmatic given the lower risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/backends/claude/utils/claudeSettings.ts` around lines 78 - 81,
The synchronous existsSync check can hang on slow filesystems; replace the sync
check with an async fs.promises.access call wrapped in a timeout (e.g.,
Promise.race between access(settingsPath) and a timeout rejection) so the code
checks file existence without blocking indefinitely; update the surrounding
function to be async (or await the helper) and reference the existing symbols
existsSync -> use fs.promises.access, settingsPath, and the function that reads
Claude settings to locate and change the logic accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/cli/src/backends/claude/utils/claudeSettings.ts`:
- Around line 78-81: The synchronous existsSync check can hang on slow
filesystems; replace the sync check with an async fs.promises.access call
wrapped in a timeout (e.g., Promise.race between access(settingsPath) and a
timeout rejection) so the code checks file existence without blocking
indefinitely; update the surrounding function to be async (or await the helper)
and reference the existing symbols existsSync -> use fs.promises.access,
settingsPath, and the function that reads Claude settings to locate and change
the logic accordingly.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1037536 and d53ed81.

📒 Files selected for processing (3)
  • apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts
  • apps/cli/src/backends/claude/utils/claudeSettings.ts
  • apps/cli/src/backends/claude/utils/generateHookSettings.ts

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 28, 2026

Greptile Summary

Adds async version of readClaudeSettings() with 5-second timeout to prevent indefinite blocking when reading from iCloud Drive symlinks in daemon contexts on macOS. The sync version is preserved for backward compatibility.

Critical Issues Found:

  • Breaking change: Converting generateHookSettingsFile() to async breaks the existing caller at runClaude.ts:513 which doesn't await the call - this will cause hookSettingsPath to be a Promise instead of a string, leading to runtime errors
  • Test failures: All 4 tests in generateHookSettings.test.ts call the now-async function without await, causing test failures

Non-critical:

  • existsSync() in the async path could still block on slow filesystems, though less severely than readFileSync()

The PR correctly updates startHookServerTask.ts to await the async call, but misses the other caller and tests.

Confidence Score: 1/5

  • This PR introduces critical breaking changes that will cause runtime errors
  • Converting generateHookSettingsFile from sync to async without updating all callers creates runtime bugs - runClaude.ts:513 will assign a Promise to hookSettingsPath instead of a string, and tests will fail
  • Pay close attention to generateHookSettings.ts - the async conversion is incomplete and breaks existing code

Important Files Changed

Filename Overview
apps/cli/src/backends/claude/utils/claudeSettings.ts Added async version of settings reader with timeout; sync version remains for backward compatibility
apps/cli/src/backends/claude/utils/generateHookSettings.ts Converted to async function but missing await in existing caller at runClaude.ts:513 - will cause runtime error
apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts Type signature correctly updated and properly awaits the async call

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[preSpawn startup] --> B[startHookServerTask]
    B --> C[generateHookSettingsFile]
    C --> D{readClaudeSettingsAsync}
    D -->|timeout=5s| E[AbortSignal.timeout]
    E --> F[fs.promises.readFile]
    F -->|success| G[merge with hooks config]
    F -->|timeout| H[return null, use defaults]
    F -->|error| H
    G --> I[write temporary settings file]
    I --> J[return settings path]
    
    K[runClaude.ts:513<br/>MISSING AWAIT] -.->|broken| C
    
    style K fill:#ff6b6b,stroke:#c92a2a
    style D fill:#51cf66,stroke:#2f9e44
    style E fill:#51cf66,stroke:#2f9e44
Loading

Last reviewed commit: d53ed81

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

* @returns Path to the generated settings file
*/
export function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): string {
export async function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing this to async breaks the existing caller at apps/cli/src/backends/claude/runClaude.ts:513 which doesn't await the call

// runClaude.ts line 513 (not updated in this PR):
const hookSettingsPath = generateHookSettingsFile(hookServer.port, {

needs to be:

const hookSettingsPath = await generateHookSettingsFile(hookServer.port, {

* @returns Path to the generated settings file
*/
export function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): string {
export async function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests in generateHookSettings.test.ts need updating - all 4 test cases call this function without await

lines 26, 38, 65, and 102 all need:

  • test functions changed to async
  • calls changed to await generateHookSettingsFile(...)

Comment on lines +78 to +80
if (!existsSync(settingsPath)) {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existsSync() is still synchronous and could block on slow filesystems (same issue as readFileSync). consider using fs.promises.access() with the abort signal instead

Suggested change
if (!existsSync(settingsPath)) {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
try {
await access(settingsPath, { signal: AbortSignal.timeout(SETTINGS_READ_TIMEOUT_MS) });
} catch {
logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`);
return null;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant