Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 69 additions & 30 deletions oocana/oocana/mainframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,47 +128,86 @@ def on_message_once(_client, _userdata, message):
self._logger.info("notify ready success in {} {}".format(session_id, job_id))
return replay

def add_request_response_callback(self, session_id: str, request_id: str, callback: Callable[[Any], Any]):
"""Add a callback to be called when an error occurs while running a block."""
def _add_callback(
self,
callbacks_dict: dict[str, list[Callable]],
key: str,
topic: str,
callback: Callable[[Any], Any]
) -> None:
"""Generic method to add a callback with subscription management.

Comment on lines +131 to +139
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

PR description references _add_callback_with_subscription() / _remove_callback() helper names, but the implementation introduces _add_callback() / _remove_callback(). Please align the PR description (or rename the helper) so reviewers/users aren’t looking for the wrong method names.

Copilot uses AI. Check for mistakes.
Args:
callbacks_dict: The dictionary storing callbacks (keyed by identifier)
key: The key to use in the callbacks dict
topic: The MQTT topic to subscribe to
callback: The callback function to add
"""
if not callable(callback):
raise ValueError("Callback must be callable")

if request_id not in self.__request_response_callbacks:
self.__request_response_callbacks[request_id] = []
self.subscribe(f"session/{session_id}/request/{request_id}/response", lambda payload: [cb(payload) for cb in self.__request_response_callbacks[request_id].copy()])

self.__request_response_callbacks[request_id].append(callback)
if key not in callbacks_dict:
callbacks_dict[key] = []
self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])

Comment on lines +151 to +152
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The subscribed lambda reads callbacks_dict[key] directly; if the last callback is removed (key deleted) while the MQTT network thread is dispatching a message for this topic, this can raise a KeyError and break message handling. Consider making the handler resilient (e.g., read callbacks_dict.get(key, []) and early-return when missing) and/or ensure cleanup order avoids deleting the key before the message callback is removed.

Suggested change
self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])
def _dispatch_payload(payload, _callbacks_dict=callbacks_dict, _key=key):
callbacks = _callbacks_dict.get(_key)
if not callbacks:
return
for cb in callbacks.copy():
cb(payload)
self.subscribe(topic, _dispatch_payload)

Copilot uses AI. Check for mistakes.
callbacks_dict[key].append(callback)

def _remove_callback(
self,
callbacks_dict: dict[str, list[Callable]],
key: str,
topic: str,
callback: Callable[[Any], Any],
error_context: str
) -> None:
"""Generic method to remove a callback with subscription cleanup.

Args:
callbacks_dict: The dictionary storing callbacks
key: The key in the callbacks dict
topic: The MQTT topic to unsubscribe from
callback: The callback function to remove
error_context: Context string for warning message
"""
if key in callbacks_dict and callback in callbacks_dict[key]:
callbacks_dict[key].remove(callback)
if len(callbacks_dict[key]) == 0:
del callbacks_dict[key]
self.unsubscribe(topic)
Comment on lines +149 to +176
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

避免回调移除后触发 KeyError

当最后一个回调被移除后会 del callbacks_dict[key],若此时仍有滞后消息进入订阅回调,callbacks_dict[key] 会抛出 KeyError,导致 MQTT 回调异常。建议用 get 安全读取并用显式循环执行回调。

🛠️ 建议修复
-            self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])
+            def _dispatch(payload):
+                for cb in list(callbacks_dict.get(key, [])):
+                    cb(payload)
+            self.subscribe(topic, _dispatch)
🤖 Prompt for AI Agents
In `@oocana/oocana/mainframe.py` around lines 149 - 176, The current subscription
handler closes over callbacks_dict[key] and can raise KeyError if the last
callback is removed concurrently; update the subscribe call (the lambda created
where callbacks_dict[key] is set) to safely capture the callbacks list via
callbacks_dict.get(key, []) and copy it into a local variable, then iterate with
an explicit for-loop calling each cb(payload). Also ensure the removal logic in
_remove_callback remains unchanged except to rely on this safe access pattern so
late-arriving messages won't access callbacks_dict[key] after del; reference the
subscribe call, the lambda using callbacks_dict[key].copy(), and the
_remove_callback method when making this change.

