/ utils / settings / changeDetector.ts
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  }