Phase 2b: log tailer, block history, stdlib WebSocket push

Adds real-time block detection via ckpool log tailing and a push
channel for the upcoming Svelte UI, all stdlib-only:

- logmon.Tailer follows ckpool.log with rotation/truncation survival
  (inode + size tracking) and parses "Solved and confirmed block N"
  into BlockEvent values.
- state.Aggregator grows a 256-entry block ring, an OnRefresh hook,
  and IngestBlockEvents which best-effort enriches events with the
  block hash via bitcoind getblockhash.
- httpapi.Hub implements RFC 6455 from scratch (SHA1 handshake,
  unmasked text frames out, masked frames in, ping keepalive,
  per-client write mutex, slow-client drop) so we don't pull in a
  ws dependency before we can go mod tidy.
- New routes: GET /api/blocks and GET /api/ws. Snapshot pushes fire
  on every poll tick and immediately on block-solve.

ZMQ hashblock subscription and SQLite persistence are deferred to
Phase 2b.5 once the s9pk packaging repo exists and we have a real
build environment for adding Go deps.
This commit is contained in:
satoshi
2026-04-13 02:53:27 +03:00
parent bd0b1b0318
commit 0a40b8f84f
9 changed files with 633 additions and 14 deletions
+174
View File
@@ -0,0 +1,174 @@
// Package logmon tails the ckpool log file looking for notable events —
// primarily block-solve lines, which are the most reliable signal we have
// that a block was found, short of a ZMQ hashblock subscription.
//
// ckpool-solo logs a line like:
//
// Solved and confirmed block 840123
//
// from stratifier.c via LOGWARNING when a submitted share passes network
// difficulty and bitcoind confirms acceptance. We parse these lines,
// emit BlockEvent values on Events, and let the aggregator enrich them
// with hash/reward via bitcoind RPC.
package logmon
import (
"bufio"
"context"
"errors"
"io"
"log/slog"
"os"
"regexp"
"strconv"
"time"
)
// BlockEvent is emitted when the tailer sees a "Solved and confirmed block"
// line in the ckpool log. Hash and worker are populated later by the
// aggregator once it cross-references bitcoind.
type BlockEvent struct {
Height int64 `json:"height"`
SeenAt time.Time `json:"seen_at"`
RawLine string `json:"raw_line"`
}
// Tailer follows a log file, surviving rotation/truncation, and emits
// parsed events. Create with New, then Run in a goroutine.
type Tailer struct {
Path string
Events chan BlockEvent
Log *slog.Logger
PollWait time.Duration // how long to sleep between EOF polls
}
func New(path string, log *slog.Logger) *Tailer {
return &Tailer{
Path: path,
Events: make(chan BlockEvent, 16),
Log: log,
PollWait: 500 * time.Millisecond,
}
}
var solvedRE = regexp.MustCompile(`Solved and confirmed block\s+(\d+)`)
// Run blocks until ctx is cancelled. It opens the file, seeks to the end,
// and reads new lines as they are appended. If the file is rotated
// (shrinks, or inode changes), it re-opens.
func (t *Tailer) Run(ctx context.Context) {
defer close(t.Events)
var (
f *os.File
reader *bufio.Reader
lastIno uint64
lastPos int64
)
open := func() error {
if f != nil {
_ = f.Close()
}
nf, err := os.Open(t.Path)
if err != nil {
return err
}
// Start at end on first open so we don't replay old events.
if _, err := nf.Seek(0, io.SeekEnd); err != nil {
_ = nf.Close()
return err
}
st, err := nf.Stat()
if err != nil {
_ = nf.Close()
return err
}
f = nf
reader = bufio.NewReader(f)
lastIno = inodeOf(st)
lastPos, _ = f.Seek(0, io.SeekCurrent)
return nil
}
// Initial open; retry on failure until the file exists.
for {
if err := open(); err != nil {
t.Log.Warn("logmon: waiting for log file", "path", t.Path, "err", err)
if !sleep(ctx, 2*time.Second) {
return
}
continue
}
break
}
defer func() {
if f != nil {
_ = f.Close()
}
}()
for {
if ctx.Err() != nil {
return
}
line, err := reader.ReadString('\n')
if len(line) > 0 {
t.handleLine(line)
lastPos, _ = f.Seek(0, io.SeekCurrent)
}
if err == nil {
continue
}
if !errors.Is(err, io.EOF) {
t.Log.Warn("logmon: read error, reopening", "err", err)
if !sleep(ctx, t.PollWait) {
return
}
_ = open()
continue
}
// EOF: check for rotation (inode changed) or truncation (size < pos).
if st, statErr := os.Stat(t.Path); statErr == nil {
if inodeOf(st) != lastIno || st.Size() < lastPos {
t.Log.Info("logmon: log rotated, reopening", "path", t.Path)
if err := open(); err != nil {
t.Log.Warn("logmon: reopen failed", "err", err)
}
continue
}
}
if !sleep(ctx, t.PollWait) {
return
}
}
}
func (t *Tailer) handleLine(line string) {
m := solvedRE.FindStringSubmatch(line)
if m == nil {
return
}
height, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return
}
ev := BlockEvent{Height: height, SeenAt: time.Now(), RawLine: line}
select {
case t.Events <- ev:
t.Log.Info("logmon: block solved", "height", height)
default:
t.Log.Warn("logmon: events channel full, dropping", "height", height)
}
}
// sleep returns false if ctx was cancelled during the wait.
func sleep(ctx context.Context, d time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}