diff --git a/packages/beacon-node/src/util/index.ts b/packages/beacon-node/src/util/index.ts index 0aa95e173007..852437e3bbfd 100644 --- a/packages/beacon-node/src/util/index.ts +++ b/packages/beacon-node/src/util/index.ts @@ -1,2 +1,4 @@ // Only export the functions which are useful for external packages and difficult to reproduce or mock + +export {ClockEvent} from "./clock.js"; export * from "./kzg.js"; diff --git a/packages/cli/src/cmds/beacon/handler.ts b/packages/cli/src/cmds/beacon/handler.ts index e329bcfef4d6..d36c4c0ce68c 100644 --- a/packages/cli/src/cmds/beacon/handler.ts +++ b/packages/cli/src/cmds/beacon/handler.ts @@ -14,6 +14,7 @@ import {BeaconNodeOptions, getBeaconConfigFromArgs} from "../../config/index.js" import {getNetworkBootnodes, isKnownNetworkName, readBootnodes} from "../../networks/index.js"; import {GlobalArgs, parseBeaconNodeArgs} from "../../options/index.js"; import {LogArgs} from "../../options/logOptions.js"; +import {startBeaconBuddy} from "../../util/buddy/index.js"; import { cleanOldLogFiles, mkdir, @@ -104,6 +105,11 @@ export async function beaconHandler(args: BeaconArgs & GlobalArgs): Promise { + buddy?.stop(); try { await node.close(); logger.debug("Beacon node closed"); diff --git a/packages/cli/src/cmds/beacon/options.ts b/packages/cli/src/cmds/beacon/options.ts index 65dff288119a..d3494cf0dcbe 100644 --- a/packages/cli/src/cmds/beacon/options.ts +++ b/packages/cli/src/cmds/beacon/options.ts @@ -26,6 +26,7 @@ type BeaconExtraArgs = { validatorMonitorLogs?: boolean; attachToGlobalThis?: boolean; disableLightClientServer?: boolean; + fun?: "off" | "tty" | "file" | "both"; }; export const beaconExtraOptions: CliCommandOptions = { @@ -169,6 +170,16 @@ export const beaconExtraOptions: CliCommandOptions = { description: "Disable light client server.", type: "boolean", }, + + fun: { + hidden: true, + // A value is required: yargs would otherwise swallow the next CLI token + // (e.g. `--fun --reset` would set fun="--reset"). Use `--fun both` or `--fun=both`. + description: "Enable beacon buddy ASCII pet easter egg. Requires a value: off|tty|file|both", + type: "string", + choices: ["off", "tty", "file", "both"], + default: "off", + }, }; type ENRArgs = { diff --git a/packages/cli/src/util/buddy/buddy.ts b/packages/cli/src/util/buddy/buddy.ts new file mode 100644 index 000000000000..be57f67bb478 --- /dev/null +++ b/packages/cli/src/util/buddy/buddy.ts @@ -0,0 +1,55 @@ +import {BuddyState, Mood, RareSprite, SpriteKind} from "./types.js"; + +const SLOTS_PER_EPOCH = 32; +const REORG_LOOKBACK_SLOTS = 32; +const SYNC_LAG_THRESHOLD_SLOTS = 32; +const RARE_SLOTS = new Set([1337, 31337]); +const MILLION = 1_000_000; + +export type Telemetry = { + slot: number; + peers: number; + isSyncing: boolean; + syncDistance: number; + fork: string; + prevFork: string; + lastReorgSlot: number | null; +}; + +export function computeMood(t: Telemetry): Mood { + if (t.peers === 0) return Mood.panic; + if (t.lastReorgSlot !== null && t.slot - t.lastReorgSlot <= REORG_LOOKBACK_SLOTS) return Mood.sad; + if (t.isSyncing && t.syncDistance > SYNC_LAG_THRESHOLD_SLOTS) return Mood.sleepy; + return Mood.happy; +} + +export function computeRare(t: Telemetry): {rare: RareSprite; label?: string} | null { + if (RARE_SLOTS.has(t.slot)) { + return {rare: t.slot === 1337 ? RareSprite.slot1337 : RareSprite.slot31337, label: `slot ${t.slot}`}; + } + if (t.slot >= MILLION && t.slot % MILLION === 0) { + return {rare: RareSprite.slotMillion, label: `${t.slot / MILLION}M`}; + } + if (t.fork !== t.prevFork) { + return {rare: RareSprite.forkActivation, label: t.fork}; + } + return null; +} + +export function buildState(t: Telemetry): BuddyState { + return { + slot: t.slot, + epoch: Math.floor(t.slot / SLOTS_PER_EPOCH), + fork: t.fork, + peers: t.peers, + synced: !t.isSyncing, + syncDistance: t.syncDistance, + mood: computeMood(t), + override: computeRare(t) ?? undefined, + }; +} + +export function spriteKindFor(state: BuddyState): SpriteKind { + if (state.override) return {kind: "rare", rare: state.override.rare, label: state.override.label}; + return {kind: "mood", mood: state.mood}; +} diff --git a/packages/cli/src/util/buddy/index.ts b/packages/cli/src/util/buddy/index.ts new file mode 100644 index 000000000000..f0a80041be92 --- /dev/null +++ b/packages/cli/src/util/buddy/index.ts @@ -0,0 +1,115 @@ +import path from "node:path"; +import {routes} from "@lodestar/api"; +import type {BeaconNode} from "@lodestar/beacon-node"; +import {ClockEvent} from "@lodestar/beacon-node/util"; +import type {Logger} from "@lodestar/logger"; +import {Telemetry, buildState, spriteKindFor} from "./buddy.js"; +import {TtyRenderer, formatFrame, writeSidecar} from "./render.js"; +import {renderSprite} from "./sprites.js"; +import {BuddyMode} from "./types.js"; + +export type StartBuddyOpts = { + node: BeaconNode; + dataDir: string; + mode: BuddyMode; + logger: Logger; +}; + +export type BuddyHandle = { + stop(): void; +}; + +export function startBeaconBuddy({node, dataDir, mode, logger}: StartBuddyOpts): BuddyHandle { + if (mode === "off") return {stop: () => {}}; + + // sidecar is rewritten every slot; `watch -n1 cat /buddy.txt` to follow + const sidecarPath = path.join(dataDir, "buddy.txt"); + let lastReorgSlot: number | null = null; + let prevFork = ""; + + const ttyWanted = mode === "tty" || mode === "both"; + let fileWanted = mode === "file" || mode === "both"; + let renderer: TtyRenderer | null = null; + if (ttyWanted) { + if (TtyRenderer.isSupported()) { + renderer = new TtyRenderer(); + } else { + // No TTY available: fall back to sidecar file so the buddy still works. + fileWanted = true; + logger.warn("beacon buddy: stdout is not a TTY (or too small); falling back to sidecar file only"); + } + } + + // Easter-egg listeners must never escape an error into the beacon node's + // event emitters: wrap every body so the consensus path is unaffected. + const onReorg = (data: routes.events.EventData[routes.events.EventType.chainReorg]): void => { + try { + lastReorgSlot = data.slot; + } catch (e) { + logger.debug("beacon buddy: onReorg failed", {}, e as Error); + } + }; + + const onSlot = (slot: number): void => { + try { + const peers = safeCall(() => node.network.getConnectedPeerCount(), 0); + const sync = safeCall(() => node.sync.getSyncStatus(), null); + const fork = safeCall(() => node.chain.config.getForkName(slot), "unknown"); + if (prevFork === "") prevFork = fork; + + const telemetry: Telemetry = { + slot, + peers, + isSyncing: sync ? sync.isSyncing : false, + syncDistance: sync ? Number(sync.syncDistance) : 0, + fork, + prevFork, + lastReorgSlot, + }; + + const state = buildState(telemetry); + const sprite = renderSprite(spriteKindFor(state)); + const frame = formatFrame(state, sprite); + + if (fileWanted) { + try { + writeSidecar(sidecarPath, frame); + } catch (e) { + logger.debug("beacon buddy: sidecar write failed", {}, e as Error); + } + } + try { + renderer?.draw(frame); + } catch (e) { + logger.debug("beacon buddy: tty draw failed", {}, e as Error); + } + + prevFork = fork; + } catch (e) { + logger.debug("beacon buddy: onSlot failed", {}, e as Error); + } + }; + + node.chain.clock.on(ClockEvent.slot, onSlot); + node.chain.emitter.on(routes.events.EventType.chainReorg, onReorg); + + return { + stop: () => { + try { + node.chain.clock.off(ClockEvent.slot, onSlot); + node.chain.emitter.off(routes.events.EventType.chainReorg, onReorg); + } catch { + // ignore + } + renderer?.stop(); + }, + }; +} + +function safeCall(fn: () => T, fallback: T): T { + try { + return fn(); + } catch { + return fallback; + } +} diff --git a/packages/cli/src/util/buddy/render.ts b/packages/cli/src/util/buddy/render.ts new file mode 100644 index 000000000000..62672140fd0f --- /dev/null +++ b/packages/cli/src/util/buddy/render.ts @@ -0,0 +1,117 @@ +import {writeFile600Perm} from "../file.js"; +import {SPRITE_HEIGHT} from "./sprites.js"; +import {BuddyState} from "./types.js"; + +const MIN_ROWS = 10; +const FRAME_LINES = SPRITE_HEIGHT + 2; + +export function formatHeader(state: BuddyState): string { + return `[slot ${state.slot} | epoch ${state.epoch} | ${state.fork} | peers ${state.peers} | mood: ${state.mood}]`; +} + +export function formatFooter(state: BuddyState): string { + const sync = state.synced ? "synced [ok]" : `syncing (${state.syncDistance} behind)`; + return sync; +} + +export function formatFrame(state: BuddyState, sprite: string[]): string { + return [formatHeader(state), ...sprite, formatFooter(state)].join("\n") + "\n"; +} + +export function writeSidecar(filepath: string, frame: string): void { + writeFile600Perm(filepath, frame); +} + +/** + * Pins a fixed-height frame to the bottom of the terminal by setting a DECSTBM + * scroll region for everything above. Concurrent logger writes scroll inside + * the top region; the pinned frame is redrawn on demand. + */ +export class TtyRenderer { + private readonly stream: NodeJS.WriteStream; + private readonly height: number; + private started = false; + private lastFrame: string | null = null; + private resizeListener: (() => void) | null = null; + + constructor(stream: NodeJS.WriteStream = process.stdout, height: number = FRAME_LINES) { + this.stream = stream; + this.height = height; + } + + static isSupported(stream: NodeJS.WriteStream = process.stdout): boolean { + return Boolean(stream.isTTY) && (stream.rows ?? 0) >= MIN_ROWS; + } + + private rows(): number { + return this.stream.rows ?? 24; + } + + private setScrollRegion(): void { + const rows = this.rows(); + const top = 1; + const bottom = Math.max(top, rows - this.height); + // DECOM off: row addressing stays absolute, not relative to scroll region. + this.stream.write("\x1b[?6l"); + // DECSTBM: set scroll region [top;bottom]. Logger output stays above. + this.stream.write(`\x1b[${top};${bottom}r`); + // Park cursor at bottom of scroll region so subsequent log lines flow there. + this.stream.write(`\x1b[${bottom};1H`); + } + + private drawPinned(frame: string): void { + const rows = this.rows(); + const pinnedTop = Math.max(1, rows - this.height + 1); + // Save cursor (DECSC), move to pinned area, clear, write, restore (DECRC). + this.stream.write("\x1b7"); + this.stream.write(`\x1b[${pinnedTop};1H`); + this.stream.write("\x1b[0J"); + this.stream.write(frame); + this.stream.write("\x1b8"); + } + + /** Returns true if the terminal is currently too small to host the pinned frame. */ + private tooSmall(): boolean { + return this.rows() < MIN_ROWS; + } + + draw(frame: string): void { + this.lastFrame = frame; + if (this.tooSmall()) { + // Terminal shrank below minimum after start: tear down pinned mode so the + // user gets their full screen back. File mode keeps the buddy alive. + if (this.started) this.stop(); + return; + } + if (!this.started) { + // Reserve room: scroll up by height so existing content shifts above. + this.stream.write("\n".repeat(this.height)); + this.setScrollRegion(); + this.resizeListener = () => { + if (this.tooSmall()) { + if (this.started) this.stop(); + return; + } + this.setScrollRegion(); + if (this.lastFrame) this.drawPinned(this.lastFrame); + }; + this.stream.on("resize", this.resizeListener); + this.started = true; + } + this.drawPinned(frame); + } + + stop(): void { + if (!this.started) return; + if (this.resizeListener) { + this.stream.off("resize", this.resizeListener); + this.resizeListener = null; + } + // Reset scroll region to full screen, move cursor to bottom, newline so + // the shell prompt lands cleanly below any final logs. + this.stream.write("\x1b[r"); + this.stream.write(`\x1b[${this.rows()};1H\n`); + this.started = false; + this.lastFrame = null; + } +} diff --git a/packages/cli/src/util/buddy/sprites.ts b/packages/cli/src/util/buddy/sprites.ts new file mode 100644 index 000000000000..92deaeeb32fb --- /dev/null +++ b/packages/cli/src/util/buddy/sprites.ts @@ -0,0 +1,32 @@ +import {Mood, SpriteKind} from "./types.js"; + +export const SPRITE_HEIGHT = 4; + +type Face = {eyes: string; mouth: string}; + +const FACES: Record = { + [Mood.happy]: {eyes: "^ ^", mouth: "\\_/"}, + [Mood.sleepy]: {eyes: "- -", mouth: "___"}, + [Mood.panic]: {eyes: "O O", mouth: "/~\\"}, + [Mood.sad]: {eyes: "; ;", mouth: "/-\\"}, +}; + +function renderMood(mood: Mood): string[] { + const {eyes, mouth} = FACES[mood]; + return [" .---. ", ` / ${eyes} \\ `, ` \\ ${mouth} / `, " '---' "]; +} + +const STARBURST: string[] = [" \\ | / ", " --*--*-- ", " / | \\ ", " "]; + +function renderRare(label?: string): string[] { + const base = STARBURST.slice(); + if (label) { + base[3] = ` ${label.slice(0, 8).padEnd(8, " ")}`; + } + return base; +} + +export function renderSprite(kind: SpriteKind): string[] { + if (kind.kind === "mood") return renderMood(kind.mood); + return renderRare(kind.label); +} diff --git a/packages/cli/src/util/buddy/types.ts b/packages/cli/src/util/buddy/types.ts new file mode 100644 index 000000000000..0201932285c4 --- /dev/null +++ b/packages/cli/src/util/buddy/types.ts @@ -0,0 +1,28 @@ +export enum Mood { + happy = "happy", + sleepy = "sleepy", + panic = "panic", + sad = "sad", +} + +export enum RareSprite { + slot1337 = "slot1337", + slot31337 = "slot31337", + slotMillion = "slotMillion", + forkActivation = "forkActivation", +} + +export type SpriteKind = {kind: "mood"; mood: Mood} | {kind: "rare"; rare: RareSprite; label?: string}; + +export type BuddyMode = "off" | "file" | "tty" | "both"; + +export type BuddyState = { + slot: number; + epoch: number; + fork: string; + peers: number; + synced: boolean; + syncDistance: number; + mood: Mood; + override?: {rare: RareSprite; label?: string}; +}; diff --git a/packages/cli/test/unit/util/buddy.test.ts b/packages/cli/test/unit/util/buddy.test.ts new file mode 100644 index 000000000000..87431c19121d --- /dev/null +++ b/packages/cli/test/unit/util/buddy.test.ts @@ -0,0 +1,88 @@ +import {describe, expect, it} from "vitest"; +import {Telemetry, buildState, computeMood, computeRare, spriteKindFor} from "../../../src/util/buddy/buddy.js"; +import {formatFrame} from "../../../src/util/buddy/render.js"; +import {renderSprite} from "../../../src/util/buddy/sprites.js"; +import {Mood, RareSprite} from "../../../src/util/buddy/types.js"; + +function telemetry(overrides: Partial = {}): Telemetry { + return { + slot: 100, + peers: 30, + isSyncing: false, + syncDistance: 0, + fork: "deneb", + prevFork: "deneb", + lastReorgSlot: null, + ...overrides, + }; +} + +describe("buddy / computeMood", () => { + it("panic when peers === 0", () => { + expect(computeMood(telemetry({peers: 0}))).toBe(Mood.panic); + }); + it("sad when reorg within lookback", () => { + expect(computeMood(telemetry({slot: 100, lastReorgSlot: 80}))).toBe(Mood.sad); + }); + it("sleepy when syncing and far behind", () => { + expect(computeMood(telemetry({isSyncing: true, syncDistance: 1000}))).toBe(Mood.sleepy); + }); + it("happy default", () => { + expect(computeMood(telemetry())).toBe(Mood.happy); + }); + it("panic takes priority over sad", () => { + expect(computeMood(telemetry({peers: 0, lastReorgSlot: 99}))).toBe(Mood.panic); + }); + it("sad takes priority over sleepy", () => { + expect(computeMood(telemetry({lastReorgSlot: 80, isSyncing: true, syncDistance: 1000}))).toBe(Mood.sad); + }); + it("ignores stale reorg outside lookback", () => { + expect(computeMood(telemetry({slot: 200, lastReorgSlot: 100}))).toBe(Mood.happy); + }); +}); + +describe("buddy / computeRare", () => { + it("triggers on slot 1337", () => { + expect(computeRare(telemetry({slot: 1337}))?.rare).toBe(RareSprite.slot1337); + }); + it("triggers on slot 31337", () => { + expect(computeRare(telemetry({slot: 31337}))?.rare).toBe(RareSprite.slot31337); + }); + it("triggers on millionth slot", () => { + expect(computeRare(telemetry({slot: 1_000_000}))?.rare).toBe(RareSprite.slotMillion); + }); + it("triggers on 2 millionth slot with 2M label", () => { + const r = computeRare(telemetry({slot: 2_000_000})); + expect(r?.rare).toBe(RareSprite.slotMillion); + expect(r?.label).toBe("2M"); + }); + it("triggers on fork activation slot", () => { + expect(computeRare(telemetry({fork: "electra", prevFork: "deneb"}))?.rare).toBe(RareSprite.forkActivation); + }); + it("returns null on ordinary slot", () => { + expect(computeRare(telemetry({slot: 12345}))).toBeNull(); + }); +}); + +describe("buddy / buildState + formatFrame", () => { + it("formats sidecar frame with header, sprite, footer", () => { + const state = buildState(telemetry({slot: 8123456, peers: 47, fork: "gloas"})); + const sprite = renderSprite(spriteKindFor(state)); + const frame = formatFrame(state, sprite); + expect(frame).toContain("[slot 8123456"); + expect(frame).toContain("peers 47"); + expect(frame).toContain("mood: happy"); + expect(frame).toContain("synced [ok]"); + expect(frame.split("\n").length).toBeGreaterThan(4); + }); + it("shows syncing footer when not synced", () => { + const state = buildState(telemetry({isSyncing: true, syncDistance: 100})); + const frame = formatFrame(state, renderSprite(spriteKindFor(state))); + expect(frame).toContain("syncing (100 behind)"); + }); + it("uses rare sprite when override present", () => { + const state = buildState(telemetry({slot: 1337})); + expect(state.override?.rare).toBe(RareSprite.slot1337); + expect(spriteKindFor(state).kind).toBe("rare"); + }); +});