]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui-server): classify broadcast responses without handlers (#1924)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 24 Jun 2026 20:11:56 +0000 (22:11 +0200)
committerGitHub <noreply@github.com>
Wed, 24 Jun 2026 20:11:56 +0000 (22:11 +0200)
* fix(ui-server): classify broadcast responses without handlers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): preserve internal broadcast origin

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): cleanup broadcast aggregation reliably

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): ensure late response cleanup

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(types): introduce UIRequestOrigin enum and widen ProtocolRequestHandler

- Promote 'internal' | 'transport' string-literal union to enum
  UIRequestOrigin { INTERNAL, TRANSPORT } hosted in src/types/UIProtocol.ts.
  Matches SCREAMING_SNAKE_CASE convention used across the file
  (ProcedureName, ResponseStatus, BroadcastChannelProcedureName).
- Promote UIRequestContext interface to src/types/UIProtocol.ts.
- Widen canonical ProtocolRequestHandler with optional context? parameter
  (backwards-compatible).
- Re-export UIRequestOrigin and UIRequestContext from src/types/index.ts.
- Drop the local UIServiceProtocolRequestHandler shadow in AbstractUIService.ts;
  consume the canonical ProtocolRequestHandler.
- Replace inline 'internal' / 'transport' literals at the two AbstractUIService
  defaults and the AbstractUIServer.sendInternalRequest call site.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): tighten handleProtocolRequest, rename log context, align terminology

- Tighten the canonical ProtocolRequestHandler in src/types/UIProtocol.ts:
  required params (uuid, procedureName, payload) match the runtime invariant
  guaranteed by the ProtocolRequest tuple destructure in requestHandler();
  return union collapses to Promise<ResponsePayload | undefined> | ... | undefined.
- Tighten handleProtocolRequest signature accordingly and drop the dead
  'Invalid protocol request' runtime throw (unreachable: requestHandler only
  calls it after the tuple has been destructured into non-null values).
- Rename interface BroadcastResponseLogContext to
  BroadcastChannelResponseLogContext for symmetry with the
  BroadcastChannel{ProcedureName, RequestContext, ...} family, and export
  it so test helpers can type-check the structured log payload.
- Align log strings in logBroadcastResponseWithoutHandler from
  'transport handler' to 'response handler' to match the public API
  (hasResponseHandler / responseHandlers). Test names and regex matchers
  updated atomically.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* style(ui-server): rename active to stopped and document rollback invariants

- Rename AbstractUIService.active boolean to stopped (default false, flipped
  by stop()). The branch label 'late broadcast response after UI service
  stop' now matches the field name; the read site at
  logBroadcastResponseWithoutHandler reads as 'if (this.stopped) debug,
  else warn' without needing a comment.
- Invert the two log branches under requestContext == null accordingly:
  the stopped branch leads with debug, the active branch with warn.
- Add a 1-line rationale comment on the try/catch in
  sendBroadcastChannelRequest documenting the bookkeeping/dispatch
  atomicity invariant (rollback the just-recorded context if the worker
  dispatch throws).
- Add a 1-line rationale comment on the try/finally in
  UIServiceWorkerBroadcastChannel.responseHandler documenting the
  aggregation-state release invariant under a sendResponse throw.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): extract expectSingleLog, assert log context, regress dispatch rollback

- Add expectSingleLog(mocks, level, pattern, contextShape?) to
  UIServerTestUtils.ts. Collapses the repeated
  'warnMock.length === 0 / debugMock.length === 1 + typeof guard +
  assert.match' block previously duplicated across six broadcast-response
  tests. Accepts an optional structured contextShape that, when provided,
  deep-equals the second logger argument against the
  BroadcastChannelResponseLogContext payload.
- Rewrite the six broadcast-response classification tests to invoke the
  helper. Each test now asserts the full structured log context (origin,
  procedureName, status, uuid, hashIds{Failed,Succeeded}), not just the
  message string — catching regressions that swap origin, drop
  procedureName, or omit hashIds propagation into the log payload.
