Merge pull request #1198 from bililive-go/fix/config-snapshot-concurrent-map

fix: 消除配置快照并发读写导致的 fatal 崩溃
This commit is contained in:
kira1928 authored and GitHub committed 2026-09-04 01:58:56 +09:00
commit ef71711a7c
3 files changed
+51 -20

No files matched your search

+12 -18
View File
@@ -1170,27 +1170,21 @@ func (c *Config) RefreshLiveRoomIndexCache() {
}
}
func (c *Config) RemoveLiveRoomByUrl(url string) error {
c.RefreshLiveRoomIndexCache()
if index, ok := c.liveRoomIndexCache[url]; ok {
if index >= 0 && index < len(c.LiveRooms) && c.LiveRooms[index].Url == url {
c.LiveRooms = append(c.LiveRooms[:index], c.LiveRooms[index+1:]...)
delete(c.liveRoomIndexCache, url)
return nil
}
}
return errors.New("failed removing room: " + url)
}
func (c *Config) GetLiveRoomByUrl(url string) (*LiveRoom, error) {
room, err := c.getLiveRoomByUrlImpl(url)
if err != nil {
c.RefreshLiveRoomIndexCache()
if room, err = c.getLiveRoomByUrlImpl(url); err != nil {
return nil, err
// 配置快照是不可变的:所有写操作都走 Update 的“复制-修改-原子替换”路径,
// 并在替换前调用 RefreshLiveRoomIndexCache 维护索引缓存。因此这里只做只读查找,
// 绝不在共享快照上写 map/切片,否则会与其它 goroutine 的读发生并发读写,
// 触发 Go 运行时的 fatal error(concurrent map read and map write)。
if room, err := c.getLiveRoomByUrlImpl(url); err == nil {
return room, nil
}
// 索引缓存未命中时回退到只读线性扫描,作为兜底(例如未经 Refresh 的手工构造配置)。
for i := range c.LiveRooms {
if c.LiveRooms[i].Url == url {
return &c.LiveRooms[i], nil
}
}
return room, nil
return nil, errors.New("room " + url + " doesn't exist.")
}
func (c Config) getLiveRoomByUrlImpl(url string) (*LiveRoom, error) {
+35
View File
@@ -2,6 +2,7 @@ package configs
import (
"os"
"sync"
"testing"
"github.com/bililive-go/bililive-go/src/pkg/ratelimit"
@@ -121,6 +122,40 @@ func TestPlatformRateLimitRemainsDisabledAfterConfigUpdate(t *testing.T) {
}
}
// TestGetLiveRoomByUrlConcurrent 保证在共享的不可变配置快照上并发查询房间时不会
// 触发 Go 运行时的 "concurrent map read and map write" fatal error。
// 回归此前的 bug:GetLiveRoomByUrl 在缓存未命中时会调用 RefreshLiveRoomIndexCache
// 写入共享快照的 map,与其它 goroutine 的读并发时导致崩溃。
// 使用 -race 运行可进一步检测数据竞争。
func TestGetLiveRoomByUrlConcurrent(t *testing.T) {
cfg := NewConfig()
cfg.LiveRooms = []LiveRoom{
{Url: "https://live.bilibili.com/1", IsListening: true},
{Url: "https://live.bilibili.com/2", IsListening: true},
}
cfg.RefreshLiveRoomIndexCache()
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 500; j++ {
// 交替命中缓存与未命中(触发原先的写-回退路径)。
if (i+j)%2 == 0 {
room, err := cfg.GetLiveRoomByUrl("https://live.bilibili.com/1")
assert.NoError(t, err)
assert.Equal(t, "https://live.bilibili.com/1", room.Url)
} else {
_, err := cfg.GetLiveRoomByUrl("https://live.bilibili.com/does-not-exist")
assert.Error(t, err)
}
}
}(i)
}
wg.Wait()
}
func TestBackwardsCompatibility(t *testing.T) {
// Test that old config files still work
oldConfigYaml := `
+4 -2
View File
@@ -952,8 +952,9 @@ func getConfig(writer http.ResponseWriter, r *http.Request) {
}
func putConfig(writer http.ResponseWriter, r *http.Request) {
// 直接序列化当前快照即可;索引缓存不参与序列化,且共享快照不可变,
// 不能在此调用 RefreshLiveRoomIndexCache 写它的 map。
config := configs.GetCurrentConfig()
config.RefreshLiveRoomIndexCache()
if err := config.Marshal(); err != nil {
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
ErrNo: http.StatusBadRequest,
@@ -1002,7 +1003,8 @@ func putRawConfig(writer http.ResponseWriter, r *http.Request) {
return
}
oldConfig := configs.GetCurrentConfig()
oldConfig.RefreshLiveRoomIndexCache()
// 共享快照不可变:其索引缓存在生成时已由 Update 维护好,
// 这里只做只读查找,不能再调用 RefreshLiveRoomIndexCache 写它的 map。
// 继承原配置的文件路径
newConfig.File = oldConfig.File
// 预先将旧配置中的 LiveId 迁移到新配置(相同 URL)