Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/lib/config/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
31 changes: 29 additions & 2 deletions src/lib/services/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
const update = await updateCheck(pkg);
let update: {latest: string; fromCache: boolean} | null = null;

try {
update = await Promise.race([
updateCheck(pkg),
new Promise<null>((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`);
}
Expand Down
62 changes: 61 additions & 1 deletion tests/services/simulator.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<typeof vi.spyOn>;

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", () => {
Expand Down