else:
self._logger.warning(f"Callback not found in {error_context}")

def add_request_response_callback(self, session_id: str, request_id: str, callback: Callable[[Any], Any]):
"""Add a callback to be called when a request response is received."""
topic = f"session/{session_id}/request/{request_id}/response"
self._add_callback(self.__request_response_callbacks, request_id, topic, callback)

def remove_request_response_callback(self, session_id: str, request_id: str, callback: Callable[[Any], Any]):
"""Remove a previously added run block error callback."""
if request_id in self.__request_response_callbacks and callback in self.__request_response_callbacks[request_id]:
self.__request_response_callbacks[request_id].remove(callback)
if len(self.__request_response_callbacks[request_id]) == 0:
del self.__request_response_callbacks[request_id]
self.unsubscribe(f"session/{session_id}/request/{request_id}/response")
else:
self._logger.warning("Callback not found in request/response callbacks for session {} and request {}.".format(session_id, request_id))
"""Remove a previously added request response callback."""
topic = f"session/{session_id}/request/{request_id}/response"
self._remove_callback(
self.__request_response_callbacks,
request_id,
topic,
callback,
f"request/response callbacks for session {session_id} and request {request_id}"
)

def add_session_callback(self, session_id: str, callback: Callable[[dict], Any]):
"""Add a callback to be called when a session message is received."""
if not callable(callback):
raise ValueError("Callback must be callable")

if session_id not in self.__session_callbacks:
self.__session_callbacks[session_id] = []
self.subscribe(f"session/{session_id}", lambda payload: [cb(payload) for cb in self.__session_callbacks[session_id].copy()])

self.__session_callbacks[session_id].append(callback)
topic = f"session/{session_id}"
self._add_callback(self.__session_callbacks, session_id, topic, callback)

def remove_session_callback(self, session_id: str, callback: Callable[[dict], Any]):
"""Remove a previously added session callback."""
if session_id in self.__session_callbacks and callback in self.__session_callbacks[session_id]:
self.__session_callbacks[session_id].remove(callback)
if len(self.__session_callbacks[session_id]) == 0:
del self.__session_callbacks[session_id]
self.unsubscribe(f"session/{session_id}")
else:
self._logger.warning("Callback not found in session callbacks for session: {}".format(session_id))
topic = f"session/{session_id}"
self._remove_callback(
self.__session_callbacks,
session_id,
topic,
callback,
f"session callbacks for session: {session_id}"
)


def add_report_callback(self, fn):
Expand Down
157 changes: 157 additions & 0 deletions oocana/tests/test_mainframe_callbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import unittest
from unittest.mock import MagicMock, patch


class TestCallbackManagement(unittest.TestCase):
"""Test cases for callback management methods in Mainframe."""

def setUp(self):
# Patch the mqtt client to avoid real network connections
self.mock_client_patcher = patch('paho.mqtt.client.Client')
self.mock_client_class = self.mock_client_patcher.start()
self.mock_client = MagicMock()
self.mock_client_class.return_value = self.mock_client
self.mock_client.is_connected.return_value = True

from oocana import Mainframe
self.mainframe = Mainframe('mqtt://localhost:1883')
self.mainframe.client = self.mock_client

def tearDown(self):
self.mock_client_patcher.stop()

def test_add_request_response_callback(self):
"""Test adding a request response callback."""
session_id = 'test-session'
request_id = 'test-request'
callback = MagicMock()

self.mainframe.add_request_response_callback(session_id, request_id, callback)

# Verify subscribe was called with correct topic
expected_topic = f"session/{session_id}/request/{request_id}/response"
self.mock_client.message_callback_add.assert_called()

Comment on lines +31 to +34
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

expected_topic is computed but the test only asserts message_callback_add.assert_called() without checking the topic argument, so this test would still pass even if the code subscribed to the wrong topic. Assert the call args (e.g., that message_callback_add was called with expected_topic as the first parameter).

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +34
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

