]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat: resolve #1020 — Persist minimal simulator state and reconstruct template indexe...
authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sun, 17 May 2026 19:14:56 +0000 (21:14 +0200)
committerGitHub <noreply@github.com>
Sun, 17 May 2026 19:14:56 +0000 (21:14 +0200)
* feat: persist minimal simulator state and reconstruct template indexes on startup

- Add persistState boolean to ConfigurationData (default: true)
- Add SimulatorState to FileType enum
- Add simulatorState to AsyncLockType enum
- Implement BootstrapStateUtils with:
  - readStateFile: reads and validates state.json with schema version check
  - writeStateFile: atomic write via tmp+rename with AsyncLock
  - reconstructTemplateIndexes: scans per-station config files to rebuild indexes
  - deleteStateFile: safe file deletion
- Integrate into Bootstrap:
  - reconstructTemplateIndexes called in start() before worker spawn
  - State file written on start() (started:true) and stop() (started:false)
  - shouldAutoStart() reads state file to control auto-start behavior
  - persistStateEnabled getter checks persistState config and SIMULATOR_COLD_START env
- Update start.ts to conditionally start based on shouldAutoStart()
- Handle edge cases: corrupt files, missing fields, incompatible schema version
- Add comprehensive tests for all state persistence and reconstruction scenarios

Closes #1020

* refactor(bootstrap): harmonize persisted state feature with codebase conventions

Address review findings on PR #1858:

- HIGH #1: Phase split start() lifecycle. Add public Bootstrap.startUIServer()
  that always brings up the UI server (and reconstructs template indexes
  before accepting requests). start.ts unconditionally calls startUIServer()
  and only gates Bootstrap.start() on shouldAutoStart(), so a persisted
  stopped state no longer turns the simulator into a zombie process.

- HIGH #2: Add canonical default. New defaultPersistState constant and
  Configuration.getPersistState() accessor matching the existing
  defaultUIServerConfiguration / defaultWorkerConfiguration pattern.
  Bootstrap.persistStateEnabled now delegates instead of inlining ?? true.

- MEDIUM #3: Document persistState tunable and SIMULATOR_COLD_START
  environment override in README.md.

- MEDIUM #4: writeStateFile and deleteStateFile now swallow filesystem
  errors via handleFileException with throwError:false, mirroring
  watchJsonFile and the storage layer. Persistence failures no longer
  surface as misleading 'Startup error' / 'Shutdown error'.

- MEDIUM #5: reconstructTemplateIndexes runs inside startUIServer() before
  uiServer.start(), closing the race where UI requests could arrive before
  index reconstruction completes.

- LOW #7: Add formatLogPrefix() utility and use it in BootstrapStateUtils,
  removing the leading-space artifact when logPrefixFn is undefined.

- LOW #8: On state file schema mismatch, quarantine to <path>.v<N>.bak
  instead of silently deleting, allowing forensics during partial schema
  rollouts. JSON parse errors still delete (true corruption, unrecoverable).

- LOW #9: Move state.json from dist/assets/configurations/ to dist/assets/.
  Drops the fragile basename-based filter from reconstructTemplateIndexes
  and separates control file from per-station configuration files.

Drop shouldAutoStart() from IBootstrap interface (boot-time concern, no UI
service consumer needs it). The method stays public on the concrete
Bootstrap class for start.ts.

Tests: align fixtures with new state.json location, add quarantine assertion,
add non-fatal writeStateFile assertion, drop obsolete state.json filter test.

* test(bootstrap): align BootstrapStateUtils tests with project style guide

- Rename BootstrapState.test.ts to BootstrapStateUtils.test.ts to match the
  source module name (TEST_STYLE_GUIDE.md §1: 'Files: ModuleName.test.ts').
- Add mandatory standardCleanup() in afterEach (TEST_STYLE_GUIDE.md §3),
  matching the convention used by Configuration.test.ts, FileUtils.test.ts,
  ErrorUtils.test.ts and ConfigurationKeyUtils.test.ts.

* fix(bootstrap): address review findings on persisted simulator state

Distinguish UI-initiated stop from signal/restart via a private
StopReason enum, so SIGINT/SIGTERM/SIGQUIT and config-reload restarts
no longer flip the persisted state to stopped (HIGH-1).

Short-circuit persistStateEnabled when the UI server is disabled, since
persistence has no recovery channel without a UI; warn once on the
inconsistent configuration (HIGH-2).

Reconstruct template indexes via a shared prepareTemplateStatistics
helper called from constructor and restart, not only from startUIServer,
to prevent index collisions on config-reload of UI-added stations
(MED-3).

Move state file from dist/assets/state.json (wiped by pnpm build) to
dist/assets/configurations/.simulator-state.json; filter dot-prefixed
metadata files in reconstructTemplateIndexes so the state file co-located
with charging station configurations is silently skipped (MIN-10).