- Add a new regression test 'should rollback expected responses when
  broadcast dispatch throws' that stubs uiServiceWorkerBroadcastChannel
  .sendRequest to throw, calls service.requestHandler with a broadcast
  procedure, and asserts service.getBroadcastChannelExpectedResponses
  rolled back to 0 — locking the try/catch invariant introduced in this
  PR.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): address post-design-pass nits

- tests: replace Reflect.get + double 'as never' chain in the broadcast
  dispatch rollback regression test with a typed cast to
  UIServiceWorkerBroadcastChannel. Kills both 'as never' casts and the
  '(): never' return annotation. Field rename now fails the test loudly
  via TS instead of silently mis-testing.
- tests: rename 'should warn on untracked broadcast responses while
  service is active' to '... before service stop' so the test name
  matches the post-rename 'stopped' field vocabulary.
- types: add JSDoc on the new public surface introduced by this PR -
  UIRequestOrigin, UIRequestContext, ProtocolRequestHandler widening
  (UIProtocol.ts), and BroadcastChannelResponseLogContext
  (AbstractUIService.ts). Docs describe contract, not mechanism.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/ui-services/AbstractUIService.ts
src/charging-station/ui-server/ui-services/UIService001.ts
src/types/UIProtocol.ts
src/types/index.ts
tests/charging-station/ui-server/UIServerTestUtils.ts
tests/charging-station/ui-server/ui-services/AbstractUIService.test.ts

index 07dc12741679b72cbd72578921edd8980eb72828..36a13f674929a9440b25d1926a4a5e1bb3c42260 100644 (file)
@@ -111,9 +111,13 @@ export class UIServiceWorkerBroadcastChannel extends WorkerBroadcastChannel {
     }
     const responses = this.responses.get(uuid)
     if (responses != null && responses.responsesReceived >= responses.responsesExpected) {
-      this.uiService.sendResponse(uuid, this.buildResponsePayload(uuid))
-      this.responses.delete(uuid)
-      this.uiService.deleteBroadcastChannelRequest(uuid)
+      // Always release aggregation state, even if downstream sendResponse throws.
+      try {
+        this.uiService.sendResponse(uuid, this.buildResponsePayload(uuid))
+      } finally {
+        this.responses.delete(uuid)
+        this.uiService.deleteBroadcastChannelRequest(uuid)
+      }
     }
   }
 }
index 802d6860c650f4c979064586cf744ca6fef861d0..327668304d32ece0fdaccd767281628d4b1a2a77 100644 (file)
@@ -22,6 +22,7 @@ import {
   ProtocolVersion,
   type RequestPayload,
   type ResponsePayload,
+  UIRequestOrigin,
   type UIServerConfiguration,
   type UUIDv4,
 } from '../../types/index.js'
@@ -300,7 +301,7 @@ export abstract class AbstractUIServer {
     this.registerProtocolVersionUIService(protocolVersion)
     return await (this.uiServices
       .get(protocolVersion)
-      ?.requestHandler(request) as Promise<ProtocolResponse>)
+      ?.requestHandler(request, { origin: UIRequestOrigin.INTERNAL }) as Promise<ProtocolResponse>)
   }
 
   public abstract sendRequest (request: ProtocolRequest): void
index 4d39ad4ac9c716e188f11032797962553beb37f9..5deeade3bbdb1f35ffdf408f2c24cd541583db5d 100644 (file)
@@ -18,13 +18,14 @@ import {
   type ResponsePayload,
   ResponseStatus,
   type StorageConfiguration,
+  type UIRequestContext,
+  UIRequestOrigin,
   type UUIDv4,
 } from '../../../types/index.js'
 import {
   Configuration,
   ensureError,
   getErrorMessage,
-  isAsyncFunction,
   isNotEmptyArray,
   logger,
 } from '../../../utils/index.js'
