-
Notifications
You must be signed in to change notification settings - Fork 344
Fix BLE close() deadlock from re-entrant disconnect callback #976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| """Meshtastic unit tests for ble_interface.py""" | ||
|
|
||
| import atexit | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
@@ -67,3 +68,31 @@ def test_ble_receive_wraps_unexpected_bleak_error_with_kind(): | |
| with pytest.raises(BLEInterface.BLEError) as excinfo: | ||
| iface._receiveFromRadioImpl() | ||
| assert excinfo.value.kind == BLEInterface.BLEError.READ_ERROR | ||
|
|
||
|
|
||
| @pytest.mark.unit | ||
| def test_ble_close_reentrant_does_not_deadlock() -> None: | ||
| """close() must not deadlock when disconnect callback re-enters close().""" | ||
| iface = object.__new__(BLEInterface) | ||
| iface._closing = False | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| iface._want_receive = False | ||
| iface._receiveThread = None | ||
|
|
||
| disconnect_count = 0 | ||
|
|
||
| def fake_disconnect(): | ||
| nonlocal disconnect_count | ||
| disconnect_count += 1 | ||
| iface.close() # re-entrant call — should return immediately | ||
|
Comment on lines
+83
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add the return annotation to Declare the nested callback as As per coding guidelines, Python files must use type hints for all new function parameters and return values. 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| mock_client = MagicMock() | ||
| mock_client.disconnect.side_effect = fake_disconnect | ||
| iface.client = mock_client | ||
| iface._exit_handler = lambda: None | ||
|
|
||
| with patch("meshtastic.mesh_interface.MeshInterface.close"), \ | ||
| patch.object(iface, "_disconnected"): | ||
| iface.close() | ||
|
|
||
| assert disconnect_count == 1, "disconnect() should be called exactly once" | ||
| assert iface.client is None, "client should be cleaned up" | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Allow failed shutdown to be retried.
At Line 256,
_closingbecomesTruebefore cleanup and remainsTrueif an uncaught cleanup call raises. For example,self.client.disconnect()can fail beforeself.client.close()andself._disconnected()run. A laterinterface.close()frommeshtastic/__main__.pythen returns at Line 255 and cannot finish cleanup. Keep the re-entrancy guard active during cleanup, but track successful completion separately or restore a retryable state when cleanup fails.🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The existing code already has no retry path for failed cleanup and this PR only addresses the deadlock. Resetting _closing on failure would reintroduce the re-entrant deadlock this PR fixes, since disconnect() is the call that triggers the callback. If cleanup-failure retry is desired, it would need a different approach (e.g. wrapping the disconnect in its own try/except) and could be addressed in a follow-up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ko7m, the guard must remain active whiledisconnect()runs. That prevents the callback deadlock.However, the previous implementation did allow a later
close()call to retry afterself.client.disconnect()raised.self.clientremained set because the method exited before assigningNone.This change removes that behavior because
_closingremainsTrue. A narrow failure path can preserve the guard duringdisconnect()and restore retryability only after cleanup fails. The follow-up approach you describe is valid, but the behavioral regression remains in this PR.Would you like to track the retry behavior in a follow-up issue instead?
You are interacting with an AI system.