Repository navigation
test(ocpp): every simulated charger dials through one safe socket - #1846
Merged
Merged
Conversation
#1836 wrapped ocpp-go's client for internal/ocpp's tests, because it races with itself on close. The OCPP simulator and two cmd/ftw tests still dialed with the plain client. The wrapper moves to its own package, internal/ocpp/chargersocket, with constructors for 1.6 and 2.0.1, and all of them use it. A test fails if any Go file in the module builds a charger on the plain client again. Core is the OCPP server and never dials, so its binary does not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Review notes: an import without an alias is named ocpp16 or ocpp2, not after its path, so the guard missed it; it missed a dot import too. It now uses the real names and checks dot imports. It skips what the go tool skips (_ and . directories, testdata, vendor) and the nested modules under go/, and leaves a file that does not parse to the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Found on 25 October 2026 while fixing flaky OCPP tests (#1836, "Not fixed here").
Problem
ocpp-go v0.19.0's WebSocket client races with itself on close. #1836 lists the three ways, each seen in CI: a reply sent while the charger stops can send on a closed channel and panic;
ocppj.Client.Stopswaps the disconnect handler while the write pump reads it; and Stop can wait forever for a disconnect the pump has already reported. #1836 wrapped the client aschargerSocket, but only insideinternal/ocpp's tests. Three other places still dialed with the plain client:cmd/sim-ocpp/ocppcp/sim.go, the OCPP simulator behindmake sim-ocppandtest/e2e/ocpp_evify_test.go, for both 1.6 and 2.0.1;cmd/ftw/ev_send_test.goandcmd/ftw/ev_ocpp_reconnect_test.go.Core's own paths
Core is the OCPP server and never dials.
go list -deps ./cmd/ftwdoes not contain the new package, before or after, and no non-test file incmd/ftworinternal/builds a client. There is no self-test that dials. The Core binary does not change.Fix
chargerSocketmoves unchanged, apart from its name and one error text, frominternal/ocpp/charger_socket_test.goto a package of its own,internal/ocpp/chargersocket, asSocket. It addsChargePoint16(id, client)andChargingStation201(id, client), which build the charge point on aSocket.cmd/ftwtests andinternal/ocpp's dial helpers use them.TestEveryChargerDialsThroughASocketparses every Go file in the module and fails if one uses ocpp-go'sNewChargePoint(1.6) orNewChargingStation(2.0.1) outside the package, under any import name or a dot import. It skips what the go tool skips and the nested modules undergo/. The races need a few instructions of bad luck, so a charger built on the plain client passes its own tests and fails CI weeks later. This test finds it the day it is written. It does not look for a charger built one level lower, onocppj.NewClientor a barews.Client; nothing in the module does that.Evidence
The races themselves cannot be made to happen on demand: #1836 measured the data race at about 1 in 3,000 runs. The new guard is the test that fails first. On master, with the package added:
The last two already used the wrapper, through the old local type. On this branch the guard passes.
TestSocketPassesAMissedDisconnectToStopmoves with the type and still covers the missed-disconnect path.go test -race ./internal/ocpp/... ./cmd/sim-ocpp/...passes.go test -race -count=2 -run 'OCPP|EVSend|Ocpp|Charger' ./cmd/ftw/passes.FTW_E2E=1 go test -race -count=2 -run Evify ./test/e2e/passes (withoutFTW_E2E=1these tests skip).go vet ./...passes.go test ./internal/... ./cmd/ftw/...passes.Not changed: the server's StopConnection
ocpp-go's
ws.Server.StopConnectionreads the socket under its lock, lets go, and then sends on the socket's close channel. If the socket cleans itself up in between, the send panics. Core calls it in one place,guardedServer.SetNewClientHandler(socket_sessions.go:166), to refuse a socket whose handshake reservation has gone. That happens when the request's context ended during the handshake, which in practice means the charger hung up while its socket was opening. The library's read pump then sees that close and cleans up at the same moment.It is reachable. The panic runs on the HTTP handler's goroutine, and net/http recovers it, so Core keeps running. But Core holds that charger's
slot.muacross the call and unlocks withoutdefer, so the lock stays held, and every later handshake for that charger id waits on it. The charger cannot connect again until Core restarts. Any peer that can reach the port can try this for a known id when basic auth is off.How likely it is:
-race, with aprintlninside the window. In one of two runs the send panicked, and 3,372 handshake goroutines were then stuck onslot.mu(socket_sessions.go:88).println(16 × 400 requests, mixed RST and FIN) reached line 166 3 to 17 times per run and locked the slot in 0 of 30 runs.So the mechanism is confirmed, but the window is a few instructions wide, and no test can make it fail on demand: the branch needs the request context to end between two library steps, and
StopConnectionis a method on the library's concrete type, with no seam for a test. I have not changed it, because the fix would have no test that fails first.My first suggestion here, to unlock
slot.mubefore the call, is wrong:StopConnectionlooks up the socket by charger id, and the lock is what keeps a newer socket for that id out of the table meanwhile. Without it, Core could close a healthy new socket. A fix that keeps the lock: recover the library's panic around that one call, or move the unlock into adefer. The library fix is to send under its lock. Both are left for a decision.Also not changed
Socket.Writeif Core closes the socket while a write is queued; it cannot stop its chargers before Core restarts under it. Tests stop chargers first. Not seen; no test can make it happen on demand.Stopduring a reconnect still races inside ocpp-go (ws.Client.StartagainstStoponreconnectC). With the default 10–25 s backoff it is unlikely in tests; it is on master too.SocketinheritsStartWithRetriesfromws.Client, which skipsSocket.Start. Nothing calls it.Review
An independent review (Sonnet) found the move clean: the code matches master apart from the name, one error text and the nil-client default, basic auth carries over, 60 reconnect rounds never hung
Stop, andcmd/ftwdoes not link the package. Its findings:FTW_E2E=1. Now run with it, above.ocpp1.6), but the package isocpp16(andocpp2), and it missed dot imports; it also failed on any file that did not parse and walked into the nested modules. Fixed in the second commit. With temporary files, it now reports an unaliased and a dot import and ignores a_directory and a file that does not parse.StartWithRetriesnotes, listed above as not changed.No changeset: this PR changes tests and a simulator that is not in any release (
no-changesetlabel).🤖 Generated with Claude Code