Download src/utils/settings/changeDetector.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 16.4 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/settings/changeDetector.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/settings/changeDetector.ts
-
curl -L -o changeDetector.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/settings/changeDetector.ts
16.4 kB
| import chokidar, { type FSWatcher } from 'chokidar' | |
| import { stat } from 'fs/promises' | |
| import * as platformPath from 'path' | |
| import { getIsRemoteMode } from '../../bootstrap/state.js' | |
| import { registerCleanup } from '../cleanupRegistry.js' | |
| import { logForDebugging } from '../debug.js' | |
| import { errorMessage } from '../errors.js' | |
| import { | |
| type ConfigChangeSource, | |
| executeConfigChangeHooks, | |
| hasBlockingResult, | |
| } from '../hooks.js' | |
| import { createSignal } from '../signal.js' | |
| import { jsonStringify } from '../slowOperations.js' | |
| import { SETTING_SOURCES, type SettingSource } from './constants.js' | |
| import { clearInternalWrites, consumeInternalWrite } from './internalWrites.js' | |
| import { getManagedSettingsDropInDir } from './managedPath.js' | |
| import { | |
| getHkcuSettings, | |
| getMdmSettings, | |
| refreshMdmSettings, | |
| setMdmSettingsCache, | |
| } from './mdm/settings.js' | |
| import { getSettingsFilePathForSource } from './settings.js' | |
| import { resetSettingsCache } from './settingsCache.js' | |
| /** | |
| * Time in milliseconds to wait for file writes to stabilize before processing. | |
| * This helps avoid processing partial writes or rapid successive changes. | |
| */ | |
| const FILE_STABILITY_THRESHOLD_MS = 1000 | |
| /** | |
| * Polling interval in milliseconds for checking file stability. | |
| * Used by chokidar's awaitWriteFinish option. | |
| * Must be lower than FILE_STABILITY_THRESHOLD_MS. | |
| */ | |
| const FILE_STABILITY_POLL_INTERVAL_MS = 500 | |
| /** | |
| * Time window in milliseconds to consider a file change as internal. | |
| * If a file change occurs within this window after markInternalWrite() is called, | |
| * it's assumed to be from Claude Code itself and won't trigger a notification. | |
| */ | |
| const INTERNAL_WRITE_WINDOW_MS = 5000 | |
| /** | |
| * Poll interval for MDM settings (registry/plist) changes. | |
| * These can't be watched via filesystem events, so we poll periodically. | |
| */ | |
| const MDM_POLL_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes | |
| /** | |
| * Grace period in milliseconds before processing a settings file deletion. | |
| * Handles the common delete-and-recreate pattern during auto-updates or when | |
| * another session starts up. If an `add` or `change` event fires within this | |
| * window (file was recreated), the deletion is cancelled and treated as a change. | |
| * | |
| * Must exceed chokidar's awaitWriteFinish delay (stabilityThreshold + pollInterval) | |
| * so the grace window outlasts the write stability check on the recreated file. | |
| */ | |
| const DELETION_GRACE_MS = | |
| FILE_STABILITY_THRESHOLD_MS + FILE_STABILITY_POLL_INTERVAL_MS + 200 | |
| let watcher: FSWatcher | null = null | |
| let mdmPollTimer: ReturnType<typeof setInterval> | null = null | |
| let lastMdmSnapshot: string | null = null | |
| let initialized = false | |
| let disposed = false | |
| const pendingDeletions = new Map<string, ReturnType<typeof setTimeout>>() | |
| const settingsChanged = createSignal<[source: SettingSource]>() | |
| // Test overrides for timing constants | |
| let testOverrides: { | |
| stabilityThreshold?: number | |
| pollInterval?: number | |
| mdmPollInterval?: number | |
| deletionGrace?: number | |
| } | null = null | |
| /** | |
| * Initialize file watching | |
| */ | |
| export async function initialize(): Promise<void> { | |
| if (getIsRemoteMode()) return | |
| if (initialized || disposed) return | |
| initialized = true | |
| // Start MDM poll for registry/plist changes (independent of filesystem watching) | |
| startMdmPoll() | |
| // Register cleanup to properly dispose during graceful shutdown | |
| registerCleanup(dispose) | |
| const { dirs, settingsFiles, dropInDir } = await getWatchTargets() | |
| if (disposed) return // dispose() ran during the await | |
| if (dirs.length === 0) return | |
| logForDebugging( | |
| `Watching for changes in setting files ${[...settingsFiles].join(', ')}...${dropInDir ? ` and drop-in directory ${dropInDir}` : ''}`, | |
| ) | |
| watcher = chokidar.watch(dirs, { | |
| persistent: true, | |
| ignoreInitial: true, | |
| depth: 0, // Only watch immediate children, not subdirectories | |
| awaitWriteFinish: { | |
| stabilityThreshold: | |
| testOverrides?.stabilityThreshold ?? FILE_STABILITY_THRESHOLD_MS, | |
| pollInterval: | |
| testOverrides?.pollInterval ?? FILE_STABILITY_POLL_INTERVAL_MS, | |
| }, | |
| ignored: (path, stats) => { | |
| // Ignore special file types (sockets, FIFOs, devices) - they cannot be watched | |
| // and will error with EOPNOTSUPP on macOS. | |
| if (stats && !stats.isFile() && !stats.isDirectory()) return true | |
| // Ignore .git directories | |
| if (path.split(platformPath.sep).some(dir => dir === '.git')) return true | |
| // Allow directories (chokidar needs them for directory-level watching) | |
| // and paths without stats (chokidar's initial check before stat) | |
| if (!stats || stats.isDirectory()) return false | |
| // Only watch known settings files, ignore everything else in the directory | |
| // Note: chokidar normalizes paths to forward slashes on Windows, so we | |
| // normalize back to native format for comparison | |
| const normalized = platformPath.normalize(path) | |
| if (settingsFiles.has(normalized)) return false | |
| // Also accept .json files inside the managed-settings.d/ drop-in directory | |
| if ( | |
| dropInDir && | |
| normalized.startsWith(dropInDir + platformPath.sep) && | |
| normalized.endsWith('.json') | |
| ) { | |
| return false | |
| } | |
| return true | |
| }, | |
| // Additional options for stability | |
| ignorePermissionErrors: true, | |
| usePolling: false, // Use native file system events | |
| atomic: true, // Handle atomic writes better | |
| }) | |
| watcher.on('change', handleChange) | |
| watcher.on('unlink', handleDelete) | |
| watcher.on('add', handleAdd) | |
| } | |
| /** | |
| * Clean up file watcher. Returns a promise that resolves when chokidar's | |
| * close() settles — callers that need the watcher fully stopped before | |
| * removing the watched directory (e.g. test teardown) must await this. | |
| * Fire-and-forget is still valid where timing doesn't matter. | |
| */ | |
| export function dispose(): Promise<void> { | |
| disposed = true | |
| if (mdmPollTimer) { | |
| clearInterval(mdmPollTimer) | |
| mdmPollTimer = null | |
| } | |
| for (const timer of pendingDeletions.values()) clearTimeout(timer) | |
| pendingDeletions.clear() | |
| lastMdmSnapshot = null | |
| clearInternalWrites() | |
| settingsChanged.clear() | |
| const w = watcher | |
| watcher = null | |
| return w ? w.close() : Promise.resolve() | |
| } | |
| /** | |
| * Subscribe to settings changes | |
| */ | |
| export const subscribe = settingsChanged.subscribe | |
| /** | |
| * Collect settings file paths and their deduplicated parent directories to watch. | |
| * Returns all potential settings file paths for watched directories, not just those | |
| * that exist at init time, so that newly-created files are also detected. | |
| */ | |
| async function getWatchTargets(): Promise<{ | |
| dirs: string[] | |
| settingsFiles: Set<string> | |
| dropInDir: string | null | |
| }> { | |
| // Map from directory to all potential settings files in that directory | |
| const dirToSettingsFiles = new Map<string, Set<string>>() | |
| const dirsWithExistingFiles = new Set<string>() | |
| for (const source of SETTING_SOURCES) { | |
| // Skip flagSettings - they're provided via CLI and won't change during the session. | |
| // Additionally, they may be temp files in $TMPDIR which can contain special files | |
| // (FIFOs, sockets) that cause the file watcher to hang or error. | |
| // See: https://github.com/anthropics/claude-code/issues/16469 | |
| if (source === 'flagSettings') { | |
| continue | |
| } | |
| const path = getSettingsFilePathForSource(source) | |
| if (!path) { | |
| continue | |
| } | |
| const dir = platformPath.dirname(path) | |
| // Track all potential settings files in each directory | |
| if (!dirToSettingsFiles.has(dir)) { | |
| dirToSettingsFiles.set(dir, new Set()) | |
| } | |
| dirToSettingsFiles.get(dir)!.add(path) | |
| // Check if file exists - only watch directories that have at least one existing file | |
| try { | |
| const stats = await stat(path) | |
| if (stats.isFile()) { | |
| dirsWithExistingFiles.add(dir) | |
| } | |
| } catch { | |
| // File doesn't exist, that's fine | |
| } | |
| } | |
| // For watched directories, include ALL potential settings file paths | |
| // This ensures files created after init are also detected | |
| const settingsFiles = new Set<string>() | |
| for (const dir of dirsWithExistingFiles) { | |
| const filesInDir = dirToSettingsFiles.get(dir) | |
| if (filesInDir) { | |
| for (const file of filesInDir) { | |
| settingsFiles.add(file) | |
| } | |
| } | |
| } | |
| // Also watch the managed-settings.d/ drop-in directory for policy fragments. | |
| // We add it as a separate watched directory so chokidar's depth:0 watches | |
| // its immediate children (the .json files). Any .json file inside it maps | |
| // to the 'policySettings' source. | |
| let dropInDir: string | null = null | |
| const managedDropIn = getManagedSettingsDropInDir() | |
| try { | |
| const stats = await stat(managedDropIn) | |
| if (stats.isDirectory()) { | |
| dirsWithExistingFiles.add(managedDropIn) | |
| dropInDir = managedDropIn | |
| } | |
| } catch { | |
| // Drop-in directory doesn't exist, that's fine | |
| } | |
| return { dirs: [...dirsWithExistingFiles], settingsFiles, dropInDir } | |
| } | |
| function settingSourceToConfigChangeSource( | |
| source: SettingSource, | |
| ): ConfigChangeSource { | |
| switch (source) { | |
| case 'userSettings': | |
| return 'user_settings' | |
| case 'projectSettings': | |
| return 'project_settings' | |
| case 'localSettings': | |
| return 'local_settings' | |
| case 'flagSettings': | |
| case 'policySettings': | |
| return 'policy_settings' | |
| } | |
| } | |
| function handleChange(path: string): void { | |
| const source = getSourceForPath(path) | |
| if (!source) return | |
| // If a deletion was pending for this path (delete-and-recreate pattern), | |
| // cancel the deletion — we'll process this as a change instead. | |
| const pendingTimer = pendingDeletions.get(path) | |
| if (pendingTimer) { | |
| clearTimeout(pendingTimer) | |
| pendingDeletions.delete(path) | |
| logForDebugging( | |
| `Cancelled pending deletion of ${path} — file was recreated`, | |
| ) | |
| } | |
| // Check if this was an internal write | |
| if (consumeInternalWrite(path, INTERNAL_WRITE_WINDOW_MS)) { | |
| return | |
| } | |
| logForDebugging(`Detected change to ${path}`) | |
| // Fire ConfigChange hook first — if blocked (exit code 2 or decision: 'block'), | |
| // skip applying the change to the session | |
| void executeConfigChangeHooks( | |
| settingSourceToConfigChangeSource(source), | |
| path, | |
| ).then(results => { | |
| if (hasBlockingResult(results)) { | |
| logForDebugging(`ConfigChange hook blocked change to ${path}`) | |
| return | |
| } | |
| fanOut(source) | |
| }) | |
| } | |
| /** | |
| * Handle a file being re-added (e.g. after a delete-and-recreate). Cancels any | |
| * pending deletion grace timer and treats the event as a change. | |
| */ | |
| function handleAdd(path: string): void { | |
| const source = getSourceForPath(path) | |
| if (!source) return | |
| // Cancel any pending deletion — the file is back | |
| const pendingTimer = pendingDeletions.get(path) | |
| if (pendingTimer) { | |
| clearTimeout(pendingTimer) | |
| pendingDeletions.delete(path) | |
| logForDebugging(`Cancelled pending deletion of ${path} — file was re-added`) | |
| } | |
| // Treat as a change (re-read settings) | |
| handleChange(path) | |
| } | |
| /** | |
| * Handle a file being deleted. Uses a grace period to absorb delete-and-recreate | |
| * patterns (e.g. auto-updater, another session starting up). If the file is | |
| * recreated within the grace period (detected via 'add' or 'change' event), | |
| * the deletion is cancelled and treated as a normal change instead. | |
| */ | |
| function handleDelete(path: string): void { | |
| const source = getSourceForPath(path) | |
| if (!source) return | |
| logForDebugging(`Detected deletion of ${path}`) | |
| // If there's already a pending deletion for this path, let it run | |
| if (pendingDeletions.has(path)) return | |
| const timer = setTimeout( | |
| (p, src) => { | |
| pendingDeletions.delete(p) | |
| // Fire ConfigChange hook first — if blocked, skip applying the deletion | |
| void executeConfigChangeHooks( | |
| settingSourceToConfigChangeSource(src), | |
| p, | |
| ).then(results => { | |
| if (hasBlockingResult(results)) { | |
| logForDebugging(`ConfigChange hook blocked deletion of ${p}`) | |
| return | |
| } | |
| fanOut(src) | |
| }) | |
| }, | |
| testOverrides?.deletionGrace ?? DELETION_GRACE_MS, | |
| path, | |
| source, | |
| ) | |
| pendingDeletions.set(path, timer) | |
| } | |
| function getSourceForPath(path: string): SettingSource | undefined { | |
| // Normalize path because chokidar uses forward slashes on Windows | |
| const normalizedPath = platformPath.normalize(path) | |
| // Check if the path is inside the managed-settings.d/ drop-in directory | |
| const dropInDir = getManagedSettingsDropInDir() | |
| if (normalizedPath.startsWith(dropInDir + platformPath.sep)) { | |
| return 'policySettings' | |
| } | |
| return SETTING_SOURCES.find( | |
| source => getSettingsFilePathForSource(source) === normalizedPath, | |
| ) | |
| } | |
| /** | |
| * Start polling for MDM settings changes (registry/plist). | |
| * Takes a snapshot of current MDM settings and compares on each tick. | |
| */ | |
| function startMdmPoll(): void { | |
| // Capture initial snapshot (includes both admin MDM and user-writable HKCU) | |
| const initial = getMdmSettings() | |
| const initialHkcu = getHkcuSettings() | |
| lastMdmSnapshot = jsonStringify({ | |
| mdm: initial.settings, | |
| hkcu: initialHkcu.settings, | |
| }) | |
| mdmPollTimer = setInterval(() => { | |
| if (disposed) return | |
| void (async () => { | |
| try { | |
| const { mdm: current, hkcu: currentHkcu } = await refreshMdmSettings() | |
| if (disposed) return | |
| const currentSnapshot = jsonStringify({ | |
| mdm: current.settings, | |
| hkcu: currentHkcu.settings, | |
| }) | |
| if (currentSnapshot !== lastMdmSnapshot) { | |
| lastMdmSnapshot = currentSnapshot | |
| // Update the cache so sync readers pick up new values | |
| setMdmSettingsCache(current, currentHkcu) | |
| logForDebugging('Detected MDM settings change via poll') | |
| fanOut('policySettings') | |
| } | |
| } catch (error) { | |
| logForDebugging(`MDM poll error: ${errorMessage(error)}`) | |
| } | |
| })() | |
| }, testOverrides?.mdmPollInterval ?? MDM_POLL_INTERVAL_MS) | |
| // Don't let the timer keep the process alive | |
| mdmPollTimer.unref() | |
| } | |
| /** | |
| * Reset the settings cache, then notify all listeners. | |
| * | |
| * The cache reset MUST happen here (single producer), not in each listener | |
| * (N consumers). Previously, listeners like useSettingsChange and | |
| * applySettingsChange reset defensively because some notification paths | |
| * (file-watch at :289/340, MDM poll at :385) did not reset before iterating | |
| * listeners. That defense caused N-way thrashing when N listeners were | |
| * subscribed: each listener cleared the cache, re-read from disk (populating | |
| * it), then the next listener cleared it again — N full disk reloads per | |
| * notification. Profile showed 5 loadSettingsFromDisk calls in 12ms when | |
| * remote managed settings resolved at startup. | |
| * | |
| * With the reset centralized here, one notification = one disk reload: the | |
| * first listener to call getSettingsWithErrors() pays the miss and | |
| * repopulates; all subsequent listeners hit the cache. | |
| */ | |
| function fanOut(source: SettingSource): void { | |
| resetSettingsCache() | |
| settingsChanged.emit(source) | |
| } | |
| /** | |
| * Manually notify listeners of a settings change. | |
| * Used for programmatic settings changes (e.g., remote managed settings refresh) | |
| * that don't involve file system changes. | |
| */ | |
| export function notifyChange(source: SettingSource): void { | |
| logForDebugging(`Programmatic settings change notification for ${source}`) | |
| fanOut(source) | |
| } | |
| /** | |
| * Reset internal state for testing purposes only. | |
| * This allows re-initialization after dispose(). | |
| * Optionally accepts timing overrides for faster test execution. | |
| * | |
| * Closes the watcher and returns the close promise so preload's afterEach | |
| * can await it BEFORE nuking perTestSettingsDir. Without this, chokidar's | |
| * pending awaitWriteFinish poll fires on the deleted dir → ENOENT (#25253). | |
| */ | |
| export function resetForTesting(overrides?: { | |
| stabilityThreshold?: number | |
| pollInterval?: number | |
| mdmPollInterval?: number | |
| deletionGrace?: number | |
| }): Promise<void> { | |
| if (mdmPollTimer) { | |
| clearInterval(mdmPollTimer) | |
| mdmPollTimer = null | |
| } | |
| for (const timer of pendingDeletions.values()) clearTimeout(timer) | |
| pendingDeletions.clear() | |
| lastMdmSnapshot = null | |
| initialized = false | |
| disposed = false | |
| testOverrides = overrides ?? null | |
| const w = watcher | |
| watcher = null | |
| return w ? w.close() : Promise.resolve() | |
| } | |
| export const settingsChangeDetector = { | |
| initialize, | |
| dispose, | |
| subscribe, | |
| notifyChange, | |
| resetForTesting, | |
| } | |