Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/beacon-node/src/util/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
// Only export the functions which are useful for external packages and difficult to reproduce or mock
export * from "./kzg.js";
export {ClockEvent} from "./clock.js";
8 changes: 8 additions & 0 deletions packages/cli/src/cmds/beacon/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -104,6 +105,11 @@ export async function beaconHandler(args: BeaconArgs & GlobalArgs): Promise<void
(globalThis as unknown as {bn: BeaconNode}).bn = node;
}

const buddy =
args.fun && args.fun !== "off"
? startBeaconBuddy({node, dataDir: beaconPaths.dataDir, mode: args.fun, logger})
: null;

// Prune invalid SSZ objects every interval
const {persistInvalidSszObjectsDir, persistInvalidSszObjects} = options.chain;
const pruneInvalidSSZObjectsInterval =
Expand Down Expand Up @@ -138,6 +144,8 @@ export async function beaconHandler(args: BeaconArgs & GlobalArgs): Promise<void
if (pruneInvalidSSZObjectsInterval !== null) {
clearInterval(pruneInvalidSSZObjectsInterval);
}

buddy?.stop();
Comment thread
ensi321 marked this conversation as resolved.
}, logger.info.bind(logger));

abortController.signal.addEventListener(
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/cmds/beacon/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type BeaconExtraArgs = {
validatorMonitorLogs?: boolean;
attachToGlobalThis?: boolean;
disableLightClientServer?: boolean;
fun?: "off" | "tty" | "file" | "both";
};

export const beaconExtraOptions: CliCommandOptions<BeaconExtraArgs> = {
Expand Down Expand Up @@ -169,6 +170,19 @@ export const beaconExtraOptions: CliCommandOptions<BeaconExtraArgs> = {
description: "Disable light client server.",
type: "boolean",
},

fun: {
hidden: true,
description: "Enable beacon buddy ASCII pet easter egg (off|tty|file|both)",
type: "string",
choices: ["off", "tty", "file", "both"],
default: "off",
coerce: (v: string | boolean | undefined): "off" | "tty" | "file" | "both" => {
// Bare `--fun` (no value) resolves to "both"
if (v === true || v === "" || v === undefined) return "both";
return v as "off" | "tty" | "file" | "both";
},
Comment thread
ensi321 marked this conversation as resolved.
Outdated
},
};

type ENRArgs = {
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/util/buddy/buddy.ts
Original file line number Diff line number Diff line change
@@ -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};
}
101 changes: 101 additions & 0 deletions packages/cli/src/util/buddy/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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 <dataDir>/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");
}
}

const onReorg = ({slot}: routes.events.EventData[routes.events.EventType.chainReorg]): void => {
lastReorgSlot = slot;
};

const onSlot = (slot: number): void => {
const peers = safeCall(() => node.network.getConnectedPeerCount(), 0);
const sync = safeCall(() => node.sync.getSyncStatus(), null);
const fork = node.chain.config.getForkName(slot);
Comment thread
ensi321 marked this conversation as resolved.
Outdated
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);
}
}
renderer?.draw(frame);

prevFork = fork;
};

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<T>(fn: () => T, fallback: T): T {
try {
return fn();
} catch {
return fallback;
}
}
117 changes: 117 additions & 0 deletions packages/cli/src/util/buddy/render.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
ensi321 marked this conversation as resolved.

/**
* 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;
}
}
32 changes: 32 additions & 0 deletions packages/cli/src/util/buddy/sprites.ts
Original file line number Diff line number Diff line change
@@ -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, Face> = {
[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);
}
28 changes: 28 additions & 0 deletions packages/cli/src/util/buddy/types.ts
Original file line number Diff line number Diff line change
@@ -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};
};
Loading
Loading