From 902766a0429b1cf1f4e2b7fd5b7fcc4111609c2d Mon Sep 17 00:00:00 2001 From: Neppkun Date: Tue, 5 May 2026 03:28:38 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20watch=20providers.jso?= =?UTF-8?q?nc=20for=20external=20edits=20so=20UI=20refreshes=20without=20r?= =?UTF-8?q?estart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual edits to ~/.mux/providers.jsonc were silently ignored by the frontend because notifyConfigChanged() was only called after API mutations. Added watchProvidersFile() to Config (fs.watch on the mux home directory, debounced 300ms) and wired it into ProviderService's constructor so external file changes propagate to all onConfigChanged subscribers automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/node/config.ts | 26 ++++++++++++++++++++++++++ src/node/services/providerService.ts | 2 ++ 2 files changed, 28 insertions(+) diff --git a/src/node/config.ts b/src/node/config.ts index 25fe09a3d3..272d1663cf 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1878,6 +1878,32 @@ export class Config { return null; } + /** + * Watch providers.jsonc for external edits. Fires callback (debounced 300 ms) + * on any create/modify/delete event. Returns a cleanup function. + * + * We watch the parent directory rather than the file directly so that + * creates (first-time manual edit) are also detected on all platforms. + */ + watchProvidersFile(callback: () => void): () => void { + const filename = path.basename(this.providersFile); + let debounceTimer: ReturnType | null = null; + + const watcher = fs.watch(this.rootDir, (_eventType, changedFilename) => { + if (changedFilename !== filename) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + callback(); + }, 300); + }); + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + }; + } + /** * Save providers configuration to JSONC file * @param config The providers configuration to save diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 0dd93364e9..8366ea66bc 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -141,6 +141,8 @@ export class ProviderService { // The provider config subscription may have many concurrent listeners (e.g. multiple windows). // Avoid noisy MaxListenersExceededWarning for normal usage. this.emitter.setMaxListeners(50); + // Notify subscribers when providers.jsonc is edited externally (e.g. manual edits). + this.config.watchProvidersFile(() => this.notifyConfigChanged()); } /** From 7e8cf0a0f455811a1a33b6d52575dbc73d5e579c Mon Sep 17 00:00:00 2001 From: Neppkun Date: Mon, 25 May 2026 14:07:43 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20guard=20against=20mis?= =?UTF-8?q?sing=20mux=20home=20dir=20and=20don't=20hold=20open=20event-loo?= =?UTF-8?q?p=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures caused by the providers.jsonc watcher: - Smoke test crashed with ENOENT because ~/.mux doesn't exist on a fresh install; now ensurePrivateDirSync is called before fs.watch. - Integration tests hung and tore down with "Jest environment has been torn down" errors because the persistent watcher kept the event loop alive; fixed by passing persistent: false to fs.watch. Co-Authored-By: Claude Sonnet 4.6 --- src/node/config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/node/config.ts b/src/node/config.ts index 272d1663cf..e0b5373468 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1886,10 +1886,18 @@ export class Config { * creates (first-time manual edit) are also detected on all platforms. */ watchProvidersFile(callback: () => void): () => void { + // The mux home directory may not exist on a fresh install. Create it so + // fs.watch doesn't throw ENOENT; the directory being empty is fine. + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + const filename = path.basename(this.providersFile); let debounceTimer: ReturnType | null = null; - const watcher = fs.watch(this.rootDir, (_eventType, changedFilename) => { + // persistent: false so the watcher doesn't prevent the process (or Jest) + // from exiting when nothing else is keeping the event loop alive. + const watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { if (changedFilename !== filename) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { From 8a9efb32266a4c29084cdefdda01aed6025b28a0 Mon Sep 17 00:00:00 2001 From: Neppkun Date: Mon, 25 May 2026 14:56:07 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20dispose=20providers.j?= =?UTF-8?q?sonc=20watcher=20on=20ServiceContainer.dispose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent: false flag was sufficient on Linux/macOS but Windows Jest workers still flagged "failed to exit gracefully" after my second commit, because the watcher's FSWatcher handle was never closed when tests tore down their ServiceContainer. Store the stop-watching callback returned by Config.watchProvidersFile, expose ProviderService.dispose() that calls it and clears emitter listeners, and wire it into ServiceContainer.dispose() alongside the other service disposals. Verified locally: - providerService unit tests: 75/75 pass - Standalone repro confirms the watcher fires on edits and disposal removes all active handles - Typecheck and lint clean on changed files Co-Authored-By: Claude Sonnet 4.6 --- src/node/services/providerService.ts | 14 +++++++++++++- src/node/services/serviceContainer.ts | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 8366ea66bc..356cb3b0cc 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -132,6 +132,7 @@ export class ProviderService { private readonly policyService: PolicyService | null; private readonly emitter = new EventEmitter(); private lastWarnedShadowedCustomProviderIds: Set | null = null; + private readonly stopWatchingProvidersFile: () => void; constructor( private readonly config: Config, @@ -142,7 +143,18 @@ export class ProviderService { // Avoid noisy MaxListenersExceededWarning for normal usage. this.emitter.setMaxListeners(50); // Notify subscribers when providers.jsonc is edited externally (e.g. manual edits). - this.config.watchProvidersFile(() => this.notifyConfigChanged()); + this.stopWatchingProvidersFile = this.config.watchProvidersFile(() => + this.notifyConfigChanged() + ); + } + + /** + * Release long-lived OS handles (e.g. the providers.jsonc file watcher). + * Called by ServiceContainer.dispose() during shutdown and by tests. + */ + dispose(): void { + this.stopWatchingProvidersFile(); + this.emitter.removeAllListeners(); } /** diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index f0cbc98db0..d241473abe 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -543,6 +543,7 @@ export class ServiceContainer { this.copilotOauthService.dispose(); this.serverAuthService.dispose(); + this.providerService.dispose(); await this.backgroundProcessManager.terminateAll(); } } From 50aed6357762ab2a147cf990bd952771686af6a5 Mon Sep 17 00:00:00 2001 From: Neppkun Date: Mon, 25 May 2026 15:17:46 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20degrade=20gracefully?= =?UTF-8?q?=20when=20fs.watch=20fails=20or=20omits=20filename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues Codex flagged on the watcher: P1 — fs.watch can throw for reasons beyond ENOENT: unsupported network filesystems (NFS/SMB), virtualized mounts, or watch-limit exhaustion (ENOSPC on Linux). Wrap the call in try/catch, log a warning, and return a no-op cleanup. The app boots and provider config keeps working; users on those filesystems just lose live-refresh (which is the pre-PR behaviour). P2 — fs.watch may pass changedFilename=null on some platforms/kernels (notably older macOS FSEvents). The previous "changedFilename !== filename" guard silently dropped valid events in that case, defeating the feature for exactly the edge it's meant to fix. Treat null as "unknown — might be ours" and fire the debounced callback; the worst case is one extra re-fetch. Co-Authored-By: Claude Sonnet 4.6 --- src/node/config.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/node/config.ts b/src/node/config.ts index e0b5373468..bb2fd6eab3 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1895,16 +1895,42 @@ export class Config { const filename = path.basename(this.providersFile); let debounceTimer: ReturnType | null = null; - // persistent: false so the watcher doesn't prevent the process (or Jest) - // from exiting when nothing else is keeping the event loop alive. - const watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { - if (changedFilename !== filename) return; + const fire = (): void => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = null; callback(); }, 300); - }); + }; + + // fs.watch can throw for reasons beyond ENOENT — network filesystems + // (NFS/SMB) and watch-limit exhaustion (ENOSPC on Linux) are the common + // ones. Degrade gracefully: log once, return a no-op cleanup, and let + // the rest of provider config keep working. The UI just won't auto- + // refresh on manual edits in that environment (same as before this PR). + let watcher: fs.FSWatcher; + try { + // persistent: false so the watcher doesn't prevent the process (or + // Jest) from exiting when nothing else is keeping the event loop alive. + watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { + // changedFilename can be null on some platforms/kernels (notably + // older macOS FSEvents). When we can't tell which file changed, + // assume providers.jsonc might have and let the consumer re-fetch + // — better an extra refresh than a missed one, since this is the + // exact scenario the feature is meant to fix. + if (changedFilename != null && changedFilename !== filename) return; + fire(); + }); + } catch (error) { + log.warn( + `Could not watch providers.jsonc for external edits (${this.rootDir}); manual edits will require a restart to take effect:`, + error + ); + const noop = (): void => { + // Nothing to clean up — fs.watch never started. + }; + return noop; + } return () => { if (debounceTimer) clearTimeout(debounceTimer); From 02e2eec6768a541aca8f251e2b87a056707da3ef Mon Sep 17 00:00:00 2001 From: Neppkun Date: Mon, 25 May 2026 15:26:48 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20also=20guard=20ensure?= =?UTF-8?q?PrivateDirSync=20against=20unwritable=20mux=20home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that ensurePrivateDirSync was outside the try/catch guarding the watcher, so a read-only filesystem or unwritable MUX_ROOT would still throw out of ProviderService construction and crash startup. Move directory creation inside the same try/catch so every failure path degrades to the documented "manual edits need restart" behavior instead of taking the app down. Co-Authored-By: Claude Sonnet 4.6 --- src/node/config.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/node/config.ts b/src/node/config.ts index bb2fd6eab3..8e56ce4a44 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1886,12 +1886,6 @@ export class Config { * creates (first-time manual edit) are also detected on all platforms. */ watchProvidersFile(callback: () => void): () => void { - // The mux home directory may not exist on a fresh install. Create it so - // fs.watch doesn't throw ENOENT; the directory being empty is fine. - if (!fs.existsSync(this.rootDir)) { - ensurePrivateDirSync(this.rootDir); - } - const filename = path.basename(this.providersFile); let debounceTimer: ReturnType | null = null; @@ -1903,13 +1897,22 @@ export class Config { }, 300); }; - // fs.watch can throw for reasons beyond ENOENT — network filesystems - // (NFS/SMB) and watch-limit exhaustion (ENOSPC on Linux) are the common - // ones. Degrade gracefully: log once, return a no-op cleanup, and let - // the rest of provider config keep working. The UI just won't auto- - // refresh on manual edits in that environment (same as before this PR). + // Anything inside this block can fail in restricted environments: + // - ensurePrivateDirSync: read-only filesystem, unwritable MUX_ROOT + // - fs.watch: ENOENT, network filesystems (NFS/SMB), watch-limit + // exhaustion (ENOSPC on Linux), unsupported virtualized mounts. + // We degrade gracefully in every case: log once, return a no-op + // cleanup, and let the rest of provider config keep working. The UI + // just won't auto-refresh on manual edits in that environment (same + // as the pre-PR behaviour). let watcher: fs.FSWatcher; try { + // The mux home directory may not exist on a fresh install. Create it + // so fs.watch doesn't throw ENOENT; the directory being empty is fine. + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + // persistent: false so the watcher doesn't prevent the process (or // Jest) from exiting when nothing else is keeping the event loop alive. watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { @@ -1927,7 +1930,7 @@ export class Config { error ); const noop = (): void => { - // Nothing to clean up — fs.watch never started. + // Nothing to clean up — watcher setup never completed. }; return noop; }