fix(discord): guard botID/appID/session under p.mu in event callbacks - #1664
Open
hi-neason wants to merge 1 commit into
Open
fix(discord): guard botID/appID/session under p.mu in event callbacks#1664hi-neason wants to merge 1 commit into
hi-neason wants to merge 1 commit into
Conversation
The Ready callback wrote p.botID and p.appID with no lock held, while MessageCreate, GuildCreate, RegisterCommands, and cacheBotRoleIDForGuild read those fields from separate goroutines. discordgo dispatches each event in its own goroutine, so the slice-header/string-pointer reads raced with the Ready write under -race. p.session was already written under p.mu.Lock on connect and in Stop, but RegisterCommands read it without the lock. - Take p.mu.Lock in Ready when setting botID/appID. - Snapshot botID and session under p.mu.RLock at the top of MessageCreate and use those locals instead of touching the fields again. - Snapshot session/appID under RLock in RegisterCommands before the bulk-overwrite call, and fail fast if the session is not connected. - Pass botID as a parameter through cacheBotRoleIDForGuild / resolveBotRoleIDForGuild so both helpers read it under RLock once. - Add a -race regression test that drives concurrent Ready-style writes against cacheBotRoleIDForGuild reads. Co-Authored-By: Claude <noreply@anthropic.com>
chenhg5
approved these changes
Aug 13, 2026
chenhg5
left a comment
Owner
There was a problem hiding this comment.
结论: Approve
总体判断: 一个干净的 concurrency 修复——把 Discord 平台 struct 里 p.botID/p.appID/p.session 的访问都统一到 p.mu 锁下,与既有 Stop / connect loop 的锁约定对齐。修复面精准,race test 在 fix 前会红、fix 后绿。建议合入。
Review 范围:
- 看了
platform/discord/discord.go中 Ready / MessageCreate / RegisterCommands / cacheBotRoleIDForGuild / resolveBotRoleIDForGuild 五个点的锁添加。 - 看了新增测试
TestPlatform_IdentityFields_Race验证 race fix(用 stubhttp.RoundTripper避免真实网络)。 - CI: run 31349761110 全绿(lint / unit-test / smoke / regression / performance)。
✅ 做得好的地方:
- 修复范围对齐既有约定:作者明确指出
Stop已经用p.mu.Lock包p.session,connect loop 也是——只是Ready回调里botID/appID漏了锁。这条 fix 不是「引入新锁」,而是「扩展既有 lock 协议到剩余字段」。最小侵入、最符合 reviewer 心智模型。 - Lock 选择合理:写用
Lock、读用RLock,符合 sync.RWMutex 语义。MessageCreate 是 hot path,用 RLock 让 Ready 写不会阻塞其他 MessageCreate 读。 - Snapshot 模式干净:MessageCreate 顶部一次性
RLock+ 读三个字段 +RUnlock,后续整段 handler 用本地 snapshot。避免锁内调用cacheBotRoleIDForGuild这种可能 reentrance 的代码——这是教科书做法(避免 lock-in-lock / lock-coupling)。 - botID 通过参数传递而不是直接读 struct:cacheBotRoleIDForGuild → resolveBotRoleIDForGuild 链改成「botID 作为参数传入」,让调用者负责锁,helper 内部无锁。这种「pass-the-token」模式让并发模型可推理。
- Race test 用 stub RoundTripper 避免真实网络:
http.RoundTripper直接 fail 让测试不依赖 Discord 真连接——这是 hermetic 测试的标准做法,避免 flaky CI。 - RegisterCommands 改 panic 为 error:原代码
p.session为 nil 会 panic——这种「Ready 之前手动调 RegisterCommands」是 misuse,但返回 error 比 panic 更友好,让调用方可以 fail-gracefully。 - PR body 描述堪称模范:明确「same field used for two different protection domains」、「string-header reads」这种 race 机制解释,加 diff 行号引用,加 -race 复现命令——reviewer 不必自己爬代码。
🚨/🔴 必须处理:
- 未发现。
🟠 建议改进(不阻塞):
- TestPlatform_IdentityFields_Race 复现 race 的复现性:作者说「revert fix locally → go test -race 失败 with race detected during execution of test」。建议在测试里加一行注释明确 race 的最小复现条件(多少 goroutine / 多少次写读),便于 future reader 理解为什么这个测试在 fix 前会红。
p.appID在 RegisterCommands 改 error 路径:return nil, fmt.Errorf("discord: session not connected")——但上层 caller 是否真的处理这个 error?建议在 cmd/cc-connect 启动流程里 grep 一下 RegisterCommands 调用点,确认上层确实把 error 上报 / fail。cacheBotRoleIDForGuild的新参数是botID string还是*Platform? 看描述「pass botID as a parameter」应该是 string,但 helper 内部如果还需要p.session还得传 session。建议作者确认 helper 链上的最小信息传递——避免「string snapshot + Platform 还是同时存在」的混淆。
🔵 可选优化:
- 测试名
identity_race_test.go暗示这是「identity 字段的 race」测试,可以补充一个TestPlatform_RegisterCommands_PreReady_ReturnsError把「RegisterCommands 改 error 路径」也钉死。低优先。
Testing / Risk:
- 已看到的验证: -race 模式下测试通过;CI 全绿;fix revert 后测试确实失败(作者 self-verify)。
- 未覆盖风险: 真生产环境下多个 guild 同时 register commands 时锁竞争——但
RegisterCommands是启动期一次性调用,hot path 不会有性能问题。 - Blast radius: 仅
platform/discord/,不影响其他 platform。
Next step:
- 建议 owner 直接 merge。Concurrency fix 范围精准,race test 设计好,PR body 描述清晰。可以现在合。
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
platform/discord/discord.gohad the samep.mufield used for two different protection domains:p.sessionwas written underp.mu.Lockon connect and inStop, butp.botID/p.appIDwere written in theReadycallback without any lock while theMessageCreate,GuildCreate, andRegisterCommandspaths read them from separate goroutines. discordgo dispatches each gateway event in its own goroutine, so the string-header reads inMessageCreateraced with theReadywrite under-race. The field declarations in the struct also putbotID/appIDabove themuthat guardssession, which made the intended protection domain non-obvious.Change
p.mu.Lockin theReadycallback when assigningbotID/appID.botIDand the connectedsessionunderRLockat the top of theMessageCreatehandler and use the locals throughout (includingresolveThreadReplyContext).session/appIDunderRLockinRegisterCommands, and return an error if called before the session is connected instead of panicking on a nil pointer.botIDas a parameter throughcacheBotRoleIDForGuild→resolveBotRoleIDForGuildso both helpers read it once underRLockrather than touching the field from inside the request path.Type of change
Testing
Automated tests added in this PR
platform/discord/identity_race_test.goTestPlatform_IdentityFields_Race— drives concurrentReady-style identity writes againstcacheBotRoleIDForGuild(which calls intoresolveBotRoleIDForGuildand readsp.botID). Uses a stubhttp.RoundTripperthat fails instantly so the test never makes a real network call. Passes only when both writer and reader holdp.mu.For bug fixes only — regression test
TestPlatform_IdentityFields_Race.go test -race -run TestPlatform_IdentityFields_Race ./platform/discord/failed withrace detected during execution of testpointing at the unlockedp.botID = r.User.IDwrite and the unlockedp.botIDread incacheBotRoleIDForGuild.Critical User Journeys (CUJ) impact
go test ./core/ -run TestCUJpasses locally.Manual / user-visible behavior change
None under normal operation.
RegisterCommandsnow returns a clear"discord: session not connected"error if invoked before the gateway Ready event, instead of panicking on a nilp.session— this only matters for callers that ignore the existingreadyChsynchronization.Checklist (reviewer will verify)
go build ./...passesgo test -race ./platform/discord/passescore/Related
Stopalready tookp.mu.Lockaroundp.session; the connect loop atdiscord.go:714already tookp.mu.Lockaroundp.session = session. This change extends that existing locking contract tobotID/appIDand the remaining reads rather than introducing a new lock.