changeDetector.ts
1 import chokidar, { type FSWatcher } from 'chokidar' 2 import { stat } from 'fs/promises' 3 import * as platformPath from 'path' 4 import { getIsRemoteMode } from '../../bootstrap/state.js' 5 import { registerCleanup } from '../cleanupRegistry.js' 6 import { logForDebugging } from '../debug.js' 7 import { errorMessage } from '../errors.js' 8 import { 9 type ConfigChangeSource, 10 executeConfigChangeHooks, 11 hasBlockingResult, 12 } from '../hooks.js' 13 import { createSignal } from '../signal.js' 14 import { jsonStringify } from '../slowOperations.js' 15 import { SETTING_SOURCES, type SettingSource } from './constants.js' 16 import { clearInternalWrites, consumeInternalWrite } from './internalWrites.js' 17 import { getManagedSettingsDropInDir } from './managedPath.js' 18 import { 19 getHkcuSettings, 20 getMdmSettings, 21 refreshMdmSettings, 22 setMdmSettingsCache, 23 } from './mdm/settings.js' 24 import { getSettingsFilePathForSource } from './settings.js' 25 import { resetSettingsCache } from './settingsCache.js' 26 27 /** 28 * Time in milliseconds to wait for file writes to stabilize before processing. 29 * This helps avoid processing partial writes or rapid successive changes. 30 */ 31 const FILE_STABILITY_THRESHOLD_MS = 1000 32 33 /** 34 * Polling interval in milliseconds for checking file stability. 35 * Used by chokidar's awaitWriteFinish option. 36 * Must be lower than FILE_STABILITY_THRESHOLD_MS. 37 */ 38 const FILE_STABILITY_POLL_INTERVAL_MS = 500 39 40 /** 41 * Time window in milliseconds to consider a file change as internal. 42 * If a file change occurs within this window after markInternalWrite() is called, 43 * it's assumed to be from Claude Code itself and won't trigger a notification. 44 */ 45 const INTERNAL_WRITE_WINDOW_MS = 5000 46 47 /** 48 * Poll interval for MDM settings (registry/plist) changes. 49 * These can't be watched via filesystem events, so we poll periodically. 50 */ 51 const MDM_POLL_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes 52 53 /** 54 * Grace period in milliseconds before processing a settings file deletion. 55 * Handles the common delete-and-recreate pattern during auto-updates or when 56 * another session starts up. If an `add` or `change` event fires within this 57 * window (file was recreated), the deletion is cancelled and treated as a change. 58 * 59 * Must exceed chokidar's awaitWriteFinish delay (stabilityThreshold + pollInterval) 60 * so the grace window outlasts the write stability check on the recreated file. 61 */ 62 const DELETION_GRACE_MS = 63 FILE_STABILITY_THRESHOLD_MS + FILE_STABILITY_POLL_INTERVAL_MS + 200 64 65 let watcher: FSWatcher | null = null 66 let mdmPollTimer: ReturnType<typeof setInterval> | null = null 67 let lastMdmSnapshot: string | null = null 68 let initialized = false 69 let disposed = false 70 const pendingDeletions = new Map<string, ReturnType<typeof setTimeout>>() 71 const settingsChanged = createSignal<[source: SettingSource]>() 72 73 // Test overrides for timing constants 74 let testOverrides: { 75 stabilityThreshold?: number 76 pollInterval?: number 77 mdmPollInterval?: number 78 deletionGrace?: number 79 } | null = null 80 81 /** 82 * Initialize file watching 83 */ 84 export async function initialize(): Promise<void> { 85 if (getIsRemoteMode()) return 86 if (initialized || disposed) return 87 initialized = true 88 89 // Start MDM poll for registry/plist changes (independent of filesystem watching) 90 startMdmPoll() 91 92 // Register cleanup to properly dispose during graceful shutdown 93 registerCleanup(dispose) 94 95 const { dirs, settingsFiles, dropInDir } = await getWatchTargets() 96 if (disposed) return // dispose() ran during the await 97 if (dirs.length === 0) return 98 99 logForDebugging( 100 `Watching for changes in setting files ${[...settingsFiles].join(', ')}...${dropInDir ? ` and drop-in directory ${dropInDir}` : ''}`, 101 ) 102 103 watcher = chokidar.watch(dirs, { 104 persistent: true, 105 ignoreInitial: true, 106 depth: 0, // Only watch immediate children, not subdirectories 107 awaitWriteFinish: { 108 stabilityThreshold: 109 testOverrides?.stabilityThreshold ?? FILE_STABILITY_THRESHOLD_MS, 110 pollInterval: 111 testOverrides?.pollInterval ?? FILE_STABILITY_POLL_INTERVAL_MS, 112 }, 113 ignored: (path, stats) => { 114 // Ignore special file types (sockets, FIFOs, devices) - they cannot be watched 115 // and will error with EOPNOTSUPP on macOS. 116 if (stats && !stats.isFile() && !stats.isDirectory()) return true 117 // Ignore .git directories 118 if (path.split(platformPath.sep).some(dir => dir === '.git')) return true 119 // Allow directories (chokidar needs them for directory-level watching) 120 // and paths without stats (chokidar's initial check before stat) 121 if (!stats || stats.isDirectory()) return false 122 // Only watch known settings files, ignore everything else in the directory 123 // Note: chokidar normalizes paths to forward slashes on Windows, so we 124 // normalize back to native format for comparison 125 const normalized = platformPath.normalize(path) 126 if (settingsFiles.has(normalized)) return false 127 // Also accept .json files inside the managed-settings.d/ drop-in directory 128 if ( 129 dropInDir && 130 normalized.startsWith(dropInDir + platformPath.sep) && 131 normalized.endsWith('.json') 132 ) { 133 return false 134 } 135 return true 136 }, 137 // Additional options for stability 138 ignorePermissionErrors: true, 139 usePolling: false, // Use native file system events 140 atomic: true, // Handle atomic writes better 141 }) 142 143 watcher.on('change', handleChange) 144 watcher.on('unlink', handleDelete) 145 watcher.on('add', handleAdd) 146 } 147 148 /** 149 * Clean up file watcher. Returns a promise that resolves when chokidar's 150 * close() settles — callers that need the watcher fully stopped before 151 * removing the watched directory (e.g. test teardown) must await this. 152 * Fire-and-forget is still valid where timing doesn't matter. 153 */ 154 export function dispose(): Promise<void> { 155 disposed = true 156 if (mdmPollTimer) { 157 clearInterval(mdmPollTimer) 158 mdmPollTimer = null 159 } 160 for (const timer of pendingDeletions.values()) clearTimeout(timer) 161 pendingDeletions.clear() 162 lastMdmSnapshot = null 163 clearInternalWrites() 164 settingsChanged.clear() 165 const w = watcher 166 watcher = null 167 return w ? w.close() : Promise.resolve() 168 } 169 170 /** 171 * Subscribe to settings changes 172 */ 173 export const subscribe = settingsChanged.subscribe 174 175 /** 176 * Collect settings file paths and their deduplicated parent directories to watch. 177 * Returns all potential settings file paths for watched directories, not just those 178 * that exist at init time, so that newly-created files are also detected. 179 */ 180 async function getWatchTargets(): Promise<{ 181 dirs: string[] 182 settingsFiles: Set<string> 183 dropInDir: string | null 184 }> { 185 // Map from directory to all potential settings files in that directory 186 const dirToSettingsFiles = new Map<string, Set<string>>() 187 const dirsWithExistingFiles = new Set<string>() 188 189 for (const source of SETTING_SOURCES) { 190 // Skip flagSettings - they're provided via CLI and won't change during the session. 191 // Additionally, they may be temp files in $TMPDIR which can contain special files 192 // (FIFOs, sockets) that cause the file watcher to hang or error. 193 // See: https://github.com/anthropics/claude-code/issues/16469 194 if (source === 'flagSettings') { 195 continue 196 } 197 const path = getSettingsFilePathForSource(source) 198 if (!path) { 199 continue 200 } 201 202 const dir = platformPath.dirname(path) 203 204 // Track all potential settings files in each directory 205 if (!dirToSettingsFiles.has(dir)) { 206 dirToSettingsFiles.set(dir, new Set()) 207 } 208 dirToSettingsFiles.get(dir)!.add(path) 209 210 // Check if file exists - only watch directories that have at least one existing file 211 try { 212 const stats = await stat(path) 213 if (stats.isFile()) { 214 dirsWithExistingFiles.add(dir) 215 } 216 } catch { 217 // File doesn't exist, that's fine 218 } 219 } 220 221 // For watched directories, include ALL potential settings file paths 222 // This ensures files created after init are also detected 223 const settingsFiles = new Set<string>() 224 for (const dir of dirsWithExistingFiles) { 225 const filesInDir = dirToSettingsFiles.get(dir) 226 if (filesInDir) { 227 for (const file of filesInDir) { 228 settingsFiles.add(file) 229 } 230 } 231 } 232 233 // Also watch the managed-settings.d/ drop-in directory for policy fragments. 234 // We add it as a separate watched directory so chokidar's depth:0 watches 235 // its immediate children (the .json files). Any .json file inside it maps 236 // to the 'policySettings' source. 237 let dropInDir: string | null = null 238 const managedDropIn = getManagedSettingsDropInDir() 239 try { 240 const stats = await stat(managedDropIn) 241 if (stats.isDirectory()) { 242 dirsWithExistingFiles.add(managedDropIn) 243 dropInDir = managedDropIn 244 } 245 } catch { 246 // Drop-in directory doesn't exist, that's fine 247 } 248 249 return { dirs: [...dirsWithExistingFiles], settingsFiles, dropInDir } 250 } 251 252 function settingSourceToConfigChangeSource( 253 source: SettingSource, 254 ): ConfigChangeSource { 255 switch (source) { 256 case 'userSettings': 257 return 'user_settings' 258 case 'projectSettings': 259 return 'project_settings' 260 case 'localSettings': 261 return 'local_settings' 262 case 'flagSettings': 263 case 'policySettings': 264 return 'policy_settings' 265 } 266 } 267 268 function handleChange(path: string): void { 269 const source = getSourceForPath(path) 270 if (!source) return 271 272 // If a deletion was pending for this path (delete-and-recreate pattern), 273 // cancel the deletion — we'll process this as a change instead. 274 const pendingTimer = pendingDeletions.get(path) 275 if (pendingTimer) { 276 clearTimeout(pendingTimer) 277 pendingDeletions.delete(path) 278 logForDebugging( 279 `Cancelled pending deletion of ${path} — file was recreated`, 280 ) 281 } 282 283 // Check if this was an internal write 284 if (consumeInternalWrite(path, INTERNAL_WRITE_WINDOW_MS)) { 285 return 286 } 287 288 logForDebugging(`Detected change to ${path}`) 289 290 // Fire ConfigChange hook first — if blocked (exit code 2 or decision: 'block'), 291 // skip applying the change to the session 292 void executeConfigChangeHooks( 293 settingSourceToConfigChangeSource(source), 294 path, 295 ).then(results => { 296 if (hasBlockingResult(results)) { 297 logForDebugging(`ConfigChange hook blocked change to ${path}`) 298 return 299 } 300 fanOut(source) 301 }) 302 } 303 304 /** 305 * Handle a file being re-added (e.g. after a delete-and-recreate). Cancels any 306 * pending deletion grace timer and treats the event as a change. 307 */ 308 function handleAdd(path: string): void { 309 const source = getSourceForPath(path) 310 if (!source) return 311 312 // Cancel any pending deletion — the file is back 313 const pendingTimer = pendingDeletions.get(path) 314 if (pendingTimer) { 315 clearTimeout(pendingTimer) 316 pendingDeletions.delete(path) 317 logForDebugging(`Cancelled pending deletion of ${path} — file was re-added`) 318 } 319 320 // Treat as a change (re-read settings) 321 handleChange(path) 322 } 323 324 /** 325 * Handle a file being deleted. Uses a grace period to absorb delete-and-recreate 326 * patterns (e.g. auto-updater, another session starting up). If the file is 327 * recreated within the grace period (detected via 'add' or 'change' event), 328 * the deletion is cancelled and treated as a normal change instead. 329 */ 330 function handleDelete(path: string): void { 331 const source = getSourceForPath(path) 332 if (!source) return 333 334 logForDebugging(`Detected deletion of ${path}`) 335 336 // If there's already a pending deletion for this path, let it run 337 if (pendingDeletions.has(path)) return 338 339 const timer = setTimeout( 340 (p, src) => { 341 pendingDeletions.delete(p) 342 343 // Fire ConfigChange hook first — if blocked, skip applying the deletion 344 void executeConfigChangeHooks( 345 settingSourceToConfigChangeSource(src), 346 p, 347 ).then(results => { 348 if (hasBlockingResult(results)) { 349 logForDebugging(`ConfigChange hook blocked deletion of ${p}`) 350 return 351 } 352 fanOut(src) 353 }) 354 }, 355 testOverrides?.deletionGrace ?? DELETION_GRACE_MS, 356 path, 357 source, 358 ) 359 pendingDeletions.set(path, timer) 360 } 361 362 function getSourceForPath(path: string): SettingSource | undefined { 363 // Normalize path because chokidar uses forward slashes on Windows 364 const normalizedPath = platformPath.normalize(path) 365 366 // Check if the path is inside the managed-settings.d/ drop-in directory 367 const dropInDir = getManagedSettingsDropInDir() 368 if (normalizedPath.startsWith(dropInDir + platformPath.sep)) { 369 return 'policySettings' 370 } 371 372 return SETTING_SOURCES.find( 373 source => getSettingsFilePathForSource(source) === normalizedPath, 374 ) 375 } 376 377 /** 378 * Start polling for MDM settings changes (registry/plist). 379 * Takes a snapshot of current MDM settings and compares on each tick. 380 */ 381 function startMdmPoll(): void { 382 // Capture initial snapshot (includes both admin MDM and user-writable HKCU) 383 const initial = getMdmSettings() 384 const initialHkcu = getHkcuSettings() 385 lastMdmSnapshot = jsonStringify({ 386 mdm: initial.settings, 387 hkcu: initialHkcu.settings, 388 }) 389 390 mdmPollTimer = setInterval(() => { 391 if (disposed) return 392 393 void (async () => { 394 try { 395 const { mdm: current, hkcu: currentHkcu } = await refreshMdmSettings() 396 if (disposed) return 397 398 const currentSnapshot = jsonStringify({ 399 mdm: current.settings, 400 hkcu: currentHkcu.settings, 401 }) 402 403 if (currentSnapshot !== lastMdmSnapshot) { 404 lastMdmSnapshot = currentSnapshot 405 // Update the cache so sync readers pick up new values 406 setMdmSettingsCache(current, currentHkcu) 407 logForDebugging('Detected MDM settings change via poll') 408 fanOut('policySettings') 409 } 410 } catch (error) { 411 logForDebugging(`MDM poll error: ${errorMessage(error)}`) 412 } 413 })() 414 }, testOverrides?.mdmPollInterval ?? MDM_POLL_INTERVAL_MS) 415 416 // Don't let the timer keep the process alive 417 mdmPollTimer.unref() 418 } 419 420 /** 421 * Reset the settings cache, then notify all listeners. 422 * 423 * The cache reset MUST happen here (single producer), not in each listener 424 * (N consumers). Previously, listeners like useSettingsChange and 425 * applySettingsChange reset defensively because some notification paths 426 * (file-watch at :289/340, MDM poll at :385) did not reset before iterating 427 * listeners. That defense caused N-way thrashing when N listeners were 428 * subscribed: each listener cleared the cache, re-read from disk (populating 429 * it), then the next listener cleared it again — N full disk reloads per 430 * notification. Profile showed 5 loadSettingsFromDisk calls in 12ms when 431 * remote managed settings resolved at startup. 432 * 433 * With the reset centralized here, one notification = one disk reload: the 434 * first listener to call getSettingsWithErrors() pays the miss and 435 * repopulates; all subsequent listeners hit the cache. 436 */ 437 function fanOut(source: SettingSource): void { 438 resetSettingsCache() 439 settingsChanged.emit(source) 440 } 441 442 /** 443 * Manually notify listeners of a settings change. 444 * Used for programmatic settings changes (e.g., remote managed settings refresh) 445 * that don't involve file system changes. 446 */ 447 export function notifyChange(source: SettingSource): void { 448 logForDebugging(`Programmatic settings change notification for ${source}`) 449 fanOut(source) 450 } 451 452 /** 453 * Reset internal state for testing purposes only. 454 * This allows re-initialization after dispose(). 455 * Optionally accepts timing overrides for faster test execution. 456 * 457 * Closes the watcher and returns the close promise so preload's afterEach 458 * can await it BEFORE nuking perTestSettingsDir. Without this, chokidar's 459 * pending awaitWriteFinish poll fires on the deleted dir → ENOENT (#25253). 460 */ 461 export function resetForTesting(overrides?: { 462 stabilityThreshold?: number 463 pollInterval?: number 464 mdmPollInterval?: number 465 deletionGrace?: number 466 }): Promise<void> { 467 if (mdmPollTimer) { 468 clearInterval(mdmPollTimer) 469 mdmPollTimer = null 470 } 471 for (const timer of pendingDeletions.values()) clearTimeout(timer) 472 pendingDeletions.clear() 473 lastMdmSnapshot = null 474 initialized = false 475 disposed = false 476 testOverrides = overrides ?? null 477 const w = watcher 478 watcher = null 479 return w ? w.close() : Promise.resolve() 480 } 481 482 export const settingsChangeDetector = { 483 initialize, 484 dispose, 485 subscribe, 486 notifyChange, 487 resetForTesting, 488 }