From d53ed8116de775549180103841ceb652e3ae9268 Mon Sep 17 00:00:00 2001 From: Aaron Fields Date: Sat, 28 Feb 2026 08:21:56 -0500 Subject: [PATCH] fix: use async settings read with timeout in preSpawn path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../startup/tasks/startHookServerTask.ts | 5 +- .../backends/claude/utils/claudeSettings.ts | 64 ++++++++++++++++--- .../claude/utils/generateHookSettings.ts | 15 +++-- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts b/apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts index 28eac31e0..eb7487866 100644 --- a/apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts +++ b/apps/cli/src/backends/claude/startup/tasks/startHookServerTask.ts @@ -5,7 +5,7 @@ type HookServer = Readonly<{ port: number; stop: () => void }>; export function createClaudeStartHookServerTask(params: { startHookServer: () => Promise; - generateHookSettingsFile: (port: number) => string; + generateHookSettingsFile: (port: number) => Promise; }): StartupTask { return { id: 'claude.start_hook_server', @@ -13,8 +13,7 @@ export function createClaudeStartHookServerTask(params: { run: async ({ artifacts }) => { const hookServer = await params.startHookServer(); artifacts.hookServer = hookServer; - artifacts.hookSettingsPath = params.generateHookSettingsFile(hookServer.port); + artifacts.hookSettingsPath = await params.generateHookSettingsFile(hookServer.port); }, }; } - diff --git a/apps/cli/src/backends/claude/utils/claudeSettings.ts b/apps/cli/src/backends/claude/utils/claudeSettings.ts index 6fcc14443..6c5f119a7 100644 --- a/apps/cli/src/backends/claude/utils/claudeSettings.ts +++ b/apps/cli/src/backends/claude/utils/claudeSettings.ts @@ -1,11 +1,12 @@ /** * Utilities for reading Claude's settings.json configuration - * + * * Handles reading Claude's settings.json file to respect user preferences * like includeCoAuthoredBy setting for commit message generation. */ import { existsSync, readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { logger } from '@/ui/logger'; @@ -15,6 +16,9 @@ export interface ClaudeSettings { [key: string]: any; } +/** Maximum time to wait for settings file read before giving up. */ +const SETTINGS_READ_TIMEOUT_MS = 5_000; + /** * Get the path to Claude's settings.json file */ @@ -26,25 +30,30 @@ function getClaudeSettingsPath(claudeConfigDirOverride?: string | null): string } /** - * Read Claude's settings.json file from the default location - * + * Read Claude's settings.json file synchronously. + * + * WARNING: This can block indefinitely if the settings file is on a slow or + * inaccessible filesystem (e.g. iCloud Drive symlinks in a LaunchAgent + * context on macOS). Prefer readClaudeSettingsAsync() for startup-critical + * code paths. + * * @returns Claude settings object or null if file doesn't exist or can't be read */ export function readClaudeSettings(claudeConfigDirOverride?: string | null): ClaudeSettings | null { try { const settingsPath = getClaudeSettingsPath(claudeConfigDirOverride); - + if (!existsSync(settingsPath)) { logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`); return null; } - + const settingsContent = readFileSync(settingsPath, 'utf-8'); const settings = JSON.parse(settingsContent) as ClaudeSettings; - + logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`); logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`); - + return settings; } catch (error) { logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`); @@ -52,10 +61,49 @@ export function readClaudeSettings(claudeConfigDirOverride?: string | null): Cla } } +/** + * Read Claude's settings.json file asynchronously with a timeout. + * + * This is the preferred method for startup-critical code paths. It will not + * block indefinitely if the settings file is on a slow or inaccessible + * filesystem (e.g. symlinks to iCloud Drive in LaunchAgent/daemon context + * on macOS where TCC entitlements may prevent filesystem access). + * + * @returns Claude settings object or null if file doesn't exist, can't be read, or times out + */ +export async function readClaudeSettingsAsync(claudeConfigDirOverride?: string | null): Promise { + try { + const settingsPath = getClaudeSettingsPath(claudeConfigDirOverride); + + if (!existsSync(settingsPath)) { + logger.debug(`[ClaudeSettings] No Claude settings file found at ${settingsPath}`); + return null; + } + + const settingsContent = await readFile(settingsPath, { + encoding: 'utf-8', + signal: AbortSignal.timeout(SETTINGS_READ_TIMEOUT_MS), + }); + const settings = JSON.parse(settingsContent) as ClaudeSettings; + + logger.debug(`[ClaudeSettings] Successfully read Claude settings from ${settingsPath}`); + logger.debug(`[ClaudeSettings] includeCoAuthoredBy: ${settings.includeCoAuthoredBy}`); + + return settings; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + logger.debug(`[ClaudeSettings] Timed out reading settings after ${SETTINGS_READ_TIMEOUT_MS}ms — file may be on a slow or inaccessible filesystem`); + } else { + logger.debug(`[ClaudeSettings] Error reading Claude settings: ${error}`); + } + return null; + } +} + /** * Check if Co-Authored-By lines should be included in commit messages * based on Claude's settings - * + * * @returns true if Co-Authored-By should be included, false otherwise */ export function shouldIncludeCoAuthoredBy(): boolean { diff --git a/apps/cli/src/backends/claude/utils/generateHookSettings.ts b/apps/cli/src/backends/claude/utils/generateHookSettings.ts index 209315ab9..bfb6d1f98 100644 --- a/apps/cli/src/backends/claude/utils/generateHookSettings.ts +++ b/apps/cli/src/backends/claude/utils/generateHookSettings.ts @@ -1,6 +1,6 @@ /** * Generate temporary settings file with Claude hooks for session tracking - * + * * Creates a settings.json file that configures Claude's SessionStart hook * to notify our HTTP server when sessions change (new session, resume, compact, etc.) */ @@ -10,7 +10,7 @@ import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; import { configuration } from '@/configuration'; import { logger } from '@/ui/logger'; import { projectPath } from '@/projectPath'; -import { readClaudeSettings, type ClaudeSettings } from './claudeSettings'; +import { readClaudeSettingsAsync, type ClaudeSettings } from './claudeSettings'; import { isBun } from '@/utils/runtime'; export interface GenerateHookSettingsOptions { @@ -25,11 +25,14 @@ export interface GenerateHookSettingsOptions { /** * Generate a temporary settings file with SessionStart hook configuration - * + * + * Uses async file I/O with timeout to read base settings, preventing + * indefinite hangs on slow or inaccessible filesystems. + * * @param port - The port where Happy server is listening * @returns Path to the generated settings file */ -export function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): string { +export async function generateHookSettingsFile(port: number, options: GenerateHookSettingsOptions = {}): Promise { const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); mkdirSync(hooksDir, { recursive: true }); @@ -78,7 +81,7 @@ export function generateHookSettingsFile(port: number, options: GenerateHookSett ]; } - const baseSettings: ClaudeSettings = readClaudeSettings(options.claudeConfigDir) ?? {}; + const baseSettings: ClaudeSettings = await readClaudeSettingsAsync(options.claudeConfigDir) ?? {}; const baseHooks = baseSettings && typeof baseSettings === 'object' && baseSettings.hooks && typeof baseSettings.hooks === 'object' ? (baseSettings.hooks as Record) @@ -124,7 +127,7 @@ export function generateHookSettingsFile(port: number, options: GenerateHookSett /** * Clean up the temporary hook settings file - * + * * @param filepath - Path to the settings file to remove */ export function cleanupHookSettingsFile(filepath: string): void {