Clean up the .tmp file on atomic-rename failure in writeStateFile and
guard readStateFile against JSON null/primitive/array content with an
explicit message (MIN-7, MIN-8).

Unify path computations into readonly assetsDir/configurationsDir/
stateFilePath fields, deduplicate setChargingStationTemplates via a
syncUIServerTemplates helper, and route the SIMULATOR_COLD_START env
var name through Constants.ENV_SIMULATOR_COLD_START (MIN-5, MIN-6).

Export DEFAULT_PERSIST_STATE and add the persistState entry to
config-template.json so the tunable is discoverable (MED-4).

Update README to document the new state file path, the UI server
requirement, and the signal-shutdown semantics.

Test coverage added for tmp cleanup, JSON null/primitive/array guards,
and dot-prefixed metadata filtering; the misleading 'read-only
directory' test is renamed to reflect the actual OS-rejected-path
scenario (MIN-9).

* docs(bootstrap): clarify persisted state semantics from review feedback

- README: drop developer-internals sentence on template index reconstruction
  from the persistState row
- TemplateStatistics: document the `added` (process-scoped) vs `indexes`
  (process+disk-scoped) distinction exposed via SimulatorState
- IBootstrap: document the contract scope (UI-server-facing, excluding
  process-lifecycle helpers and the internal StopReason)
- formatLogPrefix: document the trailing-space contract
- reconstructTemplateIndexes: refine the warn message wording for non-station
  configuration files (level unchanged)

* refactor(utils): default formatLogPrefix prefix to logPrefix

Avoids silent loss of the timestamp when callers do not pass a module-specific
prefix function. Aligns with the existing convention where every module-level
logPrefix delegates to Utils.logPrefix.

---------

Co-authored-by: Agent <agent@github.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
15 files changed:
README.md
src/assets/config-template.json
src/charging-station/Bootstrap.ts
src/charging-station/BootstrapStateUtils.ts [new file with mode: 0644]
src/charging-station/IBootstrap.ts
src/start.ts
src/types/ConfigurationData.ts
src/types/FileType.ts
src/types/Statistics.ts
src/utils/AsyncLock.ts
src/utils/Configuration.ts
src/utils/Constants.ts
src/utils/Utils.ts
src/utils/index.ts
tests/charging-station/BootstrapStateUtils.test.ts [new file with mode: 0644]

index aefd24ef0c54dbf7b8904727d9484725e86593ee..08085ce26a7222f64fc24a633402bee76c1821ec 100644 (file)
--- a/README.md
+++ b/README.md
@@ -182,6 +182,7 @@ But the modifications to test have to be done to the files in the build target d
 | uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />}                                                                                | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />}<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | performanceStorage         |                                              | {<br />"enabled": true,<br />"type": "none",<br />}                                                                                                                                                                           | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />}                                                                                                                                                                                                         | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
 | stationTemplateUrls        |                                              | {}[]                                                                                                                                                                                                                          | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[]                                                                                                                                                                         | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _provisionedNumberOfStations_: template provisioned number of stations after startup                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
+| persistState               | true/false                                   | true                                                                                                                                                                                                                          | boolean                                                                                                                                                                                                                                                                         | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned                                                                                                                                                            |
 
 #### Worker process model
 
index 943e9c5b2c4998b556c614eb80714666e5afbf4c..da09c9992c3c84c04c0a9ea7feb731cfef30c9e6 100644 (file)
@@ -24,6 +24,7 @@
       "password": "admin"
     }
   },
+  "persistState": true,
   "stationTemplateUrls": [
     {
       "file": "siemens.station-template.json",
index 14eb79ab5a075711f6cdb6a59d0cd7b8595adf50..76422a7216dfd5ae77b445e61c0fcf06cdc534a3 100644 (file)
@@ -4,7 +4,7 @@ import type { Worker } from 'node:worker_threads'
 
 import { EventEmitter } from 'node:events'
 import { dirname, extname, join } from 'node:path'
-import process, { exit } from 'node:process'
+import process, { env, exit } from 'node:process'
 import { fileURLToPath } from 'node:url'
 import { isMainThread } from 'node:worker_threads'
 import { availableParallelism, type MessageHandler } from 'poolifier'
@@ -43,6 +43,7 @@ import {
   isNotEmptyArray,
   logger,
   logPrefix,
+  once,
 } from '../utils/index.js'
 import {
   DEFAULT_ELEMENTS_PER_WORKER,
@@ -51,6 +52,7 @@ import {
   type WorkerAbstract,
   WorkerFactory,
 } from '../worker/index.js'
+import { readStateFile, reconstructTemplateIndexes, writeStateFile } from './BootstrapStateUtils.js'
 import { buildTemplateName, waitChargingStationEvents } from './Helpers.js'
 import { UIServerFactory } from './ui-server/UIServerFactory.js'
 
@@ -64,6 +66,12 @@ enum exitCodes {
   gracefulShutdownError = 4,
 }
 
+enum StopReason {
+  reload = 'reload',
+  shutdown = 'shutdown',
+  user = 'user',
+}
+
 export class Bootstrap extends EventEmitter implements IBootstrap {
   private static instance: Bootstrap | null = null
   public get numberOfChargingStationTemplates (): number {
@@ -84,14 +92,18 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     )
   }
 
+  private readonly assetsDir: string
+  private readonly configurationsDir: string
   private started: boolean
   private starting: boolean
+  private readonly stateFilePath: string
   private stopping: boolean
   private storage?: Storage
   private readonly templateStatistics: Map<string, TemplateStatistics>
   private readonly uiServer: AbstractUIServer
   private uiServerStarted: boolean
   private readonly version: string = packageJson.version
+  private readonly warnPersistStateWithoutUIServerOnce: () => void
   private workerImplementation?: WorkerAbstract<ChargingStationWorkerData, ChargingStationInfo>
 
   private get numberOfAddedChargingStations (): number {
@@ -108,6 +120,23 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     )
   }
 
+  private get persistStateEnabled (): boolean {
+    if (env[Constants.ENV_SIMULATOR_COLD_START] === 'true') {
+      return false
+    }
+    if (!Configuration.getPersistState()) {
+      return false
+    }
+    if (
+      Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
+        .enabled !== true
+    ) {
+      this.warnPersistStateWithoutUIServerOnce()
+      return false
+    }
+    return true
+  }
+
   private constructor () {
     super()
     for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTERM']) {
@@ -121,11 +150,19 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     this.stopping = false
     this.uiServerStarted = false
     this.templateStatistics = new Map<string, TemplateStatistics>()
+    this.assetsDir = join(dirname(fileURLToPath(import.meta.url)), 'assets')
+    this.configurationsDir = join(this.assetsDir, 'configurations')
+    this.stateFilePath = join(this.configurationsDir, '.simulator-state.json')
+    this.warnPersistStateWithoutUIServerOnce = once(() => {
+      logger.warn(
+        `${this.logPrefix()} ${moduleName}: persistState is enabled but UI server is disabled. Persistence has no recovery channel and is ignored`
+      )
+    })
     this.uiServer = UIServerFactory.getUIServerImplementation(
       Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer),
       this
     )
-    this.initializeCounters()
+    this.prepareTemplateStatistics()
     this.initializeWorkerImplementation(
       Configuration.getConfigurationSection<WorkerConfiguration>(ConfigurationSection.worker)
     )
@@ -154,12 +191,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     const stationInfo = await this.workerImplementation?.addElement({
       index,
       options,
-      templateFile: join(
-        dirname(fileURLToPath(import.meta.url)),
-        'assets',
-        'station-templates',
-        templateFile
-      ),
+      templateFile: join(this.assetsDir, 'station-templates', templateFile),
     })
     const templateStatistics = this.templateStatistics.get(buildTemplateName(templateFile))
     if (stationInfo != null && templateStatistics != null) {
@@ -196,6 +228,17 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     }
   }
 
+  public shouldAutoStart (): boolean {
+    if (!this.persistStateEnabled) {
+      return true
+    }
+    const stateFile = readStateFile(this.stateFilePath, this.logPrefix)
+    if (stateFile == null) {
+      return true
+    }
+    return stateFile.started
+  }
+
   public async start (): Promise<void> {
     if (!this.started) {
       if (!this.starting) {
@@ -228,17 +271,13 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
               await this.storage.open()
             }
           }
-          this.uiServer.setChargingStationTemplates(
-            Configuration.getStationTemplateUrls()?.map(stationTemplateUrl =>
-              buildTemplateName(stationTemplateUrl.file)
-            )
-          )
           if (
             !this.uiServerStarted &&
             Configuration.getConfigurationSection<UIServerConfiguration>(
               ConfigurationSection.uiServer
             ).enabled === true
           ) {
+            this.syncUIServerTemplates()
             this.uiServer.start()
             this.uiServerStarted = true
           }
@@ -307,6 +346,9 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
             this.workerImplementation?.info
           )
           this.started = true
+          if (this.persistStateEnabled) {
+            await writeStateFile(this.stateFilePath, true, this.logPrefix)
+          }
         } finally {
           this.starting = false
         }
@@ -322,7 +364,22 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     }
   }
 
