diff --git a/source/core/self-update-check.ts b/source/core/self-update-check.ts index 1440fd9..bfd503a 100644 --- a/source/core/self-update-check.ts +++ b/source/core/self-update-check.ts @@ -76,6 +76,8 @@ export interface SelfUpdateResult { export const CACHE_FILENAME = 'self-update.json'; export const TTL_MS = 24 * 60 * 60 * 1000; export const FETCH_TIMEOUT_MS = 3000; +/** Node's `setTimeout` delay ceiling (2^31−1 ms); larger values overflow to ~1ms. */ +const TIMER_MAX_MS = 2_147_483_647; export const NPM_REGISTRY_URL = 'https://registry.npmjs.org/shardmind/latest'; const CACHE_SCHEMA_VERSION = 1 as const; const SHARDMIND_DIRNAME = 'shardmind'; @@ -130,6 +132,29 @@ export function getSelfUpdateCacheDir(): string { return path.join(os.homedir(), '.cache', SHARDMIND_DIRNAME); } +/** + * Resolve the fetch timeout at call time. Production default is + * `FETCH_TIMEOUT_MS` (3s) — short, because a courtesy notifier must never make + * a real command feel slow. It can be raised via + * `SHARDMIND_SELF_UPDATE_FETCH_TIMEOUT_MS` for two cases: a user on a genuinely + * slow link, and Layer-1 flow tests where heavy parallel CPU load can starve + * the event loop past 3s before the (fast, local) stub response is processed — + * which would spuriously abort the fetch and the banner would never render. + * Mirrors the call-time env reads in `getRegistryUrl` / `getSelfUpdateCacheDir`. + * A non-numeric or non-positive value falls back to the default (never disables + * the timeout). + */ +export function getConfiguredFetchTimeoutMs(): number { + const raw = process.env['SHARDMIND_SELF_UPDATE_FETCH_TIMEOUT_MS']; + if (raw === undefined) return FETCH_TIMEOUT_MS; + const n = Number(raw.trim()); + if (!Number.isFinite(n) || n <= 0) return FETCH_TIMEOUT_MS; + // Node's setTimeout delay is a 32-bit signed int; a value above that overflows + // to ~1ms (the OPPOSITE of a longer timeout — it would abort the fetch almost + // immediately). Truncate fractions and clamp to the timer ceiling. + return Math.min(Math.trunc(n), TIMER_MAX_MS); +} + function cachePath(cacheDir: string): string { return path.join(cacheDir, CACHE_FILENAME); } @@ -253,7 +278,7 @@ export async function checkSelfUpdate( currentVersion, cacheDir = getSelfUpdateCacheDir(), ttlMs = TTL_MS, - fetchTimeoutMs = FETCH_TIMEOUT_MS, + fetchTimeoutMs = getConfiguredFetchTimeoutMs(), now = Date.now(), signal, } = opts; diff --git a/tests/component/flows/self-update-flow.test.tsx b/tests/component/flows/self-update-flow.test.tsx index e189cb7..512df86 100644 --- a/tests/component/flows/self-update-flow.test.tsx +++ b/tests/component/flows/self-update-flow.test.tsx @@ -121,6 +121,7 @@ describe('self-update notifier — Layer 1 flow tests (#113)', () => { 'SHARDMIND_SELF_UPDATE_FORCE_TTY', 'SHARDMIND_SELF_UPDATE_REGISTRY_URL', 'SHARDMIND_SELF_UPDATE_CACHE_DIR', + 'SHARDMIND_SELF_UPDATE_FETCH_TIMEOUT_MS', 'CI', ] as const; let envSnapshot: Partial>; @@ -165,6 +166,13 @@ describe('self-update notifier — Layer 1 flow tests (#113)', () => { delete process.env['CI']; process.env['SHARDMIND_SELF_UPDATE_FORCE_TTY'] = '1'; process.env['SHARDMIND_SELF_UPDATE_REGISTRY_URL'] = npmStub.url; + // The local stub answers in single-digit ms, but under heavy parallel CPU + // load the event loop can starve past the production 3s fetch timeout + // before the response is processed — aborting the fetch so the banner + // never renders and the banner-wait below times out. Give the fetch a wide + // budget here (above the 30s banner waitFor below, under the 60s test + // timeout); production keeps the 3s default. (Fixes the flaky scenario 7.) + process.env['SHARDMIND_SELF_UPDATE_FETCH_TIMEOUT_MS'] = '45000'; // Per-test cache dir keeps the dev's real ~/.cache/shardmind clean // and avoids one test's cache hit suppressing the next test's fetch. const cacheDir = path.join(cacheDirParent, crypto.randomUUID()); @@ -293,7 +301,7 @@ describe('self-update notifier — Layer 1 flow tests (#113)', () => { f.includes(`shardmind ${NEWER_VERSION}`) && f.includes('npm install -g shardmind@latest') && /shardmind\/minimal/.test(f), - 15_000, + 30_000, ); // Banner is above the status header (StatusView's first line is // the namespace/name + version badge). String-position check @@ -402,7 +410,7 @@ describe('self-update notifier — Layer 1 flow tests (#113)', () => { await waitFor( r.lastFrame, (f) => f.includes(`shardmind ${NEWER_VERSION}`), - 15_000, + 30_000, ); } finally { if (vault) await vault.cleanup(); diff --git a/tests/unit/self-update-check.test.ts b/tests/unit/self-update-check.test.ts index e9ea74a..a61f893 100644 --- a/tests/unit/self-update-check.test.ts +++ b/tests/unit/self-update-check.test.ts @@ -24,8 +24,10 @@ import crypto from 'node:crypto'; import { checkSelfUpdate, getSelfUpdateCacheDir, + getConfiguredFetchTimeoutMs, CACHE_FILENAME, TTL_MS, + FETCH_TIMEOUT_MS, } from '../../source/core/self-update-check.js'; function npmLatestResponse(version: string): Response { @@ -614,4 +616,43 @@ describe('self-update-check', () => { expect(result).toEqual({ outdated: true, latest: '1.0.0' }); }); }); + + describe('getConfiguredFetchTimeoutMs — SHARDMIND_SELF_UPDATE_FETCH_TIMEOUT_MS', () => { + const KEY = 'SHARDMIND_SELF_UPDATE_FETCH_TIMEOUT_MS'; + let original: string | undefined; + beforeEach(() => { + original = process.env[KEY]; + }); + afterEach(() => { + if (original === undefined) delete process.env[KEY]; + else process.env[KEY] = original; + }); + + it('defaults to FETCH_TIMEOUT_MS when unset', () => { + delete process.env[KEY]; + expect(getConfiguredFetchTimeoutMs()).toBe(FETCH_TIMEOUT_MS); + }); + + it('honors a positive numeric override (trimmed)', () => { + process.env[KEY] = ' 45000 '; + expect(getConfiguredFetchTimeoutMs()).toBe(45000); + }); + + it('falls back to the default on non-numeric / non-positive / empty input', () => { + for (const bad of ['', ' ', 'abc', '0', '-5', 'NaN', 'Infinity']) { + process.env[KEY] = bad; + expect(getConfiguredFetchTimeoutMs()).toBe(FETCH_TIMEOUT_MS); + } + }); + + it('truncates fractions and clamps to the 32-bit setTimeout ceiling', () => { + // Fractional → truncated (setTimeout would truncate anyway). + process.env[KEY] = '45000.9'; + expect(getConfiguredFetchTimeoutMs()).toBe(45000); + // Above 2^31−1 would overflow setTimeout to ~1ms; clamp to the ceiling + // so a fat-fingered huge value never makes the timeout *shorter*. + process.env[KEY] = '9999999999'; + expect(getConfiguredFetchTimeoutMs()).toBe(2_147_483_647); + }); + }); });