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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user