fix: data race on the shared userNames map - #147
piotrsynowiec wants to merge 1 commit into
Conversation
resolveUserCached memoised a resolved display name by writing it back
into the shared userNames map, and said so:
// UI-goroutine callers only — the map write is what
// lookupUserCached exists to avoid.
Every one of its four call sites was off the UI goroutine — three fetch
paths running as bubbletea commands, and rtmEventHandler.OnMessage on
the WebSocket reader. The map is read by the UI goroutine while
rendering, so the write races it, and Go aborts the process outright on
a detected concurrent map write.
The window is small and data-dependent: the write only fires on a cache
hit for a name not yet memoised this session, so it is common on a warm
cache and absent on a cold one — the opposite of what a fresh test run
exercises.
Points the call sites at lookupUserCached, which does not write, and
deletes resolveUserCached rather than leaving it unused. A function
whose stated contract no caller satisfies is a trap for the next
person; the memoisation it bought was one indexed SQLite lookup against
a map the resolver repopulates on the UI goroutine anyway.
The race test was verified by reintroducing the write and confirming
-race reports it, so it fails on the original defect rather than merely
passing on the fix.
|
The bug is real and it's the most serious thing in the current PR queue — thanks for finding it. It's also worse than your description claims, in your favour. Every call site of
And the description says "the write only fires on a cache hit for a name not yet memoised in this session" — that's not right. I reproduced it by restoring the write-back under your test: A Two things before I merge. 1. The fix is incomplete — there's a second unsynchronised writer. 2.
Smaller:
Your CI lint failure is not your fault: it's an old golangci-lint panicking under go1.27 ( |
|
#171 has landed and I've re-run CI here — the Note that #171 also enabled My review above still stands — that's what's needed to move this forward. |
What breaks
slkcan die outright with Go'sfatal error: concurrent map read and map write. It is not recoverable — the runtime aborts the process, so the whole client disappears mid-session.Why
resolveUserCachedmemoised a resolved display name by writing it back into the shareduserNamesmap. Its own doc comment stated the constraint:All four of its call sites violated it:
messageAuthorviafetchChannelMessagesmessageAuthorviafetchOlderMessagesmessageAuthorviafetchThreadRepliesrtmEventHandler.OnMessageThe UI goroutine reads that same map while rendering, so every resolved name raced it.
The window is narrow and data-dependent: the write only fires on a cache hit for a name not yet memoised in this session. That makes it common on a warm cache and absent on a cold one — the opposite of what a fresh test run exercises, which is likely why it went unnoticed.
The fix
Point the call sites at
lookupUserCached, which does not write, and deleteresolveUserCachedrather than leave it unused. A function whose stated contract no caller satisfies is a trap for the next person.Nothing is lost by dropping the memoisation: it bought one indexed SQLite point lookup, against a map the resolver repopulates on the UI goroutine anyway.
Testing
cmd/slk/message_author_race_test.godrivesmessageAuthorfrom a background goroutine while the main goroutine reads the map, and is meant to be run under-race.The test was validated against the defect, not just against the fix: reintroducing the write makes
-racereport it. A race test that has never failed proves nothing, so this seemed worth confirming.Full suite passes, including
go test -race ./cmd/slk/.🤖 Generated with Claude Code