]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(charging-station): cancel a pending reset when the station is deleted (#2027...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 18 Jul 2026 21:31:40 +0000 (23:31 +0200)
committerGitHub <noreply@github.com>
Sat, 18 Jul 2026 21:31:40 +0000 (23:31 +0200)
reset() stopped the station, slept resetTime, then re-initialized and
reconnected without checking whether the station had been deleted during
the sleep. A station deleted mid-reset was resurrected and reconnected to
the CSMS (the "zombie" reconnect that triggers #2017).

Add an instance AbortController tripped by delete(); reset() now awaits
interruptibleSleep(resetTime, signal) and rechecks the aborted state
before re-initializing, bailing out cleanly on abort. The dispose signal
is kept distinct from started/stopping, which reset() itself toggles via
stop() and therefore cannot be used to detect deletion.

The OCPP Reset response stays Accepted and the handler still invokes
reset() fire-and-forget; only the reset/delete lifecycle changes.

Part B (element-granular worker-thread termination) remains open.

src/charging-station/ChargingStation.ts
tests/charging-station/ChargingStation-ResetCancellation.test.ts [new file with mode: 0644]

index 3dc9d512523f68b325182c59abca317c257774c6..36343f823195f76d84cde2f03f361371f6accaa2 100644 (file)
@@ -96,6 +96,7 @@ import {
   getMessageTypeString,
   getWebSocketCloseEventStatusString,
   handleFileException,
+  interruptibleSleep,
   isEmpty,
   isNotEmptyArray,
   isNotEmptyString,
@@ -214,6 +215,10 @@ export class ChargingStation extends EventEmitter {
   private readonly connectors: Map<number, ConnectorStatus>
   private connectorsConfigurationHash: string
   private readonly creationOptions?: ChargingStationOptions
+  // Tripped by delete() to cancel a pending reset(). Kept distinct from
+  // started/stopping (which reset() itself toggles through stop()) so a station
+  // deleted during the reset window is not resurrected and reconnected.
+  private readonly deleteAbortController: AbortController
   private readonly evses: Map<number, EvseStatus>
   private evsesConfigurationHash: string
   private flushingMessageBuffer: boolean
@@ -247,6 +252,7 @@ export class ChargingStation extends EventEmitter {
     this.started = false
     this.starting = false
     this.stopping = false
+    this.deleteAbortController = new AbortController()
     this.wsConnection = null
     this.wsConnectionClosedByRequest = false
     this.wsConnectionRetryCount = 0
@@ -422,6 +428,9 @@ export class ChargingStation extends EventEmitter {
    * @param deleteConfiguration - Whether to delete the persisted configuration file
    */
   public async delete (deleteConfiguration = true): Promise<void> {
+    // Cancel any pending reset() so a station deleted during its reset window is
+    // not re-initialized and reconnected to the CSMS.
+    this.deleteAbortController.abort()
     if (this.started) {
       try {
         await this.stop()
@@ -1091,7 +1100,12 @@ export class ChargingStation extends EventEmitter {
       logger.error(`${this.logPrefix()} ${moduleName}.reset: Error during reset stop phase:`, e)
       return
     }
-    await sleep(this.stationInfo?.resetTime ?? 0)
+    await interruptibleSleep(this.stationInfo?.resetTime ?? 0, this.deleteAbortController.signal)
+    // A delete() during the reset window aborts the sleep above; bail out before
+    // re-initializing so a deleted station is not re-initialized or reconnected.
+    if (this.deleteAbortController.signal.aborted) {
+      return
+    }
     OCPPAuthServiceFactory.clearInstance(this)
     this.initialize(this.reinitializationOptions)
     this.start()
diff --git a/tests/charging-station/ChargingStation-ResetCancellation.test.ts b/tests/charging-station/ChargingStation-ResetCancellation.test.ts
new file mode 100644 (file)
index 0000000..8dd47bf
--- /dev/null
@@ -0,0 +1,170 @@
+/**
+ * @file Tests that delete() cancels a pending reset() (issue #2027 part A).
+ * @description reset() stops the station, waits resetTime, then re-initializes
+ * and reconnects. A delete() during that window must abort the pending
+ * re-initialize so the deleted station is not resurrected and reconnected to the
+ * CSMS (the "zombie" reconnect that triggers issue #2017).
+ */
+import assert from 'node:assert/strict'
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ChargingStationOptions } from '../../src/types/index.js'
+
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import {
+  flushMicrotasks,
+  standardCleanup,
+  withMockTimers,
+} from '../helpers/TestLifecycleHelpers.js'
+
+const RESET_TIME_MS = 60000
+
+const tmpRoots: string[] = []
+
+// Fresh template under its own temp station-templates dir so each test is
+// isolated, mirroring the ChargingStation-ResetIdentity harness.
+const makeTemplate = (): string => {
+  const root = mkdtempSync(join(tmpdir(), 'cs-reset-cancel-'))
+  tmpRoots.push(root)
+  mkdirSync(join(root, 'station-templates'), { recursive: true })
+  const file = join(root, 'station-templates', 'virtual-simple.station-template.json')
+  copyFileSync(
+    join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'),
+    file
+  )
+  return file
+}
+
+interface ResetInternals {
+  initialize: (options?: ChargingStationOptions) => void
+  start: () => void
+  stop: () => Promise<void>
+}
+
+const newStation = (resetTimeMs = RESET_TIME_MS): ChargingStation => {
+  const station = new ChargingStation(1, makeTemplate(), {
+    autoStart: false,
+    baseName: 'TEST-RESET-CANCEL',
+    fixedName: true,
+    persistentConfiguration: false,
+    supervisionUrls: 'ws://localhost:9999/',
+  })
+  if (station.stationInfo != null) {
+    station.stationInfo.resetTime = resetTimeMs
+  }
+  return station
+}
+
+await describe('ChargingStation cancellable reset', async () => {
+  afterEach(() => {
+    standardCleanup()
+    for (const root of tmpRoots.splice(0)) {
+      rmSync(root, { force: true, recursive: true })
+    }
+  })
+
+  await it('should not re-initialize or reconnect when deleted during the reset sleep window', async t => {
+    await withMockTimers(t, ['setTimeout'], async () => {
+      const station = newStation()
+      // Stub stop/start/initialize so the real reset() neither tears down nor
+      // dials a socket; the spies observe whether a deleted station is resurrected.
+      const internals = station as unknown as ResetInternals
+      t.mock.method(internals, 'stop', () => Promise.resolve())
+      const initializeSpy = t.mock.method(internals, 'initialize', () => undefined)
+      const startSpy = t.mock.method(internals, 'start', () => undefined)
+
+      // Fire-and-forget reset() (matching the OCPP Reset handler) and let it
+      // reach the interruptible sleep.
+      const resetPromise = station.reset()
+      await flushMicrotasks()
+
+      // Delete mid-reset; this must abort the pending re-initialize.
+      await station.delete(false)
+
+      // Elapse the full reset delay to prove even a completed timer cannot
+      // resurrect a deleted station.
+      t.mock.timers.tick(RESET_TIME_MS)
+      await resetPromise
+
+      assert.strictEqual(initializeSpy.mock.calls.length, 0)
+      assert.strictEqual(startSpy.mock.calls.length, 0)
+    })
+  })
+
+  await it('should not re-initialize when deleted after the reset sleep resolves but before re-initialization', async t => {
+    await withMockTimers(t, ['setTimeout'], async () => {
+      const station = newStation()
+      const internals = station as unknown as ResetInternals
+      t.mock.method(internals, 'stop', () => Promise.resolve())
+      const initializeSpy = t.mock.method(internals, 'initialize', () => undefined)
+      const startSpy = t.mock.method(internals, 'start', () => undefined)
+
+      const resetPromise = station.reset()
+      await flushMicrotasks()
+
+      // Fire the timer first: the sleep resolves normally and queues reset's
+      // continuation as a microtask. Deleting before that microtask runs (abort
+      // is synchronous) must still be observed by the post-sleep recheck.
+      t.mock.timers.tick(RESET_TIME_MS)
+      const deletePromise = station.delete(false)
+      await deletePromise
+      await resetPromise
+
+      assert.strictEqual(initializeSpy.mock.calls.length, 0)
+      assert.strictEqual(startSpy.mock.calls.length, 0)
+    })
+  })
+
+  await it('should not re-initialize when deleted during a zero reset time window', async t => {
+    await withMockTimers(t, ['setTimeout'], async () => {
+      const station = newStation(0)
+      const internals = station as unknown as ResetInternals
+      t.mock.method(internals, 'stop', () => Promise.resolve())
+      const initializeSpy = t.mock.method(internals, 'initialize', () => undefined)
+      const startSpy = t.mock.method(internals, 'start', () => undefined)
+
+      const resetPromise = station.reset()
+      await flushMicrotasks()
+
+      await station.delete(false)
+
+      t.mock.timers.tick(0)
+      await resetPromise
+
+      assert.strictEqual(initializeSpy.mock.calls.length, 0)
+      assert.strictEqual(startSpy.mock.calls.length, 0)
+    })
+  })
+
+  await it('should re-initialize and start on each consecutive normal reset when not deleted', async t => {
+    await withMockTimers(t, ['setTimeout'], async () => {
+      const station = newStation()
+      const internals = station as unknown as ResetInternals
+      t.mock.method(internals, 'stop', () => Promise.resolve())
+      const initializeSpy = t.mock.method(internals, 'initialize', () => undefined)
+      const startSpy = t.mock.method(internals, 'start', () => undefined)
+
+      // Positive control: a normal reset (no delete) must re-initialize and
+      // start, so the abort guard cannot fire spuriously. A second reset must
+      // re-initialize too, proving reset() never consumes the shared signal.
+      const firstReset = station.reset()
+      await flushMicrotasks()
+      t.mock.timers.tick(RESET_TIME_MS)
+      await firstReset
+
+      assert.strictEqual(initializeSpy.mock.calls.length, 1)
+      assert.strictEqual(startSpy.mock.calls.length, 1)
+
+      const secondReset = station.reset()
+      await flushMicrotasks()
+      t.mock.timers.tick(RESET_TIME_MS)
+      await secondReset
+
+      assert.strictEqual(initializeSpy.mock.calls.length, 2)
+      assert.strictEqual(startSpy.mock.calls.length, 2)
+    })
+  })
+})