From: Jérôme Benoit Date: Wed, 8 Jul 2026 22:19:05 +0000 (+0200) Subject: fix(ocpp): cancel deferred OCPP 1.6 firmware setTimeout on stop() (#1984) X-Git-Tag: cli@v4.11.0~43 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=2e691254ddc64aece42656d031f36b11169a6c7c;p=e-mobility-charging-stations-simulator.git fix(ocpp): cancel deferred OCPP 1.6 firmware setTimeout on stop() (#1984) The OCPP 1.6 UPDATE_FIRMWARE event listener schedules updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate is in the future. .unref() prevents the timer from blocking process exit, but the callback still fires after stop(): if the station restarts, the deferred simulation runs against the new connection and can emit FirmwareStatusNotification messages the CSMS did not request. Store the timer handle on OCPP16StationState.deferredFirmwareUpdateTimer (per-station state introduced by #1963 / PR #1983) and release it via a shared cancelDeferredFirmwareUpdate helper called from both the schedule site (to supersede a prior pending schedule) and resetStationState (invoked by the inherited stop() template before the base deletes the WeakMap entry). The callback clears the handle before awaiting updateFirmwareSimulation so resetStationState never targets a fired timer. Mirrors the OCPP 2.0.1 pattern (OCPP20IncomingRequestService.resetStationState cancels certSigningRetryManager.cancelRetryTimer, commit c0b25553). Closes #1972 --- diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index e186a00a..b1dfd0c7 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -107,7 +107,9 @@ import { type UnlockConnectorResponse, } from '../../../types/index.js' import { + clampToSafeTimerValue, Configuration, + Constants, convertToDate, convertToInt, convertToIntOrNaN, @@ -142,16 +144,26 @@ const moduleName = 'OCPP16IncomingRequestService' /** * Per-station lifecycle state carried on {@link OCPP16IncomingRequestService}. * - * As of #1963 this interface is intentionally empty. Companion PRs will - * populate this interface with their own fields: + * Populated incrementally as companion PRs land. Fields currently + * present: + * - `deferredFirmwareUpdateTimer` (#1972). + * + * Companion PRs still pending will add their own fields: * - #1971 (GetDiagnostics supersession): `activeDiagnosticsAbortController`, * `activeDiagnosticsRequestId`; - * - #1972 (deferred firmware `setTimeout` cancel): firmware timer handle; * - #1973 (trigger cross-check): `activeDiagnosticsRequestId` (shared with * #1971), `activeFirmwareUpdateRequestId`. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- populated by companion PRs #1971 / #1972 / #1973 -interface OCPP16StationState {} +interface OCPP16StationState { + /** + * `setTimeout` handle for the deferred `updateFirmwareSimulation` + * scheduled by the `UPDATE_FIRMWARE` event listener when the incoming + * request carries a future `retrieveDate`. Released by + * {@link OCPP16IncomingRequestService.cancelDeferredFirmwareUpdate} + * on stop, supersession, or normal fire (#1972). + */ + deferredFirmwareUpdateTimer?: NodeJS.Timeout +} /** * OCPP 1.6 Incoming Request Service - handles and processes all incoming requests @@ -605,24 +617,44 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { + // Fire-and-forget deferred update: setTimeout(...).unref() keeps + // the pending timer from blocking node.js exit. The handle is + // stored on OCPP16StationState so stop() cancels it via + // resetStationState (#1972); the schedule site supersedes any + // prior pending timer via the same helper, and the callback + // clears the handle before the awaited simulation so + // resetStationState never targets a fired timer. + const stationState = this.getOrCreateStationState(chargingStation) + this.cancelDeferredFirmwareUpdate(stationState) + // Clamp via canonical helper: a retrieveDate beyond + // Constants.MAX_SETINTERVAL_DELAY_MS would otherwise be silently + // reduced to 1 ms by Node and fire immediately. + const delayMs = retrieveDate.getTime() - now + const scheduleDelayMs = clampToSafeTimerValue(delayMs) + if (scheduleDelayMs !== delayMs) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware retrieveDate delay ${delayMs.toString()} ms exceeds ${Constants.MAX_SETINTERVAL_DELAY_MS.toString()} ms, clamping to ${scheduleDelayMs.toString()} ms` + ) + } + stationState.deferredFirmwareUpdateTimer = setTimeout(() => { + delete stationState.deferredFirmwareUpdateTimer this.updateFirmwareSimulation(chargingStation).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware simulation error:`, error ) }) - }, retrieveDate.getTime() - now).unref() + }, scheduleDelayMs).unref() } } ) } /** - * @returns Fresh empty state — companion PRs (#1971 / #1972 / #1973) - * populate fields via declaration merging on {@link OCPP16StationState}. + * @returns Fresh state with all optional {@link OCPP16StationState} + * fields unset. Fields are lazily assigned by the request handlers + * that own them (see {@link OCPP16StationState}); companion PRs + * (#1971 / #1973) will populate their own fields on first use. */ protected override createStationState (): OCPP16StationState { return {} @@ -642,16 +674,32 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { // Assert: test passes if no unhandled rejection was thrown }) }) + + // #1972: deferred UpdateFirmware timer lifecycle on OCPP16StationState + await describe('UPDATE_FIRMWARE deferred timer lifecycle', async () => { + interface OCPP16StationStateShape { + deferredFirmwareUpdateTimer?: NodeJS.Timeout + } + + interface PlumbingAccess { + stationsState: WeakMap + } + + const asPlumbing = (service: OCPP16IncomingRequestService): PlumbingAccess => + service as unknown as PlumbingAccess + + let listenerService: OCPP16IncomingRequestService + let updateFirmwareMock: ReturnType + + beforeEach(() => { + listenerService = new OCPP16IncomingRequestService() + updateFirmwareMock = mock.method( + listenerService as unknown as { + updateFirmwareSimulation: (chargingStation: unknown) => Promise + }, + 'updateFirmwareSimulation', + mock.fn(async () => Promise.resolve()) + ) + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should store the deferred timer handle on OCPP16StationState when retrieveDate is in the future', async t => { + // Arrange + const { station } = createOCPP16ListenerStation('listener-timer-store') + const request: OCPP16UpdateFirmwareRequest = { + location: 'ftp://localhost/firmware.bin', + retrieveDate: new Date(Date.now() + 60_000), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + request, + response + ) + + const state = asPlumbing(listenerService).stationsState.get(station) + assert.notStrictEqual(state, undefined) + assert.notStrictEqual(state?.deferredFirmwareUpdateTimer, undefined) + }) + }) + + await it('should cancel the deferred timer on stop() and never fire updateFirmwareSimulation', async t => { + // Arrange + const { station } = createOCPP16ListenerStation('listener-timer-stop') + const request: OCPP16UpdateFirmwareRequest = { + location: 'ftp://localhost/firmware.bin', + retrieveDate: new Date(Date.now() + 60_000), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], async () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + request, + response + ) + + const plumbing = asPlumbing(listenerService) + assert.strictEqual(plumbing.stationsState.has(station), true) + + listenerService.stop(station) + assert.strictEqual(plumbing.stationsState.has(station), false) + + t.mock.timers.tick(120_000) + await flushMicrotasks() + + assert.strictEqual(updateFirmwareMock.mock.callCount(), 0) + }) + }) + + 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') + const request: OCPP16UpdateFirmwareRequest = { + location: 'ftp://localhost/firmware.bin', + retrieveDate: new Date(Date.now() + 100), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], async () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + request, + response + ) + + const plumbing = asPlumbing(listenerService) + assert.notStrictEqual( + plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer, + undefined + ) + + t.mock.timers.tick(200) + await flushMicrotasks() + + assert.strictEqual(updateFirmwareMock.mock.callCount(), 1) + assert.strictEqual( + plumbing.stationsState.get(station)?.deferredFirmwareUpdateTimer, + undefined + ) + }) + }) + + await it('should cancel the previous deferred timer when re-scheduled before it fires', async t => { + // Arrange + const { station } = createOCPP16ListenerStation('listener-timer-reschedule') + 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() + 30_000), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], async () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + firstRequest, + response + ) + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + secondRequest, + response + ) + + t.mock.timers.tick(31_000) + await flushMicrotasks() + assert.strictEqual(updateFirmwareMock.mock.callCount(), 1) + + t.mock.timers.tick(60_000) + await flushMicrotasks() + assert.strictEqual(updateFirmwareMock.mock.callCount(), 1) + }) + }) + + await it('should isolate deferred timers between stations - stop(A) must not cancel B', async t => { + // Arrange + const { station: stationA } = createOCPP16ListenerStation('listener-timer-iso-a') + const { station: stationB } = createOCPP16ListenerStation('listener-timer-iso-b') + const request: OCPP16UpdateFirmwareRequest = { + location: 'ftp://localhost/firmware.bin', + retrieveDate: new Date(Date.now() + 60_000), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], async () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + stationA, + request, + response + ) + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + stationB, + request, + response + ) + + const plumbing = asPlumbing(listenerService) + assert.strictEqual(plumbing.stationsState.has(stationA), true) + assert.strictEqual(plumbing.stationsState.has(stationB), true) + + listenerService.stop(stationA) + assert.strictEqual(plumbing.stationsState.has(stationA), false) + assert.strictEqual(plumbing.stationsState.has(stationB), true) + assert.notStrictEqual( + plumbing.stationsState.get(stationB)?.deferredFirmwareUpdateTimer, + undefined + ) + + t.mock.timers.tick(70_000) + await flushMicrotasks() + + assert.strictEqual(updateFirmwareMock.mock.callCount(), 1) + }) + }) + + await it('should self-clear the deferred timer handle even when updateFirmwareSimulation rejects', async t => { + // Arrange + const { station } = createOCPP16ListenerStation('listener-timer-defer-reject') + const rejectingMock = mock.method( + listenerService as unknown as { + updateFirmwareSimulation: (chargingStation: unknown) => Promise + }, + 'updateFirmwareSimulation', + mock.fn(async () => Promise.reject(new Error('deferred firmware simulation error'))) + ) + const request: OCPP16UpdateFirmwareRequest = { + location: 'ftp://localhost/firmware.bin', + retrieveDate: new Date(Date.now() + 100), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], async () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + request, + response + ) + + t.mock.timers.tick(200) + await flushMicrotasks() + await flushMicrotasks() + + assert.strictEqual(rejectingMock.mock.callCount(), 1) + assert.strictEqual( + asPlumbing(listenerService).stationsState.get(station)?.deferredFirmwareUpdateTimer, + undefined + ) + }) + }) + + await it('should clamp retrieveDate delay to Node setTimeout 32-bit maximum and warn', async t => { + // Arrange + const { station } = createOCPP16ListenerStation('listener-timer-clamp') + const warnSpy = t.mock.method(logger, 'warn') + // MAX_SETINTERVAL_DELAY_MS + 1000 ms drift buffer: Date.now() is not + // mocked, so a 1 s cushion avoids flake if the listener re-samples + // Date.now() a few ms later and delayMs drops to exactly MAX. + const request: OCPP16UpdateFirmwareRequest = { + location: 'ftp://localhost/firmware.bin', + retrieveDate: new Date(Date.now() + Constants.MAX_SETINTERVAL_DELAY_MS + 1000), + } + const response: OCPP16UpdateFirmwareResponse = {} + + // Act & Assert + await withMockTimers(t, ['setTimeout'], () => { + listenerService.emit( + OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, + station, + request, + response + ) + + assert.strictEqual(warnSpy.mock.callCount(), 1) + const warnMessage = warnSpy.mock.calls[0].arguments[0] as unknown as string + assert.match( + warnMessage, + new RegExp( + `exceeds ${Constants.MAX_SETINTERVAL_DELAY_MS.toString()} ms, clamping to ${Constants.MAX_SETINTERVAL_DELAY_MS.toString()} ms` + ) + ) + assert.notStrictEqual( + asPlumbing(listenerService).stationsState.get(station)?.deferredFirmwareUpdateTimer, + undefined + ) + }) + }) + }) })