@@ -33,12 +34,33 @@ import { DEFAULT_MAX_STATIONS, isValidNumberOfStations } from '../UIServerSecuri
 
 const moduleName = 'AbstractUIService'
 
+/**
+ * Structured payload of the `logger.{debug,warn}` calls emitted by
+ * {@link AbstractUIService.logBroadcastResponseWithoutHandler}. Fields are
+ * optional when the corresponding context is unavailable (untracked or
+ * late responses, payload without hashIds).
+ */
+export interface BroadcastChannelResponseLogContext {
+  readonly hashIdsFailed?: string[]
+  readonly hashIdsSucceeded?: string[]
+  readonly origin?: UIRequestOrigin
+  readonly procedureName?: BroadcastChannelProcedureName
+  readonly status: ResponseStatus
+  readonly uuid: UUIDv4
+}
+
 interface AddChargingStationsRequestPayload extends RequestPayload {
   numberOfStations: number
   options?: ChargingStationOptions
   template: string
 }
 
+interface BroadcastChannelRequestContext {
+  readonly expectedResponses: number
+  readonly origin: UIRequestOrigin
+  readonly procedureName: BroadcastChannelProcedureName
+}
+
 export abstract class AbstractUIService {
   protected static readonly ProcedureNameToBroadCastChannelProcedureNameMapping = new Map<
     ProcedureName,
@@ -99,7 +121,8 @@ export abstract class AbstractUIService {
   ])
 
   protected readonly requestHandlers: Map<ProcedureName, ProtocolRequestHandler>
-  private readonly broadcastChannelRequests: Map<UUIDv4, number>
+  private readonly broadcastChannelRequests: Map<UUIDv4, BroadcastChannelRequestContext>
+  private stopped = false
 
   private readonly uiServer: AbstractUIServer
   private readonly uiServiceWorkerBroadcastChannel: UIServiceWorkerBroadcastChannel
@@ -118,7 +141,7 @@ export abstract class AbstractUIService {
       [ProcedureName.STOP_SIMULATOR, this.handleStopSimulator.bind(this)],
     ])
     this.uiServiceWorkerBroadcastChannel = new UIServiceWorkerBroadcastChannel(this)