-  public async stop (): Promise<void> {
+  public startUIServer (): void {
+    if (this.uiServerStarted) {
+      return
+    }
+    if (
+      Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
+        .enabled !== true
+    ) {
+      return
+    }
+    this.syncUIServerTemplates()
+    this.uiServer.start()
+    this.uiServerStarted = true
+  }
+
+  public async stop (reason: StopReason = StopReason.user): Promise<void> {
     if (this.started) {
       if (!this.stopping) {
         this.stopping = true
@@ -341,6 +398,9 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
           await this.storage?.close()
           delete this.storage
           this.started = false
+          if (this.persistStateEnabled && reason === StopReason.user) {
+            await writeStateFile(this.stateFilePath, false, this.logPrefix)
+          }
         } finally {
           this.stopping = false
         }
@@ -357,7 +417,7 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
   }
 
   private gracefulShutdown (): void {
-    this.stop()
+    this.stop(StopReason.shutdown)
       .then(() => {
         logger.info(`${this.logPrefix()} ${moduleName}.gracefulShutdown: Graceful shutdown`)
         if (this.uiServerStarted) {
@@ -564,8 +624,13 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     }
   }
 
+  private prepareTemplateStatistics (): void {
+    this.initializeCounters()
+    reconstructTemplateIndexes(this.configurationsDir, this.templateStatistics, this.logPrefix)
+  }
+
   private async restart (): Promise<void> {
-    await this.stop()
+    await this.stop(StopReason.reload)
     if (
       this.uiServerStarted &&
       Configuration.getConfigurationSection<UIServerConfiguration>(ConfigurationSection.uiServer)
@@ -574,7 +639,8 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
       this.uiServer.stop()
       this.uiServerStarted = false
     }
-    this.initializeCounters()
+    this.prepareTemplateStatistics()
+    this.syncUIServerTemplates()
     // TODO: compare worker configuration hash to skip unnecessary re-initialization
     this.initializeWorkerImplementation(
       Configuration.getConfigurationSection<WorkerConfiguration>(ConfigurationSection.worker)
@@ -582,6 +648,14 @@ export class Bootstrap extends EventEmitter implements IBootstrap {
     await this.start()
   }
 
+  private syncUIServerTemplates (): void {
+    this.uiServer.setChargingStationTemplates(
+      Configuration.getStationTemplateUrls()?.map(stationTemplateUrl =>
+        buildTemplateName(stationTemplateUrl.file)
+      )
+    )
+  }
+
   private async waitChargingStationsStopped (): Promise<string> {
     return await new Promise<string>((resolve, reject: (reason?: unknown) => void) => {
       const waitTimeout = setTimeout(() => {
diff --git a/src/charging-station/BootstrapStateUtils.ts b/src/charging-station/BootstrapStateUtils.ts
new file mode 100644 (file)
index 0000000..9b2ff8d
--- /dev/null
@@ -0,0 +1,176 @@
+// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+import {
+  existsSync,
+  mkdirSync,
+  readdirSync,
+  readFileSync,
+  renameSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs'
+import { basename, dirname, join } from 'node:path'
+
+import type { TemplateStatistics } from '../types/index.js'
+
+import { FileType } from '../types/index.js'
+import {
+  AsyncLock,
+  AsyncLockType,
+  ensureError,
+  formatLogPrefix,
+  handleFileException,
+  logger,
+} from '../utils/index.js'
+
+const moduleName = 'BootstrapStateUtils'
+
+export const STATE_FILE_VERSION = 1
+
+export interface SimulatorStateFile {
+  started: boolean
+  version: number
+}
+
+export const deleteStateFile = (stateFilePath: string, logPrefixFn?: () => string): void => {
+  try {
+    rmSync(stateFilePath, { force: true })
+  } catch (error) {
+    handleFileException(
+      stateFilePath,
+      FileType.SimulatorState,
+      ensureError(error),
+      logPrefixFn?.() ?? '',
+      { throwError: false }
+    )
+  }
+}
+
+export const readStateFile = (
+  stateFilePath: string,
+  logPrefixFn?: () => string
+): SimulatorStateFile | undefined => {
+  if (!existsSync(stateFilePath)) {
+    return undefined
+  }
+  try {
+    const parsed = JSON.parse(readFileSync(stateFilePath, 'utf8')) as unknown
+    if (parsed == null || typeof parsed !== 'object') {
+      logger.warn(
+        `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: State file content is not a JSON object, deleting`
+      )
+      deleteStateFile(stateFilePath, logPrefixFn)
+      return undefined
+    }
+    const content = parsed as Partial<SimulatorStateFile>
+    if (typeof content.version !== 'number' || typeof content.started !== 'boolean') {
+      logger.warn(
+        `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Invalid state file content, deleting`
+      )
+      deleteStateFile(stateFilePath, logPrefixFn)
+      return undefined
+    }
+    if (content.version !== STATE_FILE_VERSION) {
+      const backupPath = `${stateFilePath}.v${content.version.toString()}.bak`
+      try {
+        renameSync(stateFilePath, backupPath)
+        logger.warn(
+          `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Incompatible state file schema version ${content.version.toString()} (expected ${STATE_FILE_VERSION.toString()}), quarantined to ${basename(backupPath)}`
+        )
+      } catch (renameError) {
+        logger.warn(
+          `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Failed to quarantine incompatible state file, deleting:`,
+          renameError
+        )
+        deleteStateFile(stateFilePath, logPrefixFn)
+      }
+      return undefined
+    }
+    return content as SimulatorStateFile
+  } catch (error) {
+    logger.warn(
+      `${formatLogPrefix(logPrefixFn)}${moduleName}.readStateFile: Failed to read state file, deleting:`,
+      error
+    )
+    deleteStateFile(stateFilePath, logPrefixFn)
+    return undefined
+  }
+}
+
+export const reconstructTemplateIndexes = (
+  configurationsDir: string,
+  templateStatistics: Map<string, TemplateStatistics>,
+  logPrefixFn?: () => string
+): void => {
+  if (!existsSync(configurationsDir)) {
+    return
+  }
+  let files: string[]
+  try {
+    files = readdirSync(configurationsDir).filter(
+      file => file.endsWith('.json') && !file.startsWith('.')
+    )
+  } catch (error) {
+    logger.warn(
+      `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Failed to read configurations directory:`,
+      error
+    )
+    return
+  }
+  for (const file of files) {
+    const filePath = join(configurationsDir, file)
+    try {
+      const { stationInfo } = JSON.parse(readFileSync(filePath, 'utf8')) as {
+        stationInfo?: { templateIndex?: number; templateName?: string }
+      }
+      if (stationInfo?.templateName == null || stationInfo.templateIndex == null) {
+        logger.warn(
+          `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Skipping ${file}: not a charging station configuration (missing stationInfo.templateName/templateIndex)`
+        )
+        continue
+      }
+      const templateStats = templateStatistics.get(stationInfo.templateName)
+      if (templateStats != null) {
+        templateStats.indexes.add(stationInfo.templateIndex)
+      }
+    } catch (error) {
+      logger.warn(
+        `${formatLogPrefix(logPrefixFn)}${moduleName}.reconstructTemplateIndexes: Skipping corrupt file ${file}:`,
+        error
+      )
+    }
+  }
+}
+
+export const writeStateFile = async (
+  stateFilePath: string,
+  started: boolean,
+  logPrefixFn?: () => string
+): Promise<void> => {
+  await AsyncLock.runExclusive(AsyncLockType.simulatorState, () => {
+    const tmpFile = `${stateFilePath}.tmp`
+    try {
+      mkdirSync(dirname(stateFilePath), { recursive: true })
+      const stateData: SimulatorStateFile = {
+        started,
+        version: STATE_FILE_VERSION,
+      }
+      writeFileSync(tmpFile, JSON.stringify(stateData, undefined, 2), 'utf8')
+      renameSync(tmpFile, stateFilePath)
+    } catch (error) {
+      // Best-effort tmp cleanup; ignore secondary failure to surface the original error.
+      try {
+        rmSync(tmpFile, { force: true })
+      } catch {
+        // Ignore
+      }
+      handleFileException(
+        stateFilePath,
+        FileType.SimulatorState,
+        ensureError(error),
+        logPrefixFn?.() ?? '',
+        { throwError: false }
+      )
+    }
+  })
+}
index b12cbcbedc82c10457fb39c829f7b4bc4c2d73bf..1614c6cf2b5246a5d2769800c529940ffa662cfd 100644 (file)
@@ -5,6 +5,11 @@ import type {
   Statistics,
 } from '../types/index.js'
 
+/**
+ * Contract exposed by the simulator core to UI servers and UI services. Process-lifecycle helpers used only by the
+ * entry point (`shouldAutoStart`, `startUIServer`) and the internal `StopReason` parameter of `stop` are intentionally
+ * excluded from this contract.
+ */
 export interface IBootstrap {
   addChargingStation(
     index: number,
index a28d1e91522ed590ce777a4154088ed16274ac34..c573db98101e53bd559bae01ce137559b374e822 100644 (file)
@@ -5,7 +5,11 @@ import chalk from 'chalk'
 import { Bootstrap } from './charging-station/index.js'
 
 try {
-  await Bootstrap.getInstance().start()
+  const bootstrap = Bootstrap.getInstance()
+  bootstrap.startUIServer()
+  if (bootstrap.shouldAutoStart()) {
+    await bootstrap.start()
+  }
 } catch (error) {
   console.error(chalk.red('Startup error: '), error)
 }
index d66050241c8f67f538fbea99c0ab74daf9b623f0..2634567ad1fd68bbd7a7b99a151260b174c40b33 100644 (file)
@@ -53,6 +53,7 @@ export interface ConfigurationData {
   /** @deprecated Moved to log configuration section. */
   logStatisticsInterval?: number
   performanceStorage?: StorageConfiguration
+  persistState?: boolean
   stationTemplateUrls: StationTemplateUrl[]
   supervisionUrlDistribution?: SupervisionUrlDistribution
   supervisionUrls?: string | string[]
index 9071002b78acdc408cbfa00a3f2391973538d3bb..b38814f813ca9d19befce6f79910c6e9e7899db6 100644 (file)
@@ -5,4 +5,5 @@ export enum FileType {
   Configuration = 'configuration',
   JsonSchema = 'json schema',
   PerformanceRecords = 'performance records',
+  SimulatorState = 'simulator state',
 }
index 02927e0725dbd4a9ff4432059d2169df063d58a6..fc979871c244e3382af9eebb6edc6071d5ef0d70 100644 (file)
@@ -29,10 +29,20 @@ export type StatisticsData = Partial<{
 }>
 
 export interface TemplateStatistics {
+  /** Number of charging stations added via `addChargingStation` in the current process. Decremented when a station is deleted. */
   added: number
+  /** `numberOfStations` from the matching `stationTemplateUrls` entry. */
   configured: number
+  /**
+   * Template indexes known to the simulator: those allocated in the current process plus those reconstructed at startup
+   * from existing charging station configuration files in `dist/assets/configurations/`. Used by `getLastContiguousIndex`
+   * to allocate collision-free indexes when adding stations via UI. May exceed `added` because reconstructed indexes
+   * reserve disk-persisted slots without re-spawning their stations.
+   */
   indexes: Set<number>
+  /** `provisionedNumberOfStations` from the matching `stationTemplateUrls` entry. */
   provisioned: number
+  /** Number of currently started charging stations across this template. */
   started: number
 }
 
index 4813d958490ee91c58ff85398d662303ec0e5b06..bd12164718212b999a97822114c032d456d6696d 100644 (file)
@@ -7,6 +7,7 @@ import { isAsyncFunction } from './Utils.js'
 export enum AsyncLockType {
   configuration = 'configuration',
   performance = 'performance',
+  simulatorState = 'simulatorState',
 }
 
 type ResolveType = (value: PromiseLike<void> | void) => void
index deb6d3fd3fc5e9de331044869d04f282daf53b4d..1b364b16a0c576cd6e92d0c6064e9cc8c2e42ac8 100644 (file)
@@ -84,6 +84,10 @@ const defaultWorkerConfiguration: WorkerConfiguration = {
   startDelay: DEFAULT_WORKER_START_DELAY_MS,
 }
 
+const defaultPersistState = true
+
+export const DEFAULT_PERSIST_STATE = defaultPersistState
+
 // eslint-disable-next-line @typescript-eslint/no-extraneous-class
 export class Configuration {
   public static configurationChangeCallback?: () => Promise<void>
@@ -167,6 +171,10 @@ export class Configuration {
     return Configuration.configurationSectionCache.get(sectionName) as T
   }
 
+  public static getPersistState (): boolean {
+    return Configuration.getConfigurationData()?.persistState ?? defaultPersistState
+  }
+
   public static getStationTemplateUrls (): StationTemplateUrl[] | undefined {
     const checkDeprecatedConfigurationKeysOnce = once(() => {
       checkDeprecatedConfigurationKeys(Configuration.getConfigurationData())
index 837b74ebed4b7e17780b3b8d2773f044f328ec95..65fcfad680185dd9a0cb4582369e2718821d9911 100644 (file)
@@ -109,6 +109,8 @@ export class Constants {
     /* This is intentional */
   })
 
+  static readonly ENV_SIMULATOR_COLD_START = 'SIMULATOR_COLD_START'
+
   static readonly MAX_RANDOM_INTEGER = 281474976710655 // 2^48 - 1 (randomInit() limit)
 
   // Node.js setInterval/setTimeout maximum safe delay value (2^31-1 ms ≈ 24.8 days)
index 1ade0f7ab90a44fd4b8a21de0170c5c321ce8f7c..98f706ab2878e3fc508c121aeace11ab18e6d73f 100644 (file)
@@ -32,6 +32,18 @@ export const logPrefix = (prefixString = ''): string => {
   return `${new Date().toLocaleString()}${prefixString}`
 }
 
+/**
+ * Formats a log prefix for direct concatenation with a module/method tag.
+ * @param logPrefixFn - Prefix-producing function. Defaults to `logPrefix` (timestamp only) so callers without a
+ *                     module-specific prefix still emit a timestamped log line.
+ * @returns The prefix followed by a single trailing space (e.g. `"<prefix> "`). The trailing space is part of the
+ *          contract: call sites concatenate the result directly with the message body, e.g.
+ *          `` `${formatLogPrefix(fn)}${moduleName}.method: ...` ``.
+ */
+export const formatLogPrefix = (logPrefixFn: () => string = logPrefix): string => {
+  return `${logPrefixFn()} `
+}
+
 export const once = <A extends unknown[], R>(fn: (...args: A) => R): ((...args: A) => R) => {
   let hasBeenCalled = false
   let result!: R
index 799d80b562875e6b843b3f5cb52d03a203b7bba7..ace35ae46b119e83c8cf6de46bfe51f431cc4e58 100644 (file)
@@ -43,6 +43,7 @@ export {
   extractTimeSeriesValues,
   formatDurationMilliSeconds,
   formatDurationSeconds,
+  formatLogPrefix,
   generateUUID,
   getMessageTypeString,
   getRandomFloatFluctuatedRounded,
diff --git a/tests/charging-station/BootstrapStateUtils.test.ts b/tests/charging-station/BootstrapStateUtils.test.ts
new file mode 100644 (file)
index 0000000..18decf1
--- /dev/null
@@ -0,0 +1,465 @@
+/**
+ * @file Tests for BootstrapStateUtils
+ * @description Unit tests for simulator state file read/write and template index reconstruction
+ */
+import assert from 'node:assert/strict'
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { TemplateStatistics } from '../../src/types/index.js'
+
+import {
+  deleteStateFile,
+  readStateFile,
+  reconstructTemplateIndexes,
+  STATE_FILE_VERSION,
+  writeStateFile,
+} from '../../src/charging-station/BootstrapStateUtils.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+await describe('BootstrapStateUtils', async () => {
+  let testDir: string
+  let stateFilePath: string
+  let configurationsDir: string
+
+  beforeEach(() => {
+    testDir = join(
+      tmpdir(),
+      `bootstrap-state-test-${Date.now().toString()}-${Math.random().toString(36).slice(2)}`
+    )
+    configurationsDir = join(testDir, 'configurations')
+    stateFilePath = join(testDir, 'state.json')
+    mkdirSync(configurationsDir, { recursive: true })
+  })
+
+  afterEach(() => {
+    rmSync(testDir, { force: true, recursive: true })
+    standardCleanup()
+  })
+
+  await describe('writeStateFile', async () => {
+    await it('should write state file with started true', async () => {
+      // Act
+      await writeStateFile(stateFilePath, true)
+
+      // Assert
+      assert.strictEqual(existsSync(stateFilePath), true)
+      const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as {
+        started: boolean
+        version: number
+      }
+      assert.strictEqual(content.version, STATE_FILE_VERSION)
+      assert.strictEqual(content.started, true)
+    })
+
+    await it('should write state file with started false', async () => {
+      // Act
+      await writeStateFile(stateFilePath, false)
+
+      // Assert
+      const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as {
+        started: boolean
+        version: number
+      }
+      assert.strictEqual(content.started, false)
+    })
+
+    await it('should atomically overwrite existing state file', async () => {
+      // Arrange
+      await writeStateFile(stateFilePath, true)
+
+      // Act
+      await writeStateFile(stateFilePath, false)
+
+      // Assert
+      const content = JSON.parse(readFileSync(stateFilePath, 'utf8')) as {
+        started: boolean
+        version: number
+      }
+      assert.strictEqual(content.started, false)
+    })
+
+    await it('should create parent directory if it does not exist', async () => {
+      // Arrange
+      const deepPath = join(testDir, 'nested', 'dir', 'state.json')
+
+      // Act
+      await writeStateFile(deepPath, true)
+
+      // Assert
+      assert.strictEqual(existsSync(deepPath), true)
+    })
+
+    await it('should not leave tmp file after successful write', async () => {
+      // Act
+      await writeStateFile(stateFilePath, true)
+
+      // Assert
+      assert.strictEqual(existsSync(`${stateFilePath}.tmp`), false)
+    })
+
+    await it('should not throw when target path is rejected by the OS', async () => {
+      // Arrange
+      const invalidPath = join(testDir, 'does-not-exist', '\0invalid', 'state.json')
+
+      // Act & Assert: writeStateFile must swallow filesystem errors so callers
+      // (Bootstrap.start/stop) do not surface persistence failures as fatal errors.
+      await assert.doesNotReject(async () => {
+        await writeStateFile(invalidPath, true)
+      })
+    })
+
+    await it('should clean up tmp file when atomic rename fails', async () => {
+      // Arrange
+      mkdirSync(stateFilePath, { recursive: true })
+      writeFileSync(join(stateFilePath, 'placeholder'), 'x', 'utf8')
+
+      // Act
+      await writeStateFile(stateFilePath, true)
+
+      // Assert
+      assert.strictEqual(existsSync(`${stateFilePath}.tmp`), false)
+    })
+  })
+
+  await describe('readStateFile', async () => {
+    await it('should return undefined when file does not exist', () => {
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+    })
+
+    await it('should return parsed state when file is valid', async () => {
+      // Arrange
+      await writeStateFile(stateFilePath, true)
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.notStrictEqual(result, undefined)
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+      assert.strictEqual(result!.version, STATE_FILE_VERSION)
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+      assert.strictEqual(result!.started, true)
+    })
+
+    await it('should return undefined and delete file when content is corrupt JSON', () => {
+      // Arrange
+      writeFileSync(stateFilePath, '{not valid json', 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+
+    await it('should return undefined and quarantine file when schema version is incompatible', () => {
+      // Arrange
+      writeFileSync(stateFilePath, JSON.stringify({ started: true, version: 999 }), 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+      assert.strictEqual(existsSync(`${stateFilePath}.v999.bak`), true)
+    })
+
+    await it('should return undefined and delete file when started field is missing', () => {
+      // Arrange
+      writeFileSync(stateFilePath, JSON.stringify({ version: STATE_FILE_VERSION }), 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+
+    await it('should return undefined and delete file when version field is missing', () => {
+      // Arrange
+      writeFileSync(stateFilePath, JSON.stringify({ started: true }), 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+
+    await it('should return undefined and delete file when content is JSON null', () => {
+      // Arrange
+      writeFileSync(stateFilePath, 'null', 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+
+    await it('should return undefined and delete file when content is a JSON primitive', () => {
+      // Arrange
+      writeFileSync(stateFilePath, '42', 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+
+    await it('should return undefined and delete file when content is a JSON array', () => {
+      // Arrange
+      writeFileSync(stateFilePath, '[]', 'utf8')
+
+      // Act
+      const result = readStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(result, undefined)
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+  })
+
+  await describe('deleteStateFile', async () => {
+    await it('should delete existing state file', async () => {
+      // Arrange
+      await writeStateFile(stateFilePath, true)
+
+      // Act
+      deleteStateFile(stateFilePath)
+
+      // Assert
+      assert.strictEqual(existsSync(stateFilePath), false)
+    })
+
+    await it('should not throw when file does not exist', () => {
+      // Act & Assert
+      assert.doesNotThrow(() => {
+        deleteStateFile(stateFilePath)
+      })
+    })
+  })
+
+  await describe('reconstructTemplateIndexes', async () => {
+    const createTemplateStatistics = (
+      entries: [string, number][]
+    ): Map<string, TemplateStatistics> => {
+      return new Map(
+        entries.map(([name, configured]) => [
+          name,
+          { added: 0, configured, indexes: new Set<number>(), provisioned: 0, started: 0 },
+        ])
+      )
+    }
+
+    await it('should reconstruct indexes from valid station config files', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 2]])
+      writeFileSync(
+        join(configurationsDir, 'station1.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }),
+        'utf8'
+      )
+      writeFileSync(
+        join(configurationsDir, 'station2.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 2, templateName: 'template-a' } }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      const indexes = templateStatistics.get('template-a')?.indexes
+      assert.notStrictEqual(indexes, undefined)
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+      assert.strictEqual(indexes!.size, 2)
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+      assert.strictEqual(indexes!.has(1), true)
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by notStrictEqual
+      assert.strictEqual(indexes!.has(2), true)
+    })
+
+    await it('should skip files for templates not in templateStatistics', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(
+        join(configurationsDir, 'station1.json'),
+        JSON.stringify({
+          stationInfo: { templateIndex: 1, templateName: 'removed-template' },
+        }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should skip files missing templateName', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(
+        join(configurationsDir, 'station1.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 1 } }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should skip files missing templateIndex', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(
+        join(configurationsDir, 'station1.json'),
+        JSON.stringify({ stationInfo: { templateName: 'template-a' } }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should skip corrupt JSON files', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(join(configurationsDir, 'corrupt.json'), '{not valid}', 'utf8')
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should skip non-JSON files', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(join(configurationsDir, 'readme.txt'), 'not a config', 'utf8')
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should handle empty configurations directory', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should handle non-existent configurations directory', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      const nonExistentDir = join(testDir, 'nonexistent')
+
+      // Act
+      reconstructTemplateIndexes(nonExistentDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should reconstruct indexes for multiple templates', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([
+        ['template-a', 2],
+        ['template-b', 1],
+      ])
+      writeFileSync(
+        join(configurationsDir, 'stationA1.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }),
+        'utf8'
+      )
+      writeFileSync(
+        join(configurationsDir, 'stationB1.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-b' } }),
+        'utf8'
+      )
+      writeFileSync(
+        join(configurationsDir, 'stationA2.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 2, templateName: 'template-a' } }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 2)
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(1), true)
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(2), true)
+      assert.strictEqual(templateStatistics.get('template-b')?.indexes.size, 1)
+      assert.strictEqual(templateStatistics.get('template-b')?.indexes.has(1), true)
+    })
+
+    await it('should handle files missing stationInfo entirely', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(
+        join(configurationsDir, 'station1.json'),
+        JSON.stringify({ connectorsStatus: [] }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 0)
+    })
+
+    await it('should skip dot-prefixed metadata files', () => {
+      // Arrange
+      const templateStatistics = createTemplateStatistics([['template-a', 1]])
+      writeFileSync(
+        join(configurationsDir, '.simulator-state.json'),
+        JSON.stringify({ started: true, version: 1 }),
+        'utf8'
+      )
+      writeFileSync(
+        join(configurationsDir, 'station1.json'),
+        JSON.stringify({ stationInfo: { templateIndex: 1, templateName: 'template-a' } }),
+        'utf8'
+      )
+
+      // Act
+      reconstructTemplateIndexes(configurationsDir, templateStatistics)
+
+      // Assert
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.size, 1)
+      assert.strictEqual(templateStatistics.get('template-a')?.indexes.has(1), true)
+    })
+  })
+})