Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion packages/cli/src/ui/utils/updateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
*/

import { vi, describe, it, expect, beforeEach } from 'vitest';
import { checkForUpdates, checkForUpdatesDetailed } from './updateCheck.js';
import {
checkForUpdates,
checkForUpdatesDetailed,
FETCH_TIMEOUT_MS,
UpdateCheckTimeoutError,
} from './updateCheck.js';

const getPackageJson = vi.hoisted(() => vi.fn());
vi.mock('../../utils/package.js', () => ({
Expand Down Expand Up @@ -254,4 +259,114 @@ describe('checkForUpdates', () => {
expect(result?.update.latest).toBe('1.2.3-nightly.2');
});
});

describe('fetchInfo timeout (#6857)', () => {
it('returns a detailed error when fetchInfo does not resolve within FETCH_TIMEOUT_MS', async () => {
// update-notifier's fetchInfo() takes no timeout option, so an
// unreachable registry (proxy, offline, corporate mirror without
// scoped .npmrc auth) would hang the check. We race it against a
// bounded timer instead — this asserts the timer actually fires and
// surfaces a real error rather than silently reporting "up to date".
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
Comment on lines +270 to +273

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The timeout tests only exercise the non-nightly (single-fetch) path. The nightly branch uses Promise.all with two concurrent fetchInfoWithTimeout calls — a more complex interaction that has no timeout test. If a bug existed in the nightly path's Promise.all + timeout interaction, it would go undetected. — Concrete cost: the nightly Promise.all path is untested for timeout behavior despite being the more complex of the two code paths.

Add a test with version: '1.0.0-nightly.1' where one or both fetchInfo mocks return a never-resolving promise, advance timers past FETCH_TIMEOUT_MS, and assert status: 'error' with UpdateCheckTimeoutError.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added in 51578836a. Two new tests cover the nightly Promise.all timeout path:

  • surfaces a timeout when only the nightly dist-tag stalls — mocks nightly as a never-resolving promise and latest as a fast success, advances timers past FETCH_TIMEOUT_MS, and asserts Promise.all propagates the timeout AND the error message names for nightly. This catches both directions of the wiring: that the timer actually reaches inside Promise.all, and that the correct dist-tag gets tagged.
  • surfaces a timeout when both nightly dist-tags stall — full outage; asserts a typed UpdateCheckTimeoutError with either dist-tag on the message (whichever rejection Promise.all sees first is a valid symptom of the same failure).

updateNotifier.mockReturnValue({
// never resolves
fetchInfo: vi.fn().mockReturnValue(new Promise(() => {})),
});

const resultPromise = checkForUpdatesDetailed();
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1);
const result = await resultPromise;

expect(result.status).toBe('error');
if (result.status === 'error') {
expect(result.error).toBeInstanceOf(UpdateCheckTimeoutError);
expect(result.error.message).toContain(`${FETCH_TIMEOUT_MS}ms`);
// Non-nightly path only queries the `latest` dist-tag; the message
// must name it so oncall can tell which registry endpoint stalled.
expect(result.error.message).toContain('for latest');
expect(result.currentVersion).toBe('1.0.0');
}
});

it('still resolves the update path when fetchInfo returns before the timeout', async () => {
// Guards against the timer accidentally firing on a healthy fast fetch —
// if it did, every /update call would silently drop back to error.
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
updateNotifier.mockReturnValue({
fetchInfo: vi
.fn()
.mockResolvedValue({ current: '1.0.0', latest: '1.1.0' }),
});

const result = await checkForUpdatesDetailed();

expect(result.status).toBe('update');
if (result.status === 'update') {
expect(result.info.update.latest).toBe('1.1.0');
}
});

it('surfaces a timeout when only the nightly dist-tag stalls', async () => {
// The nightly path fires `latest` and `nightly` fetches concurrently via
// Promise.all — if the timer wiring is wrong (e.g. only the outer race
// has one, or the reject reaches Promise.all and Promise.all doesn't
// propagate), a single stalled fetch would let /update silently degrade.
// Assert Promise.all propagates the timeout AND names the exact dist-tag
// that stalled so oncall reading logs can point at the endpoint.
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-nightly.1',
});
updateNotifier.mockImplementation(({ distTag }) => ({
fetchInfo: () =>
distTag === 'nightly'
? new Promise(() => {}) // never resolves
: Promise.resolve({
current: '1.0.0-nightly.1',
latest: '1.0.0',
}),
}));

const resultPromise = checkForUpdatesDetailed();
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1);
const result = await resultPromise;

expect(result.status).toBe('error');
if (result.status === 'error') {
expect(result.error).toBeInstanceOf(UpdateCheckTimeoutError);
expect(result.error.message).toContain('for nightly');
expect(result.currentVersion).toBe('1.0.0-nightly.1');
}
});

it('surfaces a timeout when both nightly dist-tags stall', async () => {
// Full outage / offline network — both fetches hang, both timers fire.
// The first rejection Promise.all sees wins; assert only that we get a
// typed UpdateCheckTimeoutError for one of the two dist-tags (either is
// a valid symptom of the same failure).
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-nightly.1',
});
updateNotifier.mockImplementation(() => ({
fetchInfo: () => new Promise(() => {}),
}));

