]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): cancel deferred OCPP 1.6 firmware setTimeout on stop() (#1984)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 8 Jul 2026 22:19:05 +0000 (00:19 +0200)
committerGitHub <noreply@github.com>
Wed, 8 Jul 2026 22:19:05 +0000 (00:19 +0200)
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

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

index e186a00a594bf9ed65bbf5fcef357a0b6c0c32f5..b1dfd0c7cfa1e71a840c08eb16a1604f19031097 100644 (file)
@@ -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<OCP
             )
           })
         } else {
-          // Unref: fire-and-forget deferred firmware update must not
-          // block node.js exit.
-          setTimeout(() => {
+          // 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<OCP
   }
 
   /**
-   * Companion PRs (#1971 / #1972 / #1973) will populate this method with
-   * release logic paired with the {@link OCPP16StationState} fields they
-   * add. They MUST follow the abort-before-clear ordering documented on
-   * {@link OCPP20IncomingRequestService.resetStationState}: abort in-flight
-   * signals BEFORE clearing controller references, cancel timers BEFORE
-   * the base template deletes the entry.
-   * @param _stationState - Per-station state (currently empty; unused).
+   * Release resources held by the per-station state. Called by the
+   * inherited `stop()` template BEFORE the base deletes the WeakMap
+   * entry. Follow the abort-before-clear / cancel-before-delete
+   * ordering documented on
+   * {@link OCPP20IncomingRequestService.resetStationState}: abort
+   * in-flight signals BEFORE clearing controller references, cancel
+   * timers BEFORE the base template deletes the entry.
+   *
+   * Companion PRs (#1971 GetDiagnostics supersession, #1973 trigger
+   * cross-check) will extend this method with their own field releases.
+   * @param stationState - Per-station state to release.
    */
-  protected override resetStationState (_stationState: OCPP16StationState): void {
-    /* no-op: OCPP 1.6 carries no state fields yet (see companion PRs) */
+  protected override resetStationState (stationState: OCPP16StationState): void {
+    this.cancelDeferredFirmwareUpdate(stationState)
+  }
+
+  /**
+   * Cancels the deferred `updateFirmwareSimulation` timer and clears
+   * the handle (#1972). No-op when no timer is pending.
+   * @param stationState - Per-station state carrying the timer handle.
+   */
+  private cancelDeferredFirmwareUpdate (stationState: OCPP16StationState): void {
+    if (stationState.deferredFirmwareUpdateTimer != null) {
+      clearTimeout(stationState.deferredFirmwareUpdateTimer)
+      delete stationState.deferredFirmwareUpdateTimer
+    }
   }
 
   private composeCompositeSchedule (
index f981471960889bb56ab6bcf5e9240810e26a2495..e4a4b6457a44b4400e36ec3d742ae841db99ce8d 100644 (file)
@@ -7,6 +7,7 @@
 import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
 import type { GetDiagnosticsRequest } from '../../../../src/types/index.js'
 
 import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
@@ -17,6 +18,7 @@ import {
   type OCPP16UpdateFirmwareRequest,
   type OCPP16UpdateFirmwareResponse,
 } from '../../../../src/types/index.js'
+import { Constants, logger } from '../../../../src/utils/index.js'
 import {
   flushMicrotasks,
   standardCleanup,
@@ -268,4 +270,282 @@ await describe('OCPP16IncomingRequestService — Firmware', async () => {
       // 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<ChargingStation, OCPP16StationStateShape>
+    }
+
+    const asPlumbing = (service: OCPP16IncomingRequestService): PlumbingAccess =>
+      service as unknown as PlumbingAccess
+
+    let listenerService: OCPP16IncomingRequestService
+    let updateFirmwareMock: ReturnType<typeof mock.fn>
+
+    beforeEach(() => {
+      listenerService = new OCPP16IncomingRequestService()
+      updateFirmwareMock = mock.method(
+        listenerService as unknown as {
+          updateFirmwareSimulation: (chargingStation: unknown) => Promise<void>
+        },
+        '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<void>
+        },
+        '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
+        )
+      })
+    })
+  })
 })