Skip to content

Fix OCPP subprotocol negotiation and version handling across reconnects - #2012

Merged
drc38 merged 1 commit into
lbbrhzn:mainfrom
KingHavok:fix/reconnect-ocpp-version-rederive
Jul 29, 2026
Merged

Fix OCPP subprotocol negotiation and version handling across reconnects#2012
drc38 merged 1 commit into
lbbrhzn:mainfrom
KingHavok:fix/reconnect-ocpp-version-rederive

Conversation

@KingHavok

@KingHavok KingHavok commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #2008

Background

Setting up a FoxESS A-series charger (A022KP1) against this integration was
unreliable until the charger was locked to OCPP 1.6J on the charger side; switching
it to 2.0.1 (to explore ISO 15118/SoC support) produced the FormatViolationError
loop in #2008, and switching back to 1.6 did not recover — only a config-entry
reload did. To isolate whether the charger or the integration was at fault, the
charger was pointed at a standalone test CSMS and its handshakes captured in all
three of its modes (1.6J / 2.0.1 / auto). The charger turned out to be well-behaved
(it honours whatever subprotocol is negotiated and holds a stable 2.0.1 session);
the captures identified the integration-side root causes fixed here.

Problem

A charge point that supports several OCPP versions can wedge the integration into a
FormatViolationError loop that only a config-entry reload clears. Root cause was
confirmed on the wire against a FoxESS A022KP1 using a standalone test CSMS:

  • After an OCPP version switch, the charger makes a short-lived ocpp1.6 probe
    connection
    (~3 s, self-dropped) before its real connection. The probe caused a
    v1.6 ChargePoint to be built and cached for the cp_id. The real 2.0.1
    connection then reused the cached v16 object, and its 2.0.1 BootNotification
    (chargingStation/reason) was validated against the 1.6 schema:

    jsonschema.exceptions.ValidationError: Additional properties are not allowed
        ('chargingStation', 'reason' were unexpected)
    ocpp.exceptions.FormatViolationError: Payload for Action is syntactically incorrect ...
    

    This repeats on every reconnect (both directions: 1.6→2.0.1 and 2.0.1→1.6);
    charger power-cycles don't clear it, only reloading the config entry does.

  • Independently, select_subprotocol iterated a set() of the charger's offer, so
    when a charger offered several subprotocols (this charger's "auto" mode offers
    ocpp2.0.1, ocpp1.6) the negotiated version depended on set-iteration order and
    could vary between handshakes.

Changes

  • on_connect: when a charger reconnects negotiating a different OCPP version
    than the cached ChargePoint was built with, stop the stale object and rebuild it
    from the current handshake's subprotocol instead of reusing it. Same-version
    reconnects keep the existing fast reconnect() path.
  • select_subprotocol: iterate the server's ordered subprotocol list, which is
    the documented behaviour of the websockets default this function overrides
    ("pick the first one in the list declared the server"). The override closely
    mirrors that default but iterated the client's offer rather than the server's
    declared list, which is what made selection non-deterministic. The other deviation
    from the default — returning None instead of raising when the client offers no
    subprotocol — is deliberate and is preserved, since it is what allows a charger
    offering no subprotocol to default to OCPP 1.6. This restores the intended
    deterministic behaviour. Chargers offering a single subprotocol are unaffected; a
    dual-stack charger now consistently negotiates the first entry of
    DEFAULT_SUBPROTOCOLS (ocpp1.6) instead of an arbitrary one, so a user whose
    process happened to select 2.0.1 through the old unordered set may see the
    negotiated version change — the new OCPP-version option is the explicit lever for
    that.
  • New config-flow option "OCPP version" (default auto, preserving current
    behaviour). Pinning a version restricts negotiation to that version's subprotocol,
    so mixed-version probes are rejected at the websocket handshake and never reach the
    charge point cache. When pinned to a 2.x version, connections offering no
    subprotocol are rejected too (previously they silently became v1.6 charge points).
  • Reconfigure flow (async_step_reconfigure): existing entries can change
    central-system settings — including the new pin — without deleting and re-adding
    the integration.
  • Debug log of the charger's offered subprotocols to aid diagnosis.

Scope note

The pin applies to the whole central-system entry (one websocket port). Mixed-version
installations should keep auto (which now self-heals thanks to the rebuild) or use
separate ports/entries.

