Skip to content

fix: data race on the shared userNames map - #147

Open
piotrsynowiec wants to merge 1 commit into
gammons:mainfrom
piotrsynowiec:fix/usernames-map-data-race
Open

piotrsynowiec wants to merge 1 commit into
gammons:mainfrom
piotrsynowiec:fix/usernames-map-data-race

Conversation

@piotrsynowiec

Copy link
Copy Markdown

What breaks

slk can die outright with Go's fatal error: concurrent map read and map write. It is not recoverable — the runtime aborts the process, so the whole client disappears mid-session.

Why

resolveUserCached memoised a resolved display name by writing it back into the shared userNames map. Its own doc comment stated the constraint:

// UI-goroutine callers only — the map write is what
// lookupUserCached exists to avoid.

All four of its call sites violated it:

Call site Goroutine
messageAuthor via fetchChannelMessages bubbletea command
messageAuthor via fetchOlderMessages bubbletea command
messageAuthor via fetchThreadReplies bubbletea command
rtmEventHandler.OnMessage WebSocket reader

The 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 delete resolveUserCached rather 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.go drives messageAuthor from 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 -race report 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

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.
@gammons

gammons commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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 resolveUserCached is off the UI goroutine, despite the doc comment at main.go:2771 saying "UI-goroutine callers only":

call site goroutine
messageAuthor (main.go:2919, 2933) via fetchChannelMessages bubbletea cmd (reducer_channels.go:391)
via fetchOlderMessages bubbletea cmd (app.go:1490-1492)
via fetchMessagesAround bubbletea cmd (reducer_links.go:103-105)
via fetchThreadReplies bubbletea cmd (reducer_threads.go:82)
rtmEventHandler.OnMessage (main.go:4058) WebSocket reader

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. resolveUserCached writes back on every ok, including a hit that came straight out of userNames itself (main.go:2776-2779). So it fires on essentially every message with a known author. Not a narrow window at all.

I reproduced it by restoring the write-back under your test:

fatal error: concurrent map read and map write
WARNING: DATA RACE
  Previous write at 0x00c00027a138 by goroutine 48:
    resolveUserCachedOLD() main.go:2896
    messageAuthor()        main.go:2903

A fatal error that kills the whole client mid-session is the highest-value class of bug in a TUI, and removing the writer rather than adding a lock is the right call on a hot path.

Two things before I merge.

1. The fix is incomplete — there's a second unsynchronised writer. resolveUser writes userNames[userID] = name at main.go:2827, and its caller resolveDMNames is launched as a bare go resolveDMNames(...) at main.go:2152. Same map, same UI-goroutine readers, plus the WS-goroutine reads at main.go:3988 and main.go:4002. You fixed 4 of 5 off-UI writers; please fix or explicitly scope out the fifth.

2. TestMessageAuthor_DoesNotMutateSharedNameMap passes on the unfixed code. I verified — restoring resolveUserCachedOLD gives --- PASS. With db == nil the write-back only ever re-writes an existing key, so len(userNames) never changes and the unknown-user branch is never reached. Its comment claims "the plain run still catches a regression via the explicit map-mutation assertion below," which isn't true. Either delete it or give it a real *cache.DB with a user row absent from the map — that's the only configuration where the assertion has teeth.

TestMessageAuthor_SafeUnderConcurrentReads is the one that earns this PR. I confirmed it fails on pre-fix code with both a -race report and a hard runtime fatal error. Keep that one.

Smaller:

  • main.go:215 and main.go:431 still reference resolveUserCached by name — dangling doc references to a deleted function.
  • The 10-line tombstone comment at main.go:2748-2757 explaining what used to be there is diff noise; git history covers it. I'd drop it.
  • TestMessageAuthor_FallsBackToTheID's comment says "a message with neither a user nor a bot id" but it passes User: "U-UNRESOLVED". Also overlaps bot_identity_test.go:29.
  • Please correct the description — the write fires on every cache hit, not only un-memoised ones.

Your CI lint failure is not your fault: it's an old golangci-lint panicking under go1.27 (could not import math/rand/v2 ... (typecheck)), fixed on main by 6d39fe5. Rebase and it clears. Your test job passed.

@gammons gammons added the changes requested Blocking issues found in review label Sep 3, 2026
@gammons gammons closed this Sep 3, 2026
@gammons gammons reopened this Sep 3, 2026
@gammons

gammons commented Sep 3, 2026

Copy link
Copy Markdown
Owner

#171 has landed and I've re-run CI here — the lint failure is gone and this is green now. That was the stale golangci-lint/go1.27 issue, not anything you did.

Note that #171 also enabled gofmt as an enforced lint check, so please run gofmt -w over your changes when you push the next revision.

My review above still stands — that's what's needed to move this forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes requested Blocking issues found in review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants