From 444d0838e2a506a51132e8ba4c2f9122575013c7 Mon Sep 17 00:00:00 2001 From: Hussein Adeiza Date: Sun, 27 Sep 2026 13:05:19 +0200 Subject: [PATCH] fix(cli): never let the update check block `up`/`init` (#418) `up` and `init` await `checkCliVersion()` as their first step, before Docker is consulted. That method awaited `update-check` with no deadline and no try/catch, so the unauthenticated npm registry request could abort the whole command: $ node dist/index.js up --headless - Checking CLI version... TypeError: Cannot read properties of undefined (reading 'code') at getMostRecent (node_modules/update-check/index.js:125:11) at async SimulatorService.checkCliVersion (dist/index.js:59025:20) at async StartAction.execute (dist/index.js:59385:5) Confirmed failure modes of update-check@1.5.4: an unreachable or blackholed registry, a 5xx, a proxy login page served with 200, and a cache file left truncated by an interrupted run. It also rejects on timeout without destroying the request socket, so the promise settles while the event loop stays held and the process hangs instead of exiting. An upgrade notice is cosmetic and must not gate startup, so bound the call with a short deadline and treat every failure as "no update information". The warning still prints when the check succeeds in time. Adds regression tests for each failure mode plus the timeout path. --- src/lib/config/simulator.ts | 8 +++++ src/lib/services/simulator.ts | 31 ++++++++++++++-- tests/services/simulator.test.ts | 62 +++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 3 deletions(-) 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", () => {