Validation

  • Live capture of the charger's handshakes in all three of its modes (1.6J / 2.0.1 /
    auto) via a standalone CSMS, which is how the transient 1.6 probe and the per-mode
    subprotocol offers were identified.
  • Deployed to a live Home Assistant 2026.7.4 with the physical charger: switching the
    charger between 1.6J, 2.0.1 and auto — including six connections inside one minute —
    logged a rebuild on every version change and completed post-connect setup on the
    first attempt each time, with no FormatViolationError and no reconnect loop.
  • New unit tests: deterministic server-order selection (both dual-stack client offer
    orders select 1.6), pin overriding that preference, pin restriction +
    no-subprotocol rejection, on_connect rebuild-on-version-change and
    reuse-when-unchanged, reconfigure flow round-trip.
  • Full test suite passes (coverage stays ≥95%); ruff clean.
  • Independently code-reviewed across several adversarial passes; the rebuild and the
    no-subprotocol rejection were additionally verified through real loopback websocket
    connections.

Note

A companion fix for the OCPP 2.0.1 units_changed / connector-count symptom also
described in #2008 is being prepared as a separate PR against ocppv201.py; the two
changes touch disjoint files and are independent.

Summary by CodeRabbit

  • New Features

    • Added configurable OCPP version selection, including automatic negotiation and pinned OCPP 1.6 or 2.0 modes.
    • Improved connection handling when chargers reconnect using a different OCPP version.
    • Added support for reconfiguring existing OCPP connections without recreating them.
  • Bug Fixes

    • Improved subprotocol negotiation and connection recovery for unsupported or changed versions.
  • Tests

    • Added coverage for version negotiation, reconnection behavior, and configuration reconfiguration.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b96c7de2-d693-4fae-a0c4-84dd9e3ad588

📥 Commits

Reviewing files that changed from the base of the PR and between 6383787 and 0f3ddb3.

📒 Files selected for processing (8)
  • custom_components/ocpp/api.py
  • custom_components/ocpp/config_flow.py
  • custom_components/ocpp/const.py
  • custom_components/ocpp/translations/en.json
  • custom_components/ocpp/translations/i-default.json
  • tests/const.py
  • tests/test_api_paths.py
  • tests/test_config_flow.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • custom_components/ocpp/translations/i-default.json
  • custom_components/ocpp/translations/en.json
  • tests/test_config_flow.py
  • custom_components/ocpp/config_flow.py
  • custom_components/ocpp/const.py
  • tests/test_api_paths.py
  • custom_components/ocpp/api.py

📝 Walkthrough

Walkthrough

The integration adds OCPP version configuration, server-ordered websocket negotiation, version-specific ChargePoint construction, reconnection replacement on version changes, and an existing-entry reconfiguration flow.

Changes

OCPP version support

