Skip to content

Commit 35dbfe5

Browse files
pandafynemesifier
authored andcommitted
[fix] Fixed live updates for "Send commands"
Bug: When multiple connections are made to the websocket command endpoint of the same device (e.g. when multiple browser tabs are open for the same device), the UI does not receive updates from the websocket and keeps showing a loader in the command output field, even when the command has completed execution. Fix: The issue was caused by mutating the shared `event` dictionary in the `send_update` method of `CommandConsumer`. Specifically, calling `event.pop('type')` removed the `'type'` key from the event. Since the same event object is dispatched to all consumer instances (one for each websocket connection), removing `'type'` in one instance caused the others to raise: ValueError: Incoming message has no 'type' attribute This broke message dispatch for the remaining connections. The fix is to avoid modifying the original `event` dictionary. This preserves the `'type'` key, ensuring all consumer instances continue to receive well-formed events and function correctly.
1 parent 498728f commit 35dbfe5

File tree

2 files changed

+46
-11
lines changed

2 files changed

+46
-11
lines changed

openwisp_controller/connection/channels/consumers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from copy import deepcopy
23

34
from swapper import load_model
45

@@ -9,5 +10,6 @@
910

1011
class CommandConsumer(BaseDeviceConsumer):
1112
def send_update(self, event):
12-
event.pop('type')
13-
self.send(json.dumps(event))
13+
data = deepcopy(event)
14+
data.pop('type')
15+
self.send(json.dumps(data))

openwisp_controller/connection/tests/pytest.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,7 @@ async def _get_communicator(self, admin_client, device_id):
3636
assert connected is True
3737
return communicator
3838

39-
@mock.patch('paramiko.SSHClient.connect')
40-
async def test_new_command_created(self, admin_user, admin_client):
41-
device_conn = await database_sync_to_async(self._create_device_connection)()
42-
communicator = await self._get_communicator(admin_client, device_conn.device_id)
43-
39+
async def _create_command(self, device_conn):
4440
command = Command(
4541
device_id=device_conn.device_id,
4642
connection=device_conn,
@@ -52,11 +48,12 @@ async def test_new_command_created(self, admin_user, admin_client):
5248
mocked_exec_command.return_value = self._exec_command_return_value(
5349
stdout='test'
5450
)
55-
await database_sync_to_async(command.save)()
56-
await database_sync_to_async(command.refresh_from_db)()
51+
await database_sync_to_async(command.save)()
52+
await database_sync_to_async(command.refresh_from_db)()
53+
return command
5754

58-
response = await communicator.receive_json_from()
59-
expected_response = {
55+
def _get_expected_response(self, command):
56+
return {
6057
'model': 'Command',
6158
'data': {
6259
'id': str(command.id),
@@ -70,5 +67,41 @@ async def test_new_command_created(self, admin_user, admin_client):
7067
'connection': str(command.connection_id),
7168
},
7269
}
70+
71+
@mock.patch('paramiko.SSHClient.connect')
72+
async def test_new_command_created(self, admin_user, admin_client):
73+
device_conn = await database_sync_to_async(self._create_device_connection)()
74+
communicator = await self._get_communicator(admin_client, device_conn.device_id)
75+
command = await self._create_command(device_conn)
76+
response = await communicator.receive_json_from()
77+
expected_response = self._get_expected_response(command)
7378
assert response == expected_response
7479
await communicator.disconnect()
80+
81+
async def test_multiple_connections_receive_updates_with_redis(
82+
self, admin_user, admin_client, settings
83+
):
84+
settings.CHANNEL_LAYERS = {
85+
'default': {
86+
'BACKEND': 'channels_redis.core.RedisChannelLayer',
87+
'CONFIG': {
88+
'hosts': [('localhost', 6379)],
89+
},
90+
},
91+
}
92+
93+
device_conn = await database_sync_to_async(self._create_device_connection)()
94+
communicator1 = await self._get_communicator(
95+
admin_client, device_conn.device_id
96+
)
97+
communicator2 = await self._get_communicator(
98+
admin_client, device_conn.device_id
99+
)
100+
command = await self._create_command(device_conn)
101+
response1 = await communicator1.receive_json_from()
102+
response2 = await communicator2.receive_json_from()
103+
expected_response = self._get_expected_response(command)
104+
assert response1 == expected_response
105+
assert response2 == expected_response
106+
await communicator1.disconnect()
107+
await communicator2.disconnect()

0 commit comments

Comments
 (0)