用 expected_topic 做断言,避免无用变量和弱断言

当前 expected_topic 未被使用,且只断言 “被调用” 无法确保 topic 正确。建议直接断言 topic。

🧾 建议修复
-        self.mock_client.message_callback_add.assert_called()
+        self.mock_client.message_callback_add.assert_called()
+        self.assertEqual(self.mock_client.message_callback_add.call_args[0][0], expected_topic)
🧰 Tools
🪛 Ruff (0.14.14)

[error] 32-32: Local variable expected_topic is assigned to but never used

Remove assignment to unused variable expected_topic

(F841)

🤖 Prompt for AI Agents
In `@oocana/tests/test_mainframe_callbacks.py` around lines 23 - 34, The test
creates expected_topic but never uses it and only asserts message_callback_add
was called; update test_add_request_response_callback to assert the exact topic
and callback were passed by calling
self.mock_client.message_callback_add.assert_called_with(expected_topic,
callback) (or include any additional args/kwargs if the real call includes them)
so the assertion verifies the correct topic string produced by
add_request_response_callback and the callback reference.

def test_add_session_callback(self):
"""Test adding a session callback."""
session_id = 'test-session'
callback = MagicMock()

self.mainframe.add_session_callback(session_id, callback)

# Verify subscribe was called
self.mock_client.message_callback_add.assert_called()

def test_add_callback_requires_callable(self):
"""Test that non-callable raises ValueError."""
session_id = 'test-session'

with self.assertRaises(ValueError) as context:
self.mainframe.add_session_callback(session_id, "not a callable")

self.assertIn("callable", str(context.exception))

def test_add_request_response_callback_requires_callable(self):
"""Test that non-callable raises ValueError for request response callback."""
with self.assertRaises(ValueError) as context:
self.mainframe.add_request_response_callback("session", "request", "not a callable")

self.assertIn("callable", str(context.exception))
Comment on lines +45 to +59
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

类型检查失败:传入非可调用值需显式忽略

CI 已报 reportArgumentType,这里是为了验证运行时校验,建议在测试里显式忽略类型检查。

🧾 建议修复
-            self.mainframe.add_session_callback(session_id, "not a callable")
+            self.mainframe.add_session_callback(session_id, "not a callable")  # type: ignore[arg-type]
@@
-            self.mainframe.add_request_response_callback("session", "request", "not a callable")
+            self.mainframe.add_request_response_callback("session", "request", "not a callable")  # type: ignore[arg-type]
🧰 Tools
🪛 GitHub Actions: layer

[error] 50-50: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(dict[Unknown, Unknown]) -> Any" in function "add_session_callback". Type "Literal['not a callable']" is not assignable to type "(dict[Unknown, Unknown]) -> Any" (reportArgumentType)


[error] 57-57: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(Any) -> Any" in function "add_request_response_callback". Type "Literal['not a callable']" is not assignable to type "(Any) -> Any" (reportArgumentType)

🪛 GitHub Actions: pr

[error] 50-50: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(dict[Unknown, Unknown]) -> Any" in function "add_session_callback" (reportArgumentType)


[error] 57-57: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(Any) -> Any" in function "add_request_response_callback" (reportArgumentType)

🤖 Prompt for AI Agents
In `@oocana/tests/test_mainframe_callbacks.py` around lines 45 - 59,
在这两个测试中传入的字符串目的是触发运行时 ValueError,但静态检查器在 CI 报告了 reportArgumentType;在调用
add_session_callback 和 add_request_response_callback
的那两行将非可调用字面量保留并在参数上添加显式类型忽略注释以屏蔽静态检查器(例如在
mainframe.add_session_callback(session_id, "not a callable") 和
mainframe.add_request_response_callback("session", "request", "not a callable")
所在行分别添加 "# type: ignore[reportArgumentType]" 或通用 "# type:
ignore"),以便仅测试运行时行为而不触发类型检查错误。


def test_remove_session_callback(self):
"""Test removing a session callback."""
session_id = 'test-session'
callback = MagicMock()

# Add then remove
self.mainframe.add_session_callback(session_id, callback)
self.mainframe.remove_session_callback(session_id, callback)

# Verify unsubscribe was called
self.mock_client.unsubscribe.assert_called()

def test_remove_request_response_callback(self):
"""Test removing a request response callback."""
session_id = 'test-session'
request_id = 'test-request'
callback = MagicMock()

# Add then remove
self.mainframe.add_request_response_callback(session_id, request_id, callback)
self.mainframe.remove_request_response_callback(session_id, request_id, callback)

# Verify unsubscribe was called
self.mock_client.unsubscribe.assert_called()

def test_remove_nonexistent_callback_logs_warning(self):
"""Test that removing a nonexistent callback logs a warning."""
session_id = 'test-session'
callback = MagicMock()

# Create a mock logger
mock_logger = MagicMock()
self.mainframe._logger = mock_logger

# Try to remove callback that was never added
self.mainframe.remove_session_callback(session_id, callback)

# Verify warning was logged
mock_logger.warning.assert_called_once()

def test_multiple_callbacks_for_same_session(self):
"""Test that multiple callbacks can be added for the same session."""
session_id = 'test-session'
callback1 = MagicMock()
callback2 = MagicMock()

self.mainframe.add_session_callback(session_id, callback1)
self.mainframe.add_session_callback(session_id, callback2)

# Remove first callback, should not unsubscribe yet
self.mainframe.remove_session_callback(session_id, callback1)

# Subscribe should have been called only once (for first add)
call_count_before = self.mock_client.message_callback_add.call_count

# Remove second callback, should unsubscribe now
self.mainframe.remove_session_callback(session_id, callback2)

self.mock_client.unsubscribe.assert_called()
Comment on lines +113 to +119
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

call_count_before is assigned but never asserted/used, and the test doesn’t verify the intended behavior (“subscribe only once” / “don’t unsubscribe after removing the first callback”). Add assertions on message_callback_add.call_count and unsubscribe (e.g., unsubscribe.assert_not_called() after the first removal, then called after the second).

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +119
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

补上订阅次数断言,消除未使用变量

call_count_before 未使用,且当前未真正断言“只订阅一次”。建议直接断言调用次数。

🧾 建议修复
-        call_count_before = self.mock_client.message_callback_add.call_count
+        self.assertEqual(self.mock_client.message_callback_add.call_count, 1)
🧰 Tools
🪛 Ruff (0.14.14)

[error] 114-114: Local variable call_count_before is assigned to but never used

Remove assignment to unused variable call_count_before

(F841)

🤖 Prompt for AI Agents
In `@oocana/tests/test_mainframe_callbacks.py` around lines 101 - 119, In
test_multiple_callbacks_for_same_session, remove the unused local variable
call_count_before and add an explicit assertion that message_callback_add was
called exactly once after adding two callbacks (use
self.mock_client.message_callback_add.assert_called_once() or assertEqual on
call_count) before removing the second callback, then assert unsubscribe was
called after the second removal; reference the test function name and the mocked
methods self.mock_client.message_callback_add and self.mock_client.unsubscribe
and the class methods add_session_callback/remove_session_callback to locate
where to update the test.


def test_add_report_callback(self):
"""Test adding a report callback."""
callback = MagicMock()

self.mainframe.add_report_callback(callback)

# No error should occur

def test_add_report_callback_requires_callable(self):
"""Test that non-callable raises ValueError for report callback."""
with self.assertRaises(ValueError) as context:
self.mainframe.add_report_callback("not a callable")

self.assertIn("callable", str(context.exception))

def test_remove_report_callback(self):
"""Test removing a report callback."""
callback = MagicMock()

self.mainframe.add_report_callback(callback)
self.mainframe.remove_report_callback(callback)

# No error should occur

def test_remove_nonexistent_report_callback_logs_warning(self):
"""Test that removing a nonexistent report callback logs a warning."""
callback = MagicMock()
mock_logger = MagicMock()
self.mainframe._logger = mock_logger

self.mainframe.remove_report_callback(callback)

mock_logger.warning.assert_called_once()


if __name__ == '__main__':
unittest.main()
Loading