reliability: wrap all 27 bare goroutines with safe.Go/GoNamed panic recovery

Applied safe.GoNamed to:
- 9 exchange order_sync goroutines (OKX, Hyperliquid, Aster, Bybit, KuCoin, Gate, Bitget, Lighter, Binance×2)
- Drawdown monitor (auto_trader_risk.go)
- Brain news scanner + market briefs (agent/brain.go)
- Sentinel scanner (agent/sentinel.go)
- Agent scheduler (agent/scheduler.go)
- x402 idle watchdog (mcp/payment/x402.go)
- MCP stream idle watchdog (mcp/client.go)
- Rate limiter cleanup (api/rate_limiter.go)
- 3 telemetry fire-and-forget sends (telemetry/experience.go)
- CoinAnk WS handler (provider/coinank/coinank_api/kline_ws.go)
- API server goroutine (main.go)

Added manual defer/recover with error reporting to:
- Telegram AI agent handler (sends error msg to user on panic)
- Trader data fetch (returns error result on panic to prevent deadlock)

Before: a panic in ANY of these 27 goroutines would crash the entire
trading process with zero diagnostics. Now all panics are caught, logged
with stack traces, and the process continues running.
This commit is contained in:
shinchan-zhai
2026-03-23 10:30:13 +08:00
parent 07b4f30b85
commit 5e06037fa2
21 changed files with 84 additions and 48 deletions

View File

@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"net/http"
"nofx/safe"
"strings"
"sync"
"time"
@@ -49,7 +50,7 @@ func (b *Brain) HandleSignal(sig Signal) {
func (b *Brain) StartNewsScan(interval time.Duration) {
seen := make(map[string]bool)
go func() {
safe.GoNamed("brain-news-scan", func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
@@ -59,7 +60,7 @@ func (b *Brain) StartNewsScan(interval time.Duration) {
b.scanNews(seen)
}
}
}()
})
}
func (b *Brain) scanNews(seen map[string]bool) {
@@ -117,7 +118,7 @@ func (b *Brain) scanNews(seen map[string]bool) {
}
func (b *Brain) StartMarketBriefs(hours []int) {
go func() {
safe.GoNamed("brain-market-briefs", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
sent := make(map[string]bool)
@@ -134,7 +135,7 @@ func (b *Brain) StartMarketBriefs(hours []int) {
}
}
}
}()
})
}
func (b *Brain) sendBrief(hour int) {

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"nofx/safe"
"strings"
"time"
)
@@ -19,7 +20,7 @@ func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
safe.GoNamed("agent-scheduler", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastReport := time.Time{}
@@ -50,7 +51,7 @@ func (s *Scheduler) Start(ctx context.Context) {
}
}
}
}()
})
}
func (s *Scheduler) Stop() { close(s.stopCh) }

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"math"
"net/http"
"nofx/safe"
"strconv"
"strings"
"sync"
@@ -61,7 +62,7 @@ func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sent
}
func (s *Sentinel) Start() {
go func() {
safe.GoNamed("sentinel", func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
s.scan()
@@ -73,7 +74,7 @@ func (s *Sentinel) Start() {
s.scan()
}
}
}()
})
}
func (s *Sentinel) Stop() { close(s.stopCh) }

View File

@@ -2,6 +2,7 @@ package api
import (
"net/http"
"nofx/safe"
"sync"
"time"
@@ -35,13 +36,13 @@ func NewLoginRateLimiter() *LoginRateLimiter {
}
// Clean up stale entries every 10 minutes
go func() {
safe.GoNamed("rate-limiter-cleanup", func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.cleanup()
}
}()
})
return rl
}

View File

@@ -10,6 +10,7 @@ import (
"nofx/telemetry"
"nofx/logger"
"nofx/manager"
"nofx/safe"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
@@ -136,11 +137,11 @@ func main() {
telegramReloadCh := make(chan struct{}, 1)
server.SetTelegramReloadCh(telegramReloadCh)
go func() {
safe.GoNamed("api-server", func() {
if err := server.Start(); err != nil {
logger.Fatalf("❌ Failed to start API server: %v", err)
}
}()
})
// Start NOFXi Agent (proactive intelligence layer) — must be created BEFORE Telegram bot
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())

View File

@@ -273,6 +273,16 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
// Concurrently fetch data for each trader
for i, t := range traders {
go func(index int, trader *trader.AutoTrader) {
defer func() {
if r := recover(); r != nil {
logger.Errorf("🔥 [trader-data-fetch] panic for trader %s: %v", trader.GetName(), r)
resultChan <- traderResult{index: index, data: map[string]interface{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
"error": "internal panic",
}}
}
}()
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -281,14 +291,14 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
accountChan := make(chan map[string]interface{}, 1)
errorChan := make(chan error, 1)
go func() {
safe.GoNamed("trader-account-info", func() {
account, err := trader.GetAccountInfo()
if err != nil {
errorChan <- err
} else {
accountChan <- account
}
}()
})
status := trader.GetStatus()
var traderData map[string]interface{}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"nofx/safe"
"strings"
"time"
)
@@ -689,7 +690,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resetCh := make(chan struct{}, 1)
go func() {
safe.GoNamed("mcp-stream-idle-watchdog", func() {
t := time.NewTimer(idleTimeout)
defer t.Stop()
for {
@@ -710,7 +711,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
t.Reset(idleTimeout)
}
}
}()
})
httpReq = httpReq.WithContext(ctx)
resp, err := client.HTTPClient.Do(httpReq)

View File

@@ -14,6 +14,7 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"nofx/mcp"
"nofx/safe"
)
const (
@@ -405,7 +406,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
// Start idle-timeout watchdog AFTER the 402 dance is done.
resetCh := make(chan struct{}, 1)
go func() {
safe.GoNamed("x402-idle-watchdog", func() {
t := time.NewTimer(x402StreamIdleTimeout)
defer t.Stop()
for {
@@ -426,7 +427,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
t.Reset(x402StreamIdleTimeout)
}
}
}()
})
onLine := func() {
select {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"nofx/provider/coinank"
"nofx/provider/coinank/coinank_enum"
"nofx/safe"
"strconv"
"strings"
@@ -105,7 +106,7 @@ func read(conn *websocket.Conn, ch chan string) {
func handleResponse(ch <-chan string, needKline bool, needTicker bool) (<-chan *WsResult[coinank.KlineResult], <-chan *WsResult[KlineTickers]) {
klineCh := make(chan *WsResult[coinank.KlineResult], 1024)
tickersCh := make(chan *WsResult[KlineTickers], 1024)
go func() {
safe.GoNamed("coinank-ws-handler", func() {
if needKline {
defer close(klineCh)
} else {
@@ -147,7 +148,7 @@ func handleResponse(ch <-chan string, needKline bool, needTicker bool) (<-chan *
}
}
}
}()
})
return klineCh, tickersCh
}

View File

@@ -227,6 +227,13 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
// ── AI agent ─────────────────────────────────────────────────────────
go func(chatID int64, text string) {
defer func() {
if r := recover(); r != nil {
logger.Errorf("🔥 [telegram-ai-agent] panic: %v", r)
msg := tgbotapi.NewMessage(chatID, "⚠️ Internal error. Please try again.")
bot.Send(msg) //nolint:errcheck
}
}()
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
placeholderID := 0
if err == nil {

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"net/http"
"nofx/safe"
"sync"
"time"
)
@@ -108,9 +109,9 @@ func TrackTrade(event TradeEvent) {
}
// Send asynchronously to not block trading
go func() {
safe.Go(func() {
_ = sendTradeEvent(event)
}()
})
}
// sendTradeEvent sends the trade event to GA4
@@ -165,7 +166,7 @@ func TrackStartup(version string) {
return
}
go func() {
safe.Go(func() {
client.mu.RLock()
installationID := client.installationID
client.mu.RUnlock()
@@ -194,7 +195,7 @@ func TrackStartup(version string) {
resp.Body.Close()
}
}
}()
})
}
func TrackAIUsage(event AIUsageEvent) {
@@ -202,7 +203,7 @@ func TrackAIUsage(event AIUsageEvent) {
return
}
go func() {
safe.Go(func() {
client.mu.RLock()
installationID := client.installationID
client.mu.RUnlock()
@@ -238,5 +239,5 @@ func TrackAIUsage(event AIUsageEvent) {
resp.Body.Close()
}
}
}()
})
}

