]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commit
fix(ocpp): guard post-stop() handler dispatch against WeakMap resurrection (#1992)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 9 Jul 2026 22:45:56 +0000 (00:45 +0200)
committerGitHub <noreply@github.com>
Thu, 9 Jul 2026 22:45:56 +0000 (00:45 +0200)
commit4fce19aef989e2c3f62989b4623cb908a5d5ae7c
tree71cb51d64a8d2f13d4540d9119e9dfdc09705c7b
parent4d1d2d88addd7598b9979f7d215ec7a575dda866
fix(ocpp): guard post-stop() handler dispatch against WeakMap resurrection (#1992)

Closes #1970

OCPP 2.0.1 handlers dispatched from queued microtasks, unref'd retry
timers, or EventEmitter-dispatched `.on(...)` listeners could run
AFTER `stop()` and resurrect the `stationsState` WeakMap entry via
`getOrCreateStationState` lazy-init. `sendQueuedSecurityEvents` also
scheduled a retry `setTimeout(...)` whose handle was never stored, so
`stop()` had no way to cancel a pending retry, and the recursive
retry callback re-entered `sendQueuedSecurityEvents` — resurrecting
state.

Three coordinated parts.

Part 1 — base plumbing (`OCPPIncomingRequestService.ts`):

- Generic bound widened to
  `TStationState extends { stopped?: boolean } = { stopped?: boolean }`.
- `stop()` template: after `resetStationState`, marks
  `stationState.stopped = true` instead of `stationsState.delete(cs)`.
  Deletion would re-enable resurrection via `getOrCreateStationState`
  lazy-init on any late dispatch. Keeping the sealed entry lets the
  getter return the sealed state and raw `.get()` null-guarded callers
  observe the marker and drop. The WeakMap entry is naturally
  collected when the ChargingStation reference is dropped.
- `getOrCreateStationState` returns the sealed stopped state when
  `state?.stopped === true`; no fresh entry is lazy-init'd post-stop.
- Both concrete state interfaces (`OCPP16StationState`,
  `OCPP20StationState`) get an alphabetized `stopped?: boolean` field.

Part 2 — OCPP 2.0.1 call-site audit (11 sites):

Two sites converted to raw `stationsState.get(cs)` + `stopped === true`
null-guard, silent-drop:

- `sendNotifyReportRequest` — dispatched from
  `.on(GET_BASE_REPORT).catch(...)` microtask; state usually created
  by sibling `handleRequestGetBaseReport` but external `emit(...)`
  paths and post-stop dispatch are covered by the null-guard.
- `sendQueuedSecurityEvents` — dispatched from unref'd retry
  `setTimeout`; guard is the last line of defense against a
  fired-before-cancel race.

Four sites use `getOrCreateStationState` + explicit `stopped` check
(state may not exist yet on the pre-stop happy path):

- `sendSecurityEventNotification` — lifecycle-entry on the
  invalid-cert-on-first-request edge case
  (`handleRequestCertificateSigned` X.509-invalid branch,
  `handleRequestUpdateFirmware` invalid-PEM branch).
- `getCertSigningRetryManager` — public accessor; return type widened
  to `OCPP20CertSigningRetryManager | undefined`; two callers
  (`handleRequestCertificateSigned`, `OCPP20ResponseService`) updated
  with `?.` to tolerate the undefined return. Without this guard, a
  late `handleResponseSignCertificate` dispatch (a SignCertificate
  response can arrive between `ocppIncomingRequestService.stop(cs)`
  and `closeWSConnection()` in `ChargingStation.stop()`) would
  materialize a fresh manager on the sealed state and schedule a
  non-`.unref()`'d retry `setTimeout` holding the ChargingStation
  reference.
- `simulateFirmwareUpdateLifecycle` — dispatched from
  `.on(UPDATE_FIRMWARE).catch(...)` microtask; guards against driving
  the full async lifecycle on a sealed state (which would send OCPP
  requests through a closed WebSocket and clobber the abort-controller
  fields).
- `simulateLogUploadLifecycle` — symmetric,
  `.on(GET_LOG).catch(...)`.

Five sites kept unchanged (Part 1 guard covers them):
`getRestoredConnectorStatus`, `handleRequestGetBaseReport`,
`handleRequestUpdateFirmware`, `savePreInoperativeStatuses`,
`sendFirmwareStatusNotification`.

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

- Store the retry `setTimeout` handle on
  `OCPP20StationState.securityEventRetryTimer`.
- Cancel any previously scheduled retry before scheduling a new one.
- Self-clear the handle before the recursive
  `sendQueuedSecurityEvents` call inside the callback.
- New `cancelSecurityEventRetryTimer` helper is called from
  `resetStationState` (after abort-controller aborts and cert-signing
  retry cancel, before the two `resetActive*State` helpers).
- `.unref()` preserved.

Files touched (7 modified + 1 new):

- `src/charging-station/ocpp/OCPPIncomingRequestService.ts`
- `src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts`
- `src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts`
- `src/charging-station/ocpp/2.0/OCPP20ResponseService.ts`
- `tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts`
- `tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts`
- `tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-GetDiagnostics.test.ts`
- `tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts`
  (new)

Design rationale — silent-drop over `StationStoppedError` throw:
matches the "post-stop silently drop" semantic and avoids catch-block
sprawl at every handler.

Design rationale — return `undefined` from `getCertSigningRetryManager`
over convert-with-explicit-null-manager: two callers already handle
optional chaining cleanly; return-type widening is the minimum change.

Verification: pnpm format / typecheck / lint pass; pnpm test 3029
pass, 0 fail; pnpm build exit 0. No `stationsState.delete` remains in
`src/`; `securityEventRetryTimer` lives in exactly three logical
places (interface field, retry-site store + self-clear, cancel helper
called from `resetStationState`).

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/OCPPIncomingRequestService.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-GetDiagnostics.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts