]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): guard OCPP 1.6 UpdateFirmware deferred-timer schedule against sealed state...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 10 Jul 2026 13:09:12 +0000 (15:09 +0200)
committerGitHub <noreply@github.com>
Fri, 10 Jul 2026 13:09:12 +0000 (15:09 +0200)
The .on(UPDATE_FIRMWARE, ...) listener's deferred branch obtains state via
getOrCreateStationState() and then writes stationState.deferredFirmwareUpdateTimer
= setTimeout(...). After PR #1992 sealed the base-plumbing behavior,
getOrCreateStationState() returns the stopped state instead of lazy-initializing
a fresh entry once stop() has marked stationsState.get(cs)?.stopped === true. A
late UPDATE_FIRMWARE dispatch after ChargingStation.stop() runs
ocppIncomingRequestService.stop(this) at ChargingStation.ts:1273 (before
this.started = false at :1280) therefore writes a fresh Timeout onto the sealed
state.

The callback body is safe (unref'd, checkChargingStationState gate), but the
write is inconsistent with the OCPP 2.0.1 GUARD sites shipped in PR #1992
(getCertSigningRetryManager, sendSecurityEventNotification,
simulateFirmwareUpdateLifecycle, simulateLogUploadLifecycle) and leaves a Timeout
closure-referenced entry pending in Node's queue until GC reclaims the
ChargingStation.

Insert a stopped GUARD immediately after getOrCreateStationState, before
cancelDeferredFirmwareUpdate — mirroring the exact syntax of the OCPP 2.0.1
GUARD sites (bare 'return' after 'if (stationState.stopped === true) {'). The
GUARD short-circuits the schedule branch before any write reaches the sealed
state.

Add a regression test covering the post-stop() deferred-schedule path in
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts.

Closes #1993

Touchpoint:
- src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts:705 — stopped GUARD

References:
- PR #1992 — source of the 'stopped' sentinel + OCPP 2.0.1 GUARD sites
- PR #1984 — source of 'deferredFirmwareUpdateTimer'

This is purely additive; no observable OCPP behavior change (the pre-existing
fire-time checkChargingStationState gate inside the setTimeout callback remains
as defense-in-depth).

src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts

index d27daec4d513051919b3b99ee9fe80ca3fffc606..5b124e53ef495fa22dc1b723e8a085540138f52e 100644 (file)
@@ -702,6 +702,12 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
           // the handle before the awaited simulation so resetStationState
           // never targets a fired timer.
           const stationState = this.getOrCreateStationState(chargingStation)
+          // Silent-drop late `.on(UPDATE_FIRMWARE, ...)` dispatch after
+          // `stop()`: the sealed state must not acquire a new
+          // `deferredFirmwareUpdateTimer` write.
+          if (stationState.stopped === true) {
+            return
+          }
           this.cancelDeferredFirmwareUpdate(stationState)
           // Clamp via canonical helper: a retrieveDate beyond
           // Constants.MAX_SETINTERVAL_DELAY_MS would otherwise be silently
index 7fc1c1172ef6762aef8205f0a1ba5ea208c64233..e706f84b29b9569cbf652d7d1e37d4cb25e7d645 100644 (file)
@@ -444,6 +444,59 @@ await describe('OCPP16IncomingRequestService — Firmware', async () => {
       })
     })
 
+    await it('should not schedule a deferred timer when UPDATE_FIRMWARE fires after stop() sealed the state', async t => {
+      // Arrange
+      const { station } = createOCPP16ListenerStation('listener-timer-post-stop-schedule')
+      const firstRequest: OCPP16UpdateFirmwareRequest = {
+        location: 'ftp://localhost/firmware.bin',
+        retrieveDate: new Date(Date.now() + 60_000),
+      }
+      const secondRequest: OCPP16UpdateFirmwareRequest = {
+        location: 'ftp://localhost/firmware.bin',
+        retrieveDate: new Date(Date.now() + 90_000),
+      }
+      const response: OCPP16UpdateFirmwareResponse = {}
+
+      // Act & Assert
+      await withMockTimers(t, ['setTimeout'], async () => {
+        listenerService.emit(
+          OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+          station,
+          firstRequest,
+          response
+        )
+        listenerService.stop(station)
+        const plumbing = asPlumbing(listenerService)
+        assert.strictEqual(plumbing.stationsState.get(station)?.stopped, true)
+        assert.strictEqual(
+          plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer,
+          undefined
+        )
+
+        listenerService.emit(
+          OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+          station,
+          secondRequest,
+          response
+        )
+
+        assert.strictEqual(plumbing.stationsState.get(station)?.stopped, true)
+        assert.strictEqual(
+          plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer,
+          undefined
+        )
+
+        t.mock.timers.tick(120_000)
+        await flushMicrotasks()
+
+        assert.strictEqual(updateFirmwareMock.mock.callCount(), 0)
+        assert.strictEqual(
+          plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer,
+          undefined
+        )
+      })
+    })
+
     await it('should self-clear the deferred timer handle when the callback fires on schedule', async t => {
       // Arrange
       const { station } = createOCPP16ListenerStation('listener-timer-self-clear')