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.
175 lines
4.0 KiB
Go
175 lines
4.0 KiB
Go
// 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
|
|
}
|
|
}
|