* carry a `requestId`, so the temporal position tells the CSMS
* which upload it belonged to).
* 2. {@link OCPP16IncomingRequestService.resetStationState} on
- * `stop()`, following the cancel-before-delete ordering documented
+ * `stop()`, following the cancel-before-mark ordering documented
* on {@link OCPP20IncomingRequestService.resetStationState}.
*/
activeDiagnosticsAbortController?: AbortController
* invocation before the first completes.
*/
firmwareUpdateInProgress?: boolean
+ /**
+ * `true` after {@link OCPPIncomingRequestService.stop} has released
+ * per-station resources via {@link resetStationState}. Never
+ * set from OCPP 1.6 code directly.
+ */
+ stopped?: boolean
}
/**
/**
* Release resources held by the per-station state. Called by the
- * inherited `stop()` template BEFORE the base deletes the WeakMap
- * entry. Follow the cancel-before-delete ordering documented on
+ * inherited `stop()` template BEFORE the base marks the WeakMap
+ * entry `stopped: true`. Follow the abort-before-clear /
+ * cancel-before-mark ordering documented on
* {@link OCPP20IncomingRequestService.resetStationState}: abort
* in-flight signals and cancel timers BEFORE the base template
- * deletes the state entry. The AbortController reference itself
- * is left in place — WeakMap eviction reclaims the state object,
- * and any still-unwinding handler's identity-guarded `finally`
+ * marks the entry `stopped: true`. The AbortController reference
+ * itself is left in place — the sealed stopped state persists in
+ * the WeakMap until GC reclaims the ChargingStation, and any
+ * still-unwinding handler's identity-guarded `finally`
* (see {@link OCPP16StationState.activeDiagnosticsAbortController})
- * clears the fields on the now-orphaned object without racing a
+ * clears the fields on the sealed object without racing a
* successor.
* @param stationState - Per-station state to release.
*/
preInoperativeConnectorStatuses: Map<number, OCPP20ConnectorStatusEnumType>
reportDataCache: Map<number, ReportDataType[]>
securityEventQueue: QueuedSecurityEvent[]
+ /**
+ * `setTimeout` handle for the pending {@link sendQueuedSecurityEvents}
+ * retry. Stored so {@link resetStationState} can cancel it before the
+ * base template marks the entry `stopped: true`. Released by
+ * {@link cancelSecurityEventRetryTimer} on stop, re-schedule, or
+ * self-clear when the callback fires.
+ */
+ securityEventRetryTimer?: NodeJS.Timeout
+ /**
+ * `true` after {@link OCPPIncomingRequestService.stop} has released
+ * per-station resources via {@link resetStationState}. Never set from
+ * OCPP 2.0.1 code directly.
+ */
+ stopped?: boolean
}
interface QueuedSecurityEvent {
)
}
+ /**
+ * Returns the cert-signing retry manager for the given station,
+ * lazily creating it on first access.
+ * @param chargingStation - Target charging station.
+ * @returns The retry manager, or `undefined` when the station has
+ * been stopped so callers must optional-chain the result.
+ */
public getCertSigningRetryManager (
chargingStation: ChargingStation
- ): OCPP20CertSigningRetryManager {
- const state = this.getOrCreateStationState(chargingStation)
- state.certSigningRetryManager ??= new OCPP20CertSigningRetryManager(chargingStation)
- return state.certSigningRetryManager
+ ): OCPP20CertSigningRetryManager | undefined {
+ // Return undefined when the state is sealed stopped: a
+ // fresh manager would schedule a non-`.unref()`'d retry
+ // `setTimeout` holding the ChargingStation reference. Callers
+ // must optional-chain the return.
+ const stationState = this.getOrCreateStationState(chargingStation)
+ if (stationState.stopped === true) {
+ return undefined
+ }
+ stationState.certSigningRetryManager ??= new OCPP20CertSigningRetryManager(chargingStation)
+ return stationState.certSigningRetryManager
}
/**
}
/**
- * Reset per-station lifecycle state prior to WeakMap eviction.
+ * Reset per-station lifecycle state prior to the base template
+ * marking the entry `stopped: true`.
*
* ORDERING INVARIANT (preserved bit-for-bit from the pre-refactor
* `stop()` body): abort in-flight signals BEFORE clearing the fields
* that hold their controllers (otherwise the subsequent `?.abort()`
* short-circuits on the nulled field and the in-flight operation is
- * never signaled to cancel), and cancel the retry timer BEFORE the
- * base template deletes the state entry.
+ * never signaled to cancel), and cancel the retry timers BEFORE the
+ * base template marks the entry `stopped: true`.
* @param stationState - Per-station state to reset.
*/
protected override resetStationState (stationState: OCPP20StationState): void {
stationState.activeFirmwareUpdateAbortController?.abort()
stationState.activeLogUploadAbortController?.abort()
stationState.certSigningRetryManager?.cancelRetryTimer()
+ this.cancelSecurityEventRetryTimer(stationState)
this.resetActiveFirmwareUpdateState(stationState)
this.resetActiveLogUploadState(stationState)
}
return reportData
}
+ /**
+ * Cancels a pending {@link sendQueuedSecurityEvents} retry
+ * `setTimeout` and clears the stored handle. No-op when no retry is
+ * pending.
+ * @param stationState - Per-station state carrying the retry handle.
+ */
+ private cancelSecurityEventRetryTimer (stationState: OCPP20StationState): void {
+ if (stationState.securityEventRetryTimer != null) {
+ clearTimeout(stationState.securityEventRetryTimer)
+ delete stationState.securityEventRetryTimer
+ }
+ }
+
private clearActiveFirmwareUpdate (chargingStation: ChargingStation, requestId: number): void {
const stationState = this.stationsState.get(chargingStation)
if (stationState == null) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Certificate chain stored successfully`
)
- // A02.FR.20: Cancel retry timer when CertificateSignedRequest is received and accepted
- this.getCertSigningRetryManager(chargingStation).cancelRetryTimer()
+ // A02.FR.20: Cancel retry timer when CertificateSignedRequest is received and accepted.
+ // Optional-chain: `getCertSigningRetryManager` returns undefined
+ // when the station has been stopped.
+ this.getCertSigningRetryManager(chargingStation)?.cancelRetryTimer()
return {
status: GenericStatus.Accepted,
}
request: OCPP20GetBaseReportRequest,
response: OCPP20GetBaseReportResponse
): Promise<void> {
+ // Silent-drop late `.on(GET_BASE_REPORT).catch(...)` dispatch when
+ // no state entry exists or the entry is sealed stopped.
+ const stationState = this.stationsState.get(chargingStation)
+ if (stationState == null || stationState.stopped === true) {
+ return
+ }
const { reportBase, requestId } = request
- const stationState = this.getOrCreateStationState(chargingStation)
const cached = stationState.reportDataCache.get(requestId)
const reportData = cached ?? this.buildReportData(chargingStation, reportBase)
}
private sendQueuedSecurityEvents (chargingStation: ChargingStation): void {
- const stationState = this.getOrCreateStationState(chargingStation)
+ // Silent-drop when no state entry exists or the entry is sealed
+ // stopped: covers a fired-before-cancel race on the unref'd retry
+ // setTimeout scheduled below.
+ const stationState = this.stationsState.get(chargingStation)
+ if (stationState == null || stationState.stopped === true) {
+ return
+ }
if (
stationState.isDrainingSecurityEvents ||
!chargingStation.isWebSocketConnectionOpened() ||
`${chargingStation.logPrefix()} ${moduleName}.sendQueuedSecurityEvents: Draining ${queue.length.toString()} queued security event(s)`
)
const drainNextEvent = (): void => {
- if (!isNotEmptyArray(queue) || !chargingStation.isWebSocketConnectionOpened()) {
+ if (
+ stationState.stopped === true ||
+ !isNotEmptyArray(queue) ||
+ !chargingStation.isWebSocketConnectionOpened()
+ ) {
stationState.isDrainingSecurityEvents = false
return
}
return undefined
})
.catch((error: unknown) => {
+ if (stationState.stopped === true) {
+ return
+ }
const retryCount = (event.retryCount ?? 0) + 1
if (retryCount >= OCPP20Constants.MAX_SECURITY_EVENT_SEND_ATTEMPTS) {
logger.warn(
)
queue.unshift({ ...event, retryCount })
stationState.isDrainingSecurityEvents = false
- // Unref: fire-and-forget retry must not block node.js exit.
- setTimeout(() => {
+ // Store the retry handle on `stationState` so
+ // `resetStationState` can cancel it on `stop()`.
+ // Cancel any previously scheduled retry first. Unref:
+ // fire-and-forget retry must not block node.js exit.
+ this.cancelSecurityEventRetryTimer(stationState)
+ stationState.securityEventRetryTimer = setTimeout(() => {
+ // Self-clear before the recursive call: a subsequent retry
+ // must not observe a stale reference.
+ delete stationState.securityEventRetryTimer
this.sendQueuedSecurityEvents(chargingStation)
}, OCPP20Constants.SECURITY_EVENT_RETRY_DELAY_MS).unref()
})
type: string,
techInfo?: string
): void {
+ // Lifecycle-entry: state may not exist yet on the first invalid-cert
+ // request. Drop only when already sealed stopped.
+ const stationState = this.getOrCreateStationState(chargingStation)
+ if (stationState.stopped === true) {
+ return
+ }
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.sendSecurityEventNotification: [SecurityEvent] type=${type}${techInfo != null ? `, techInfo=${techInfo}` : ''}`
)
- this.getOrCreateStationState(chargingStation).securityEventQueue.push({
+ stationState.securityEventQueue.push({
timestamp: new Date(),
type,
...(techInfo !== undefined && { techInfo }),
): Promise<void> {
const { installDateTime, location, retrieveDateTime, signature } = firmware
+ const stationState = this.getOrCreateStationState(chargingStation)
+ // Silent-drop late `.on(UPDATE_FIRMWARE).catch(...)` dispatch after
+ // `stop()`: the sealed state must not drive the async lifecycle
+ // nor clobber `activeFirmwareUpdateAbortController`.
+ if (stationState.stopped === true) {
+ return
+ }
// Store the abort controller so a subsequent UpdateFirmware can abort this in-progress update
const abortController = new AbortController()
- const stationState = this.getOrCreateStationState(chargingStation)
stationState.activeFirmwareUpdateAbortController = abortController
stationState.activeFirmwareUpdateRequestId = requestId
requestId: number
): Promise<void> {
const stationState = this.getOrCreateStationState(chargingStation)
+ // Silent-drop late `.on(GET_LOG).catch(...)` dispatch after
+ // `stop()`; symmetric with `simulateFirmwareUpdateLifecycle`.
+ if (stationState.stopped === true) {
+ return
+ }
const abortController = new AbortController()
stationState.activeLogUploadAbortController = abortController
stationState.activeLogUploadRequestId = requestId
`${chargingStation.logPrefix()} ${moduleName}.handleResponseSignCertificate: SignCertificate response received, status: ${payload.status}`
)
if (payload.status === GenericStatus.Accepted) {
+ // Optional-chain: `getCertSigningRetryManager` returns undefined
+ // when the station has been stopped.
OCPP20IncomingRequestService.getInstance()
.getCertSigningRetryManager(chargingStation)
- .startRetryTimer(requestPayload.certificateType)
+ ?.startRetryTimer(requestPayload.certificateType)
}
}
*
* Provides shared plumbing for per-station lifecycle state:
* - a protected {@link WeakMap} keyed by {@link ChargingStation};
- * - a lazy-init getter (`getOrCreateStationState`);
- * - a concrete `stop()` template that resets and drops the entry.
+ * - a lazy-init getter (`getOrCreateStationState`) that returns the
+ * sealed stopped state instead of resurrecting a fresh entry after
+ * {@link stop};
+ * - a concrete `stop()` template that resets and marks the entry
+ * `stopped: true`, preserving the WeakMap entry so late dispatch
+ * observes the marker and drops.
*
* Subclass contract:
* - `createStationState` — factory returning the initial state object.
* - `resetStationState` — releases any resources held by the state
* (abort controllers, timer handles, retry managers).
*
- * The `stop()` template owns the ordering invariant (reset before delete)
+ * The `stop()` template owns the ordering invariant (reset before mark)
* and the "only-if-present" guard. Subclasses that need additional
* lifecycle cleanup should override `stop()` and call `super.stop()`
* first; override `resetStationState` (not `stop()`) for state-field
* reset logic.
* @template TStationState - Concrete per-station state shape.
- * The default `object` bound is load-bearing: it allows the bare
+ * The default `{ stopped?: boolean }` bound is load-bearing: the bare
* `OCPPIncomingRequestService` reference in the static singleton
- * registry ({@link OCPPIncomingRequestService.instances}) and in the
- * `getInstance<T extends OCPPIncomingRequestService>` constraint to
- * resolve to `OCPPIncomingRequestService<object>`. Removing the default
- * would break both declarations with `TS2314` (generic type requires
- * type arguments). The bound also matches the `WeakMap` value-type
- * requirement (values must be non-primitive).
+ * registry ({@link OCPPIncomingRequestService.instances}) and the
+ * `getInstance<T extends OCPPIncomingRequestService>` constraint
+ * resolve to `OCPPIncomingRequestService<{ stopped?: boolean }>`;
+ * removing the default breaks both with `TS2314`. The bound also
+ * matches the `WeakMap` value-type requirement (values must be
+ * non-primitive) and carries the marker field consumed by {@link stop}
+ * and {@link getOrCreateStationState}.
*/
export abstract class OCPPIncomingRequestService<
- TStationState extends object = object
+ TStationState extends { stopped?: boolean } = { stopped?: boolean }
> extends EventEmitter {
private static readonly instances = new Map<
new () => OCPPIncomingRequestService,
* Stops the incoming-request service for the given charging station.
*
* Template method: subclasses SHOULD NOT override the template steps
- * (WeakMap lookup, reset, delete) — override {@link resetStationState}
- * for field-level cleanup. Subclasses MAY override to add
- * lifecycle-scoped side effects (e.g. clearing external caches keyed
- * on the station); such overrides MUST call `super.stop()` first so
- * the reset-then-delete ordering is preserved.
+ * (WeakMap lookup, reset, mark stopped) — override
+ * {@link resetStationState} for field-level cleanup. Subclasses MAY
+ * override to add lifecycle-scoped side effects (e.g. clearing
+ * external caches keyed on the station); such overrides MUST call
+ * `super.stop()` first so the reset-then-mark ordering is preserved.
*
- * Exception behavior: if {@link resetStationState} throws, `WeakMap`
- * eviction is skipped and a subsequent `stop()` re-invokes the hook
- * on the same state. Any subclass extension after `super.stop()` is
- * also skipped, as the throw propagates. Matches pre-refactor
- * semantics exactly.
+ * The WeakMap entry is NOT deleted — it is marked `stopped: true`
+ * after {@link resetStationState} releases its resources.
+ * Deletion would re-enable resurrection via
+ * {@link getOrCreateStationState} lazy-init on any late handler
+ * dispatch. Keeping the sealed entry lets
+ * {@link getOrCreateStationState} return the sealed state and
+ * `stationsState.get(cs)` null-guarded callers observe the marker
+ * and drop. The WeakMap entry is naturally collected when the
+ * {@link ChargingStation} reference is dropped.
+ *
+ * Exception behavior: if {@link resetStationState} throws, the
+ * `stopped` mark is NOT set and a subsequent `stop()` re-invokes the
+ * hook on the same state. Any subclass extension after `super.stop()`
+ * is also skipped, as the throw propagates.
* @param chargingStation - Target charging station.
*/
public stop (chargingStation: ChargingStation): void {
const stationState = this.stationsState.get(chargingStation)
if (stationState != null) {
this.resetStationState(stationState)
- this.stationsState.delete(chargingStation)
+ stationState.stopped = true
}
}
/**
* Returns the state entry for `chargingStation`, creating one on first
* access via {@link createStationState}. Subsequent calls return the
- * same reference until {@link stop} evicts the entry.
+ * same reference for the station's active lifecycle.
+ *
+ * Once {@link stop} has marked the entry `stopped: true`,
+ * this method returns the sealed stopped state instead of
+ * lazy-init'ing a fresh entry. `.stopped` is written only from
+ * {@link stop}, so the guard is unreachable on the pre-stop happy
+ * path. Callers that need "silent-drop on stop" semantics MUST NOT
+ * rely on this guard alone — they use `stationsState.get(cs)` with
+ * an inline `stopped === true` null-guard and return early, because
+ * writes performed through this getter still land on the sealed
+ * object (harmless but wasteful).
* @param chargingStation - Target charging station.
- * @returns The lazily-initialized per-station state.
+ * @returns The lazily-initialized per-station state, or the sealed
+ * stopped state after {@link stop}.
+ * @see OCPP20IncomingRequestService.sendNotifyReportRequest for the
+ * consume-only null-guard pattern.
+ * @see OCPP20IncomingRequestService.sendSecurityEventNotification for
+ * the lifecycle-entry pattern where creation is required.
*/
protected getOrCreateStationState (chargingStation: ChargingStation): TStationState {
let state = this.stationsState.get(chargingStation)
+ if (state?.stopped === true) {
+ return state
+ }
if (state == null) {
state = this.createStationState()
this.stationsState.set(chargingStation, state)
/**
* Hook method paired with the {@link stop} template: releases
* resources held by the per-station state (abort controllers, timer
- * handles, retry managers, etc.) prior to eviction from
- * {@link stationsState}.
+ * handles, retry managers, etc.) prior to the template marking the
+ * entry `stopped: true` in {@link stationsState}.
*
- * Implementations MAY throw; on throw the base template skips
- * `WeakMap` eviction and a subsequent `stop()` re-invokes this hook
- * on the same partially-reset state. Implementations MUST therefore
+ * Implementations MAY throw; on throw the base template skips the
+ * `stopped` mark and a subsequent `stop()` re-invokes this hook on
+ * the same partially-reset state. Implementations MUST therefore
* tolerate re-entry on a partially-reset state (idempotent field
* clears + optional-chained aborts satisfy this). See {@link stop}
* for the full exception-behavior contract.
await describe('UPDATE_FIRMWARE deferred timer lifecycle', async () => {
interface OCPP16StationStateShape {
deferredFirmwareUpdateTimer?: NodeJS.Timeout
+ stopped?: boolean
}
interface PlumbingAccess {
assert.strictEqual(plumbing.stationsState.has(station), true)
listenerService.stop(station)
- assert.strictEqual(plumbing.stationsState.has(station), false)
+ assert.strictEqual(plumbing.stationsState.has(station), true)
+ 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(plumbing.stationsState.has(stationB), true)
listenerService.stop(stationA)
- assert.strictEqual(plumbing.stationsState.has(stationA), false)
+ assert.strictEqual(plumbing.stationsState.has(stationA), true)
+ assert.strictEqual(plumbing.stationsState.get(stationA)?.stopped, true)
+ assert.strictEqual(
+ plumbing.stationsState.get(stationA)?.deferredFirmwareUpdateTimer,
+ undefined
+ )
assert.strictEqual(plumbing.stationsState.has(stationB), true)
assert.notStrictEqual(
plumbing.stationsState.get(stationB)?.deferredFirmwareUpdateTimer,
interface OCPP16StationStateShape {
activeDiagnosticsAbortController?: AbortController
diagnosticsUploadInProgress?: boolean
+ stopped?: boolean
}
interface PlumbingAccess {
assert.strictEqual(finalState?.diagnosticsUploadInProgress, undefined)
})
- await it('should abort activeDiagnosticsAbortController and delete the WeakMap entry on stop()', () => {
+ await it('should abort activeDiagnosticsAbortController and mark the WeakMap entry stopped on stop()', () => {
// Arrange — pre-populate an in-flight lifecycle, then stop.
const { incomingRequestService, station } = context
const plumbing = asPlumbing(incomingRequestService)
// Act
incomingRequestService.stop(station)
- // Assert — cancel-before-delete ordering: the signal must fire before the
- // base template deletes the WeakMap entry, and the entry must be gone.
+ // Assert — cancel-before-mark ordering: the signal must fire before the
+ // base template marks the entry stopped, and the entry must be preserved
+ // and sealed.
assert.strictEqual(
inflightController.signal.aborted,
true,
'stop() must signal the in-flight diagnostics AbortController'
)
- assert.strictEqual(plumbing.stationsState.has(station), false)
+ assert.strictEqual(plumbing.stationsState.has(station), true)
+ assert.strictEqual(plumbing.stationsState.get(station)?.stopped, true)
})
await it('should not install activeDiagnosticsAbortController for non-FTP locations', async () => {
--- /dev/null
+/**
+ * @file Tests for OCPP20IncomingRequestService post-stop() resurrection guard
+ * @description Covers the OCPP 2.0.1 side of the shared post-stop
+ * resurrection guard: `sendSecurityEventNotification`,
+ * `sendQueuedSecurityEvents`, `sendNotifyReportRequest`,
+ * `getCertSigningRetryManager`, `simulateFirmwareUpdateLifecycle`,
+ * and `simulateLogUploadLifecycle` all silent-drop (or return
+ * undefined) when the WeakMap entry is absent or sealed
+ * `stopped: true`. Also exercises the `sendQueuedSecurityEvents`
+ * retry `setTimeout` handle discipline: store on state, self-clear
+ * on fire, cancel on re-schedule, cancel in `resetStationState`.
+ */
+
+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 { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPPVersion } from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import {
+ flushMicrotasks,
+ standardCleanup,
+ withMockTimers,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+
+interface OCPP20StationStateShape {
+ activeFirmwareUpdateAbortController?: AbortController
+ activeFirmwareUpdateRequestId?: number
+ activeLogUploadAbortController?: AbortController
+ activeLogUploadRequestId?: number
+ certSigningRetryManager?: unknown
+ isDrainingSecurityEvents: boolean
+ reportDataCache: Map<number, unknown[]>
+ securityEventQueue: QueuedSecurityEventShape[]
+ securityEventRetryTimer?: NodeJS.Timeout
+ stopped?: boolean
+}
+
+interface PlumbingAccess {
+ createStationState: () => OCPP20StationStateShape
+ getOrCreateStationState: (chargingStation: ChargingStation) => OCPP20StationStateShape
+ sendNotifyReportRequest: (
+ chargingStation: ChargingStation,
+ request: { reportBase: string; requestId: number },
+ response: { status: string }
+ ) => Promise<void>
+ sendQueuedSecurityEvents: (chargingStation: ChargingStation) => void
+ sendSecurityEventNotification: (
+ chargingStation: ChargingStation,
+ type: string,
+ techInfo?: string
+ ) => void
+ simulateFirmwareUpdateLifecycle: (
+ chargingStation: ChargingStation,
+ requestId: number,
+ firmware: unknown,
+ retries?: number,
+ retryInterval?: number
+ ) => Promise<void>
+ simulateLogUploadLifecycle: (chargingStation: ChargingStation, requestId: number) => Promise<void>
+ stationsState: WeakMap<ChargingStation, OCPP20StationStateShape>
+}
+
+interface QueuedSecurityEventShape {
+ retryCount?: number
+ techInfo?: string
+ timestamp: Date
+ type: string
+}
+
+const asPlumbing = (service: OCPP20IncomingRequestService): PlumbingAccess =>
+ service as unknown as PlumbingAccess
+
+const createStation = (baseNameSuffix: string): ChargingStation => {
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-${baseNameSuffix}`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const openStation = station as ChargingStation & { isWebSocketConnectionOpened: () => boolean }
+ openStation.isWebSocketConnectionOpened = () => true
+ return station
+}
+
+const setRequestHandler = (
+ station: ChargingStation,
+ handler: (...args: unknown[]) => Promise<unknown>
+): void => {
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = handler
+}
+
+await describe('OCPP20IncomingRequestService — post-stop resurrection guard', async () => {
+ let service: OCPP20IncomingRequestService
+ let plumbing: PlumbingAccess
+
+ beforeEach(() => {
+ service = new OCPP20IncomingRequestService()
+ plumbing = asPlumbing(service)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('sendSecurityEventNotification', async () => {
+ await it('should create state and enqueue on a fresh station (lifecycle-entry preserved)', () => {
+ const station = createStation('sec-fresh')
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ setRequestHandler(station, requestHandlerMock)
+
+ plumbing.sendSecurityEventNotification(station, 'FirmwareUpdated', 'info')
+
+ const state = plumbing.stationsState.get(station)
+ assert.notStrictEqual(state, undefined)
+ assert.strictEqual(state?.stopped, undefined)
+ })
+
+ await it('should silent-drop after stop() without resurrecting the entry', () => {
+ const station = createStation('sec-post-stop')
+ const state = plumbing.getOrCreateStationState(station)
+ const queueRef = state.securityEventQueue
+
+ service.stop(station)
+ assert.strictEqual(state.stopped, true)
+
+ plumbing.sendSecurityEventNotification(station, 'FirmwareUpdated', 'info')
+
+ assert.strictEqual(plumbing.stationsState.get(station), state)
+ assert.strictEqual(state.stopped, true)
+ assert.strictEqual(queueRef.length, 0)
+ })
+
+ await it('should push to the queue on the pre-stop happy path (regression guard)', () => {
+ const station = createStation('sec-pre-stop')
+ // Close the WebSocket so `sendQueuedSecurityEvents` bails before
+ // draining and the queued event stays observable.
+ const closedStation = station as ChargingStation & {
+ isWebSocketConnectionOpened: () => boolean
+ }
+ closedStation.isWebSocketConnectionOpened = () => false
+ const state = plumbing.getOrCreateStationState(station)
+ const queueLengthBefore = state.securityEventQueue.length
+
+ plumbing.sendSecurityEventNotification(station, 'FirmwareUpdated', 'tech-info')
+
+ assert.strictEqual(state.stopped, undefined)
+ assert.strictEqual(state.securityEventQueue.length, queueLengthBefore + 1)
+ assert.strictEqual(state.securityEventQueue[0].type, 'FirmwareUpdated')
+ assert.strictEqual(state.securityEventQueue[0].techInfo, 'tech-info')
+ })
+ })
+
+ await describe('sendNotifyReportRequest', async () => {
+ await it('should silent-drop when no state entry exists', async () => {
+ const station = createStation('nr-no-entry')
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ setRequestHandler(station, requestHandlerMock)
+
+ await plumbing.sendNotifyReportRequest(
+ station,
+ { reportBase: 'FullInventory', requestId: 1 },
+ { status: 'Accepted' }
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(plumbing.stationsState.has(station), false)
+ })
+
+ await it('should silent-drop after stop() without resurrecting the entry', async () => {
+ const station = createStation('nr-post-stop')
+ const state = plumbing.getOrCreateStationState(station)
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ setRequestHandler(station, requestHandlerMock)
+
+ service.stop(station)
+ assert.strictEqual(state.stopped, true)
+
+ await plumbing.sendNotifyReportRequest(
+ station,
+ { reportBase: 'FullInventory', requestId: 42 },
+ { status: 'Accepted' }
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(plumbing.stationsState.get(station), state)
+ })
+ })
+
+ await describe('sendQueuedSecurityEvents retry timer discipline', async () => {
+ await it('should store the retry handle on stationState after a failed send', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const station = createStation('retry-store')
+ const requestHandlerMock = mock.fn(async () =>
+ Promise.reject(new Error('websocket rejected'))
+ )
+ setRequestHandler(station, requestHandlerMock)
+
+ plumbing.sendSecurityEventNotification(station, 'TamperDetected', 'sim')
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ const state = plumbing.stationsState.get(station)
+ assert.notStrictEqual(state, undefined)
+ assert.notStrictEqual(state?.securityEventRetryTimer, undefined)
+ })
+ })
+
+ await it('should cancel the retry timer on stop() and prevent late fire', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const station = createStation('retry-cancel-on-stop')
+ const requestHandlerMock = mock.fn(async () =>
+ Promise.reject(new Error('websocket rejected'))
+ )
+ setRequestHandler(station, requestHandlerMock)
+
+ plumbing.sendSecurityEventNotification(station, 'TamperDetected', 'sim')
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ const state = plumbing.stationsState.get(station)
+ assert.notStrictEqual(state?.securityEventRetryTimer, undefined)
+
+ service.stop(station)
+
+ assert.strictEqual(state?.securityEventRetryTimer, undefined)
+ assert.strictEqual(state?.stopped, true)
+
+ const callCountBeforeTick = requestHandlerMock.mock.callCount()
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), callCountBeforeTick)
+ })
+ })
+
+ await it('should self-clear the retry handle when the retry callback fires', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const station = createStation('retry-self-clear')
+ let rejectCount = 0
+ const requestHandlerMock = mock.fn(async () => {
+ rejectCount += 1
+ if (rejectCount === 1) {
+ return Promise.reject(new Error('first attempt fails'))
+ }
+ return Promise.resolve({})
+ })
+ setRequestHandler(station, requestHandlerMock)
+
+ plumbing.sendSecurityEventNotification(station, 'TamperDetected', 'sim')
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ const state = plumbing.stationsState.get(station)
+ assert.notStrictEqual(state?.securityEventRetryTimer, undefined)
+
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ assert.strictEqual(state?.securityEventRetryTimer, undefined)
+ })
+ })
+
+ await it('should cancel the previous retry timer when a new retry is scheduled', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const station = createStation('retry-reschedule')
+ const requestHandlerMock = mock.fn(async () => Promise.reject(new Error('always fails')))
+ setRequestHandler(station, requestHandlerMock)
+
+ plumbing.sendSecurityEventNotification(station, 'TamperDetected', 'first')
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ const state = plumbing.stationsState.get(station)
+ const firstHandle = state?.securityEventRetryTimer
+ assert.notStrictEqual(firstHandle, undefined)
+
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+ await flushMicrotasks()
+
+ const secondHandle = state?.securityEventRetryTimer
+ assert.notStrictEqual(secondHandle, undefined)
+ assert.notStrictEqual(secondHandle, firstHandle)
+ })
+ })
+
+ await it('should silent-drop the recursive retry call after stop() even if the handle survives race', () => {
+ const station = createStation('retry-recursive-drop')
+ const state = plumbing.getOrCreateStationState(station)
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ setRequestHandler(station, requestHandlerMock)
+ state.securityEventQueue.push({
+ retryCount: 1,
+ timestamp: new Date(),
+ type: 'TamperDetected',
+ })
+
+ service.stop(station)
+
+ plumbing.sendQueuedSecurityEvents(station)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(state.stopped, true)
+ })
+ })
+
+ await describe('getCertSigningRetryManager', async () => {
+ await it('should return undefined after stop() to prevent leaked retry timer', () => {
+ const station = createStation('cert-post-stop')
+ plumbing.getOrCreateStationState(station)
+
+ service.stop(station)
+
+ const manager = service.getCertSigningRetryManager(station)
+ assert.strictEqual(manager, undefined)
+ })
+
+ await it('should lazily create the retry manager on the pre-stop happy path', () => {
+ const station = createStation('cert-pre-stop')
+
+ const first = service.getCertSigningRetryManager(station)
+ const second = service.getCertSigningRetryManager(station)
+
+ assert.notStrictEqual(first, undefined)
+ assert.strictEqual(first, second)
+ })
+ })
+
+ await describe('simulateFirmwareUpdateLifecycle', async () => {
+ await it('should silent-drop after stop() without setting activeFirmwareUpdateAbortController', async () => {
+ const station = createStation('firmware-post-stop')
+ const state = plumbing.getOrCreateStationState(station)
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ setRequestHandler(station, requestHandlerMock)
+
+ service.stop(station)
+
+ await plumbing.simulateFirmwareUpdateLifecycle(station, 42, {
+ location: 'ftp://localhost/f.bin',
+ retrieveDateTime: new Date(),
+ })
+
+ assert.strictEqual(state.activeFirmwareUpdateAbortController, undefined)
+ assert.strictEqual(state.activeFirmwareUpdateRequestId, undefined)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+ })
+
+ await describe('simulateLogUploadLifecycle', async () => {
+ await it('should silent-drop after stop() without setting activeLogUploadAbortController', async () => {
+ const station = createStation('log-post-stop')
+ const state = plumbing.getOrCreateStationState(station)
+ const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+ setRequestHandler(station, requestHandlerMock)
+
+ service.stop(station)
+
+ await plumbing.simulateLogUploadLifecycle(station, 42)
+
+ assert.strictEqual(state.activeLogUploadAbortController, undefined)
+ assert.strictEqual(state.activeLogUploadRequestId, undefined)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+ })
+})
/**
* @file Tests for OCPPIncomingRequestService per-station state plumbing (#1963)
* @description Unit tests for the shared WeakMap + lazy-init + stop() template
- * in `OCPPIncomingRequestService` and for the OCPP 1.6 `resetStationState`
- * contract.
+ * in `OCPPIncomingRequestService`, the OCPP 1.6 `resetStationState` contract,
+ * and the post-stop resurrection guard: `stop()` marks the entry
+ * `stopped: true` instead of evicting the WeakMap entry, and
+ * `getOrCreateStationState` returns the sealed stopped state on late
+ * dispatch instead of lazy-init'ing a fresh entry.
*
- * The OCPP 2.0.1 5-statement ordering invariant (abort firmware → abort log →
- * cancel retry → resetActiveFirmwareUpdateState → resetActiveLogUploadState)
- * is enforced by three independent mechanisms verified out-of-band from these
- * tests: (1) JSDoc rationale on `OCPP20IncomingRequestService.resetStationState`
+ * The OCPP 2.0.1 6-statement ordering invariant (abort firmware → abort log →
+ * cancel cert signing retry → cancel security-event retry →
+ * resetActiveFirmwareUpdateState → resetActiveLogUploadState) is enforced by
+ * three independent mechanisms verified out-of-band from these tests: (1)
+ * JSDoc rationale on `OCPP20IncomingRequestService.resetStationState`
* documenting the `?.abort()` short-circuit consequence of reordering, (2) the
* `no-restricted-syntax` ESLint rule preventing direct `stationsState`
* mutations from subclass code, and (3) byte-identical preservation of the
import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
import { createMockChargingStation } from '../helpers/StationHelpers.js'
-type OCPP16StationStateShape = Record<string, unknown>
+interface OCPP16StationStateShape {
+ stopped?: boolean
+}
-interface PlumbingAccess<T extends object> {
+interface PlumbingAccess<T extends { stopped?: boolean }> {
createStationState: () => T
getOrCreateStationState: (chargingStation: ChargingStation) => T
resetStationState: (state: T) => void
assert.strictEqual(plumbing.stationsState.get(stationA), first)
})
- await it('should delete the WeakMap entry after stop()', () => {
- plumbing.getOrCreateStationState(stationA)
+ await it('should mark stopped and preserve the WeakMap entry after stop()', () => {
+ const state = plumbing.getOrCreateStationState(stationA)
assert.strictEqual(plumbing.stationsState.has(stationA), true)
+ assert.strictEqual(state.stopped, undefined)
service.stop(stationA)
- assert.strictEqual(plumbing.stationsState.has(stationA), false)
+ assert.strictEqual(plumbing.stationsState.has(stationA), true)
+ assert.strictEqual(plumbing.stationsState.get(stationA), state)
+ assert.strictEqual(state.stopped, true)
+ })
+
+ await it('should return the sealed stopped state from getOrCreateStationState after stop(), without re-invoking createStationState', () => {
+ const first = plumbing.getOrCreateStationState(stationA)
+
+ service.stop(stationA)
+
+ const createSpy: ReturnType<typeof mock.fn> = mock.method(plumbing, 'createStationState')
+ const afterStop = plumbing.getOrCreateStationState(stationA)
+
+ assert.strictEqual(afterStop, first)
+ assert.strictEqual(afterStop.stopped, true)
+ assert.strictEqual(createSpy.mock.callCount(), 0)
})
await it('should invoke resetStationState exactly once with the current state on stop()', () => {
service.stop(stationA)
- assert.strictEqual(plumbing.stationsState.has(stationA), false)
+ assert.strictEqual(plumbing.stationsState.has(stationA), true)
+ assert.strictEqual(plumbing.stationsState.get(stationA), stateA)
+ assert.strictEqual(stateA.stopped, true)
assert.strictEqual(plumbing.stationsState.has(stationB), true)
assert.strictEqual(plumbing.stationsState.get(stationB), stateB)
+ assert.strictEqual(stateB.stopped, undefined)
})
await it('should not mutate state in the OCPP 1.6 default resetStationState', () => {
assert.strictEqual(plumbing.stationsState.get(stationA), state)
})
- await it('should not delete the WeakMap entry when resetStationState throws, and re-invoke on subsequent stop()', () => {
+ await it('should not mark stopped when resetStationState throws, and re-invoke on subsequent stop()', () => {
const state = plumbing.getOrCreateStationState(stationA)
const failure = new Error('reset failed')
let shouldThrow = true
)
assert.strictEqual(plumbing.stationsState.has(stationA), true)
assert.strictEqual(plumbing.stationsState.get(stationA), state)
+ assert.strictEqual(state.stopped, undefined)
shouldThrow = false
service.stop(stationA)
assert.strictEqual(resetSpy.mock.callCount(), 2)
assert.strictEqual(resetSpy.mock.calls[1].arguments[0], state)
- assert.strictEqual(plumbing.stationsState.has(stationA), false)
+ assert.strictEqual(plumbing.stationsState.has(stationA), true)
+ assert.strictEqual(plumbing.stationsState.get(stationA), state)
+ assert.strictEqual(state.stopped, true)
})
})