-    this.broadcastChannelRequests = new Map<UUIDv4, number>()
+    this.broadcastChannelRequests = new Map<UUIDv4, BroadcastChannelRequestContext>()
   }
 
   public deleteBroadcastChannelRequest (uuid: UUIDv4): void {
@@ -126,14 +149,17 @@ export abstract class AbstractUIService {
   }
 
   public getBroadcastChannelExpectedResponses (uuid: UUIDv4): number {
-    return this.broadcastChannelRequests.get(uuid) ?? 0
+    return this.broadcastChannelRequests.get(uuid)?.expectedResponses ?? 0
   }
 
   public logPrefix = (modName: string, methodName: string): string => {
     return this.uiServer.logPrefix(modName, methodName, this.version)
   }
 
-  public async requestHandler (request: ProtocolRequest): Promise<ProtocolResponse | undefined> {
+  public async requestHandler (
+    request: ProtocolRequest,
+    context: UIRequestContext = { origin: UIRequestOrigin.TRANSPORT }
+  ): Promise<ProtocolResponse | undefined> {
     let uuid: undefined | UUIDv4
     let command: ProcedureName | undefined
     let requestPayload: RequestPayload | undefined
@@ -156,17 +182,7 @@ export abstract class AbstractUIService {
       if (requestHandler == null) {
         throw new BaseError(`'${command}' request handler not found`)
       }
-      if (isAsyncFunction(requestHandler)) {
-        responsePayload = await requestHandler(uuid, command, requestPayload)
-      } else {
-        responsePayload = (
-          requestHandler as (
-            uuid?: string,
-            procedureName?: ProcedureName,
-            payload?: RequestPayload
-          ) => ResponsePayload | undefined
-        )(uuid, command, requestPayload)
-      }
+      responsePayload = await requestHandler(uuid, command, requestPayload, context)
     } catch (error) {
       // Log
       logger.error(`${this.logPrefix(moduleName, 'requestHandler')} Handle request error:`, error)
@@ -206,15 +222,13 @@ export abstract class AbstractUIService {
   public sendResponse (uuid: UUIDv4, responsePayload: ResponsePayload): void {
     if (this.uiServer.hasResponseHandler(uuid)) {
       this.uiServer.sendResponse(this.uiServer.buildProtocolResponse(uuid, responsePayload))
-    } else {
-      logger.warn(`${this.logPrefix(moduleName, 'sendResponse')} Response handler not found:`, {
-        responsePayload,
-        uuid,
-      })
+      return
     }
+    this.logBroadcastResponseWithoutHandler(uuid, responsePayload)
   }
 
   public stop (): void {
+    this.stopped = true
     this.broadcastChannelRequests.clear()
     this.uiServiceWorkerBroadcastChannel.close()
   }
@@ -222,14 +236,37 @@ export abstract class AbstractUIService {
   protected handleProtocolRequest (
     uuid: UUIDv4,
     procedureName: ProcedureName,
-    payload: RequestPayload
-  ): void {
+    payload: RequestPayload,
+    context: UIRequestContext = { origin: UIRequestOrigin.TRANSPORT }
+  ): undefined {
     const broadCastChannelProcedureName =
       AbstractUIService.ProcedureNameToBroadCastChannelProcedureNameMapping.get(procedureName)
     if (broadCastChannelProcedureName == null) {
       throw new BaseError(`No broadcast channel mapping for procedure '${procedureName}'`)
     }
-    this.sendBroadcastChannelRequest(uuid, broadCastChannelProcedureName, payload)
+    this.sendBroadcastChannelRequest(uuid, broadCastChannelProcedureName, payload, context)
+    return undefined
+  }
+
+  private buildBroadcastResponseLogContext (
+    uuid: UUIDv4,
+    responsePayload: ResponsePayload,
+    requestContext?: BroadcastChannelRequestContext
+  ): BroadcastChannelResponseLogContext {
+    return {
+      ...(responsePayload.hashIdsFailed != null && {
+        hashIdsFailed: responsePayload.hashIdsFailed,
+      }),
+      ...(responsePayload.hashIdsSucceeded != null && {
+        hashIdsSucceeded: responsePayload.hashIdsSucceeded,
+      }),
+      ...(requestContext != null && {
+        origin: requestContext.origin,
+        procedureName: requestContext.procedureName,
+      }),
+      status: responsePayload.status,
+      uuid,
+    }
   }
 
   private async handleAddChargingStations (
@@ -386,10 +423,41 @@ export abstract class AbstractUIService {
     }
   }
 
+  private logBroadcastResponseWithoutHandler (uuid: UUIDv4, responsePayload: ResponsePayload): void {
+    const requestContext = this.broadcastChannelRequests.get(uuid)
+    const logContext = this.buildBroadcastResponseLogContext(uuid, responsePayload, requestContext)
+    if (requestContext == null) {
+      if (this.stopped) {
+        logger.debug(
+          `${this.logPrefix(moduleName, 'sendResponse')} Dropping late broadcast response after UI service stop:`,
+          logContext
+        )
+      } else {
+        logger.warn(
+          `${this.logPrefix(moduleName, 'sendResponse')} Dropping untracked broadcast response:`,
+          logContext
+        )
+      }
+      return
+    }
+    if (responsePayload.status === ResponseStatus.SUCCESS) {
+      logger.debug(
+        `${this.logPrefix(moduleName, 'sendResponse')} Broadcast response completed without response handler:`,
+        logContext
+      )
+      return
+    }
+    logger.warn(
+      `${this.logPrefix(moduleName, 'sendResponse')} Failed broadcast response completed without response handler:`,
+      logContext
+    )
+  }
+
   private sendBroadcastChannelRequest (
     uuid: UUIDv4,
     procedureName: BroadcastChannelProcedureName,
-    payload: BroadcastChannelRequestPayload
+    payload: BroadcastChannelRequestPayload,
+    context: UIRequestContext
   ): void {
     if (isNotEmptyArray(payload.hashIds)) {
       payload.hashIds = payload.hashIds
@@ -417,7 +485,17 @@ export abstract class AbstractUIService {
         'hashIds array in the request payload does not contain any valid charging station hashId'
       )
     }
-    this.uiServiceWorkerBroadcastChannel.sendRequest([uuid, procedureName, payload])
-    this.broadcastChannelRequests.set(uuid, expectedNumberOfResponses)
+    this.broadcastChannelRequests.set(uuid, {
+      expectedResponses: expectedNumberOfResponses,
+      origin: context.origin,
+      procedureName,
+    })
+    // Rollback expected-response accounting if dispatch to the worker channel throws.
+    try {
+      this.uiServiceWorkerBroadcastChannel.sendRequest([uuid, procedureName, payload])
+    } catch (error) {
+      this.broadcastChannelRequests.delete(uuid)
+      throw error
+    }
   }
 }
index 515536696704a4753ad5d8c3560c5ea07e4cc377..b777fe15c0d99d2f969c655fd2a052ecf2ae15ab 100644 (file)
@@ -1,16 +1,13 @@
 import type { AbstractUIServer } from '../AbstractUIServer.js'
 
-import { type ProtocolRequestHandler, ProtocolVersion } from '../../../types/index.js'
+import { ProtocolVersion } from '../../../types/index.js'
 import { AbstractUIService } from './AbstractUIService.js'
 
 export class UIService001 extends AbstractUIService {
   constructor (uiServer: AbstractUIServer) {
     super(uiServer, ProtocolVersion['0.0.1'])
     for (const procedureName of AbstractUIService.ProcedureNameToBroadCastChannelProcedureNameMapping.keys()) {
-      this.requestHandlers.set(
-        procedureName,
-        this.handleProtocolRequest.bind(this) as ProtocolRequestHandler
-      )
+      this.requestHandlers.set(procedureName, this.handleProtocolRequest.bind(this))
     }
   }
 }
index da5e4db3bece9e49b8582562fe9f3072685ab5e0..a08b833d4138c99d2251b099fa0d383ccef494cf 100644 (file)
@@ -71,15 +71,32 @@ export enum ServerNotification {
   REFRESH = 'refresh',
 }
 
+/**
+ * Origin of a UI service request. Drives broadcast-response classification:
+ * `INTERNAL` requests originate from `AbstractUIServer.sendInternalRequest`
+ * (e.g. `Bootstrap.doStop`) and have no transport-side response handler.
+ * `TRANSPORT` requests originate from a UI client (WebSocket/HTTP/MCP).
+ */
+export enum UIRequestOrigin {
+  INTERNAL = 'internal',
+  TRANSPORT = 'transport',
+}
+
 export type ProtocolNotification = [ServerNotification]
 
 export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
 
+/**
+ * Signature of any UI service request handler stored in the dispatch map.
+ * Sync or async; may return a payload or nothing. The optional `context`
+ * carries the request origin so broadcast handlers can classify late responses.
+ */
 export type ProtocolRequestHandler = (
-  uuid?: UUIDv4,
-  procedureName?: ProcedureName,
-  payload?: RequestPayload
-) => Promise<ResponsePayload> | Promise<undefined> | ResponsePayload | undefined
+  uuid: UUIDv4,
+  procedureName: ProcedureName,
+  payload: RequestPayload,
+  context?: UIRequestContext
+) => Promise<ResponsePayload | undefined> | ResponsePayload | undefined
 
 export type ProtocolResponse = [UUIDv4, ResponsePayload]
 
@@ -94,3 +111,11 @@ export interface ResponsePayload extends JsonObject {
   responsesFailed?: BroadcastChannelResponsePayload[]
   status: ResponseStatus
 }
+
+/**
+ * Context carried alongside a UI protocol request. Currently records only the
+ * request origin; additive fields are non-breaking.
+ */
+export interface UIRequestContext {
+  readonly origin: UIRequestOrigin
+}
index 039ba113a8905f23d275d64f034a0b72c5ae5f0e..8685f3f6f160319e38b20455f48bbfed55e0d2cf 100644 (file)
@@ -458,6 +458,8 @@ export {
   type ResponsePayload,
   ResponseStatus,
   ServerNotification,
+  type UIRequestContext,
+  UIRequestOrigin,
 } from './UIProtocol.js'
 export type { UUIDv4 } from './UUID.js'
 export {
index fca39ea9d1204025f242cbd62e6576cee860250d..a06254993426d9393c0ec7d05e158c908af06392 100644 (file)
@@ -6,9 +6,11 @@ import type { IncomingMessage } from 'node:http'
 import type { Duplex } from 'node:stream'
 import type { mock } from 'node:test'
 
+import assert from 'node:assert/strict'
 import { EventEmitter, once } from 'node:events'
 
 import type { IBootstrap } from '../../../src/charging-station/IBootstrap.js'
+import type { BroadcastChannelResponseLogContext } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
 import type {
   ChargingStationData,
   ProcedureName,
@@ -529,3 +531,39 @@ export const createMockChargingStationDataWithVersion = (
       templateName: 'test-template',
     },
   })
+
+interface LogMock {
+  readonly mock: {
+    readonly calls: readonly { readonly arguments: readonly unknown[] }[]
+  }
+}
+
+/**
+ * Assert that exactly one of the two `logger` levels was invoked with a
+ * message matching `pattern` and, optionally, a structured second argument
+ * deep-equal to `contextShape`.
+ * @param mocks - Tracked `logger.debug` and `logger.warn` mocks.
+ * @param mocks.debug - Mock tracking `logger.debug` invocations.
+ * @param mocks.warn - Mock tracking `logger.warn` invocations.
+ * @param level - Expected level for the single invocation.
+ * @param pattern - Regular expression the message must match.
+ * @param contextShape - Optional deep-equal shape for the second log argument.
+ */
+export const expectSingleLog = (
+  mocks: { readonly debug: LogMock; readonly warn: LogMock },
+  level: 'debug' | 'warn',
+  pattern: RegExp,
+  contextShape?: BroadcastChannelResponseLogContext
+): void => {
+  const [hit, miss] = level === 'debug' ? [mocks.debug, mocks.warn] : [mocks.warn, mocks.debug]
+  assert.strictEqual(miss.mock.calls.length, 0)
+  assert.strictEqual(hit.mock.calls.length, 1)
+  const [message, context] = hit.mock.calls[0]?.arguments ?? []
+  if (typeof message !== 'string') {
+    assert.fail(`Expected ${level} log message to be a string`)
+  }
+  assert.match(message, pattern)
+  if (contextShape != null) {
+    assert.deepStrictEqual(context, contextShape)
+  }
+}
index d42ae92e30fd06fc338178ed23819ae643f8859b..0fd3a017dd9f81ab1cfe9b1a6c955164866493b3 100644 (file)
@@ -6,16 +6,54 @@
 import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
-import { ProcedureName, ProtocolVersion, ResponseStatus } from '../../../../src/types/index.js'
+import type { UIServiceWorkerBroadcastChannel } from '../../../../src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.js'
+import type { AbstractUIService } from '../../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
+
+import {
+  BroadcastChannelProcedureName,
+  ProcedureName,
+  ProtocolVersion,
+  ResponseStatus,
+  UIRequestOrigin,
+} from '../../../../src/types/index.js'
+import { logger } from '../../../../src/utils/Logger.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_HASH_ID, TEST_UUID } from '../UIServerTestConstants.js'
 import {
   createMockChargingStationData,
   createMockUIServerConfiguration,
   createProtocolRequest,
+  expectSingleLog,
   TestableUIWebSocketServer,
 } from '../UIServerTestUtils.js'
 
+const createServiceContext = (): {
+  readonly server: TestableUIWebSocketServer
+  readonly service: AbstractUIService
+} => {
+  const config = createMockUIServerConfiguration()
+  const server = new TestableUIWebSocketServer(config)
+  server.testRegisterProtocolVersionUIService(ProtocolVersion['0.0.1'])
+  server.setChargingStationData(TEST_HASH_ID, createMockChargingStationData(TEST_HASH_ID))
+  const service = server.getUIService(ProtocolVersion['0.0.1'])
+  if (service == null) {
+    assert.fail('Expected UI service to be registered')
+  }
+  return { server, service }
+}
+
+const registerInternalStopRequest = async (server: TestableUIWebSocketServer): Promise<void> => {
+  await server.sendInternalRequest(
+    server.buildProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {})
+  )
+}
+
+const registerTransportStopRequest = async (service: AbstractUIService): Promise<void> => {
+  await service.requestHandler(
+    createProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {})
+  )
+}
+
 await describe('AbstractUIService', async () => {
   afterEach(() => {
     standardCleanup()
@@ -148,6 +186,189 @@ await describe('AbstractUIService', async () => {
     }
   })
 
+  await it('should log internal successful broadcast responses without response handler at debug', async t => {
+    const mocks = {
+      debug: t.mock.method(logger, 'debug', () => undefined),
+      warn: t.mock.method(logger, 'warn', () => undefined),
+    }
+    const { server, service } = createServiceContext()
+
+    try {
+      await registerInternalStopRequest(server)
+      service.sendResponse(TEST_UUID, {
+        hashIdsSucceeded: [TEST_HASH_ID],
+        status: ResponseStatus.SUCCESS,
+      })
+
+      expectSingleLog(mocks, 'debug', /Broadcast response completed without response handler/, {
+        hashIdsSucceeded: [TEST_HASH_ID],
+        origin: UIRequestOrigin.INTERNAL,
+        procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+        status: ResponseStatus.SUCCESS,
+        uuid: TEST_UUID,
+      })
+    } finally {
+      service.stop()
+    }
+  })
+
+  await it('should warn on internal failed broadcast responses without response handler', async t => {
+    const mocks = {
+      debug: t.mock.method(logger, 'debug', () => undefined),
+      warn: t.mock.method(logger, 'warn', () => undefined),
+    }
+    const { server, service } = createServiceContext()
+
+    try {
+      await registerInternalStopRequest(server)
+      service.sendResponse(TEST_UUID, {
+        hashIdsFailed: [TEST_HASH_ID],
+        status: ResponseStatus.FAILURE,
+      })
+
+      expectSingleLog(
+        mocks,
+        'warn',
+        /Failed broadcast response completed without response handler/,
+        {
+          hashIdsFailed: [TEST_HASH_ID],
+          origin: UIRequestOrigin.INTERNAL,
+          procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+          status: ResponseStatus.FAILURE,
+          uuid: TEST_UUID,
+        }
+      )
+    } finally {
+      service.stop()
+    }
+  })
+
+  await it('should log transport successful broadcast responses without response handler at debug', async t => {
+    const mocks = {
+      debug: t.mock.method(logger, 'debug', () => undefined),
+      warn: t.mock.method(logger, 'warn', () => undefined),
+    }
+    const { service } = createServiceContext()
+
+    try {
+      await registerTransportStopRequest(service)
+      service.sendResponse(TEST_UUID, {
+        hashIdsSucceeded: [TEST_HASH_ID],
+        status: ResponseStatus.SUCCESS,
+      })
+
+      expectSingleLog(mocks, 'debug', /Broadcast response completed without response handler/, {
+        hashIdsSucceeded: [TEST_HASH_ID],
+        origin: UIRequestOrigin.TRANSPORT,
+        procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+        status: ResponseStatus.SUCCESS,
+        uuid: TEST_UUID,
+      })
+    } finally {
+      service.stop()
+    }
+  })
+
+  await it('should warn on transport failed broadcast responses without response handler', async t => {
+    const mocks = {
+      debug: t.mock.method(logger, 'debug', () => undefined),
+      warn: t.mock.method(logger, 'warn', () => undefined),
+    }
+    const { service } = createServiceContext()
+
+    try {
+      await registerTransportStopRequest(service)
+      service.sendResponse(TEST_UUID, {
+        hashIdsFailed: [TEST_HASH_ID],
+        status: ResponseStatus.FAILURE,
+      })
+
+      expectSingleLog(
+        mocks,
+        'warn',
+        /Failed broadcast response completed without response handler/,
+        {
+          hashIdsFailed: [TEST_HASH_ID],
+          origin: UIRequestOrigin.TRANSPORT,
+          procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+          status: ResponseStatus.FAILURE,
+          uuid: TEST_UUID,
+        }
+      )
+    } finally {
+      service.stop()
+    }
+  })
+
+  await it('should warn on untracked broadcast responses before service stop', t => {
+    const mocks = {
+      debug: t.mock.method(logger, 'debug', () => undefined),
+      warn: t.mock.method(logger, 'warn', () => undefined),
+    }
+    const { service } = createServiceContext()
+
+    try {
+      service.sendResponse(TEST_UUID, { status: ResponseStatus.SUCCESS })
+
+      expectSingleLog(mocks, 'warn', /Dropping untracked broadcast response/, {
+        status: ResponseStatus.SUCCESS,
+        uuid: TEST_UUID,
+      })
+    } finally {
+      service.stop()
+    }
+  })
+
+  await it('should log late broadcast responses after service stop at debug', async t => {
+    const mocks = {
+      debug: t.mock.method(logger, 'debug', () => undefined),
+      warn: t.mock.method(logger, 'warn', () => undefined),
+    }
+    const { service } = createServiceContext()
+
+    try {
+      await registerTransportStopRequest(service)
+      service.stop()
+      service.sendResponse(TEST_UUID, {
+        hashIdsSucceeded: [TEST_HASH_ID],
+        status: ResponseStatus.SUCCESS,
+      })
+
+      expectSingleLog(mocks, 'debug', /Dropping late broadcast response/, {
+        hashIdsSucceeded: [TEST_HASH_ID],
+        status: ResponseStatus.SUCCESS,
+        uuid: TEST_UUID,
+      })
+    } finally {
+      service.stop()
+    }
+  })
+
+  await it('should rollback expected responses when broadcast dispatch throws', async t => {
+    const { service } = createServiceContext()
+    const channel = Reflect.get(
+      service,
+      'uiServiceWorkerBroadcastChannel'
+    ) as UIServiceWorkerBroadcastChannel
+    t.mock.method(channel, 'sendRequest', () => {
+      throw new Error('dispatch failed')
+    })
+
+    try {
+      const response = await service.requestHandler(
+        createProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {})
+      )
+
+      assert.notStrictEqual(response, undefined)
+      if (response != null) {
+        assert.strictEqual(response[1].status, ResponseStatus.FAILURE)
+      }
+      assert.strictEqual(service.getBroadcastChannelExpectedResponses(TEST_UUID), 0)
+    } finally {
+      service.stop()
+    }
+  })
+
   await it('should return failure response when request handler throws', async () => {
     const config = createMockUIServerConfiguration()
     const server = new TestableUIWebSocketServer(config)