Skip to content

Commit c4e75ff

Browse files
authored
fix: add Promise.race hard timeout to feature flags fetch (#465) (#467)
* fix: add Promise.race hard timeout to feature flags fetch (#465) On Windows + Node 24 / undici 7.x, AbortController.abort() fails to interrupt an in-progress TCP connect — the fetch hangs until the OS-level TCP timeout (~30s). This blocks the MCP initialize response for users on high-latency networks (e.g. Australia). Changes: - fetchFlags(): wrap fetch in Promise.race with hard 3s timeout alongside AbortController, so the JS-level timeout is enforced regardless of whether AbortController actually interrupts the underlying socket - waitForFreshFlags(): add 5s safety timeout so it can never hang indefinitely (protects the new-user onboarding path that awaits this inside the MCP initialize handler) - Lower fetch timeout from 5s to 3s — flags load from cache anyway, a fresh fetch is not worth perceived startup latency - Add test/test-feature-flags-timeout.js with 6 tests covering: AbortController behavior, Promise.race pattern, slow-response servers, new-user onboarding path, and simulated broken AbortController
1 parent c74339f commit c4e75ff

2 files changed

Lines changed: 472 additions & 9 deletions

File tree

src/utils/feature-flags.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,23 @@ class FeatureFlagManager {
113113
* Wait for fresh flags to be fetched from network.
114114
* Use this when you need to ensure flags are loaded before making decisions
115115
* (e.g., A/B test assignments for new users who don't have a cache yet)
116+
*
117+
* Has a hard timeout to prevent blocking MCP startup if the fetch hangs.
118+
* See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465
116119
*/
117120
async waitForFreshFlags(): Promise<void> {
118121
if (this.freshFetchPromise) {
119-
await this.freshFetchPromise;
122+
let safetyTimeoutHandle: NodeJS.Timeout | undefined;
123+
try {
124+
const safetyTimeout = new Promise<void>((resolve) => {
125+
safetyTimeoutHandle = setTimeout(resolve, 5000);
126+
});
127+
await Promise.race([this.freshFetchPromise, safetyTimeout]);
128+
} finally {
129+
if (safetyTimeoutHandle) {
130+
clearTimeout(safetyTimeoutHandle);
131+
}
132+
}
120133
}
121134
}
122135

@@ -150,21 +163,35 @@ class FeatureFlagManager {
150163
* Fetch flags from remote URL
151164
*/
152165
private async fetchFlags(): Promise<void> {
166+
const FETCH_TIMEOUT_MS = 3000;
167+
const controller = new AbortController();
168+
const abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
169+
let hardTimeoutHandle: NodeJS.Timeout | undefined;
170+
153171
try {
154172
// Don't log here - runs async and can interfere with MCP clients
155-
156-
const controller = new AbortController();
157-
const timeout = setTimeout(() => controller.abort(), 5000);
158-
159-
const response = await fetch(this.flagUrl, {
173+
174+
// Use Promise.race as a hard timeout safety net.
175+
// On some platforms (Windows + Node 24 / undici 7.x), AbortController.abort()
176+
// fails to interrupt an in-progress TCP connect — the fetch hangs until the
177+
// OS-level TCP timeout (~30s on Windows). Promise.race guarantees we reject
178+
// at the JS level regardless of AbortController behavior.
179+
// See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/465
180+
const fetchPromise = fetch(this.flagUrl, {
160181
signal: controller.signal,
161182
headers: {
162183
'Cache-Control': 'no-cache',
163184
}
164185
});
165-
166-
clearTimeout(timeout);
167-
186+
const hardTimeout = new Promise<never>((_, reject) =>
187+
hardTimeoutHandle = setTimeout(
188+
() => reject(new Error('Feature flags fetch timed out')),
189+
FETCH_TIMEOUT_MS
190+
)
191+
);
192+
193+
const response = await Promise.race([fetchPromise, hardTimeout]);
194+
168195
if (!response.ok) {
169196
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
170197
}
@@ -183,6 +210,11 @@ class FeatureFlagManager {
183210
} catch (error: any) {
184211
logger.debug('Failed to fetch feature flags:', error.message);
185212
// Continue with cached values
213+
} finally {
214+
clearTimeout(abortTimeout);
215+
if (hardTimeoutHandle) {
216+
clearTimeout(hardTimeoutHandle);
217+
}
186218
}
187219
}
188220

0 commit comments

Comments
 (0)