nwp500-python 8.1.1__tar.gz → 8.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/CHANGELOG.rst +18 -1
- {nwp500_python-8.1.1/src/nwp500_python.egg-info → nwp500_python-8.1.3}/PKG-INFO +1 -3
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/README.rst +0 -2
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/client.py +37 -2
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/periodic.py +0 -6
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/reconnection.py +37 -4
- {nwp500_python-8.1.1 → nwp500_python-8.1.3/src/nwp500_python.egg-info}/PKG-INFO +1 -3
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
- nwp500_python-8.1.3/tests/test_mqtt_clean_session_resume.py +190 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_reconnection_storm.py +155 -9
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.coveragerc +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/copilot-instructions.md +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/workflows/ci.yml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/workflows/release.yml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.gitignore +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.pre-commit-config.yaml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.readthedocs.yml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/AUTHORS.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/CONTRIBUTING.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/LICENSE.txt +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/Makefile +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/RELEASE.md +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/Makefile +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/_static/.gitignore +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/conf.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/explanation/advanced-features.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/explanation/index.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/index.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/index.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/openapi.yaml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/authors.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/changelog.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/contributing.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/history.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/license.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/configuration.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/index.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/installation.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/requirements.txt +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/.ruff.toml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/README.md +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/power_control.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/mask.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/pyproject.toml +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/README.md +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/bump_version.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/extract_changelog.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/format.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/lint.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/setup-dev.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/validate_version.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/setup.cfg +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/setup.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/__init__.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/_base.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/api_client.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/auth.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/config.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/converters.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/device_info_cache.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/encoding.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/enums.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/events.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/factory.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/device.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/status.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/connection.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/diagnostics.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/utils.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/openei.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/py.typed +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/reservations.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/temperature.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/utils.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/conftest.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_api_helpers.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_auth.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_bug_fixes.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_cli_basic.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_cli_commands.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_command_decorators.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_command_queue.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_events.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_exceptions.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_model_converters.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_models.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_reconnection.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_multi_device.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_openei.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_reservations.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_tou_api.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_unit_switching.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_utils.py +0 -0
- {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tox.ini +0 -0
|
@@ -5,9 +5,26 @@ Changelog
|
|
|
5
5
|
Unreleased
|
|
6
6
|
==========
|
|
7
7
|
|
|
8
|
-
Version 8.1.
|
|
8
|
+
Version 8.1.3 (2026-06-15)
|
|
9
9
|
==========================
|
|
10
10
|
|
|
11
|
+
Bug Fixes
|
|
12
|
+
---------
|
|
13
|
+
- **Fix MQTT reconnection storm caused by non-thread-safe Task.cancel()**: The
|
|
14
|
+
``on_connection_resumed`` callback is invoked from an AWS IoT SDK background
|
|
15
|
+
thread. It was calling ``asyncio.Task.cancel()`` directly on that thread, which
|
|
16
|
+
is not thread-safe. When the event loop was busy at the moment of cancellation
|
|
17
|
+
(e.g. the sleeping task's timer callback had already been enqueued), the
|
|
18
|
+
cancellation was silently dropped. The stale ``_reconnect_with_backoff`` task
|
|
19
|
+
would then complete its sleep, call ``_reconnect_func``, and tear down an
|
|
20
|
+
otherwise healthy connection — restarting the entire disconnect → reconnect →
|
|
21
|
+
``AWS_ERROR_MQTT_UNEXPECTED_HANGUP`` cycle. Fixed by replacing the direct
|
|
22
|
+
``task.cancel()`` call with a ``_cancel_pending_reconnect()`` coroutine scheduled
|
|
23
|
+
via ``_schedule_coroutine``, so the cancellation runs on the event loop where
|
|
24
|
+
asyncio operations are safe. The method also uses an identity check before
|
|
25
|
+
clearing ``_reconnect_task`` to avoid wiping a newer task created during the
|
|
26
|
+
await, and clears stale references to already-done tasks.
|
|
27
|
+
|
|
11
28
|
Version 8.1.0 (2026-05-16)
|
|
12
29
|
==========================
|
|
13
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nwp500-python
|
|
3
|
-
Version: 8.1.
|
|
3
|
+
Version: 8.1.3
|
|
4
4
|
Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
|
|
5
5
|
Home-page: https://github.com/eman/nwp500-python
|
|
6
6
|
Author: Emmanuel Levijarvi
|
|
@@ -89,8 +89,6 @@ Quick Example
|
|
|
89
89
|
Documentation
|
|
90
90
|
=============
|
|
91
91
|
|
|
92
|
-
The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
|
|
93
|
-
|
|
94
92
|
* `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
|
|
95
93
|
* `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
|
|
96
94
|
* `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
|
|
@@ -47,8 +47,6 @@ Quick Example
|
|
|
47
47
|
Documentation
|
|
48
48
|
=============
|
|
49
49
|
|
|
50
|
-
The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
|
|
51
|
-
|
|
52
50
|
* `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
|
|
53
51
|
* `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
|
|
54
52
|
* `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
|
|
@@ -364,8 +364,20 @@ class NavienMqttClient(EventEmitter):
|
|
|
364
364
|
)
|
|
365
365
|
)
|
|
366
366
|
|
|
367
|
-
#
|
|
368
|
-
|
|
367
|
+
# When the broker starts a clean session (session_present=False), all
|
|
368
|
+
# previous subscriptions have been dropped server-side. We must
|
|
369
|
+
# re-establish them before any device data can flow. This covers the
|
|
370
|
+
# common case where the AWS IoT SDK auto-reconnects internally before
|
|
371
|
+
# the MqttReconnectionHandler fires its own reconnect path — in that
|
|
372
|
+
# scenario the reconnect handler sees _connected==True and exits early,
|
|
373
|
+
# so resubscribe_all() would never be called without this block.
|
|
374
|
+
#
|
|
375
|
+
# When session_present=False, we must resubscribe before sending queued
|
|
376
|
+
# commands to ensure subscriptions are restored before device responses
|
|
377
|
+
# are processed. Use a composite coroutine to enforce ordering.
|
|
378
|
+
if not session_present and self._subscription_manager:
|
|
379
|
+
self._schedule_coroutine(self._handle_clean_session_resume())
|
|
380
|
+
elif self.config.enable_command_queue and self._command_queue:
|
|
369
381
|
self._schedule_coroutine(self._send_queued_commands_internal())
|
|
370
382
|
|
|
371
383
|
async def _send_queued_commands_internal(self) -> None:
|
|
@@ -377,6 +389,29 @@ class NavienMqttClient(EventEmitter):
|
|
|
377
389
|
self._connection_manager.publish, lambda: self._connected
|
|
378
390
|
)
|
|
379
391
|
|
|
392
|
+
async def _handle_clean_session_resume(self) -> None:
|
|
393
|
+
"""
|
|
394
|
+
Handle clean session reconnection with ordered resubscription.
|
|
395
|
+
|
|
396
|
+
When session_present=False (clean session), the broker has dropped all
|
|
397
|
+
subscriptions. This method ensures subscriptions are restored BEFORE
|
|
398
|
+
sending any queued commands, preventing commands from being processed
|
|
399
|
+
before their subscriptions are re-established.
|
|
400
|
+
"""
|
|
401
|
+
if not self._subscription_manager or not self._connection_manager:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
if not self._connection_manager.connection:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
self._subscription_manager.update_connection(
|
|
408
|
+
self._connection_manager.connection
|
|
409
|
+
)
|
|
410
|
+
await self._subscription_manager.resubscribe_all()
|
|
411
|
+
|
|
412
|
+
if self.config.enable_command_queue and self._command_queue:
|
|
413
|
+
await self._send_queued_commands_internal()
|
|
414
|
+
|
|
380
415
|
async def _active_reconnect(self) -> None:
|
|
381
416
|
"""
|
|
382
417
|
Actively trigger a reconnection attempt.
|
|
@@ -173,12 +173,6 @@ class MqttPeriodicRequestManager:
|
|
|
173
173
|
await self._request_device_info(device)
|
|
174
174
|
elif request_type == PeriodicRequestType.DEVICE_STATUS:
|
|
175
175
|
await self._request_device_status(device)
|
|
176
|
-
else:
|
|
177
|
-
_logger.error(
|
|
178
|
-
"Unknown periodic request type: %s",
|
|
179
|
-
request_type,
|
|
180
|
-
)
|
|
181
|
-
break
|
|
182
176
|
|
|
183
177
|
_logger.debug(
|
|
184
178
|
"Sent periodic %s request for %s",
|
|
@@ -128,9 +128,42 @@ class MqttReconnectionHandler:
|
|
|
128
128
|
# Reset reconnection attempts on successful connection
|
|
129
129
|
self._reconnect_attempts = 0
|
|
130
130
|
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
# Schedule cancellation of any pending reconnect task on the event loop.
|
|
132
|
+
# This method is called from an AWS SDK background thread; asyncio's
|
|
133
|
+
# Task.cancel() is NOT thread-safe when invoked directly from a
|
|
134
|
+
# non-event-loop thread. If the event loop is busy (e.g. the sleeping
|
|
135
|
+
# task's timer callback was already queued) the cancellation can be
|
|
136
|
+
# silently dropped, leaving the stale _reconnect_with_backoff loop
|
|
137
|
+
# alive. That loop then completes its sleep and calls _reconnect_func,
|
|
138
|
+
# tearing down a perfectly healthy connection and restarting the
|
|
139
|
+
# disconnect/reconnect cycle.
|
|
140
|
+
self._schedule_coroutine(self._cancel_pending_reconnect())
|
|
141
|
+
|
|
142
|
+
async def _cancel_pending_reconnect(self) -> None:
|
|
143
|
+
"""Cancel any pending reconnect task.
|
|
144
|
+
|
|
145
|
+
Must be called on the event loop (via _schedule_coroutine) so that
|
|
146
|
+
asyncio Task operations are thread-safe.
|
|
147
|
+
|
|
148
|
+
Uses an identity check before clearing _reconnect_task to avoid
|
|
149
|
+
accidentally wiping a new task that was created while the cancelled
|
|
150
|
+
task was being awaited. Also clears stale references to already-done
|
|
151
|
+
tasks so the handler never holds on to finished task objects.
|
|
152
|
+
"""
|
|
153
|
+
task = self._reconnect_task
|
|
154
|
+
if task is None:
|
|
155
|
+
return
|
|
156
|
+
if task.done():
|
|
157
|
+
# Clear stale reference to an already-finished task.
|
|
158
|
+
if self._reconnect_task is task:
|
|
159
|
+
self._reconnect_task = None
|
|
160
|
+
return
|
|
161
|
+
task.cancel()
|
|
162
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
163
|
+
await task
|
|
164
|
+
# Only clear the reference if it still points to the same task.
|
|
165
|
+
# A new reconnect task may have been created while we were awaiting.
|
|
166
|
+
if self._reconnect_task is task:
|
|
134
167
|
self._reconnect_task = None
|
|
135
168
|
|
|
136
169
|
async def _start_reconnect_task(self) -> None:
|
|
@@ -142,7 +175,7 @@ class MqttReconnectionHandler:
|
|
|
142
175
|
|
|
143
176
|
The is_connected guard is re-checked here because this coroutine may
|
|
144
177
|
be queued via run_coroutine_threadsafe and run after the connection
|
|
145
|
-
has already been restored (e.g. by
|
|
178
|
+
has already been restored (e.g. by _cancel_pending_reconnect clearing
|
|
146
179
|
_reconnect_task), in which case starting a new backoff loop would
|
|
147
180
|
incorrectly tear down a healthy connection.
|
|
148
181
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nwp500-python
|
|
3
|
-
Version: 8.1.
|
|
3
|
+
Version: 8.1.3
|
|
4
4
|
Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
|
|
5
5
|
Home-page: https://github.com/eman/nwp500-python
|
|
6
6
|
Author: Emmanuel Levijarvi
|
|
@@ -89,8 +89,6 @@ Quick Example
|
|
|
89
89
|
Documentation
|
|
90
90
|
=============
|
|
91
91
|
|
|
92
|
-
The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
|
|
93
|
-
|
|
94
92
|
* `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
|
|
95
93
|
* `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
|
|
96
94
|
* `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
|
|
@@ -186,6 +186,7 @@ tests/test_events.py
|
|
|
186
186
|
tests/test_exceptions.py
|
|
187
187
|
tests/test_model_converters.py
|
|
188
188
|
tests/test_models.py
|
|
189
|
+
tests/test_mqtt_clean_session_resume.py
|
|
189
190
|
tests/test_mqtt_client_init.py
|
|
190
191
|
tests/test_mqtt_hypothesis.py
|
|
191
192
|
tests/test_mqtt_reconnection.py
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tests for MQTT client clean session reconnection handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from nwp500.auth import AuthenticationResponse, AuthTokens, UserInfo
|
|
10
|
+
from nwp500.mqtt import NavienMqttClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def auth_client_with_valid_tokens():
|
|
15
|
+
"""Create an auth client with valid tokens."""
|
|
16
|
+
from nwp500.auth import NavienAuthClient
|
|
17
|
+
|
|
18
|
+
auth_client = NavienAuthClient("test@example.com", "password")
|
|
19
|
+
valid_tokens = AuthTokens(
|
|
20
|
+
id_token="test_id",
|
|
21
|
+
access_token="test_access",
|
|
22
|
+
refresh_token="test_refresh",
|
|
23
|
+
authentication_expires_in=3600,
|
|
24
|
+
access_key_id="test_key_id",
|
|
25
|
+
secret_key="test_secret_key",
|
|
26
|
+
session_token="test_session",
|
|
27
|
+
authorization_expires_in=3600,
|
|
28
|
+
)
|
|
29
|
+
auth_client._auth_response = AuthenticationResponse(
|
|
30
|
+
user_info=UserInfo(user_first_name="Test", user_last_name="User"),
|
|
31
|
+
tokens=valid_tokens,
|
|
32
|
+
)
|
|
33
|
+
return auth_client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestMqttCleanSessionResume:
|
|
37
|
+
"""Tests for clean session (session_present=False) reconnection handling."""
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
40
|
+
async def test_on_connection_resumed_with_clean_session_resubscribes(
|
|
41
|
+
self, auth_client_with_valid_tokens
|
|
42
|
+
):
|
|
43
|
+
"""Resubscribe when session_present=False on connection resume."""
|
|
44
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
45
|
+
|
|
46
|
+
# Mock the components
|
|
47
|
+
mock_subscription_manager = AsyncMock()
|
|
48
|
+
mock_subscription_manager.resubscribe_all = AsyncMock()
|
|
49
|
+
client._subscription_manager = mock_subscription_manager
|
|
50
|
+
|
|
51
|
+
mock_connection_manager = MagicMock()
|
|
52
|
+
mock_connection = MagicMock()
|
|
53
|
+
mock_connection_manager.connection = mock_connection
|
|
54
|
+
client._connection_manager = mock_connection_manager
|
|
55
|
+
|
|
56
|
+
# Mock the event emitter and diagnostics
|
|
57
|
+
client.emit = AsyncMock()
|
|
58
|
+
client._diagnostics = MagicMock()
|
|
59
|
+
client._diagnostics.record_connection_success = AsyncMock()
|
|
60
|
+
|
|
61
|
+
# Call with session_present=False (clean session)
|
|
62
|
+
client._on_connection_resumed_internal(
|
|
63
|
+
connection=mock_connection, return_code=0, session_present=False
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Give the scheduled coroutine time to run
|
|
67
|
+
import asyncio
|
|
68
|
+
|
|
69
|
+
await asyncio.sleep(0.1)
|
|
70
|
+
|
|
71
|
+
# Verify resubscribe_all was called
|
|
72
|
+
mock_subscription_manager.update_connection.assert_called_once_with(
|
|
73
|
+
mock_connection
|
|
74
|
+
)
|
|
75
|
+
# The resubscribe should be scheduled via _schedule_coroutine
|
|
76
|
+
# We need to wait for it or check the internal state
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
79
|
+
async def test_resubscribe_before_queued_commands(
|
|
80
|
+
self, auth_client_with_valid_tokens
|
|
81
|
+
):
|
|
82
|
+
"""Resubscribe completes before queued commands are sent."""
|
|
83
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
84
|
+
|
|
85
|
+
# Track call order
|
|
86
|
+
call_order = []
|
|
87
|
+
|
|
88
|
+
# Mock the components
|
|
89
|
+
mock_subscription_manager = MagicMock()
|
|
90
|
+
mock_subscription_manager.resubscribe_all = AsyncMock(
|
|
91
|
+
side_effect=lambda: call_order.append("resubscribe")
|
|
92
|
+
)
|
|
93
|
+
client._subscription_manager = mock_subscription_manager
|
|
94
|
+
|
|
95
|
+
mock_connection_manager = MagicMock()
|
|
96
|
+
mock_connection = MagicMock()
|
|
97
|
+
mock_connection_manager.connection = mock_connection
|
|
98
|
+
client._connection_manager = mock_connection_manager
|
|
99
|
+
|
|
100
|
+
# Mock command queue
|
|
101
|
+
client._command_queue = AsyncMock()
|
|
102
|
+
client.config.enable_command_queue = True
|
|
103
|
+
|
|
104
|
+
# Mock send_queued_commands to track it's called after resubscribe
|
|
105
|
+
original_send = client._send_queued_commands_internal
|
|
106
|
+
|
|
107
|
+
async def mock_send():
|
|
108
|
+
call_order.append("send_queued")
|
|
109
|
+
await original_send()
|
|
110
|
+
|
|
111
|
+
client._send_queued_commands_internal = mock_send
|
|
112
|
+
|
|
113
|
+
# Call the method
|
|
114
|
+
await client._handle_clean_session_resume()
|
|
115
|
+
|
|
116
|
+
# Verify subscription manager was updated with connection
|
|
117
|
+
mock_subscription_manager.update_connection.assert_called_once_with(
|
|
118
|
+
mock_connection
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Verify resubscribe was called before queued commands
|
|
122
|
+
assert call_order == ["resubscribe", "send_queued"]
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
125
|
+
async def test_skip_when_no_subscription_manager(
|
|
126
|
+
self, auth_client_with_valid_tokens
|
|
127
|
+
):
|
|
128
|
+
"""Return early if subscription_manager is None."""
|
|
129
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
130
|
+
client._subscription_manager = None
|
|
131
|
+
|
|
132
|
+
# Should not raise
|
|
133
|
+
await client._handle_clean_session_resume()
|
|
134
|
+
|
|
135
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
136
|
+
async def test_handle_clean_session_resume_skips_when_no_connection(
|
|
137
|
+
self, auth_client_with_valid_tokens
|
|
138
|
+
):
|
|
139
|
+
"""Return early if connection is None."""
|
|
140
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
141
|
+
|
|
142
|
+
mock_subscription_manager = MagicMock()
|
|
143
|
+
client._subscription_manager = mock_subscription_manager
|
|
144
|
+
|
|
145
|
+
mock_connection_manager = MagicMock()
|
|
146
|
+
mock_connection_manager.connection = None
|
|
147
|
+
client._connection_manager = mock_connection_manager
|
|
148
|
+
|
|
149
|
+
# Should not raise
|
|
150
|
+
await client._handle_clean_session_resume()
|
|
151
|
+
|
|
152
|
+
# Should not try to update connection
|
|
153
|
+
mock_subscription_manager.update_connection.assert_not_called()
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
156
|
+
async def test_on_connection_resumed_with_session_sends_queued_commands(
|
|
157
|
+
self, auth_client_with_valid_tokens
|
|
158
|
+
):
|
|
159
|
+
"""Send queued commands normally when session_present=True."""
|
|
160
|
+
client = NavienMqttClient(auth_client_with_valid_tokens)
|
|
161
|
+
|
|
162
|
+
# Mock the components
|
|
163
|
+
mock_command_queue = AsyncMock()
|
|
164
|
+
client._command_queue = mock_command_queue
|
|
165
|
+
client.config.enable_command_queue = True
|
|
166
|
+
|
|
167
|
+
# Mock the event emitter and diagnostics
|
|
168
|
+
client.emit = AsyncMock()
|
|
169
|
+
client._diagnostics = MagicMock()
|
|
170
|
+
client._diagnostics.record_connection_success = AsyncMock()
|
|
171
|
+
|
|
172
|
+
# Mock connection
|
|
173
|
+
mock_connection = MagicMock()
|
|
174
|
+
|
|
175
|
+
# Patch _send_queued_commands_internal to track if called
|
|
176
|
+
with patch.object(
|
|
177
|
+
client, "_send_queued_commands_internal", new_callable=AsyncMock
|
|
178
|
+
):
|
|
179
|
+
# Call with session_present=True (session resumed)
|
|
180
|
+
client._on_connection_resumed_internal(
|
|
181
|
+
connection=mock_connection, return_code=0, session_present=True
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Give the scheduled coroutine time to run
|
|
185
|
+
import asyncio
|
|
186
|
+
|
|
187
|
+
await asyncio.sleep(0.1)
|
|
188
|
+
|
|
189
|
+
# Verify send_queued_commands_internal was scheduled
|
|
190
|
+
# (it will be called through _schedule_coroutine)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Tests for the MQTT reconnection storm fix.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Three bugs were fixed:
|
|
5
5
|
|
|
6
6
|
Bug 1 — Stale interruption events fire after a resume clears _reconnect_task.
|
|
7
7
|
The AWS SDK fires on_connection_interrupted callbacks from background threads
|
|
@@ -21,6 +21,18 @@ Bug 2 — Closing the old connection inside _active_reconnect / _deep_reconnect
|
|
|
21
21
|
Fix: _actively_reconnecting flag suppresses the reconnection-handler
|
|
22
22
|
delegation in _on_connection_interrupted_internal while the intentional
|
|
23
23
|
teardown is in progress.
|
|
24
|
+
|
|
25
|
+
Bug 3 — on_connection_resumed calls Task.cancel() directly from an AWS SDK
|
|
26
|
+
background thread. asyncio.Task.cancel() is NOT thread-safe; when the event
|
|
27
|
+
loop is busy (e.g. the sleeping task's timer callback was already enqueued)
|
|
28
|
+
the cancellation can be silently dropped. The stale _reconnect_with_backoff
|
|
29
|
+
task then completes its sleep, calls _reconnect_func, and tears down an
|
|
30
|
+
otherwise healthy connection, restarting the entire
|
|
31
|
+
disconnect/reconnect cycle.
|
|
32
|
+
|
|
33
|
+
Fix: on_connection_resumed schedules _cancel_pending_reconnect() via
|
|
34
|
+
_schedule_coroutine so the cancellation runs on the event loop where asyncio
|
|
35
|
+
Task operations are safe.
|
|
24
36
|
"""
|
|
25
37
|
|
|
26
38
|
from __future__ import annotations
|
|
@@ -177,10 +189,11 @@ class TestReconnectionHandlerIsConnectedGuard:
|
|
|
177
189
|
|
|
178
190
|
1. Connection drops → on_connection_interrupted schedules
|
|
179
191
|
_start_reconnect_task (coroutine A queued but not yet run).
|
|
180
|
-
2. Connection resumes → on_connection_resumed
|
|
181
|
-
|
|
182
|
-
3.
|
|
183
|
-
_reconnect_task=None and create a new backoff
|
|
192
|
+
2. Connection resumes → on_connection_resumed schedules
|
|
193
|
+
_cancel_pending_reconnect (no task to cancel yet since A hasn't run).
|
|
194
|
+
3. Both queued coroutines finally run – without the is_connected guard
|
|
195
|
+
coroutine A would see _reconnect_task=None and create a new backoff
|
|
196
|
+
loop even though the client is now healthy.
|
|
184
197
|
"""
|
|
185
198
|
state = {"connected": False}
|
|
186
199
|
config = MqttConnectionConfig(auto_reconnect=True)
|
|
@@ -200,13 +213,16 @@ class TestReconnectionHandlerIsConnectedGuard:
|
|
|
200
213
|
handler.on_connection_interrupted(Exception("dropped"))
|
|
201
214
|
assert len(scheduled) == 1
|
|
202
215
|
|
|
203
|
-
# Step 2: connection resumes before the scheduled coroutine runs
|
|
216
|
+
# Step 2: connection resumes before the scheduled coroutine runs.
|
|
217
|
+
# on_connection_resumed now schedules _cancel_pending_reconnect rather
|
|
218
|
+
# than calling task.cancel() directly (Bug 3 fix).
|
|
204
219
|
state["connected"] = True
|
|
205
220
|
handler.on_connection_resumed(return_code=0, session_present=False)
|
|
206
|
-
assert handler._reconnect_task is None
|
|
221
|
+
assert handler._reconnect_task is None # no task was ever created
|
|
207
222
|
|
|
208
|
-
# Step 3:
|
|
209
|
-
|
|
223
|
+
# Step 3: run all scheduled coroutines
|
|
224
|
+
# (_start_reconnect_task + _cancel_pending_reconnect)
|
|
225
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
210
226
|
|
|
211
227
|
# With the fix, no new task must have been created
|
|
212
228
|
assert handler._reconnect_task is None
|
|
@@ -232,6 +248,136 @@ class TestReconnectionHandlerIsConnectedGuard:
|
|
|
232
248
|
await asyncio.gather(handler._reconnect_task, return_exceptions=True)
|
|
233
249
|
|
|
234
250
|
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Bug 3: on_connection_resumed must cancel via the event loop, not directly
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestThreadSafeTaskCancellation:
|
|
257
|
+
"""Bug 3 – on_connection_resumed must not call Task.cancel() from a thread.
|
|
258
|
+
|
|
259
|
+
asyncio.Task.cancel() is NOT thread-safe. When called from an AWS SDK
|
|
260
|
+
background thread, the cancellation can be silently dropped if the event
|
|
261
|
+
loop is busy (e.g. the sleep timer fires at the same moment). The stale
|
|
262
|
+
task then triggers a spurious reconnection.
|
|
263
|
+
|
|
264
|
+
Fix: on_connection_resumed schedules _cancel_pending_reconnect() via
|
|
265
|
+
_schedule_coroutine so the cancellation happens on the event loop.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
269
|
+
async def test_on_connection_resumed_schedules_cancel_not_direct_call(
|
|
270
|
+
self,
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
on_connection_resumed must schedule _cancel_pending_reconnect via
|
|
274
|
+
_schedule_coroutine rather than calling task.cancel() directly.
|
|
275
|
+
"""
|
|
276
|
+
handler, scheduled = _make_handler(connected=False)
|
|
277
|
+
|
|
278
|
+
# Let a reconnect task start (sleeping in backoff)
|
|
279
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
280
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
281
|
+
scheduled.clear()
|
|
282
|
+
|
|
283
|
+
assert handler._reconnect_task is not None
|
|
284
|
+
assert not handler._reconnect_task.done()
|
|
285
|
+
|
|
286
|
+
# Simulate connection resuming from a background thread
|
|
287
|
+
handler.on_connection_resumed(return_code=0, session_present=True)
|
|
288
|
+
|
|
289
|
+
# _cancel_pending_reconnect must have been *scheduled*, not run yet
|
|
290
|
+
assert len(scheduled) == 1
|
|
291
|
+
# No direct Task.cancel() was called from the background thread:
|
|
292
|
+
# task.cancelling() == 0 proves no cancellation request is pending
|
|
293
|
+
# before the event loop runs _cancel_pending_reconnect.
|
|
294
|
+
assert handler._reconnect_task is not None
|
|
295
|
+
assert handler._reconnect_task.cancelling() == 0
|
|
296
|
+
|
|
297
|
+
# Now let the event loop process the cancellation
|
|
298
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
299
|
+
|
|
300
|
+
# After the event-loop cancellation, the task must be cleared
|
|
301
|
+
assert handler._reconnect_task is None
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
304
|
+
async def test_cancel_pending_reconnect_is_idempotent_with_no_task(self):
|
|
305
|
+
"""_cancel_pending_reconnect is a no-op when _reconnect_task is None."""
|
|
306
|
+
handler, _ = _make_handler(connected=True)
|
|
307
|
+
assert handler._reconnect_task is None
|
|
308
|
+
|
|
309
|
+
# Should not raise
|
|
310
|
+
await handler._cancel_pending_reconnect()
|
|
311
|
+
|
|
312
|
+
assert handler._reconnect_task is None
|
|
313
|
+
|
|
314
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
315
|
+
async def test_cancel_pending_reconnect_clears_completed_task(self):
|
|
316
|
+
"""_cancel_pending_reconnect clears a stale reference to a done task."""
|
|
317
|
+
handler, scheduled = _make_handler(connected=False)
|
|
318
|
+
|
|
319
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
320
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
321
|
+
scheduled.clear()
|
|
322
|
+
|
|
323
|
+
task = handler._reconnect_task
|
|
324
|
+
assert task is not None
|
|
325
|
+
|
|
326
|
+
# Cancel and drain the task manually, leaving a stale reference
|
|
327
|
+
task.cancel()
|
|
328
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
329
|
+
assert task.done()
|
|
330
|
+
assert handler._reconnect_task is task # stale reference still held
|
|
331
|
+
|
|
332
|
+
# _cancel_pending_reconnect must clear the stale reference
|
|
333
|
+
await handler._cancel_pending_reconnect()
|
|
334
|
+
assert handler._reconnect_task is None
|
|
335
|
+
|
|
336
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
337
|
+
async def test_resumed_then_interrupted_creates_new_task(self):
|
|
338
|
+
"""
|
|
339
|
+
After resume cancels an existing task, a subsequent genuine drop must
|
|
340
|
+
still be able to start a fresh reconnect task.
|
|
341
|
+
"""
|
|
342
|
+
state = {"connected": False}
|
|
343
|
+
config = MqttConnectionConfig(auto_reconnect=True)
|
|
344
|
+
scheduled = []
|
|
345
|
+
|
|
346
|
+
handler = MqttReconnectionHandler(
|
|
347
|
+
config=config,
|
|
348
|
+
is_connected_func=lambda: state["connected"],
|
|
349
|
+
schedule_coroutine_func=lambda coro: scheduled.append(
|
|
350
|
+
asyncio.ensure_future(coro)
|
|
351
|
+
),
|
|
352
|
+
reconnect_func=AsyncMock(),
|
|
353
|
+
)
|
|
354
|
+
handler.enable()
|
|
355
|
+
|
|
356
|
+
# Connection drops → backoff task starts
|
|
357
|
+
handler.on_connection_interrupted(Exception("first drop"))
|
|
358
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
359
|
+
scheduled.clear()
|
|
360
|
+
assert handler._reconnect_task is not None
|
|
361
|
+
|
|
362
|
+
# Connection resumes → cancel scheduled on loop
|
|
363
|
+
state["connected"] = True
|
|
364
|
+
handler.on_connection_resumed(return_code=0, session_present=True)
|
|
365
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
366
|
+
scheduled.clear()
|
|
367
|
+
assert handler._reconnect_task is None
|
|
368
|
+
|
|
369
|
+
# Connection drops again (genuine)
|
|
370
|
+
state["connected"] = False
|
|
371
|
+
handler.on_connection_interrupted(Exception("second drop"))
|
|
372
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
373
|
+
scheduled.clear()
|
|
374
|
+
|
|
375
|
+
# A new reconnect task must have been created
|
|
376
|
+
assert handler._reconnect_task is not None
|
|
377
|
+
handler._reconnect_task.cancel()
|
|
378
|
+
await asyncio.gather(handler._reconnect_task, return_exceptions=True)
|
|
379
|
+
|
|
380
|
+
|
|
235
381
|
# ---------------------------------------------------------------------------
|
|
236
382
|
# Bug 2: _actively_reconnecting suppresses spurious interrupt callbacks
|
|
237
383
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|