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(...)`.
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.
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`).