Skip to content

browsercheck: guard the reply table, which two goroutines were writing - #28

Merged
tannevaled merged 1 commit into
mainfrom
conn-mutex
Sep 1, 2026
Merged

browsercheck: guard the reply table, which two goroutines were writing#28
tannevaled merged 1 commit into
mainfrom
conn-mutex

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

CI on main dies partway through a check — not a failed assertion, a killed process:

document shown: 301943 pixels drawn, 165648 of them ink
a download began from x=64
the tab handed back a 934 byte PDF
fatal error: concurrent map writes
main.(*conn).call   browsercheck/main.go:252

The race

pump runs in its own goroutine from the moment the connection opens (go c.pump(ctx)), and both goroutines write the same map with nothing between them:

pump, line 230/236 reads c.replies[msg.ID], then delete(c.replies, msg.ID)
call, line 251/252 c.id++, then c.replies[id] = ch

So c.replies and the c.id counter are both raced. A map raced this way does not corrupt quietly — the runtime kills the process, which is exactly what the log shows.

The change

A mutex covers the counter and the table in both places. It is held only across the map work, never across a channel send or a websocket write, so pump cannot block on a slow reader while holding it.

call also removes its own entry on the way out:

defer func() {
    c.mu.Lock()
    delete(c.replies, id)
    c.mu.Unlock()
}()

Before, a failed ws.Write or an ended context returned without removing the entry, leaving pump holding a channel nobody would ever read.

Verification

go vet and gofmt are clean and it builds. The package has no tests — what exercises this code is the browsercheck run itself, so the CI on this pull request is the verification, against the same lane that crashed.

Found while sweeping for red default branches: this was one of seven red out of 1877, and one of only four that were not a stale Renovate lane.

CI on main dies partway through a check:

    the tab handed back a 934 byte PDF
    fatal error: concurrent map writes
    main.(*conn).call  browsercheck/main.go:252

`pump` runs in its own goroutine from the moment the connection opens. It
reads `c.replies[msg.ID]` and deletes the entry; `call` writes one and
increments `c.id`. Neither is guarded, so both the map and the counter are
raced -- and a map raced this way does not corrupt quietly, it kills the
process.

A mutex now covers the counter and the table in both places, held only
across the map work and never across a channel send or a websocket write.

`call` also deletes its own entry on the way out. Before, a failed write
or an ended context returned without removing it, leaving pump holding a
channel nobody would ever read.

The package has no tests -- what exercises this is the browsercheck run
itself, so the CI on this pull request is the verification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 3c19ede into main Sep 1, 2026
2 checks passed
@tannevaled
tannevaled deleted the conn-mutex branch September 1, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant