-
-
Notifications
You must be signed in to change notification settings - Fork 459
feat: add beacon buddy #9410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ensi321
wants to merge
4
commits into
unstable
Choose a base branch
from
nc/beacon-buddy
base: unstable
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat: add beacon buddy #9410
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6c9ce7a
feat(cli): add hidden --fun beacon buddy easter egg
ensi321 4337d65
fix(cli): restore terminal on abort path and require --fun value
ensi321 1d58980
fix(cli): isolate beacon buddy errors from chain emitter
ensi321 0448657
style(beacon-node): sort util/index.ts exports
ensi321 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}; | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.