From: Jérôme Benoit Date: Fri, 10 Jul 2026 13:09:12 +0000 (+0200) Subject: fix(ocpp): guard OCPP 1.6 UpdateFirmware deferred-timer schedule against sealed state... X-Git-Tag: cli@v4.11.0~37 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=247fef289a1786c307cecc185951e6dbc4e5d8a9;p=e-mobility-charging-stations-simulator.git fix(ocpp): guard OCPP 1.6 UpdateFirmware deferred-timer schedule against sealed state writes (#1994) 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). --- diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index d27daec4..5b124e53 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -702,6 +702,12 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { }) }) + 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')