Skip to content

fix(ocpp): a port Core cannot open fails Start and shows on each charger - #1843

Merged
frahlg merged 2 commits into
masterfrom
fix/ocpp-bind-error
Oct 25, 2026
Merged

frahlg merged 2 commits into
masterfrom
fix/ocpp-bind-error

Conversation

@frahlg

@frahlg frahlg commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Found on 25 October 2026 while fixing flaky OCPP tests (#1836, "Not fixed here").

Problem

If Core cannot open its OCPP port, the OCPP server never listens, ocpp.Start still returns success, and main.go logs ocpp: central system started. The site's chargers never connect, and nothing says why.

Cause

ws.Server.Start (ocpp-go v0.19.0) opens its own socket inside the goroutine Core starts it in. When net.Listen fails, it hands the error to the library's logger, which Core never sets, and returns. Core's goroutine then exits without a word.

ServeTLS has the same gap one step later: it loads the certificate and key inside that goroutine. serverTLS only checked that both files exist, so a file that is present but does not hold a usable pair stopped the listener the same silent way.

Fix

  • Start opens and closes each enabled port on every interface, as the library does, before it builds anything. A taken 1.6 port fails Start with ocpp: listen tcp :8887: bind: address already in use. A taken 2.0.1 port is logged at error level and only that listener is skipped: the 1.6 listener still runs, so the chargers that dial it are still steered.
  • serverTLS loads the pair with tls.LoadX509KeyPair instead of two os.Stat checks.
  • When Start fails, or skips the 2.0.1 listener, it records the error as the last error of each charger the site names. That charger has no health row until it connects, so before this it did not appear in the device table at all. Now the help report's Devices table and /api/status list it, never read, with the reason beside it. A charger's first message on the 1.6 listener clears it. After the watchdog's 60 s such a charger goes offline like any OCPP charger that stops talking: the watchdog logs it, a DriverLost event may send a notification, and /api/health reads degraded. That is the same path a connected charger takes when it falls silent.
  • main.go already logs ocpp: central system failed to start at error level when Start fails. The log ring is installed before that, so the help report's log section now shows the line. No change to main.go; its central system started line still prints the configured port_v201 when that listener was skipped, right after the error line that says so.

A port taken in the microseconds between the check and the library's bind still stops the listener silently. I left that window alone: closing it means reading the library's error channel, and its Stop closes that channel while other goroutines may still send on it.

The test helper startOCPP now also retries when Start itself reports a lost port, or starts without a 2.0.1 listener it asked for, since freePort can lose its port to another test before the check.

Evidence

New tests, run on master first:

--- FAIL: TestTLSMisconfigurationRefusesToStart/cert_does_not_load (0.00s)
    auth_test.go:247: a broken TLS config started anyway
--- FAIL: TestStartFailsWhenItsPortIsTaken (0.00s)
    server_test.go:50: Start succeeded with port 62140 taken
--- FAIL: TestATaken201PortLeavesThe16ListenerRunning (0.02s)
    server_test.go:65: no listener bound after 5 attempts

On master the last one fails because the 2.0.1 listener's goroutine gives up on the port and startOCPP never sees it listen. On this branch all three pass. TestStartFailsWhenItsPortIsTaken checks that the error names the port and that the charger's health carries it. TestATaken201PortLeavesThe16ListenerRunning checks that a 1.6 charger still connects and clears its error, while a charger that has not connected keeps it. (The first commit had one table test for both ports; the review changed the 2.0.1 behaviour, below.)

  • go test -race -count=2 ./internal/ocpp/ passes.
  • FTW_E2E=1 go test -race -count=2 -run 'Evify|OCPP' ./test/e2e/ passes (without FTW_E2E=1 these tests skip).
  • go vet ./... passes.
  • go test ./internal/... ./cmd/ftw/... passes.

Not changed

Writing the master test showed a second, older race: calling Server.Stop before the listener goroutine has called cs.Start panics with close of nil channel in ocpp-go's dispatcher (DefaultServerDispatcher.Stop). Core reaches it only if its context ends within microseconds of Start. It is separate from this fix.

Review

An independent review (Sonnet) checked the probe against the library (the same net.Listen("tcp", ":N"), the same IPv4/IPv6 and SO_REUSEADDR behaviour on Linux and macOS, no TIME_WAIT problem across a re-exec), that LoadX509KeyPair rejects no config ServeTLS would have served, and that every reader of the new health row (loadpoint controller, SumOnlineEVW, manual charging) is gated on a reading or a connected sample, so dispatch does not change. Removing the port check, the health write or the key-pair load each broke the matching test.

Its findings, fixed in the second commit:

  • One taken 2.0.1 port failed all of Start, so the 1.6 listener that used to come up did not, and 1.6 chargers lost their steering. A 2.0.1 port now skips only its own listener.
  • The Start comment still said it returns "once the listener is up". The TLS test message "serving plaintext" did not fit the new case.
  • The first version's 2.0.1 subtest used freePort without a retry. It now goes through startOCPP.

It also noted, and I left alone, that the offline transition after 60 s is new behaviour for a charger that never connected (described above), and that a charger id equal to a Lua driver name would share its health row, which was already true for its messages.

Patch changeset.

🤖 Generated with Claude Code

ws.Server.Start opens its own socket in a goroutine and drops a listen
error into a logger Core does not read, so a taken port left the OCPP
server not listening while Start reported success. Start now opens and
closes each enabled port first, loads the TLS pair that ServeTLS would
load later, and on any failure records the error on every charger the
site names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T13:19:24.470491Z 172abe1 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Review note: failing all of Start for a taken 2.0.1 port also stopped
the 1.6 listener that came up before, leaving 1.6 chargers unsteered.
Start now fails only for the 1.6 port. A taken 2.0.1 port is logged,
that listener is skipped, and the reason goes on each named charger,
which its first 1.6 message clears. The Start comment no longer says it
returns once the listener is up, and startOCPP retries when a 2.0.1
listener it asked for did not start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 5e6dde0 into master Oct 25, 2026
22 checks passed
frahlg added a commit that referenced this pull request Oct 25, 2026
#1843 added an errors.As call to this test and #1846 rewrote its imports;
the two merged without conflict but master no longer vets internal/ocpp
("undefined: errors"), which fails every PR's Go job.

Co-authored-by: Claude Opus 5.5 <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