Skip to content

server: fix the data race between runnerRef.unload and LogValue - #289

Merged
glennneuber merged 1 commit into
mainfrom
fix/sched-logvalue-race
Sep 10, 2026
Merged

glennneuber merged 1 commit into
mainfrom
fix/sched-logvalue-race

Conversation

@glennneuber

Copy link
Copy Markdown

Fixes the intermittent race (ubuntu-latest) CI failure. It is pre-existing on main: it surfaced on two branches that never touch server/ (#287 and #286), and passed on other commits of those same branches.

The race. unload clears model, Options and gpus while holding refMu. LogValue reads them holding nothing, and slog resolves it lazily inside each slog.Debug(…, "runner", runner) call. The failing job reported exactly those three fields (sched.go:1410, 1411, 1412 against 1562, 1577, 1565).

Why not just lock in LogValue. Eleven of the ~25 log sites already hold refMu when they log the runner (343, 408, 415, 417, 428, 433, 439, 451, 483, 762, 766), so refMu.Lock() inside LogValue would self-deadlock. TryLock would not fix the race, only narrow it.

The fix. A second, leaf-level sync.RWMutex guarding exactly the fields unload clears. unload takes it after refMu; LogValue takes it alone and copies what it needs into locals before building the attrs. Nothing takes refMu while holding logMu, so the order is always refMu → logMu and no deadlock is possible. Log output is unchanged, including the empty-model-name case (hasModel, not name != "").

Verification (golang:1.26.0 container):

check result
new test on the unfixed code, -race reports all three races, deterministically
new test with the fix, -race -count=20 pass
TestSchedRequestsMultipleLoadedModels (the CI failure), -race -count=20 pass
full go test -race ./server/ pass
go vet, golangci-lint clean

The negative control is the point of the new test: it spawns a reader that resolves the value exactly as slog does, signals that it is running, and keeps reading past the unload, so the two overlap with no synchronisation of their own. A first version of the test passed even without the fix because the reader goroutine had not been scheduled before the unload; that version would have been worthless as a regression test.

🤖 Generated with Claude Code

The race CI job failed on 2026-09-08 with three DATA RACE reports in the
multiple-loaded-models scheduler test: unload clears model, Options and
gpus while holding refMu, but LogValue reads them holding nothing, and
slog resolves that value lazily inside every slog.Debug(..., "runner",
runner) call. Eleven of those log sites already hold refMu, so LogValue
cannot take it -- a plain Lock there self-deadlocks.

Guard exactly the fields unload clears with a second, leaf-level RWMutex:
unload takes it after refMu, LogValue takes it alone and copies what it
needs into locals before building the attrs. Nothing takes refMu while
holding logMu, so the order is always refMu -> logMu and no deadlock is
possible. The log output is unchanged, including the empty-model-name
case.

The new test reproduces all three reported races deterministically on
the unfixed code (sched.go:1410, 1411, 1412) and is clean with the fix:
go test -race -count=20 on it and on the test that failed in CI, plus the
full -race server suite, vet and golangci-lint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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