From 57e31b2acee00afb56dde9b9dbce7572cf2b4de7 Mon Sep 17 00:00:00 2001 From: 0xYYBB | ZYY | Bobo <128128010+the-dev-z@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:50:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(trader):=20add=20mutex=20to=20prevent=20rac?= =?UTF-8?q?e=20condition=20in=20Meta=20refresh=20(#796)=20*=20fix(trader):?= =?UTF-8?q?=20add=20mutex=20to=20prevent=20race=20condition=20in=20Meta=20?= =?UTF-8?q?refresh=20(issue=20#742)=20**=E5=95=8F=E9=A1=8C**=EF=BC=9A=20?= =?UTF-8?q?=E6=A0=B9=E6=93=9A=20issue=20#742=20=E5=AF=A9=E6=9F=A5=E6=A8=99?= =?UTF-8?q?=E6=BA=96=EF=BC=8C=E7=99=BC=E7=8F=BE=20BLOCKING=20=E7=B4=9A?= =?UTF-8?q?=E5=88=A5=E7=9A=84=E4=B8=A6=E7=99=BC=E5=AE=89=E5=85=A8=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=EF=BC=9A=20-=20refreshMetaIfNeeded()=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=20`t.meta=20=3D=20meta`=20=E7=BC=BA=E5=B0=91=E4=B8=A6?= =?UTF-8?q?=E7=99=BC=E4=BF=9D=E8=AD=B7=20-=20=E5=A4=9A=E5=80=8B=20goroutin?= =?UTF-8?q?e=20=E5=90=8C=E6=99=82=E8=AA=BF=E7=94=A8=20OpenLong/OpenShort?= =?UTF-8?q?=20=E6=9C=83=E9=80=A0=E6=88=90=E7=AB=B6=E6=85=8B=E6=A2=9D?= =?UTF-8?q?=E4=BB=B6=20**=E4=BF=AE=E5=BE=A9**=EF=BC=9A=201.=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20sync.RWMutex=20=E4=BF=9D=E8=AD=B7=20meta=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=202.=20refreshMetaIfNeeded()=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=AF=AB=E9=8E=96=E4=BF=9D=E8=AD=B7=20meta=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=203.=20getSzDecimals()=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AE=80=E9=8E=96=E4=BF=9D=E8=AD=B7=20meta=20=E8=A8=AA?= =?UTF-8?q?=E5=95=8F=20**=E7=AC=A6=E5=90=88=E6=A8=99=E6=BA=96**=EF=BC=9A?= =?UTF-8?q?=20-=20issue=20#742:=20"=E4=B8=A6=E7=99=BC=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E9=9C=80=E4=BD=BF=E7=94=A8=20sync.Once=20?= =?UTF-8?q?=E7=AD=89=E6=A9=9F=E5=88=B6"=20-=20=E4=BD=BF=E7=94=A8=20RWMutex?= =?UTF-8?q?=20=E5=AF=A6=E7=8F=BE=E8=AE=80=E5=AF=AB=E5=88=86=E9=9B=A2?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E5=8D=87=E4=B8=A6=E7=99=BC=E6=80=A7=E8=83=BD?= =?UTF-8?q?=20Co-Authored-By:=20tinkle-community=20?= =?UTF-8?q?=20*=20test(trader):=20add=20comprehensive=20race=20condition?= =?UTF-8?q?=20tests=20for=20meta=20field=20mutex=20protection=20-=20Test?= =?UTF-8?q?=20concurrent=20reads=20(100=20goroutines=20accessing=20getSzDe?= =?UTF-8?q?cimals)=20-=20Test=20concurrent=20read/write=20(50=20readers=20?= =?UTF-8?q?+=2010=20writers=20simulating=20meta=20refresh)=20-=20Test=20ni?= =?UTF-8?q?l=20meta=20edge=20case=20(returns=20default=20value=204)=20-=20?= =?UTF-8?q?Test=20valid=20meta=20with=20multiple=20coins=20(BTC,=20ETH,=20?= =?UTF-8?q?SOL)=20-=20Test=20massive=20concurrency=20(1000=20iterations=20?= =?UTF-8?q?with=20race=20detector)=20All=205=20test=20cases=20passed,=20in?= =?UTF-8?q?cluding=20-race=20verification=20with=20no=20data=20races=20det?= =?UTF-8?q?ected.=20Co-Authored-By:=20tinkle-community=20=20---------=20Co-authored-by:=20ZhouYongyou=20<128128010+?= =?UTF-8?q?zhouyongyou@users.noreply.github.com>=20Co-authored-by:=20tinkl?= =?UTF-8?q?e-community=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trader/hyperliquid_trader.go | 41 ++++++ trader/hyperliquid_trader_race_test.go | 192 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 trader/hyperliquid_trader_race_test.go diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index d0f80922..885ce0d8 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -8,6 +8,7 @@ import ( "log" "strconv" "strings" + "sync" "github.com/ethereum/go-ethereum/crypto" "github.com/sonirico/go-hyperliquid" @@ -19,6 +20,7 @@ type HyperliquidTrader struct { ctx context.Context walletAddr string meta *hyperliquid.Meta // 缓存meta信息(包含精度等) + metaMutex sync.RWMutex // 保护meta字段的并发访问 isCrossMargin bool // 是否为全仓模式 } @@ -334,6 +336,41 @@ func (t *HyperliquidTrader) SetLeverage(symbol string, leverage int) error { return nil } +// refreshMetaIfNeeded 当 Meta 信息失效时刷新(Asset ID 为 0 时触发) +func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { + assetID := t.exchange.Info().NameToAsset(coin) + if assetID != 0 { + return nil // Meta 正常,无需刷新 + } + + log.Printf("⚠️ %s 的 Asset ID 为 0,尝试刷新 Meta 信息...", coin) + + // 刷新 Meta 信息 + meta, err := t.exchange.Info().Meta(t.ctx) + if err != nil { + return fmt.Errorf("刷新 Meta 信息失败: %w", err) + } + + // ✅ 并发安全:使用写锁保护 meta 字段更新 + t.metaMutex.Lock() + t.meta = meta + t.metaMutex.Unlock() + + log.Printf("✅ Meta 信息已刷新,包含 %d 个资产", len(meta.Universe)) + + // 验证刷新后的 Asset ID + assetID = t.exchange.Info().NameToAsset(coin) + if assetID == 0 { + return fmt.Errorf("❌ 即使在刷新 Meta 后,资产 %s 的 Asset ID 仍为 0。可能原因:\n"+ + " 1. 该币种未在 Hyperliquid 上市\n"+ + " 2. 币种名称错误(应为 BTC 而非 BTCUSDT)\n"+ + " 3. API 连接问题", coin) + } + + log.Printf("✅ 刷新后 Asset ID 检查通过: %s -> %d", coin, assetID) + return nil +} + // OpenLong 开多仓 func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先取消该币种的所有委托单 @@ -778,6 +815,10 @@ func (t *HyperliquidTrader) FormatQuantity(symbol string, quantity float64) (str // getSzDecimals 获取币种的数量精度 func (t *HyperliquidTrader) getSzDecimals(coin string) int { + // ✅ 并发安全:使用读锁保护 meta 字段访问 + t.metaMutex.RLock() + defer t.metaMutex.RUnlock() + if t.meta == nil { log.Printf("⚠️ meta信息为空,使用默认精度4") return 4 // 默认精度 diff --git a/trader/hyperliquid_trader_race_test.go b/trader/hyperliquid_trader_race_test.go new file mode 100644 index 00000000..f52b5036 --- /dev/null +++ b/trader/hyperliquid_trader_race_test.go @@ -0,0 +1,192 @@ +package trader + +import ( + "context" + "sync" + "testing" + + "github.com/sonirico/go-hyperliquid" +) + +// TestMetaConcurrentAccess tests that concurrent access to meta field is safe +func TestMetaConcurrentAccess(t *testing.T) { + // Create a HyperliquidTrader instance with meta initialized + trader := &HyperliquidTrader{ + ctx: context.Background(), + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + // Number of concurrent goroutines + concurrency := 100 + var wg sync.WaitGroup + + // Test concurrent reads (getSzDecimals) + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // This should not cause race conditions + decimals := trader.getSzDecimals("BTC") + if decimals != 5 { + t.Errorf("Expected decimals 5, got %d", decimals) + } + }() + } + + wg.Wait() +} + +// TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field +func TestMetaConcurrentReadWrite(t *testing.T) { + trader := &HyperliquidTrader{ + ctx: context.Background(), + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + var wg sync.WaitGroup + concurrency := 50 + + // Concurrent readers + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + trader.getSzDecimals("BTC") + }() + } + + // Concurrent writers (simulating meta refresh) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(iteration int) { + defer wg.Done() + // Simulate meta update + trader.metaMutex.Lock() + trader.meta = &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5 + iteration%3}, + {Name: "ETH", SzDecimals: 4}, + }, + } + trader.metaMutex.Unlock() + }(i) + } + + wg.Wait() + + // Verify meta is not nil after all operations + trader.metaMutex.RLock() + if trader.meta == nil { + t.Error("Meta should not be nil after concurrent operations") + } + trader.metaMutex.RUnlock() +} + +// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta +func TestGetSzDecimals_NilMeta(t *testing.T) { + trader := &HyperliquidTrader{ + meta: nil, + metaMutex: sync.RWMutex{}, + } + + // Should return default value 4 when meta is nil + decimals := trader.getSzDecimals("BTC") + expectedDecimals := 4 + + if decimals != expectedDecimals { + t.Errorf("Expected default decimals %d for nil meta, got %d", expectedDecimals, decimals) + } +} + +// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta +func TestGetSzDecimals_ValidMeta(t *testing.T) { + trader := &HyperliquidTrader{ + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + {Name: "SOL", SzDecimals: 3}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + tests := []struct { + coin string + expectedDecimals int + }{ + {"BTC", 5}, + {"ETH", 4}, + {"SOL", 3}, + } + + for _, tt := range tests { + t.Run(tt.coin, func(t *testing.T) { + decimals := trader.getSzDecimals(tt.coin) + if decimals != tt.expectedDecimals { + t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals) + } + }) + } +} + +// TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues +// Run with: go test -race -run TestMetaMutex_NoRaceCondition +func TestMetaMutex_NoRaceCondition(t *testing.T) { + trader := &HyperliquidTrader{ + ctx: context.Background(), + meta: &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + }, + }, + metaMutex: sync.RWMutex{}, + } + + var wg sync.WaitGroup + iterations := 1000 + + // Massive concurrent reads + for i := 0; i < iterations; i++ { + wg.Add(1) + go func() { + defer wg.Done() + trader.getSzDecimals("BTC") + trader.getSzDecimals("ETH") + }() + } + + // Concurrent writes + for i := 0; i < 100; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + trader.metaMutex.Lock() + trader.meta = &hyperliquid.Meta{ + Universe: []hyperliquid.AssetInfo{ + {Name: "BTC", SzDecimals: 5}, + {Name: "ETH", SzDecimals: 4}, + {Name: "SOL", SzDecimals: 3}, + }, + } + trader.metaMutex.Unlock() + }(i) + } + + wg.Wait() + + // If we reach here without race detector errors, the test passes + t.Log("No race conditions detected in concurrent meta access") +}