diff --git a/components/OptionsWatcher.ts b/components/OptionsWatcher.ts index 39870f4cd..74e3b0790 100644 --- a/components/OptionsWatcher.ts +++ b/components/OptionsWatcher.ts @@ -9,6 +9,7 @@ import { DEFAULT_CONFIG } from './DEFAULT_CONFIG.ts'; import { cloneDeep } from 'lodash'; import { POLLING_FALLBACK_OPTIONS, isWatcherExhaustionError, warnWatcherFallback } from '../utility/watcherFallback.ts'; import { overlayRootEnvConfig, isRootConfigFilename } from '../config/harperConfigEnvVars.ts'; +import { readConfigFileSync } from '../config/readConfigFileSync.ts'; export interface Config { [key: string]: ConfigValue; @@ -88,6 +89,7 @@ export class OptionsWatcher extends EventEmitter { #scopedConfig?: ConfigValue; #rootConfig?: Config; #isRootConfig: boolean; + #synchronousRead: boolean; #name: string; #logger: Logger; #usingPolling: boolean; @@ -103,7 +105,9 @@ export class OptionsWatcher extends EventEmitter { // Root-config watchers must see runtime env config (HARPER_SET_CONFIG et al.) // even when it hasn't been flushed to disk yet — see #handleChange (#1618). // Application scopes watch their own config.yaml and are never overlaid. - this.#isRootConfig = isRootConfig ?? isRootConfigFilename(filePath); + const rootConfigFile = isRootConfigFilename(filePath); + this.#isRootConfig = isRootConfig ?? rootConfigFile; + this.#synchronousRead = this.#isRootConfig || rootConfigFile; this.#logger = logger || loggerWithTag(name); this.#usingPolling = false; this.#closed = false; @@ -126,71 +130,91 @@ export class OptionsWatcher extends EventEmitter { } #handleChange() { - const read: Promise = readFile(this.#filePath, 'utf-8') - .then((contents) => { - let parsed = yaml.parse(contents); - // The on-disk root config is not guaranteed to include runtime env config at - // boot: the file flush races component loading, so a scope's boot-time reads - // (e.g. an `enabled` gate in handleApplication) could observe pre-env values - // the componentLoader itself never saw. Ask the config layer to overlay env - // config onto EVERY root-config read so scope.options matches the resolved - // view (#1618). Non-root scopes and the no-env-vars case are untouched - // (overlayRootEnvConfig is a no-op there). - if (this.#isRootConfig) parsed = overlayRootEnvConfig(parsed); - this.#rootConfig = parsed && typeof parsed === 'object' ? parsed : undefined; - // If the extension is in the config file - if (this.#rootConfig && this.#name in this.#rootConfig) { - // If a config object does not exist - if (!this.#scopedConfig) { - // set it - this.#scopedConfig = this.#rootConfig[this.#name]; - // and emit a ready event - this.emit('ready', this.#scopedConfig); - } else { - // Otherwise, merge the new config with the old config - this.#merge(this.#rootConfig[this.#name], this.#scopedConfig); - } - } else { - // Otherwise, if the extension is not in the config file - // This means the plugin was removed from the config file - if (this.#scopedConfig) { - // and a config exists, remove it - this.#scopedConfig = undefined; - this.emit('remove'); - } - // Otherwise do nothing - the user may add the config back in later - } - }) - .catch((error) => { - // If the config file does not exist - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - // A readFile ENOENT here is the install window (file not written yet) or a - // transient read race — NOT a real deletion, which chokidar routes to - // `#handleUnlink`. Env config is file-independent, so when it provides this - // scope the missing file must not discard it (#1618). When it does not, fall - // through to the original ENOENT handling with #rootConfig untouched, so a - // first boot still emits `ready` (not `remove`, which nothing consumes at - // boot → `ready` would hang forever). - if (this.#applyEnvOnlyConfig()) return; - // And a config already exists, reset it to the default - if (this.#rootConfig) { - this.#resetConfig(); - this.emit('remove'); - } else { - // Otherwise, if no config exists, then just set to default and emit ready - this.#resetConfig(); - this.emit('ready'); - } - return; - } + if (this.#synchronousRead) { + let contents: string; + try { + contents = readConfigFileSync(this.#filePath); + } catch (error) { + this.#handleReadError(error); + return; + } + try { + this.#applyContents(contents); + } catch (error) { this.emit('error', error); - }) + } + return; + } + + const read: Promise = readFile(this.#filePath, 'utf-8') + .then((contents) => this.#applyContents(contents)) + .catch((error) => this.#handleReadError(error)) .finally(() => { this.#pendingReads.delete(read); }); this.#pendingReads.add(read); } + #applyContents(contents: string) { + let parsed = yaml.parse(contents); + // The on-disk root config is not guaranteed to include runtime env config at + // boot: the file flush races component loading, so a scope's boot-time reads + // (e.g. an `enabled` gate in handleApplication) could observe pre-env values + // the componentLoader itself never saw. Ask the config layer to overlay env + // config onto EVERY root-config read so scope.options matches the resolved + // view (#1618). Non-root scopes and the no-env-vars case are untouched + // (overlayRootEnvConfig is a no-op there). + if (this.#isRootConfig) parsed = overlayRootEnvConfig(parsed); + this.#rootConfig = parsed && typeof parsed === 'object' ? parsed : undefined; + // If the extension is in the config file + if (this.#rootConfig && this.#name in this.#rootConfig) { + // If a config object does not exist + if (!this.#scopedConfig) { + // set it + this.#scopedConfig = this.#rootConfig[this.#name]; + // and emit a ready event + this.emit('ready', this.#scopedConfig); + } else { + // Otherwise, merge the new config with the old config + this.#merge(this.#rootConfig[this.#name], this.#scopedConfig); + } + } else { + // Otherwise, if the extension is not in the config file + // This means the plugin was removed from the config file + if (this.#scopedConfig) { + // and a config exists, remove it + this.#scopedConfig = undefined; + this.emit('remove'); + } + // Otherwise do nothing - the user may add the config back in later + } + } + + #handleReadError(error: unknown) { + // If the config file does not exist + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + // A readFile ENOENT here is the install window (file not written yet) or a + // transient read race — NOT a real deletion, which chokidar routes to + // `#handleUnlink`. Env config is file-independent, so when it provides this + // scope the missing file must not discard it (#1618). When it does not, fall + // through to the original ENOENT handling with #rootConfig untouched, so a + // first boot still emits `ready` (not `remove`, which nothing consumes at + // boot → `ready` would hang forever). + if (this.#applyEnvOnlyConfig()) return; + // And a config already exists, reset it to the default + if (this.#rootConfig) { + this.#resetConfig(); + this.emit('remove'); + } else { + // Otherwise, if no config exists, then just set to default and emit ready + this.#resetConfig(); + this.emit('ready'); + } + return; + } + this.emit('error', error); + } + #handleError(error: unknown) { if (isWatcherExhaustionError(error)) { // Swallow every exhaustion error — chokidar can emit several before the @@ -404,6 +428,11 @@ export class OptionsWatcher extends EventEmitter { return this.#openCount; } + // Test-only: process the current file contents without waiting for a watcher event. + _refreshForTests(): void { + this.#handleChange(); + } + /** * Closes the underlying file watcher and drains any pending config-file reads. * Emits `close` synchronously, removes all listeners, then returns a Promise that diff --git a/config/RootConfigWatcher.ts b/config/RootConfigWatcher.ts index 0812bdc41..adbe89e60 100644 --- a/config/RootConfigWatcher.ts +++ b/config/RootConfigWatcher.ts @@ -1,6 +1,6 @@ import chokidar, { FSWatcher } from 'chokidar'; -import { readFile } from 'node:fs/promises'; import { getConfigFilePath } from './configUtils.ts'; +import { readConfigFileSync } from './readConfigFileSync.ts'; import { EventEmitter, once } from 'node:events'; import { parse } from 'yaml'; import { POLLING_FALLBACK_OPTIONS, isWatcherExhaustionError, warnWatcherFallback } from '../utility/watcherFallback.ts'; @@ -79,23 +79,25 @@ export class RootConfigWatcher extends EventEmitter { } handleChange() { - readFile(this.#configFilePath, 'utf-8') - .then((data) => { - if (!data) return; + let data: string; + try { + data = readConfigFileSync(this.#configFilePath); + } catch { + return; + } + if (!data) return; - const config = parse(data); + try { + const config = parse(data); - if (!this.#config) { - this.#config = config; - this.emit('ready', this.#config); - return; - } + if (!this.#config) { + this.#config = config; + this.emit('ready', this.#config); + return; + } - this.emit('change', (this.#config = config)); - }) - .catch((_error) => { - // if yaml parse error ignore? - }); + this.emit('change', (this.#config = config)); + } catch {} } close() { diff --git a/config/configUtils.ts b/config/configUtils.ts index 846d1918e..f6d83e3c8 100644 --- a/config/configUtils.ts +++ b/config/configUtils.ts @@ -85,57 +85,76 @@ export function getConfigPath(param: string) { return path.resolve(rootPath, value); } -// Write atomically via temp file + rename so readers don't observe a truncated/empty file. -// Temp path includes randomness so two worker threads in the same process (same pid) writing -// in the same millisecond can't collide on the temp name and then race the rename. -// +// Write atomically via a randomized temp file + rename so readers do not observe partial content +// and concurrent workers, which share process.pid, do not collide on a temp path. // Windows has no POSIX-style "replace an open file" semantics: rename() fails with -// EPERM/EACCES if another thread/process has the destination momentarily open for read. -// Every worker thread runs its own RootConfigWatcher (chokidar), so a write on one thread -// routinely races a hot-reload read on another; Windows Defender / AV real-time scanning can -// hold a similar transient handle. Retry with exponential backoff to ride out the race - -// callers are synchronous, so the wait is a synchronous sleep rather than an async one. -// The budget must outlast a single AV real-time scan pass (seconds, not hundreds of ms): -// the previous ~910ms budget was exhausted twice in a row by the same test on a CI runner -// (harper#2036), so the worst case is now ~3.6s. -const RENAME_RETRY_MAX_ATTEMPTS = 12; +// EPERM/EACCES/EBUSY while another worker or AV holds the destination open. Root config watchers use +// readConfigFileSync so this blocking retry cannot wait on a read owned by its own worker. +const RENAME_RETRY_BUDGET_MS = process.platform === 'win32' ? 10_000 : 3_630; +const RENAME_RETRY_MAX_ATTEMPTS = 25; const RENAME_RETRY_INITIAL_DELAY_MS = 10; const RENAME_RETRY_MAX_DELAY_MS = 500; -// Never notified; exists only so Atomics.wait can time out (a synchronous, CPU-idle sleep). +// Never notified; Atomics.wait uses this only as a CPU-idle synchronous sleep. const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4)); +type AtomicWriteOptions = { + retryBudgetMs?: number; + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; +}; + export function atomicWriteFile( filePath, content, { + retryBudgetMs = RENAME_RETRY_BUDGET_MS, maxRetries = RENAME_RETRY_MAX_ATTEMPTS, initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS, maxDelayMs = RENAME_RETRY_MAX_DELAY_MS, - } = {} + }: AtomicWriteOptions = {} ) { + const invalidOption = + !Number.isFinite(retryBudgetMs) || + retryBudgetMs < 0 || + (!Number.isFinite(maxRetries) && maxRetries !== Infinity) || + maxRetries < 0 || + !Number.isFinite(initialDelayMs) || + initialDelayMs < 0 || + !Number.isFinite(maxDelayMs) || + maxDelayMs < 0; + if (invalidOption) { + throw new RangeError('rename retry options must be non-negative numbers'); + } const tempPath = `${filePath}.${process.pid}.${threadId}.${randomBytes(4).toString('hex')}.tmp`; fs.writeFileSync(tempPath, content); let retries = maxRetries; let delayMs = initialDelayMs; + let retryDeadline; + let finalAttempt = false; while (true) { try { fs.renameSync(tempPath, filePath); break; } catch (err) { - if (retries > 0 && (err.code === 'EPERM' || err.code === 'EACCES')) { + if (!finalAttempt && retries > 0 && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) { retries--; - // Sleep synchronously (all call sites are sync) to allow the holder to close the - // file. Atomics.wait yields the thread to the OS instead of spinning the CPU, - // which is what makes a multi-second worst-case budget affordable. - if (delayMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, delayMs); - delayMs = Math.min(delayMs * 2, maxDelayMs); - continue; + if (retryDeadline === undefined) { + retryDeadline = performance.now() + retryBudgetMs; + } + const remainingBudgetMs = retryDeadline - performance.now(); + if (remainingBudgetMs > 0) { + const sleepMs = Math.min(delayMs, remainingBudgetMs); + finalAttempt = sleepMs === remainingBudgetMs; + if (sleepMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, sleepMs); + delayMs = Math.min(Math.max(delayMs * 2, RENAME_RETRY_INITIAL_DELAY_MS), maxDelayMs); + continue; + } } - // if it fails we should clean up the tmp file try { fs.unlinkSync(tempPath); } catch { - // ignore cleanup errors + // Ignore cleanup errors and preserve the original rename failure. } throw err; } diff --git a/config/readConfigFileSync.ts b/config/readConfigFileSync.ts new file mode 100644 index 000000000..ab560d37a --- /dev/null +++ b/config/readConfigFileSync.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; + +const READ_RETRY_BUDGET_MS = 500; +const READ_RETRY_INITIAL_DELAY_MS = 10; +const READ_RETRY_MAX_DELAY_MS = 100; +const readRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4)); + +export function readConfigFileSync(filePath: string): string { + let delayMs = READ_RETRY_INITIAL_DELAY_MS; + let retryDeadline; + let finalAttempt = false; + while (true) { + try { + return readFileSync(filePath, 'utf-8'); + } catch (error) { + if ( + !finalAttempt && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) { + retryDeadline ??= performance.now() + READ_RETRY_BUDGET_MS; + const remainingBudgetMs = retryDeadline - performance.now(); + if (remainingBudgetMs > 0) { + const sleepMs = Math.min(delayMs, remainingBudgetMs); + finalAttempt = sleepMs === remainingBudgetMs; + Atomics.wait(readRetrySleepBuffer, 0, 0, sleepMs); + delayMs = Math.min(delayMs * 2, READ_RETRY_MAX_DELAY_MS); + continue; + } + } + throw error; + } + } +} diff --git a/unitTests/components/OptionsWatcher.test.js b/unitTests/components/OptionsWatcher.test.js index 0155c7061..4539aded6 100644 --- a/unitTests/components/OptionsWatcher.test.js +++ b/unitTests/components/OptionsWatcher.test.js @@ -175,6 +175,49 @@ describe('OptionsWatcher', () => { await teardown({ fixture, options }); }); + it('finishes root config reads before its change callback returns', async () => { + const fixture = mkdtempSync(getFixtureName()); + const configFilePath = join(fixture, 'harper-config.yaml'); + writeFileSync(configFilePath, stringify(CONFIG), 'utf-8'); + const options = new OptionsWatcher(NAME, configFilePath, undefined, false); + await options.ready; + + const updated = { ...CONFIG, [NAME]: { ...OPTIONS, str: 'updated' } }; + writeFileSync(configFilePath, stringify(updated), 'utf-8'); + options._refreshForTests(); + + assert.equal(options.get(['str']), 'updated', 'root watcher must not leave a same-thread read in flight'); + await teardown({ fixture, options }); + }); + + it('does not treat an ENOENT from a change listener as a missing config file', async () => { + const fixture = mkdtempSync(getFixtureName()); + const configFilePath = join(fixture, 'harper-config.yaml'); + writeFileSync(configFilePath, stringify(CONFIG), 'utf-8'); + const options = new OptionsWatcher(NAME, configFilePath, undefined, false); + await options.ready; + + const listenerError = Object.assign(new Error('listener file missing'), { code: 'ENOENT' }); + let emittedError; + options.on('error', (error) => { + emittedError = error; + }); + let removed = false; + options.on('remove', () => { + removed = true; + }); + options.on('change', () => { + throw listenerError; + }); + + writeFileSync(configFilePath, stringify({ ...CONFIG, [NAME]: { ...OPTIONS, str: 'updated' } }), 'utf-8'); + options._refreshForTests(); + + assert.equal(emittedError, listenerError); + assert.equal(removed, false, 'listener errors must not reset the scope'); + await teardown({ fixture, options }); + }); + it('should continue to watch if file is removed and recreated', async () => { // Detecting file removal and recreation can take some time so increase the timeout this.timeout = 3000; diff --git a/unitTests/config/configUtils.test.js b/unitTests/config/configUtils.test.js index d7b70e0ce..14aea6bcc 100644 --- a/unitTests/config/configUtils.test.js +++ b/unitTests/config/configUtils.test.js @@ -201,13 +201,9 @@ describe('Test configUtils module', () => { expect(stragglers).to.be.empty; }); - it('generates a unique temp path even when pid and timestamp are identical', () => { - // Worker threads share process.pid, and worker arrivals cluster within the - // same millisecond — a pid+timestamp scheme would collide. Pin Date.now() - // so this test fails if uniqueness ever stops depending on randomness. + it('generates a unique temp path for each write', () => { const writeStub = sandbox.stub(fs, 'writeFileSync'); const renameStub = sandbox.stub(fs, 'renameSync'); - const dateStub = sandbox.stub(Date, 'now').returns(1234567890); try { const tempPaths = new Set(); for (let i = 0; i < 100; i++) { @@ -218,13 +214,10 @@ describe('Test configUtils module', () => { } finally { writeStub.restore(); renameStub.restore(); - dateStub.restore(); } }); - it('retries the rename on a transient Windows EPERM/EACCES and succeeds once the holder releases the file', () => { - // Simulates a sibling worker's RootConfigWatcher (or AV) briefly holding the - // destination open for read: renameSync fails a couple of times, then succeeds. + it('retries transient Windows rename errors and succeeds once the holder releases the file', () => { const renameStub = sandbox.stub(fs, 'renameSync'); const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' }); const eaccesError = Object.assign(new Error('EACCES: permission denied, rename'), { code: 'EACCES' }); @@ -232,26 +225,60 @@ describe('Test configUtils module', () => { renameStub.onCall(1).throws(eaccesError); renameStub.onCall(2).returns(undefined); try { - atomicWriteFile(ATOMIC_TEST_PATH, 'content'); + atomicWriteFile(ATOMIC_TEST_PATH, 'content', { + retryBudgetMs: 60_000, + initialDelayMs: 0, + }); expect(renameStub.callCount).to.equal(3); } finally { renameStub.restore(); } }); - it('gives up after exhausting retries on a persistent EPERM, cleans up the temp file, and rethrows', () => { + it('preserves the explicit retry-count limit', () => { const renameStub = sandbox.stub(fs, 'renameSync'); const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' }); renameStub.throws(epermError); try { - // Use the default retry count (unspecified maxRetries) so this exercises the real - // production budget, but override the delay to ~0 so the backoff doesn't burn real - // wall-clock time (default backoff would take ~3.6s for a persistent failure). - expect(() => atomicWriteFile(ATOMIC_TEST_PATH, 'content', { initialDelayMs: 0, maxDelayMs: 0 })).to.throw( - epermError - ); - // 1 initial attempt + 12 retries (the production default maxRetries) - expect(renameStub.callCount).to.equal(13); + expect(() => + atomicWriteFile(ATOMIC_TEST_PATH, 'content', { + retryBudgetMs: 60_000, + maxRetries: 2, + initialDelayMs: 0, + }) + ).to.throw(epermError); + expect(renameStub.callCount).to.equal(3); + } finally { + renameStub.restore(); + } + }); + + it('rejects invalid retry options', () => { + expect(() => atomicWriteFile(ATOMIC_TEST_PATH, 'content', { initialDelayMs: Number.NaN })).to.throw( + RangeError, + 'rename retry options must be non-negative numbers' + ); + expect(() => atomicWriteFile(ATOMIC_TEST_PATH, 'content', { retryBudgetMs: Infinity })).to.throw( + RangeError, + 'rename retry options must be non-negative numbers' + ); + }); + + it('gives up after the retry budget expires on a persistent EPERM, cleans up the temp file, and rethrows', () => { + const renameStub = sandbox.stub(fs, 'renameSync'); + const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' }); + renameStub.throws(epermError); + try { + const startedAt = performance.now(); + expect(() => + atomicWriteFile(ATOMIC_TEST_PATH, 'content', { + retryBudgetMs: 50, + }) + ).to.throw(epermError); + const elapsedMs = performance.now() - startedAt; + expect(renameStub.callCount).to.be.at.least(2); + expect(elapsedMs).to.be.at.least(50); + expect(elapsedMs).to.be.below(1_000); const stragglers = fs .readdirSync(ATOMIC_TEST_DIR) .filter((e) => e.startsWith('atomic-write-test.yaml.') && e.endsWith('.tmp')); @@ -272,6 +299,19 @@ describe('Test configUtils module', () => { renameStub.restore(); } }); + + it('retries EBUSY', () => { + const renameStub = sandbox.stub(fs, 'renameSync'); + const ebusyError = Object.assign(new Error('EBUSY: resource busy or locked, rename'), { code: 'EBUSY' }); + renameStub.onFirstCall().throws(ebusyError); + renameStub.onSecondCall().returns(undefined); + try { + atomicWriteFile(ATOMIC_TEST_PATH, 'content', { initialDelayMs: 0 }); + expect(renameStub.callCount).to.equal(2); + } finally { + renameStub.restore(); + } + }); }); describe('Test ensureConfigKeysPresent function', () => { diff --git a/unitTests/config/rootConfigWatcher.test.js b/unitTests/config/rootConfigWatcher.test.js index ce3055e6a..49429b0ef 100644 --- a/unitTests/config/rootConfigWatcher.test.js +++ b/unitTests/config/rootConfigWatcher.test.js @@ -38,9 +38,10 @@ describe('RootConfigWatcher', () => { expected.foo = 'baz'; + const change = once(configWatcher, 'change'); await writeFile(this.configFilePath, stringify(expected)); - const [updated] = await once(configWatcher, 'change'); + const [updated] = await change; assert.deepEqual(updated, expected, 'RootConfigWatcher should emit a change event with the updated config'); @@ -68,14 +69,29 @@ describe('RootConfigWatcher', () => { const updated = { foo: 'baz' }; const tempPath = `${this.configFilePath}.${process.pid}.${Date.now()}.tmp`; writeFileSync(tempPath, stringify(updated)); + const change = once(configWatcher, 'change'); renameSync(tempPath, this.configFilePath); - const [changeValue] = await once(configWatcher, 'change'); + const [changeValue] = await change; assert.deepEqual(changeValue, updated, 'watcher should fire change after atomic rename'); configWatcher.close(); }); + it('finishes reading the config before its change callback returns', async () => { + const initial = { foo: 'bar' }; + writeFileSync(this.configFilePath, stringify(initial)); + const configWatcher = new RootConfigWatcher(); + await configWatcher.ready; + + const updated = { foo: 'baz' }; + writeFileSync(this.configFilePath, stringify(updated)); + configWatcher.handleChange(); + + assert.deepEqual(configWatcher.config, updated, 'watcher must not leave a same-thread read in flight'); + configWatcher.close(); + }); + describe('polling fallback on watcher exhaustion', () => { // harper#488: when ENOSPC/EMFILE fires on the underlying chokidar // watcher, the RootConfigWatcher should swap to a polling watcher @@ -101,8 +117,9 @@ describe('RootConfigWatcher', () => { // Polling watcher should pick up subsequent writes; default polling // interval is 1s, so allow up to ~3s for the change event. const updated = { foo: 'after-fallback' }; + const change = once(configWatcher, 'change'); await writeFile(this.configFilePath, stringify(updated)); - const [changeValue] = await once(configWatcher, 'change'); + const [changeValue] = await change; assert.deepEqual(changeValue, updated, 'polling watcher should fire change'); configWatcher.close();