diff --git a/src/configs/config.go b/src/configs/config.go index d76302a..8a06247 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -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) { diff --git a/src/configs/config_test.go b/src/configs/config_test.go index 555832d..a44fd24 100644 --- a/src/configs/config_test.go +++ b/src/configs/config_test.go @@ -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 := ` diff --git a/src/servers/handler.go b/src/servers/handler.go index 0ec6e75..78ac66b 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -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)