forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathchangeDetector.ts
More file actions
488 lines (437 loc) · 16 KB
/
Copy pathchangeDetector.ts
File metadata and controls
488 lines (437 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
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,
}