diff --git a/src/lib/config/simulator.ts b/src/lib/config/simulator.ts index 12ade540..e97988cc 100644 --- a/src/lib/config/simulator.ts +++ b/src/lib/config/simulator.ts @@ -23,6 +23,14 @@ export type RunningPlatform = (typeof AVAILABLE_PLATFORMS)[number]; export const STARTING_TIMEOUT_WAIT_CYLCE = 2000; export const STARTING_TIMEOUT_ATTEMPTS = 120; +/** + * Deadline for the best-effort "is a newer CLI published?" check. + * + * `up` and `init` run this before anything else, so an unresponsive npm registry must not + * hold the command hostage. Kept short because the check only ever gates a console warning. + */ +export const CLI_VERSION_CHECK_TIMEOUT_MS = 2000; + export type AiProviders = "ollama" | "openai" | "heuristai" | "google" | "xai"; export type AiProvidersEnvVars = "ollama" | "OPENAIKEY" | "HEURISTAIAPIKEY" | "GEMINI_API_KEY" | "XAI_API_KEY"; export type AiProvidersConfigType = { diff --git a/src/lib/services/simulator.ts b/src/lib/services/simulator.ts index cc8b3450..c6552815 100644 --- a/src/lib/services/simulator.ts +++ b/src/lib/services/simulator.ts @@ -18,7 +18,8 @@ import { VERSION_REQUIREMENTS, CONTAINERS_NAME_PREFIX, IMAGES_NAME_PREFIX, - GENLAYER_REQUIRED_CONTAINERS + GENLAYER_REQUIRED_CONTAINERS, + CLI_VERSION_CHECK_TIMEOUT_MS } from "../config/simulator"; import { checkCommand, @@ -122,8 +123,34 @@ export class SimulatorService implements ISimulatorService { return this.profileOptions; } + /** + * Prints a "a new version is available" notice. + * + * This is a cosmetic warning, but `up` and `init` await it as their first step, so any + * registry trouble used to abort or stall the whole command before Docker was consulted. + * `update-check` performs an unauthenticated request to the npm registry and rejects on an + * unreachable registry, a 5xx, a proxy login page served with 200, or a cache file left + * truncated by an interrupted run. It also leaves the request socket open on timeout, which + * keeps the event loop alive so the process hangs instead of exiting. + * + * Bound the call with a deadline and treat every failure as "no update information". + */ public async checkCliVersion(): Promise { - const update = await updateCheck(pkg); + let update: {latest: string; fromCache: boolean} | null = null; + + try { + update = await Promise.race([ + updateCheck(pkg), + new Promise((resolve) => + setTimeout(() => resolve(null), CLI_VERSION_CHECK_TIMEOUT_MS).unref?.() + ), + ]); + } catch { + // The registry is unreachable, rate limiting us, or returned something we cannot parse. + // A missing upgrade notice must never block starting the localnet. + return; + } + if (update && update.latest !== pkg.version) { console.warn(`\nA new version (${update.latest}) is available! You're using version ${pkg.version}.\nRun npm install -g genlayer to update\n`); } diff --git a/tests/services/simulator.test.ts b/tests/services/simulator.test.ts index 70e184a3..c8349894 100644 --- a/tests/services/simulator.test.ts +++ b/tests/services/simulator.test.ts @@ -1,4 +1,4 @@ -import {describe, beforeEach, test, expect, vi, Mock} from "vitest"; +import {describe, beforeEach, afterEach, test, expect, vi, Mock} from "vitest"; import * as path from "path"; import * as fs from "fs"; import * as dotenv from "dotenv"; @@ -13,6 +13,7 @@ import { IMAGES_NAME_PREFIX, AiProviders, GENLAYER_REQUIRED_CONTAINERS, + CLI_VERSION_CHECK_TIMEOUT_MS, } from "../../src/lib/config/simulator"; import {rpcClient} from "../../src/lib/clients/jsonRpcClient"; import * as semver from "semver"; @@ -607,6 +608,65 @@ describe("SimulatorService - Docker Tests", () => { consoleWarnSpy.mockRestore(); }); + + // Regression coverage for #418: `up` and `init` await checkCliVersion() as their first step, + // so a registry problem must not abort or stall the command. Every case below is a real + // failure mode of update-check@1.5.4. + describe("registry failures never block startup (#418)", () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + const rejections = [ + ["registry unreachable or blackholed", new TypeError("Cannot read properties of undefined (reading 'code')")], + ["registry returns 5xx", new Error("Request failed with code 500")], + ["proxy returns an HTML login page with 200", new SyntaxError("Unexpected token '<'")], + ["cache file left truncated by an interrupted run", new SyntaxError("Unexpected end of JSON input")], + ] as const; + + it.each(rejections)("swallows the rejection when the %s", async (_label, error) => { + (updateCheck as any).mockRejectedValue(error); + + await expect(simulatorService.checkCliVersion()).resolves.toBeUndefined(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it("resolves instead of hanging when the registry never responds", async () => { + vi.useFakeTimers(); + try { + // Never settles: models a firewall that drops packets rather than refusing them. + (updateCheck as any).mockImplementation(() => new Promise(() => {})); + + const pending = simulatorService.checkCliVersion(); + const settled = vi.fn(); + void pending.then(settled); + + await vi.advanceTimersByTimeAsync(CLI_VERSION_CHECK_TIMEOUT_MS); + + await expect(pending).resolves.toBeUndefined(); + expect(settled).toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("still warns when the check succeeds inside the deadline", async () => { + (updateCheck as any).mockResolvedValue({latest: "1.1.0"}); + + await simulatorService.checkCliVersion(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + `\nA new version (1.1.0) is available! You're using version 1.0.0.\nRun npm install -g genlayer to update\n`, + ); + }); + }); }); describe("normalizeLocalnetVersion", () => {