Layer / File(s) Summary
Version configuration and reconfiguration
custom_components/ocpp/const.py, custom_components/ocpp/config_flow.py, custom_components/ocpp/translations/*, tests/const.py, tests/test_config_flow.py
Central-system settings and forms support automatic or pinned OCPP versions, with existing entries updated through reconfiguration while preserving omitted data.
Negotiation and ChargePoint lifecycle
custom_components/ocpp/api.py, tests/test_api_paths.py
Subprotocol selection follows server preference or pinned configuration, and reconnections rebuild or reuse ChargePoint instances based on negotiated OCPP version.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Charger
  participant CentralSystem
  participant ChargePoint
  Charger->>CentralSystem: Offer websocket subprotocols
  CentralSystem->>CentralSystem: Select compatible configured subprotocol
  CentralSystem->>ChargePoint: Build version-specific ChargePoint
  CentralSystem->>ChargePoint: Start or reconnect session
Loading

Possibly related PRs

  • lbbrhzn/ocpp#1666: Adds OCPP 2.1 support related to negotiated version and ChargePoint mapping.
  • lbbrhzn/ocpp#1990: Modifies the related configuration reconfigure flow.
  • lbbrhzn/ocpp#2001: Changes ChargePoint reconnection behavior at the lifecycle boundary used by this PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main change: OCPP subprotocol negotiation and reconnect version handling.
Linked Issues check ✅ Passed Changes address #2008 by negotiating the correct OCPP version, rebuilding stale ChargePoints, and adding version selection UI.
Out of Scope Changes check ✅ Passed All changes support negotiation, reconnect handling, or the new version-setting flow; no unrelated scope stands out.
Docstring Coverage ✅ Passed Docstring coverage is 87.10% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/ocpp/api.py`:
- Around line 337-363: Update the ChargePoint replacement flow around
charge_point.stop() so a connection-close failure cannot skip cancellation of
the old instance’s background tasks. Ensure ChargePoint.stop() performs task
cancellation in a finally-style cleanup path, or explicitly invoke the existing
task-cancellation logic after a suppressed stop failure, before replacing the
instance in self.charge_points.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a567e15-745c-4ffa-9b68-2f0218f178c9

📥 Commits

Reviewing files that changed from the base of the PR and between c61c1f4 and 7a582b4.

📒 Files selected for processing (8)
  • custom_components/ocpp/api.py
  • custom_components/ocpp/config_flow.py
  • custom_components/ocpp/const.py
  • custom_components/ocpp/translations/en.json
  • custom_components/ocpp/translations/i-default.json
  • tests/const.py
  • tests/test_api_paths.py
  • tests/test_config_flow.py

Comment thread custom_components/ocpp/api.py
@KingHavok
KingHavok force-pushed the fix/reconnect-ocpp-version-rederive branch 2 times, most recently from 16b67c9 to 7223ea1 Compare July 28, 2026 22:44
@KingHavok

Copy link
Copy Markdown
Contributor Author

The CodeRabbit finding about suppressing exceptions around stop() is fixed in
7223ea1: the rebuild path now cancels the stale ChargePoint's tasks itself if
stop() raises, with a regression test covering it
(test_on_connect_cancels_tasks_when_stop_fails). self.tasks is None until
run() populates it, so the cancellation guards against that too.

A note on the root cause, since it isn't specific to this PR:

ChargePoint.stop() closes the websocket before cancelling its tasks:

# custom_components/ocpp/chargepoint.py:560
async def stop(self):
    """Close connection and cancel ongoing tasks."""
    self.status = STATE_UNAVAILABLE
    if self._connection.state is State.OPEN:
        await self._connection.close()   # if this raises...
    for task in self.tasks:              # ...this never runs
        task.cancel()

So any caller whose close() fails leaves the instance's monitor_connection
task running. That applies to the two pre-existing call sites as well — the
finally in run() (line 558) and reconnect() (line 573) — not only the
rebuild path added here.

I've handled it defensively at the new call site in api.py instead of
changing stop(), to keep this PR limited to negotiation/config files rather
than widening it into chargepoint.py.

Happy to do either of the following, whichever you prefer:

  • move the fix into stop() in this PR (a try/finally around the close, a
    few lines), so all call sites benefit; or
  • leave stop() alone here and open a separate PR for it.

Just say which and I'll follow up.

@KingHavok
KingHavok force-pushed the fix/reconnect-ocpp-version-rederive branch from 7223ea1 to 6383787 Compare July 29, 2026 05:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
tests/test_config_flow.py (1)

244-244: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the duplicated local CONF_OCPP_VERSION import to the top of the file.

Both new tests import CONF_OCPP_VERSION locally; consolidating into the existing top-level import from custom_components.ocpp.const avoids the repeated inline import.

Also applies to: 283-283

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_config_flow.py` at line 244, Move the duplicated local
CONF_OCPP_VERSION imports from both new tests into the existing top-level import
from custom_components.ocpp.const, and remove the inline imports while
preserving each test’s use of the constant.
tests/test_api_paths.py (1)

592-631: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider also covering the tasks is None cancellation path.

This test exercises stop() failing with a populated self.tasks list. The PR description notes the fix also covers the case where self.tasks is still None (via getattr(charge_point, "tasks", None) or []), but no test sets self.tasks = None (or omits it) to exercise that fallback explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_api_paths.py` around lines 592 - 631, Extend
test_on_connect_cancels_tasks_when_stop_fails to cover a stale ChargePoint whose
tasks attribute is None or absent, exercising the fallback cancellation path
without raising. Keep the existing assertions that on_connect replaces the
charge point and starts the new instance, while verifying the None/omitted tasks
case completes safely.
custom_components/ocpp/config_flow.py (1)

148-180: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Entry title isn't updated when CONF_CSID changes via reconfigure.

STEP_USER_CS_DATA_SCHEMA (reused here) still requires CONF_CSID, and the original entry's title is set from it at creation (title=self._data[CONF_CSID] in async_step_user). Reconfigure only updates entry.data, so if a user changes the CSID here the entry title in the UI will drift out of sync with the actual csid value.

💡 Proposed fix
             self.hass.config_entries.async_update_entry(
-                entry, data={**entry.data, **user_input}
+                entry,
+                title=user_input[CONF_CSID],
+                data={**entry.data, **user_input},
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/ocpp/config_flow.py` around lines 148 - 180, Update
async_step_reconfigure so changing CONF_CSID also updates the config entry title
to the new CSID while preserving the existing entry data update and reload
behavior. Use the submitted user_input value when calling the config-entry
update API, and keep the current reconfigure_successful abort flow unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@custom_components/ocpp/config_flow.py`:
- Around line 148-180: Update async_step_reconfigure so changing CONF_CSID also
updates the config entry title to the new CSID while preserving the existing
entry data update and reload behavior. Use the submitted user_input value when
calling the config-entry update API, and keep the current reconfigure_successful
abort flow unchanged.

In `@tests/test_api_paths.py`:
- Around line 592-631: Extend test_on_connect_cancels_tasks_when_stop_fails to
cover a stale ChargePoint whose tasks attribute is None or absent, exercising
the fallback cancellation path without raising. Keep the existing assertions
that on_connect replaces the charge point and starts the new instance, while
verifying the None/omitted tasks case completes safely.

In `@tests/test_config_flow.py`:
- Line 244: Move the duplicated local CONF_OCPP_VERSION imports from both new
tests into the existing top-level import from custom_components.ocpp.const, and
remove the inline imports while preserving each test’s use of the constant.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 59dbc0fb-1421-4590-b37e-8a51767271e1

📥 Commits

Reviewing files that changed from the base of the PR and between 16b67c9 and 6383787.

📒 Files selected for processing (8)
  • custom_components/ocpp/api.py
  • custom_components/ocpp/config_flow.py
  • custom_components/ocpp/const.py
  • custom_components/ocpp/translations/en.json
  • custom_components/ocpp/translations/i-default.json
  • tests/const.py
  • tests/test_api_paths.py
  • tests/test_config_flow.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • custom_components/ocpp/translations/i-default.json
  • custom_components/ocpp/const.py
  • custom_components/ocpp/translations/en.json
  • custom_components/ocpp/api.py

@KingHavok

Copy link
Copy Markdown
Contributor Author

The CodeRabbit finding about suppressing exceptions around stop() is fixed in
7223ea1: the rebuild path now cancels the stale ChargePoint's tasks itself if
stop() raises, with a regression test covering it
(test_on_connect_cancels_tasks_when_stop_fails). self.tasks is None until
run() populates it, so the cancellation guards against that too.

A note on the root cause, since it isn't specific to this PR:

ChargePoint.stop() closes the websocket before cancelling its tasks:

# custom_components/ocpp/chargepoint.py:560
async def stop(self):
    """Close connection and cancel ongoing tasks."""
    self.status = STATE_UNAVAILABLE
    if self._connection.state is State.OPEN:
        await self._connection.close()   # if this raises...
    for task in self.tasks:              # ...this never runs
        task.cancel()

So any caller whose close() fails leaves the instance's monitor_connection
task running. That applies to the two pre-existing call sites as well — the
finally in run() (line 558) and reconnect() (line 573) — not only the
rebuild path added here.

I've handled it defensively at the new call site in api.py instead of
changing stop(), to keep this PR limited to negotiation/config files rather
than widening it into chargepoint.py.

Happy to do either of the following, whichever you prefer:

  • move the fix into stop() in this PR (a try/finally around the close, a
    few lines), so all call sites benefit; or
  • leave stop() alone here and open a separate PR for it.

Just say which and I'll follow up.

@KingHavok
KingHavok temporarily deployed to continuous-integration July 29, 2026 05:36 — with GitHub Actions Inactive
@drc38

drc38 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

The CodeRabbit finding about suppressing exceptions around stop() is fixed in 7223ea1: the rebuild path now cancels the stale ChargePoint's tasks itself if stop() raises, with a regression test covering it (test_on_connect_cancels_tasks_when_stop_fails). self.tasks is None until run() populates it, so the cancellation guards against that too.

A note on the root cause, since it isn't specific to this PR:

ChargePoint.stop() closes the websocket before cancelling its tasks:

# custom_components/ocpp/chargepoint.py:560
async def stop(self):
    """Close connection and cancel ongoing tasks."""
    self.status = STATE_UNAVAILABLE
    if self._connection.state is State.OPEN:
        await self._connection.close()   # if this raises...
    for task in self.tasks:              # ...this never runs
        task.cancel()

So any caller whose close() fails leaves the instance's monitor_connection task running. That applies to the two pre-existing call sites as well — the finally in run() (line 558) and reconnect() (line 573) — not only the rebuild path added here.

I've handled it defensively at the new call site in api.py instead of changing stop(), to keep this PR limited to negotiation/config files rather than widening it into chargepoint.py.

Happy to do either of the following, whichever you prefer:

  • move the fix into stop() in this PR (a try/finally around the close, a
    few lines), so all call sites benefit; or
  • leave stop() alone here and open a separate PR for it.

Just say which and I'll follow up.

Thanks, a separate PR is preferred for the stop() fix

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.03%. Comparing base (6eb2cfc) to head (0f3ddb3).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2012      +/-   ##
==========================================
+ Coverage   94.96%   95.03%   +0.07%     
==========================================
  Files          12       12              
  Lines        2962     3004      +42     
==========================================
+ Hits         2813     2855      +42     
  Misses        149      149              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A charge point that supports several OCPP versions can break the integration
in multiple ways, observed live with a FoxESS A022KP1 (issue lbbrhzn#2008):

- After a version switch the charger makes a short-lived ocpp1.6 probe
  connection before its real connection. The probe caused a v1.6 ChargePoint
  to be built and cached for the cp_id; the real 2.0.1 connection then reused
  the cached object and validated its 2.0.1 BootNotification against the 1.6
  schema, raising FormatViolationError on every reconnect until the config
  entry was reloaded.
- select_subprotocol iterated a set() of the charger's offer, so when a
  charger offered several subprotocols the negotiated version depended on
  set-iteration order and varied between handshakes.

Changes:

- select_subprotocol: iterate the server's ordered subprotocol list, which is
  the documented behaviour of the websockets default this function overrides
  ('pick the first one in the list declared the server'). The override closely
  mirrors that default but iterated the client's offer rather than the
  server's declared list, which is what made selection non-deterministic. The
  other deviation from the default - returning None instead of raising when
  the client offers no subprotocol - is deliberate and is preserved, since it
  is what allows a charger offering no subprotocol to default to OCPP 1.6.
  This restores the intended deterministic behaviour; chargers offering a
  single subprotocol are unaffected, while a dual-stack charger now
  consistently negotiates the first entry of DEFAULT_SUBPROTOCOLS (ocpp1.6)
  instead of an arbitrary one.
- on_connect: when a charger reconnects negotiating a different OCPP version
  than the cached ChargePoint was built with, stop the stale object and
  rebuild it from the current handshake's subprotocol instead of reusing it.
- New config-flow option 'OCPP version' (default 'auto'). Pinning a version
  restricts negotiation to that version's subprotocol, so mixed-version probes
  are rejected at the websocket handshake and never reach the charge point
  cache, and it is how a charger is held to a version other than the default
  preference. When pinned to a 2.x version, connections offering no
  subprotocol at all are rejected too (previously they silently became v1.6
  charge points). Note the pin applies to the whole central system entry (one
  websocket port): mixed-version installations should keep 'auto' or use
  separate ports.
- Reconfigure flow: existing entries can now change central-system settings
  (including the new OCPP version pin) without deleting and re-adding the
  integration.
- Debug log of the charger's offered subprotocols to aid diagnosis.

Fixes lbbrhzn#2008
@KingHavok
KingHavok force-pushed the fix/reconnect-ocpp-version-rederive branch from 6383787 to 0f3ddb3 Compare July 29, 2026 05:49
@KingHavok
KingHavok temporarily deployed to continuous-integration July 29, 2026 05:52 — with GitHub Actions Inactive
@drc38
drc38 merged commit a923c39 into lbbrhzn:main Jul 29, 2026
9 checks passed
drc38 pushed a commit that referenced this pull request Jul 29, 2026
* Always cancel charge point tasks when stopping

ChargePoint.stop() closed the websocket before cancelling self.tasks, so
a close that raised (or was cancelled) skipped cancellation entirely and
left monitor_connection running against a connection the charge point no
longer owns - duplicate pings and metric writes from an instance that is
supposed to be stopped.

Move the cancellation into a finally block so it runs on every path, and
tolerate self.tasks still being None: stop() is reachable before run()
has populated it, where it previously raised TypeError.

The close exception still propagates, so callers learn the close failed.

Split out of #2012 at the maintainer's request.

* Test that a cancelled websocket close still cancels tasks

CancelledError derives from BaseException, so it is the one close failure
an "except Exception" guard at a call site cannot catch - only the finally
block covers it. Pin that path explicitly.

Addresses CodeRabbit review on #2013.

---------

Co-authored-by: KingHavok <shane@shanewilliams.com.au>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OCPP 2.0.1 charge point rejected: 2.0.1 BootNotification validated against OCPP 1.6 schema (FormatViolationError)

2 participants