const resultPromise = checkForUpdatesDetailed();
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1);
const result = await resultPromise;

expect(result.status).toBe('error');
if (result.status === 'error') {
expect(result.error).toBeInstanceOf(UpdateCheckTimeoutError);
expect(result.error.message).toMatch(/for (nightly|latest)/);
}
});
});
});
70 changes: 67 additions & 3 deletions packages/cli/src/ui/utils/updateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,50 @@ const debugLogger = createDebugLogger('UPDATE_CHECK');

export const FETCH_TIMEOUT_MS = 2000;

/**
* Sentinel error thrown when `fetchInfo()` does not resolve within
* `FETCH_TIMEOUT_MS`. `update-notifier`'s `fetchInfo()` does not accept a
* timeout option, so slow / unreachable registries (corporate proxies, offline
* networks, DNS failures) would otherwise hang the check indefinitely or fall
* through to a stale configstore cache. Race the call against a bounded timer
* and surface a real error so `/update` can report "check failed" instead of
* silently returning "up to date". The `distTag` is carried on the message so
* an oncall reading logs can tell which registry endpoint stalled — the
* nightly path fires two concurrent fetches, and only one of them may be
* blocked (e.g. a corporate proxy that lets `nightly` through but not
* `latest`). Related: #6857.
*/
export class UpdateCheckTimeoutError extends Error {
readonly distTag?: string;
constructor(timeoutMs: number, distTag?: string) {
const suffix = distTag ? ` for ${distTag}` : '';
super(`update-notifier fetchInfo timed out after ${timeoutMs}ms${suffix}`);
this.name = 'UpdateCheckTimeoutError';
this.distTag = distTag;
}
}
Comment on lines +31 to +39

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The timeout error message ("fetchInfo timed out after 2000ms") carries no indication of which dist-tag (nightly vs latest) failed. In the nightly path where two fetches run concurrently via Promise.all, a timeout from either produces an identical error. — Concrete cost: when investigating a timeout from logs alone (e.g., corporate proxy blocking only one dist-tag), the oncall engineer cannot tell which registry endpoint was unreachable.

Suggested change
export class UpdateCheckTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`update-notifier fetchInfo timed out after ${timeoutMs}ms`);
this.name = 'UpdateCheckTimeoutError';
}
}
export class UpdateCheckTimeoutError extends Error {
constructor(timeoutMs: number, distTag?: string) {
const tag = distTag ? ` for ${distTag}` : '';
super(`update-notifier fetchInfo timed out after ${timeoutMs}ms${tag}`);
this.name = 'UpdateCheckTimeoutError';
}
}

Then pass the dist-tag from fetchInfoWithTimeout through to the error constructor.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — applied in 51578836a. UpdateCheckTimeoutError now takes an optional distTag (also exposed as a public field), fetchInfoWithTimeout threads it through, and both call sites pass the tag. Non-nightly path is latest; nightly path passes nightly and latest to the two Promise.all fetches respectively. Error messages now read update-notifier fetchInfo timed out after 2000ms for latest / ... for nightly, so a log reader can tell which endpoint stalled.


async function fetchInfoWithTimeout(
notifier: { fetchInfo(): UpdateInfo | Promise<UpdateInfo> },
timeoutMs: number,
distTag?: string,
): Promise<UpdateInfo> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve(notifier.fetchInfo()),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new UpdateCheckTimeoutError(timeoutMs, distTag)),
timeoutMs,
);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

export interface UpdateObject {
message: string;
update: UpdateInfo;
Expand Down Expand Up @@ -77,10 +121,22 @@ export async function checkForUpdatesDetailed(): Promise<UpdateCheckResult> {

if (isNightly) {
const [nightlyUpdateInfo, latestUpdateInfo] = await Promise.all([
createNotifier('nightly').fetchInfo(),
createNotifier('latest').fetchInfo(),
fetchInfoWithTimeout(
createNotifier('nightly'),
FETCH_TIMEOUT_MS,
'nightly',
),
fetchInfoWithTimeout(
createNotifier('latest'),
FETCH_TIMEOUT_MS,
'latest',
),
]);

debugLogger.debug(
`fetchInfo returned nightly=${JSON.stringify(nightlyUpdateInfo)} latest=${JSON.stringify(latestUpdateInfo)} for current=${version}`,
);

const bestUpdate = getBestAvailableUpdate(
nightlyUpdateInfo,
latestUpdateInfo,
Expand All @@ -99,7 +155,15 @@ export async function checkForUpdatesDetailed(): Promise<UpdateCheckResult> {
};
}
} else {
const updateInfo = await createNotifier('latest').fetchInfo();
const updateInfo = await fetchInfoWithTimeout(
createNotifier('latest'),
FETCH_TIMEOUT_MS,
'latest',
);

debugLogger.debug(
`fetchInfo returned ${JSON.stringify(updateInfo)} for current=${version}`,
);

if (updateInfo && semver.gt(updateInfo.latest, version)) {
return {
Expand Down
Loading