View File

@@ -3,6 +3,7 @@ package aster
import (
"fmt"
"nofx/logger"
"nofx/safe"
"nofx/market"
"nofx/store"
"sort"
@@ -182,12 +183,12 @@ func deriveAsterOrderAction(side, positionSide string, realizedPnL float64) stri
// StartOrderSync starts background order sync task for Aster
func (t *AsterTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("aster-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Aster order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 Aster order sync started (interval: %v)", interval)
}

View File

@@ -3,6 +3,7 @@ package trader
import (
"fmt"
"nofx/logger"
"nofx/safe"
"strings"
"time"
)
@@ -10,7 +11,7 @@ import (
// startDrawdownMonitor starts drawdown monitoring
func (at *AutoTrader) startDrawdownMonitor() {
at.monitorWg.Add(1)
go func() {
safe.GoNamed("drawdown-monitor", func() {
defer at.monitorWg.Done()
ticker := time.NewTicker(1 * time.Minute) // Check every minute
@@ -27,7 +28,7 @@ func (at *AutoTrader) startDrawdownMonitor() {
return
}
}
}()
})
}
// checkPositionDrawdown checks position drawdown situation

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/safe"
"nofx/store"
"nofx/trader/types"
"sort"
@@ -351,21 +352,21 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized
// StartOrderSync starts background order sync task for Binance
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
// Run first sync immediately
go func() {
safe.GoNamed("binance-order-sync-initial", func() {
logger.Infof("🔄 Running initial Binance order sync...")
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Initial Binance order sync failed: %v", err)
}
}()
})
// Then run periodically
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("binance-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Binance order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 Binance order sync started (interval: %v)", interval)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/safe"
"nofx/store"
"sort"
"strconv"
@@ -281,12 +282,12 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
// StartOrderSync starts background order sync task for Bitget
func (t *BitgetTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("bitget-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Bitget order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 Bitget order sync started (interval: %v)", interval)
}

View File

@@ -10,6 +10,7 @@ import (
"net/http"
"nofx/logger"
"nofx/market"
"nofx/safe"
"nofx/store"
"sort"
"strconv"
@@ -300,12 +301,12 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
// StartOrderSync starts background order sync task for Bybit
func (t *BybitTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("bybit-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Bybit order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 Bybit order sync started (interval: %v)", interval)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/safe"
"nofx/store"
"sort"
"strconv"
@@ -293,12 +294,12 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch
// StartOrderSync starts background order sync task for Gate
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("gate-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Gate order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 Gate order sync started (interval: %v)", interval)
}

View File

@@ -3,6 +3,7 @@ package hyperliquid
import (
"fmt"
"nofx/logger"
"nofx/safe"
"nofx/market"
"nofx/store"
"sort"
@@ -138,12 +139,12 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
// StartOrderSync starts background order sync task
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("hyperliquid-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Hyperliquid order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 Hyperliquid order sync started (interval: %v)", interval)
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/safe"
"nofx/store"
"nofx/trader/types"
"sort"
@@ -401,12 +402,12 @@ func (t *KuCoinTrader) SyncOrdersFromKuCoin(traderID string, exchangeID string,
// StartOrderSync starts background order sync task for KuCoin
func (t *KuCoinTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("kucoin-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ KuCoin order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 KuCoin order sync started (interval: %v)", interval)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/safe"
"nofx/store"
"sort"
"strings"
@@ -147,7 +148,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
// StartOrderSync starts background order sync task
func (t *LighterTraderV2) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("lighter-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, st); err != nil {
// Only log non-404 errors to reduce log spam
@@ -156,6 +157,6 @@ func (t *LighterTraderV2) StartOrderSync(traderID string, exchangeID string, exc
}
}
}
}()
})
logger.Infof("🔄 Lighter order+position sync started (interval: %v)", interval)
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/safe"
"nofx/market"
"nofx/store"
"sort"
@@ -274,12 +275,12 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
// StartOrderSync starts background order sync task for OKX
func (t *OKXTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
safe.GoNamed("okx-order-sync", func() {
for range ticker.C {
if err := t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ OKX order sync failed: %v", err)
}
}
}()
})
logger.Infof("🔄 OKX order sync started (interval: %v)", interval)
}