Files
xboard-node/cmd/xboard-node/main.go
T
2026-03-15 09:34:02 +08:00

193 lines
4.9 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"runtime/debug"
"strings"
"sync"
"syscall"
"time"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/service"
)
var (
version = "dev"
buildTime = "unknown"
)
func main() {
configPath := flag.String("c", "config.yml", "config file path")
showVersion := flag.Bool("v", false, "show version")
flag.Parse()
if *showVersion {
fmt.Printf("xboard-node %s (built %s)\n", version, buildTime)
os.Exit(0)
}
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}
config.InitLogger(cfg.Log)
// Apply runtime memory tuning before anything else allocates.
applyRuntimeConfig(cfg.Runtime)
nodes := cfg.ExpandNodes()
slog.Info("xboard-node starting",
"version", version,
"build_time", buildTime,
"nodes", len(nodes),
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("received signal, shutting down gracefully", "signal", sig)
cancel()
// Force-exit on second signal or after timeout.
select {
case sig = <-sigCh:
slog.Warn("received second signal, forcing exit", "signal", sig)
os.Exit(1)
case <-time.After(15 * time.Second):
slog.Error("shutdown timed out after 15s, forcing exit")
os.Exit(2)
}
}()
// Optional health-check endpoint for container orchestrators.
if cfg.HealthPort > 0 {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HealthPort))
if err != nil {
slog.Error("failed to start health check listener", "port", cfg.HealthPort, "error", err)
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
slog.Info("health check listening", "addr", ln.Addr())
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
slog.Warn("health check server stopped", "error", err)
}
}()
go func() {
<-ctx.Done()
srv.Close()
}()
}
// Run one service per node. If any node fails, cancel all others.
errCh := make(chan error, len(nodes))
var wg sync.WaitGroup
for _, nodeCfg := range nodes {
nodeCfg := nodeCfg // capture
wg.Add(1)
go func() {
defer wg.Done()
svc := service.New(nodeCfg)
if err := svc.Run(ctx); err != nil {
slog.Error("node service exited with error",
"node_id", nodeCfg.Panel.NodeID, "error", err)
errCh <- err
cancel() // bring down all other nodes
} else {
slog.Info("node service stopped", "node_id", nodeCfg.Panel.NodeID)
}
}()
}
wg.Wait()
close(errCh)
if err := <-errCh; err != nil {
os.Exit(1)
}
slog.Info("xboard-node stopped")
}
// applyRuntimeConfig wires up Go runtime memory limits from the config file.
// Both settings can also be overridden by environment variables (GOMEMLIMIT /
// GOGC) — the env vars take precedence because Go's runtime reads them before
// we can call these functions, but we set them here for completeness and so
// the values are logged.
func applyRuntimeConfig(rt config.RuntimeConfig) {
// GOGC
if rt.GoGCPercent > 0 {
prev := debug.SetGCPercent(rt.GoGCPercent)
slog.Info("runtime: GOGC set", "gogc", rt.GoGCPercent, "prev", prev)
}
// GOMEMLIMIT — parse human-readable size string (e.g. "30MiB")
if rt.GoMemLimit != "" {
limit, err := parseMemLimit(rt.GoMemLimit)
if err != nil {
slog.Warn("runtime: invalid gomemlimit, ignoring", "value", rt.GoMemLimit, "error", err)
} else {
prev := debug.SetMemoryLimit(limit)
slog.Info("runtime: GOMEMLIMIT set",
"limit", rt.GoMemLimit,
"bytes", limit,
"prev_bytes", prev,
)
}
}
}
// parseMemLimit converts a human-readable size string to bytes.
// Supported suffixes: B, KiB, MiB, GiB, TiB (case-insensitive).
func parseMemLimit(s string) (int64, error) {
s = strings.TrimSpace(s)
suffixes := []struct {
suffix string
mult int64
}{
{"TiB", 1 << 40},
{"GiB", 1 << 30},
{"MiB", 1 << 20},
{"KiB", 1 << 10},
{"B", 1},
}
upper := strings.ToUpper(s)
for _, sf := range suffixes {
if strings.HasSuffix(upper, strings.ToUpper(sf.suffix)) {
numStr := strings.TrimSuffix(upper, strings.ToUpper(sf.suffix))
numStr = strings.TrimSpace(numStr)
var n int64
if _, err := fmt.Sscanf(numStr, "%d", &n); err != nil {
return 0, fmt.Errorf("parse number %q: %w", numStr, err)
}
return n * sf.mult, nil
}
}
// No suffix: treat as raw bytes.
var n int64
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
return 0, fmt.Errorf("unrecognised size format %q", s)
}
return n, nil
}