Skip to content

fix(ocpp): resolve memory leak, heartbeat tracking, and timer cleanup#6

Merged
evtivity-admin merged 1 commit into
EVtivity:mainfrom
kushal1o1:fix/ocpp-server-memory-leak-heartbeat-timer
Jun 14, 2026
Merged

fix(ocpp): resolve memory leak, heartbeat tracking, and timer cleanup#6
evtivity-admin merged 1 commit into
EVtivity:mainfrom
kushal1o1:fix/ocpp-server-memory-leak-heartbeat-timer

Conversation

@kushal1o1

Copy link
Copy Markdown
Contributor

Description

This PR fixes three issues in packages/ocpp/src/server/ocpp-server.ts:

1. Memory leak — ipConnectionCounts stale entries never cleaned

The ipConnectionCounts map stores per-IP connection counts but had no periodic cleanup mechanism, unlike ipMessageCounters which already uses ensureIpMessageCleanup().

Changes:

  • Changed the value type from number to { count, lastSeen }
  • Added stale entry cleanup to the existing cleanup interval
  • Entries with no activity for 5 minutes are removed during the 60-second cleanup sweep

2. Liveness false disconnect — lastHeartbeat not updated on CALLRESULT/CALLERROR

session.lastHeartbeat was only updated for incoming CALL messages.

As a result, stations that only responded to CSMS-initiated requests using CALLRESULT or CALLERROR (without sending their own CALL messages) could be incorrectly considered idle and disconnected by PingMonitor.checkHeartbeats() after the heartbeat timeout.

Changes:

  • Update session.lastHeartbeat for incoming CALLRESULT and CALLERROR messages as well

3. Orphaned timer — ipMessageCleanupTimer never cleared on shutdown

The cleanup interval created by ensureIpMessageCleanup() was never stopped when the server shut down.

Changes:

  • Added clearInterval() in stop()
  • Included a null check before clearing the timer

Type of Change

  • Bug fix
  • Refactor
  • New feature
  • Breaking change
  • Documentation update

Testing

  • TypeScript compilation passes (tsc --noEmit)
  • Internal WebSocket test confirms connection and authentication still work

Contributor License Agreement

  • I have read and agree to the Contributor License Agreement. I confirm that my contribution is my original work and I have the right to assign copyright to EVtivity.

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@kushal1o1

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@evtivity-admin

Copy link
Copy Markdown
Contributor

Thanks for this, and for clearly splitting the PR into three independent fixes. Changes #2 (update lastHeartbeat on CALLRESULT/CALLERROR) and #3 (clear ipMessageCleanupTimer in stop()) are correct and we'll take them.

Change #1 (the ipConnectionCounts cleanup) we're going to pass on. Here's exactly what it does today and why it doesn't land.

What the change does

  • Converts ipConnectionCounts from Map<string, number> to Map<string, { count, lastSeen }>.
  • Stamps lastSeen on connect and on each decrement.
  • Adds a sweep to the 60s cleanup interval that deletes an entry when entry.count === 0 && now - entry.lastSeen > IP_COUNTER_STALE_MS.

Why it's a no-op in practice

The connection counter is already self-cleaning on disconnect. The ws.on('close') handler removes the entry as soon as the last connection drops:

if (entry.count <= 1) ipConnectionCounts.delete(remoteIp);
else entry.count--;

An entry goes count: 1, then delete. It is never stored at count: 0. The sweep only deletes entries where count === 0, so it can never match anything — the branch is unreachable. ipConnectionCounts is written nowhere else, so no other path can leave a zero entry behind either.

This also means there is no stale-entry leak to fix. An entry exists only while that IP has a live connection and is removed when the connection closes. A station holding a long-lived socket keeps its entry at count: 1 for the life of that connection, which is correct accounting, not a stale entry.

The comment actually confirms this

// Only delete entries with 0 active connections — entries with count > 0
// represent live connections and must not be removed on time alone or the
// per-IP connection limit can be bypassed for long-lived connections.

The reasoning is sound: you must not evict a count > 0 entry on time alone, or you'd zero out a live IP's count and let the next MAX_CONNECTIONS_PER_IP connections bypass the limit. But because the close handler deletes entries before they ever reach count: 0, the "0 active connections" entries this guard is protecting never exist. The guard is what keeps the sweep safe, and it's also what keeps it from ever firing.

Contrast with ipMessageCounters

ipMessageCounters genuinely needs its sweep — it has no per-disconnect deletion, so a message only updates windowStart and entries accumulate until the sweep reclaims them. ipConnectionCounts is the opposite: it deletes on close, so it doesn't need one. The two maps have different lifecycles.

Path to merge

Could you drop change #1 from this PR (revert the value-type change, the sweep, and its comment) and leave just changes #2 and #3? Happy to merge once it's down to those two.

If you'd specifically like a sweep here, the only coherent way to make it live is to change the close handler to leave a count === 0 entry instead of deleting (deferring cleanup to cut delete/re-insert churn for an IP that reconnects in bursts). That's a separate change with its own tradeoff, and we're not convinced the churn is worth the extra state, so we'd rather keep the immediate delete-on-close that already works.

@evtivity-admin evtivity-admin self-assigned this Jun 14, 2026
@kushal1o1 kushal1o1 force-pushed the fix/ocpp-server-memory-leak-heartbeat-timer branch from 42a01c0 to 7743ed9 Compare June 14, 2026 05:19
@kushal1o1

Copy link
Copy Markdown
Contributor Author

Updated as requested , dropped change #1, only #2 and #3 remain.

…d timer

- Update lastHeartbeat on CALLRESULT/CALLERROR so responsive stations
  aren't falsely disconnected
- Clear ipMessageCleanupTimer on server shutdown to prevent orphaned
  interval
@kushal1o1 kushal1o1 force-pushed the fix/ocpp-server-memory-leak-heartbeat-timer branch from 7743ed9 to 52ce5f5 Compare June 14, 2026 05:32
@evtivity-admin evtivity-admin merged commit a35c061 into EVtivity:main Jun 14, 2026
6 of 7 checks passed
@evtivity-admin evtivity-admin self-requested a review June 14, 2026 14:37
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.

2 participants