Fix (some) data races found by -race#3490
Conversation
There was a problem hiding this comment.
Pull request overview
Addresses data races surfaced by the Go race detector (issue #3489) across several packages: federation discovery globals, transport cache, origin handler registration, XRootD metric accumulators, director cache initialization, JWK key mutation, *gin.Context escaping into storage/HTTP callees, advertisement struct copies, and a few test-only races. The fixes consistently introduce mutexes alongside existing sync.Once reset paths, snapshot mutable shared state before passing it to goroutines/long-lived callers, and route HTTP-derived context.Context (rather than *gin.Context) into storage and outbound calls.
Changes:
- Add mutexes to guard
sync.Once+ variable reset patterns inconfig/transport.go,config/config.go, andserver_utils/sitename.go. - Fully initialize
directorInfobefore publishing to the TTL cache; addAdvertisement.GetServerAd()for locked struct copy; snapshotstat.ReqHandlerand useItems()instead ofRange()for cache iteration. - Route
ctx.Request.Context()(via newrequestBoundContexthelper) into storage and outbound HTTP calls; clone JWK keys before mutation; per-goroutine RNG and goroutine-local errors in launcher/test code.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| config/config.go | Adds fedDiscoveryMu and helpers (resetFederationDiscoveryState, clearGlobalFederationState) guarding the Once + globals. |
| config/transport.go | Adds transportMu plus initTransport/resetTransport helpers to make init and reset mutually exclusive. |
| config/net_config.go | Replaces inline Once reset with ResetFederationForTest. |
| server_utils/sitename.go | Adds baseAdMu mutex around baseAdOnce/baseAd/baseAdErr. |
| server_utils/server_utils.go | Holds baseAdMu during ResetTestState re-init. |
| origin_serve/handlers.go | Adds handlersMu around the register/reset of handlersRegistered. |
| metrics/xrootd_metrics.go | Per-accumulator mutexes (lastStatsMu, lastOssStatsMu). |
| director/director_advertise.go | Fully constructs directorInfo before GetOrSet; cancels unused context on race loss. |
| director/monitor.go | Uses locked GetServerAd() instead of direct struct copy. |
| director/sort.go | Switches Range to Items() snapshot. |
| director/stat.go | Snapshots stat.ReqHandler into a local before launching goroutines. |
| server_structs/director.go | Adds (*Advertisement).GetServerAd() accessor under RLock. |
| oauth2/issuer/handlers.go | Introduces requestBoundContext helper and uses it for all storage calls. |
| oauth2/issuer/admin_handlers.go | Same requestBoundContext adoption for admin endpoints. |
| web_ui/server_api.go | Uses ctx.Request.Context() for GetFederation/MakeRequest. |
| launchers/launcher.go | Goroutine-local localErr/path instead of shared captures. |
| client/acquire_token.go | Clones jwk.Key before setting kid. |
| client/pelican_fs_test.go | Per-goroutine rand.Rand instance in stress test. |
| test_utils/utils.go | Drops entry.HasCaller() check (which reads racy global) in favor of entry.Caller != nil. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
fedDiscoveryOnce, globalFedInfo, and globalFedErr were read and written concurrently without synchronization. Introduce fedDiscoveryMu to guard them, and add resetFederationDiscoveryState() / clearGlobalFederationState() helpers so every mutation path holds the lock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The stress test shares work across goroutines, but math/rand.Rand is not safe for concurrent use. Giving each worker its own generator keeps the test behavior concurrent without adding locking to the hot loop or sharing mutable RNG state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Test log formatting only needs the caller attached to the log entry. Checking entry.Caller directly avoids reading the global logger's ReportCaller flag while test cleanup may restore it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The advertisement health-check goroutines need their own temporary errors. Using goroutine-local variables avoids sharing the outer LaunchModules error slot while preserving the existing error channel behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Origin handler registration uses package-level maps and a registered flag that tests can reset. A narrow mutex around registration and reset keeps the existing behavior, including error returns and test resets, while making the check-and-set atomic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The packet handlers update package-level delta accumulators while monitoring goroutines can process packets concurrently. Narrow mutexes around each accumulator preserve the existing delta calculations without serializing unrelated metric families or changing packet parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Iterating the live ttlcache list with Range can race with the cache's eviction goroutine. This branch follows the existing director pattern of taking an Items snapshot before walking advertisements, which keeps the fix local and avoids adding another lock around cache internals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
queryServersForObject only needs the request handler chosen at call time. Capturing it before launching lookup goroutines lets tests swap handlers between subtests without racing goroutines that are still completing the previous query. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Token generation only needs to attach a kid for the token being signed. Cloning the cached JWK before setting metadata keeps the cached issuer key immutable for concurrent callers and avoids adding broader locking around token acquisition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Director health checks need a copy of the current server ad, but copying the embedded ServerAd directly also reads IOLoad. A small locked snapshot method keeps the existing value semantics while sharing the synchronization already used by SetIOLoad. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Populate directorInfo fields before publishing a new entry through the director TTL cache. This avoids exposing a partially initialized ad and forwarding channel to concurrent readers while keeping the existing cache and locking structure intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
*gin.Context implements context.Context, so it compiles where a context.Context is expected. But *gin.Context is mutable request state that Gin reads and writes throughout the request lifecycle; passing it to storage methods or outbound HTTP calls gives those callees a reference to that mutable object, which races with Gins own concurrent modifications. ctx.Request.Context() is the underlying net/http request context: its lifetime is tied to the HTTP request, it is safe to share with any goroutine or downstream function, and it carries only cancellation and deadline semantics. Introduce requestBoundContext() in the issuer package to make the right choice explicit at every call site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sync.Once serializes concurrent calls through it, ensuring
setupTransport runs exactly once. But it does not protect against a
concurrent assignment to the Once variable itself. ResetConfig was
writing onceTransport = sync.Once{} and zeroing the transport pointers
with plain assignments, while any concurrent caller of GetTransport
(or the other getters) was reading those same variables. That is a data
race: the Once only synchronizes callers with each other during init,
not against a simultaneous reset.
Introduce transportMu to make initTransport (lazy init) and
resetTransport (test reset) mutually exclusive. sync.Once continues to
ensure setupTransport runs at most once per init cycle; the mutex
ensures a reset cannot race with an in-progress init or a getter read.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
IsDirectorAdFromSelf lazily caches this server's own ServerBaseAd (name, instance ID, version) using sync.Once. sync.Once serializes concurrent calls through it, but does not protect against a concurrent assignment to the Once variable itself. ResetTestState was zeroing baseAdOnce, baseAd, and baseAdErr with plain assignments, which race with any concurrent call to IsDirectorAdFromSelf reading those same variables. Introduce baseAdMu so the lazy init and the test reset are mutually exclusive, matching the same pattern used for the transport cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
3c78377 to
24b9ba4
Compare
h2zh
left a comment
There was a problem hiding this comment.
I'm going to incremently review all these 14 commits one by one, and you could address these comments either all at once or in the opposite way. First of all, this PR needs to rebase to main because of the param generation overhaul. Here is the comment for the first commit (214f55b)
| // times during unit tests | ||
| fedDiscoveryMu sync.Mutex | ||
| fedDiscoveryOnce *sync.Once | ||
| globalFedInfo atomic.Pointer[pelican_url.FederationDiscovery] |
There was a problem hiding this comment.
The new mutex makes the atomic redundant. Now that every access to globalFedInfo happens under fedDiscoveryMu, a plain field would be equally safe. The atomic is leftover from when readers were lock-free; it's harmless but no longer load-bearing.
There was a problem hiding this comment.
Besides, this mutex is held during the entire fedDiscoveryMu, which includes a HTTP call in discoverFederationImpl. If this networking call hangs, the lock will hold for a long time. A context with timeout could mitigate this problem, however, most invocations of GetFederation(ctx) doesn't have a timeout in the codebase.
A possible fix could be refactoring GetFederation(ctx) function: Split the slow part (discovery HTTP call) from the part that needs the lock (publishing the three variables). Snapshot the Once under the lock, release, run discovery lock-free, then re-acquire only to store results.
There was a problem hiding this comment.
Maybe simpler - replace the sync pointer with an atomic sync pointer?
That said, the definition of Reset is that it should only be done when no goroutine is running that could read the once. If we are seeing races then it strongly implies there are goroutines leaking between test runs: that would be the underlying issue!
There was a problem hiding this comment.
I take that back - I see where we write to this during startup.
An atomic pointer would do!
That said, if we are worried about long-held mutexes, since the mutex lifetime is guaranteed to be the same as the Once blocking, we've not made the problem any worse or better.
There was a problem hiding this comment.
The following two comments are also for the sync.Once reset race condition. (commit ef2c087)
The commit message of ef2c087 asserts the mutex prevents reset-vs-getter-read races, and the resetTransport comment (config/transport.go:128-132) names "concurrent reads in the getters" as the hazard being guarded against. However the getters (transport.go:67-115) read the pointer outside transportMu, so neither is actually achieved. I ran race condition detector at test-linux.yml:95-107 locally and the result confirms that.
| transportMu.Lock() | ||
| defer transportMu.Unlock() | ||
| onceTransport.Do(setupTransport) | ||
| } |
There was a problem hiding this comment.
InitClient (config/config.go:2380) still calls setupTransport() directly, bypassing this new mutex.
| resetTransport() | ||
|
|
||
| // Clear cached SSRF transport object | ||
| ResetSSRFTransportForTest() |
There was a problem hiding this comment.
SSRF transport singleton is similar to transport, which has the same race condition problem. Would you like to handle it in a new commit here or in a future PR?
h2zh
left a comment
There was a problem hiding this comment.
When I run the nightly race condition detector at test-linux.yml:95-107 locally against this PR, four other data races surface. Could you take a look?
This is the last piece of the first-round review. When you address these comments, could you make new commits instead of overwrite the existing ones? In this way, I can view the diff clearer.
| return false | ||
| } | ||
| currentServerAd := adItem.Value().ServerAd | ||
| currentServerAd := adItem.Value().GetServerAd() |
There was a problem hiding this comment.
director/director_api.go:106 also needs this fix. Same for director_api.go:322, :369, :376.
There was a problem hiding this comment.
Also director/cache_ads.go:282-297
| return ad.IOLoad | ||
| } | ||
|
|
||
| func (ad *Advertisement) GetServerAd() ServerAd { |
There was a problem hiding this comment.
Could you add a docstring here so human and agent knows this is the required function to get an ad? And adItem.Value().ServerAd is prohibited.
| lastStats SummaryStat | ||
| lastOssStatsMu sync.Mutex | ||
| lastOssStats OSSStatsGs | ||
| lastS3CacheStats OssS3CacheGs |
There was a problem hiding this comment.
Why here we don't add a lock for lastS3CacheStats in handleS3CacheStats?
That handler is reached through the same dispatch path as the one (handleOSSStats) this commit shows: handlePacket → handleOSSPacket → {handleOSSStats | handleS3CacheStats}. lastS3CacheStats has identical exposure to lastOssStats but was left unguarded
Fixes the races described in #3489.
Design notes by change
sync.Once+ reset races (config,server_utils)Three caches follow the same broken pattern: a
sync.Onceguards lazy initialization, but test-reset paths overwrite theOncevariable and the cached values with bare assignments, racing with concurrent readers.sync.Onceserializes concurrent calls through it, but provides no protection against a concurrent assignment to theOncevariable itself. The fix in all three cases is the same: introduce async.Mutexthat is held by both the init path and the reset path, making them mutually exclusive. TheOncecontinues to do its job (run setup at most once per cycle); the mutex closes the gap.Helper functions (
initTransport/resetTransport,resetFederationDiscoveryState/clearGlobalFederationState) encapsulate the lock scope so call sites stay linear. The lock is not held across the entireDocallback — only across theDocall and the variable assignments — which keeps the critical sections short.For the federation discovery case,
globalFedInfowas already anatomic.Pointerand remains so; onlyfedDiscoveryOnceandglobalFedErrneeded the new mutex.Director ad cache initialization (
director/director_advertise.go)updateInternalDirectorCachewas publishing a zero-value*directorInfointo the TTL cache viaGetOrSet, then populating its fields afterward. Any reader that obtained the entry betweenGetOrSetand field population would see nilforwardAdChan, nilcancel, and an empty ad.The fix constructs a fully initialized
directorInfo— includingcontext.WithCancel,forwardAdChan, and the ad itself — before callingGetOrSet. IfGetOrSetreports that an existing entry won the race, the newly created cancel function is called immediately to release the unused context.Advertisement.IOLoadread outside lock (director/monitor.go)Advertisementalready has anRWMutexused bySetIOLoad/GetIOLoad. Copying the embeddedServerAdstruct directly bypassed that lock because Go struct copies are not atomic. The fix addsGetServerAd()which holds the read lock for the duration of the copy, mirroring the existingGetIOLoadpattern.*gin.Contextescaping to storage and I/O callers (oauth2/issuer,web_ui)*gin.Contextimplementscontext.Context, so it compiles where acontext.Contextis expected. It is, however, mutable request state that gin reads and writes throughout the request lifecycle; passing it to storage or outbound-HTTP calls gives those callees a reference to that state, which races with gin's own concurrent modifications.The fix passes
ctx.Request.Context()instead — the underlyingnet/httprequest context, whose lifetime is tied to the HTTP request and which is safe to share with any goroutine. A small unexported helperrequestBoundContextis introduced in theoauth2/issuerpackage to make the right choice explicit and to document the reason at the definition site.mirrorDowntimeToRegistryinweb_uifollows the same pattern inline (reqCtx := ctx.Request.Context()) rather than calling the helper, because the helper is unexported and in a different package.JWK key cloning (
client/acquire_token.go)The cached
jwk.Keywas treated as effectively immutable for the purposes of signing, butkey.Set("kid", ...)mutates an internal map. The fix callskey.Clone()before setting the key ID, leaving the cached key untouched.Director TTL cache snapshot (
director/sort.go)The repository already contains a comment in
director_api.gonoting thatRangeraces with the eviction goroutine and thatItems()should be used instead. This change applies the same fix togetAdsForPathinsort.go.Narrow-scope fixes
XRootD metric accumulators (
metrics/xrootd_metrics.go): Two separate mutexes, one per accumulator family, rather than one coarse lock. This avoids serializing unrelated metric families while keeping each read-modify-write sequence atomic.Origin handler registration (
origin_serve/handlers.go): A single mutex around the check-and-set ofhandlersRegistered. The registration path can return errors and tests need to reset the flag, so a mutex is less surprising than async.Onceoratomic.Bool.Launcher error capture (
launchers/launcher.go): Goroutine-local error variables instead of capturing the outererr. The existing error channel is unchanged; only the intermediate local assignments are made goroutine-local.PelicanFS stress test RNG (
client/pelican_fs_test.go): Per-goroutinerand.Randinstances. No production code is affected.Test log hook (
test_utils/utils.go): The cleanup hook checksentry.Callerdirectly rather thanentry.Logger.ReportCaller. The global logger flag is only needed to decide whether to populateentry.Callerin the first place; by the time the hook fires,entry.Calleris already set or nil.(Brian A: With a tip of the hat to Copilot.)