From 1a629df6379e8135aa428f1e1ea348501c8bdfdb Mon Sep 17 00:00:00 2001 From: kira1928 Date: Fri, 4 Sep 2026 00:49:03 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B6=88=E9=99=A4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=B9=B6=E5=8F=91=E8=AF=BB=E5=86=99=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=20fatal=20=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetLiveRoomByUrl 在索引缓存未命中时会调用 RefreshLiveRoomIndexCache 写入共享的不可变配置快照的 map。该函数被列表接口、录制、监听等大量 goroutine 高频并发调用,一个 goroutine 写 map 而另一个读同一 map 时, 触发 Go 运行时的 fatal error(concurrent map read and map write), 整个进程直接退出——表现为「开始录制后一段时间报错崩溃」。 配置采用不可变快照架构(Update 复制-修改-原子替换,替换前统一 RefreshLiveRoomIndexCache),已存入的快照不应再被就地修改。本次修复: - GetLiveRoomByUrl 改为纯只读:命中缓存直接返回,未命中回退到只读线性 扫描,不再写共享快照的 map。 - 移除 putConfig / putRawConfig 中对共享快照的 RefreshLiveRoomIndexCache 调用(Marshal 不需要该缓存,后续查找也已是只读)。 - 删除无调用者且同样就地修改快照的死方法 (*Config).RemoveLiveRoomByUrl。 - 新增并发回归测试 TestGetLiveRoomByUrlConcurrent。 via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 --- src/configs/config.go | 30 ++++++++++++------------------ src/configs/config_test.go | 35 +++++++++++++++++++++++++++++++++++ src/servers/handler.go | 6 ++++-- 3 files changed, 51 insertions(+), 20 deletions(-) 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)