Publish reflector targets by copy-on-write, fix unstable proxy-entry key, gate with -race - #117
Open
aszarama wants to merge 3 commits into
Open
Publish reflector targets by copy-on-write, fix unstable proxy-entry key, gate with -race#117aszarama wants to merge 3 commits into
aszarama wants to merge 3 commits into
Conversation
Three independent correctness fixes in the registration reflector, plus a race-detector gate in CI so they stay fixed. No new API surface. rr.targets had no lock. getProxy writes the map at runtime - the auto-register timer (relay_instance_manager) and the POST /__axon/broker/reregister handler both reach it - while ServeHTTP reads it through parseTargetUri. That is a concurrent map read-and-write, which the Go runtime throws on rather than tolerating. Guard the map with a RWMutex, acquired after newProxyEntry so entry construction stays outside the critical section. proxyEntry.key() hashed one |name=resolverKey suffix per header while ranging pe.headers in map order, so two newProxyEntry calls for the same URI and headers could hash differently and register duplicate entries. It only looked stable because hashCode memoizes within one entry. accept.github.app.json rules carry two headers each, so this is reachable today. Sort the header names before hashing. parseTargetUri linear-scanned every target calling key() per iteration, even though rr.targets is already keyed by exactly that value. Replaced with a direct lookup. httpServer.Start assigned h.server from inside the serve goroutine, so Close could read and write it concurrently. Construct the server before spawning the goroutine and hand it a local reference. This also removes a time.Sleep(100ms) that was masking the race - Start no longer needs to wait, since the listener is bound before it returns. CI: the tree is not race-clean, so `make test-race` gates the packages that are (RACE_CLEAN_PKGS) plus the reflector suite at test granularity, since server/snykbroker's other tests still have known races. The reflector test list is derived from the test files, so new reflector tests are covered automatically. TestConcurrentGetProxyAndParseTargetUri reproduces the map race; against the unlocked version it fails with "fatal error: concurrent map iteration and map write". TestProxyEntryKeyStableAcrossHeaderOrder constructs 20 entries from the same three-header map to flush out iteration-order dependence.
Tunnel goroutines outlive the request that started them: runTunnel waits on both copy directions, but the handler has already returned by then. In tests those goroutines log through the zaptest logger, so one still copying when the test function returns races testing.tRunner marking the test done. Rare - it reproduced once in roughly eight runs of the new race gate, and only once enough tests shared the run to stretch the timing. Rare is still wrong for a gate that is meant to be trusted, and this is the harness, not the agent: in production the logger outlives every tunnel. The env now waits for ActiveConnections to reach zero as its last cleanup. The counter is decremented only after both copy goroutines have returned, so zero means none of them can log again.
…dler goroutines The reflector's target map was mutated in place while ServeHTTP read it through parseTargetUri. A concurrent map read and write is a Go runtime throw, not a tolerable race, so the agent process dies. Replace the RWMutex with an atomic.Pointer to a map that is frozen once published: readers do a single atomic load and synchronize on nothing, and registration swaps in a fresh copy under a CAS retry so concurrent registrations cannot lose an entry. Registration also reached the reflector from goroutines that have nothing to do with starting the broker. Split refreshTokenInfo, which only reports whether registration changed, from getUrlAndToken, which additionally registers the reflector's default entry. The reregister endpoint and the auto-register timer now use the former and hand any resulting restart to the single restart consumer instead of calling Restart inline. Set the instance response header before the entry is published, so the copy stored in the map carries it rather than only the reverse proxy's captured entry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four independent correctness fixes in the registration reflector, plus a race-detector gate in CI so they stay fixed. No new API surface, no feature.
First of a two-PR stack. #118 builds the per-request retargeting feature on top.
The
rr.targetsracerr.targetsis a plain map with no synchronization. An earlier assumption was that it is only written at render/startup — that is wrong.Startspawns a registration goroutine that retries with backoff, sogetProxycan register entries long afterStarthas returned, while the reflector is already serving traffic. That racesparseTargetUri's read on theServeHTTPpath.A concurrent map read-and-write is a Go runtime throw, not a tolerable race.
recovercannot catch it, so the agent process dies inside the customer's network. Reproduced onmainwith a probe that does what production does —ServeHTTPon one goroutine,ProxyURIon another:Fixed by publishing the map copy-on-write behind an
atomic.Pointer, not by locking:parseTargetUri,getUriForTarget) do a single atomic load and index a map that is never mutated after publication, so the request path synchronizes on nothing;getProxybuilds a fresh copy and swaps it in under a CAS retry, so two concurrent registrations cannot lose an entry.The access pattern is what makes this fit: writes happen at broker start, reads happen on every relayed request. Copy-on-write also makes the invariant structural — "the map is frozen once published" is the only state a reader can observe — rather than depending on every future caller remembering to take a lock.
Registration off HTTP handler goroutines
Two callers reached
getProxyfor reasons unrelated to starting the broker: thePOST /__axon/broker/reregisterhandler and the 5-minute auto-register timer. Both only need to know whether registration changed.getUrlAndTokenis split in two.refreshTokenInfore-registers and reports the change and touches nothing else;getUrlAndTokenadditionally registers the reflector's default entry, so it belongs to the start path alone. The reregister handler and the auto-register timer now use the former.The reregister handler also hands its restart to the single restart consumer via
requestRestartinstead of callingRestart()inline, which removes a second goroutine that could otherwise enterStart()concurrently with the restart consumer. It now writes an explicit200as well, where before it fell through to an implicit one.Response header set before publication
addResponseHeaderran after the entry was copied into the map, so the stored copy kept a nilresponseHeadersmap. It worked only becauseModifyResponsecloses over the pre-copy entry rather than reading the map value. Moved above the copy so the published entry carries the header.Order-unstable
proxyEntry.key()key()hashed one|name=resolverKeysuffix per header while rangingpe.headersin Go map order, so twonewProxyEntrycalls for the same URI and header set could hash differently and register duplicate entries. It only looked stable becausehashCodememoizes within a single entry.accept.github.app.jsonrules carry two headers each, so this is reachable today. Header names are now sorted before hashing.parseTargetUrilinear scanIt ranged over every target calling
key()per iteration, even thoughrr.targetsis already keyed by exactly that value. Replaced with a direct lookup — equivalent, and O(1).httpServerStart/CloseStartassignedh.serverfrom inside the serve goroutine, soClosecould read and write it concurrently. The server is now constructed before the goroutine is spawned, which gets a local reference.This also removes a
time.Sleep(100 * time.Millisecond)that was masking the race. Calling it out because it is a behaviour change rather than part of the race fix:Startno longer needs to wait, since the listener is already bound before it returns.Worth more than it looks — this one fix is the sole cause of four failures on
mainunder-race(TestBuildServeStack,TestProxyWithNoHeaders,TestHeaderOverwriting,TestGetProxyAndProxyURI), because so many tests start and close anhttpServer.Tests
TestConcurrentGetProxyAndParseTargetUrireproduces the map race: 16 goroutines registering distinct target URIs while 16 more resolve seeded hashes. Against a version that mutates the map in place it does not merely warn — it dies with a runtime throw. It also asserts the final map length, so a lost copy-on-write update fails it too.TestRelayReRegisterDoesNotTouchReflectorasserts the reregister endpoint registers no reflector entries, so the split above cannot quietly regress.TestProxyEntryKeyStableAcrossHeaderOrderconstructs 20 entries from the same three-header map to flush out iteration-order dependence.Why the CI gate is scoped, not suite-wide
-raceis not run in CI today, and the tree is nowhere near race-clean. Measured onmain(golang:1.26):go test -race ./...fails 5 of 12 packages —cmd,server,server/handler,server/http,server/snykbroker— with 23 failing tests and 92 race warnings. The largest single source issupervisor.go(process lifecycle, ~137 stack-frame hits), followed byserver/handler's manager and scheduled-entry. These are production-code races, and fixing them is a separate multi-package project that should not gate this PR.Turning
-raceon suite-wide would just add a red build. Gating by package does not help either, since the reflector sharesserver/snykbrokerwith the supervisor races.So
make test-racegates at test granularity: the packages that are clean today (RACE_CLEAN_PKGS), plus the reflector suite alone withinserver/snykbrokervia a-runregex generated from the test files, so new reflector tests are covered automatically and there is no hand-maintained list to rot.It is green on the fix and red on the bug:
-racemainBroaden
RACE_CLEAN_PKGSas the pre-existing races get fixed — noted in the Makefile. I can file the supervisor / handler / server races as separate issues if useful.Verification
Full suite green without
-race;make test-racegreen;go vetclean;gofmtclean on every file touched. (server/snykbroker/ws_proxy_test.goandserver/http/axon_handler.goalready failgofmt -lonmain— pre-existing, left alone.)One flake fixed while proving the gate
Turning the gate on surfaced a rare failure in the WebSocket tests: a tunnel goroutine logging through the
zaptestlogger after its test had returned, racingtesting.tRunnermarking the test done. It reproduced roughly once in eight runs.This is the harness, not the agent —
runTunneldoes wait on both copy directions, and in production the logger outlives every tunnel. But a gate that flakes is not worth having, so the reflector test env now waits forActiveConnectionsto reach zero as its last cleanup. The counter is decremented only after both copy goroutines have returned, so zero means none of them can log again.A separate flake this PR does not fix
go test ./server/snykbroker/dies withsignal: killedand no--- FAILabout half the time under Docker, consistent with the OOM killer rather than any test failing. It is pre-existing and unrelated to this stack — measured at 10 runs each under identical container settings:mainFlagging it because CI already runs
make -C agent test, so it can redden builds independently of these changes. Worth its own issue.