From b46aa64fee7aeb7ae86563f2c541e819a82d3f74 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Tue, 28 Jul 2026 17:41:36 +0530 Subject: [PATCH 1/4] Propagate known request identity on ModbusIOException After #2992, attach function_code, transaction_id, and dev_id wherever the outstanding request (or PDU) already knows them: client execute timeouts/mismatches, register decode failures, and invalid device id. Suggested in review of #2992. Signed-off-by: Sankalp Thakur --- pymodbus/pdu/pdu.py | 6 ++- pymodbus/pdu/register_message.py | 5 ++- pymodbus/transaction/transaction.py | 57 ++++++++++++++++++------- test/pdu/test_pdu.py | 6 ++- test/pdu/test_register_read_messages.py | 7 ++- test/transaction/test_transaction.py | 34 ++++++++++++--- 6 files changed, 88 insertions(+), 27 deletions(-) diff --git a/pymodbus/pdu/pdu.py b/pymodbus/pdu/pdu.py index b53ef922e..dde76e817 100644 --- a/pymodbus/pdu/pdu.py +++ b/pymodbus/pdu/pdu.py @@ -29,7 +29,11 @@ def __init__( """Initialize the base data for a modbus request.""" self.dev_id: int = dev_id if dev_id > 255: - raise ModbusIOException(f"Invalid ID {dev_id}") + raise ModbusIOException( + f"Invalid ID {dev_id}", + transaction_id=transaction_id, + dev_id=dev_id, + ) self.transaction_id: int = transaction_id self.address: int = address self.bits: list[bool] = bits or [] diff --git a/pymodbus/pdu/register_message.py b/pymodbus/pdu/register_message.py index de18f05ac..b14f166fd 100644 --- a/pymodbus/pdu/register_message.py +++ b/pymodbus/pdu/register_message.py @@ -76,7 +76,10 @@ def decode(self, data: bytes) -> None: self.registers = [] if (data_len := int(data[0])) >= len(data): raise ModbusIOException( - f"byte_count {data_len} > length of packet {len(data)}" + f"byte_count {data_len} > length of packet {len(data)}", + function_code=self.function_code, + transaction_id=self.transaction_id, + dev_id=self.dev_id, ) for i in range(1, data_len, 2): self.registers.append(struct.unpack(">H", data[i : i + 2])[0]) diff --git a/pymodbus/transaction/transaction.py b/pymodbus/transaction/transaction.py index 2863065fc..619773ce7 100644 --- a/pymodbus/transaction/transaction.py +++ b/pymodbus/transaction/transaction.py @@ -120,6 +120,24 @@ def sync_get_response(self, dev_id, tid) -> ModbusPDU: if monotonic() >= deadline: raise asyncio.exceptions.TimeoutError() + @staticmethod + def _io_exception_from_request( + message: str, request: ModbusPDU + ) -> ModbusIOException: + """Build ModbusIOException carrying known request identity. + + Follow-up to #2992: callers that already know the outstanding request + should surface its function code, transaction id, and device id so + diagnostics can correlate timeouts and mismatches without re-parsing + the wire stream. + """ + return ModbusIOException( + message, + function_code=request.function_code, + transaction_id=request.transaction_id, + dev_id=request.dev_id, + ) + def sync_execute(self, no_response_expected: bool, request: ModbusPDU) -> ModbusPDU: """Execute requests asynchronously. @@ -143,12 +161,14 @@ def sync_execute(self, no_response_expected: bool, request: ModbusPDU) -> Modbus ) self.count_until_disconnect = self.max_until_disconnect if response.dev_id != request.dev_id: - raise ModbusIOException( - f"ERROR: request uses device id={request.dev_id} but received {response.dev_id}." + raise self._io_exception_from_request( + f"ERROR: request uses device id={request.dev_id} but received {response.dev_id}.", + request, ) if response.transaction_id != request.transaction_id: - raise ModbusIOException( - f"ERROR: request uses transaction id={request.transaction_id} but received {response.transaction_id}." + raise self._io_exception_from_request( + f"ERROR: request uses transaction id={request.transaction_id} but received {response.transaction_id}.", + request, ) response.retries = count_retries return response @@ -156,13 +176,14 @@ def sync_execute(self, no_response_expected: bool, request: ModbusPDU) -> Modbus count_retries += 1 if self.count_until_disconnect < 0: self.connection_lost(asyncio.TimeoutError("Server not responding")) - raise ModbusIOException( - "ERROR: No response received of the last requests (default: retries+3), CLOSING CONNECTION." + raise self._io_exception_from_request( + "ERROR: No response received of the last requests (default: retries+3), CLOSING CONNECTION.", + request, ) self.count_until_disconnect -= 1 txt = f"No response received after {self.retries} retries, continue with next request" Log.error(txt) - raise ModbusIOException(txt) + raise self._io_exception_from_request(txt, request) async def execute( self, no_response_expected: bool, request: ModbusPDU @@ -193,33 +214,37 @@ async def execute( ) self.count_until_disconnect = self.max_until_disconnect if request.dev_id and response.dev_id != request.dev_id: - raise ModbusIOException( - f"ERROR: request uses device id={request.dev_id} but received {response.dev_id}." + raise self._io_exception_from_request( + f"ERROR: request uses device id={request.dev_id} but received {response.dev_id}.", + request, ) if ( response.transaction_id and response.transaction_id != request.transaction_id ): - raise ModbusIOException( - f"ERROR: request uses transaction id={request.transaction_id} but received {response.transaction_id}." + raise self._io_exception_from_request( + f"ERROR: request uses transaction id={request.transaction_id} but received {response.transaction_id}.", + request, ) response.retries = count_retries return response except asyncio.exceptions.TimeoutError: count_retries += 1 except asyncio.exceptions.CancelledError as exc: - raise ModbusIOException( - "Request cancelled outside library." + raise self._io_exception_from_request( + "Request cancelled outside library.", + request, ) from exc if self.count_until_disconnect < 0: self.connection_lost(asyncio.TimeoutError("Server not responding")) - raise ModbusIOException( - "ERROR: No response received of the last requests (default: retries+3), CLOSING CONNECTION." + raise self._io_exception_from_request( + "ERROR: No response received of the last requests (default: retries+3), CLOSING CONNECTION.", + request, ) self.count_until_disconnect -= 1 txt = f"No response received after {self.retries} retries, continue with next request" Log.error(txt) - raise ModbusIOException(txt) + raise self._io_exception_from_request(txt, request) def pdu_send(self, pdu: ModbusPDU, addr: tuple | None = None) -> None: """Build byte stream and send.""" diff --git a/test/pdu/test_pdu.py b/test/pdu/test_pdu.py index 7aeb7d61f..d0f796d13 100644 --- a/test/pdu/test_pdu.py +++ b/test/pdu/test_pdu.py @@ -37,8 +37,10 @@ async def test_get_pdu_size(self): async def test_pdu_id(self): """Test set illegal pdu id.""" - with pytest.raises(ModbusIOException): - ModbusPDU(256) + with pytest.raises(ModbusIOException) as exc_info: + ModbusPDU(256, transaction_id=0x42) + assert exc_info.value.dev_id == 256 + assert exc_info.value.transaction_id == 0x42 async def test_is_error(self): """Test is_error.""" diff --git a/test/pdu/test_register_read_messages.py b/test/pdu/test_register_read_messages.py index fe4ffb154..b6b01e31c 100644 --- a/test/pdu/test_register_read_messages.py +++ b/test/pdu/test_register_read_messages.py @@ -79,9 +79,12 @@ def test_register_read_response_decode(self): def test_register_read_response_decode_error(self): """Test register read response.""" - reg = ReadHoldingRegistersResponse(count=5) - with pytest.raises(ModbusIOException): + reg = ReadHoldingRegistersResponse(count=5, dev_id=9, transaction_id=0x55) + with pytest.raises(ModbusIOException) as exc_info: reg.decode(b"\x14\x00\x03\x00\x11") + assert exc_info.value.fcode == reg.function_code + assert exc_info.value.dev_id == 9 + assert exc_info.value.transaction_id == 0x55 async def test_register_read_requests_count_errors(self, mock_server_context): """This tests that the register request messages. diff --git a/test/transaction/test_transaction.py b/test/transaction/test_transaction.py index 488376414..55c05c56a 100755 --- a/test/transaction/test_transaction.py +++ b/test/transaction/test_transaction.py @@ -186,6 +186,17 @@ async def test_transaction_data_2(self, use_clc, test): transact.response_future.set_result((1, pdu)) transact.callback_data(packet) + def test_io_exception_from_request_carries_identity(self, use_port): # noqa: ARG002 + """Known request identity is attached to client-side IO exceptions.""" + request = ReadCoilsRequest( + address=117, count=5, dev_id=7, transaction_id=0x1234 + ) + exc = TransactionManager._io_exception_from_request("timeout", request) + assert isinstance(exc, ModbusIOException) + assert exc.fcode == request.function_code + assert exc.dev_id == 7 + assert exc.transaction_id == 0x1234 + @pytest.mark.parametrize("scenario", range(10)) async def test_transaction_execute(self, use_clc, scenario): """Test tracers in disconnect.""" @@ -223,14 +234,19 @@ async def test_transaction_execute(self, use_clc, scenario): elif scenario == 3: # wait receive,timeout, no_responses transact.comm_params.timeout_connect = 0.1 transact.connection_lost = mock.Mock() # type: ignore[method-assign] - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: await transact.execute(False, request) + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id + assert exc_info.value.transaction_id == request.transaction_id elif scenario == 4: # wait receive,timeout, disconnect transact.comm_params.timeout_connect = 0.1 transact.count_until_disconnect = -1 transact.connection_lost = mock.Mock() # type: ignore[method-assign] - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: await transact.execute(False, request) + assert exc_info.value.fcode == request.function_code + assert exc_info.value.transaction_id == request.transaction_id elif scenario == 5: # wait receive,timeout, no_responses pass transact.comm_params.timeout_connect = 0.1 transact.connection_lost = mock.Mock() # type: ignore[method-assign] @@ -242,8 +258,10 @@ async def test_transaction_execute(self, use_clc, scenario): await asyncio.sleep(0.1) resp.cancel() await asyncio.sleep(0.1) - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: await resp + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id elif scenario == 7: # response transact.comm_params.timeout_connect = 0.2 resp = asyncio.create_task(transact.execute(False, request)) @@ -259,8 +277,11 @@ async def test_transaction_execute(self, use_clc, scenario): new_resp.dev_id = 17 transact.response_future.set_result(new_resp) await asyncio.sleep(0.1) - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: resp.result() + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id + assert exc_info.value.transaction_id == request.transaction_id else: # if scenario == 9: # response wrong tid transact.comm_params.timeout_connect = 0.2 resp = asyncio.create_task(transact.execute(False, request)) @@ -269,8 +290,11 @@ async def test_transaction_execute(self, use_clc, scenario): new_resp.transaction_id = 17 transact.response_future.set_result(new_resp) await asyncio.sleep(0.1) - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: resp.result() + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id + assert exc_info.value.transaction_id == request.transaction_id async def test_transaction_receiver(self, use_clc): """Test tracers in disconnect.""" From a5775294daacfeff60154e1625f07a8f58681917 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Tue, 28 Jul 2026 17:45:29 +0530 Subject: [PATCH 2/4] fix: satisfy ruff for unused use_port in identity test Signed-off-by: Sankalp Thakur --- test/transaction/test_transaction.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/transaction/test_transaction.py b/test/transaction/test_transaction.py index 55c05c56a..16e1d8606 100755 --- a/test/transaction/test_transaction.py +++ b/test/transaction/test_transaction.py @@ -186,8 +186,9 @@ async def test_transaction_data_2(self, use_clc, test): transact.response_future.set_result((1, pdu)) transact.callback_data(packet) - def test_io_exception_from_request_carries_identity(self, use_port): # noqa: ARG002 + def test_io_exception_from_request_carries_identity(self, use_port): """Known request identity is attached to client-side IO exceptions.""" + _ = use_port request = ReadCoilsRequest( address=117, count=5, dev_id=7, transaction_id=0x1234 ) From 9c434d965b845be3f2f4b0e65304c77a3c03fa26 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Tue, 28 Jul 2026 17:57:00 +0530 Subject: [PATCH 3/4] fix: make IO exception identity real on wire path Critical review of #2992/#2993 found register_message identity was dead because DecodePDU swallowed ModbusIOException. Re-raise payload decode errors, enrich framing tid/dev_id in the framer, use known fcode on the server exception response when present, and forward the UDP peer addr. Also lean the helper docstring, drop a private-method unit test, and assert identity through handleFrame + sync execute paths. Signed-off-by: Sankalp Thakur --- pymodbus/framer/base.py | 14 ++++++---- pymodbus/pdu/decoders.py | 6 ++++- pymodbus/pdu/register_message.py | 4 +-- pymodbus/server/requesthandler.py | 14 +++++----- pymodbus/transaction/transaction.py | 8 +----- test/client/test_client_faulty_response.py | 6 ++++- test/framer/test_extras.py | 6 ++++- test/pdu/test_register_read_messages.py | 4 +-- test/server/test_requesthandler.py | 5 +++- test/transaction/test_transaction.py | 31 +++++++++++----------- 10 files changed, 55 insertions(+), 43 deletions(-) diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index df908d90a..b1733dd48 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -87,11 +87,15 @@ def handleFrame( f"ERROR: request ask for transaction_id={exp_tid} but got id={tid}, Skipping." ) continue - if (pdu := self.decoder.decode(frame_data)) is None: - # Preserve framing identity so the server can echo transaction/dev - # ids on the undecodable-function exception path (see #2990). - # Do not recover a function code from garbage payloads — noise on - # serial lines is the common cause of this path. + try: + pdu = self.decoder.decode(frame_data) + except ModbusIOException as exc: + # Framing ids are authoritative; payload decode never has them yet. + exc.transaction_id = tid + exc.dev_id = dev_id + raise + if pdu is None: + # Unknown/garbage FC: do not invent function_code from noise (#2990). raise ModbusIOException( "Unable to decode request", transaction_id=tid, diff --git a/pymodbus/pdu/decoders.py b/pymodbus/pdu/decoders.py index 5a686ef28..15b940d9f 100644 --- a/pymodbus/pdu/decoders.py +++ b/pymodbus/pdu/decoders.py @@ -5,7 +5,7 @@ import copy import struct -from ..exceptions import MessageRegisterException, ModbusException +from ..exceptions import MessageRegisterException, ModbusException, ModbusIOException from ..logging import Log from .exceptionresponse import ExceptionResponse from .pdu import ModbusPDU @@ -90,6 +90,10 @@ def decode(self, frame: bytes) -> ModbusPDU | None: str(pdu), ) return pdu + except ModbusIOException: + # Payload decode already attached known identity (e.g. function_code). + # Framer enriches transaction_id / dev_id from the ADU. + raise except (ModbusException, ValueError, IndexError, struct.error) as exc: Log.warning("Unable to decode frame {}", exc) return None diff --git a/pymodbus/pdu/register_message.py b/pymodbus/pdu/register_message.py index b14f166fd..f00c16d54 100644 --- a/pymodbus/pdu/register_message.py +++ b/pymodbus/pdu/register_message.py @@ -75,11 +75,11 @@ def decode(self, data: bytes) -> None: """Decode a register response packet.""" self.registers = [] if (data_len := int(data[0])) >= len(data): + # function_code is the class/request FC; framing tid/dev_id are filled + # by FramerBase after DecodePDU re-raises this exception. raise ModbusIOException( f"byte_count {data_len} > length of packet {len(data)}", function_code=self.function_code, - transaction_id=self.transaction_id, - dev_id=self.dev_id, ) for i in range(1, data_len, 2): self.registers.append(struct.unpack(">H", data[i : i + 2])[0]) diff --git a/pymodbus/server/requesthandler.py b/pymodbus/server/requesthandler.py index 170391a1b..efa2cb2c1 100644 --- a/pymodbus/server/requesthandler.py +++ b/pymodbus/server/requesthandler.py @@ -63,17 +63,19 @@ def callback_data(self, data: bytes, addr: tuple | None = None) -> int: try: used_len = super().callback_data(data, addr) except ModbusIOException as exc: - # Undecodable function codes (and frame garbage) land here. last_pdu - # is cleared before framing runs, so identity comes from the framer - # exception attributes when available. Use function code 0x00 rather - # than a hardcoded unrelated value (was 40 / 0x28) — see #2990. + # Undecodable / corrupt PDUs land here. last_pdu is cleared before + # framing runs, so identity comes from the framer exception attrs. + # Prefer a known function_code from payload decode; otherwise 0x00 + # rather than inventing a code from garbage (#2990). + # Prefer the callback addr (UDP peer) — last_addr is still cleared. + function_code = 0x00 if exc.fcode is None else exc.fcode response = ExceptionResponse( - 0x00, + function_code, exception_code=ExcCodes.ILLEGAL_FUNCTION, device_id=exc.dev_id, transaction=exc.transaction_id, ) - self.server_send(response, 0) + self.server_send(response, addr) return len(data) if self.last_pdu: self.loop.call_soon(self.handle_later) diff --git a/pymodbus/transaction/transaction.py b/pymodbus/transaction/transaction.py index 619773ce7..7a268c029 100644 --- a/pymodbus/transaction/transaction.py +++ b/pymodbus/transaction/transaction.py @@ -124,13 +124,7 @@ def sync_get_response(self, dev_id, tid) -> ModbusPDU: def _io_exception_from_request( message: str, request: ModbusPDU ) -> ModbusIOException: - """Build ModbusIOException carrying known request identity. - - Follow-up to #2992: callers that already know the outstanding request - should surface its function code, transaction id, and device id so - diagnostics can correlate timeouts and mismatches without re-parsing - the wire stream. - """ + """Build ModbusIOException from an outstanding request.""" return ModbusIOException( message, function_code=request.function_code, diff --git a/test/client/test_client_faulty_response.py b/test/client/test_client_faulty_response.py index 0e7e2c090..73387f01f 100644 --- a/test/client/test_client_faulty_response.py +++ b/test/client/test_client_faulty_response.py @@ -34,8 +34,12 @@ def test_1917_frame(self): def test_faulty_frame1(self, framer): """Test ok frame.""" faulty_frame = b"\x00\x04\x00\x00\x00\x05\x00\x03\x0a\x00\x04" - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: framer.handleFrame(faulty_frame, 0, 0) + # Known FC with corrupt body: FC survives; framing supplies tid/dev_id. + assert exc_info.value.fcode == 3 + assert exc_info.value.transaction_id == 4 + assert exc_info.value.dev_id == 0 used_len, pdu = framer.handleFrame(self.good_frame, 0, 0) assert pdu assert used_len == len(self.good_frame) diff --git a/test/framer/test_extras.py b/test/framer/test_extras.py index 132713186..1fa1f6149 100755 --- a/test/framer/test_extras.py +++ b/test/framer/test_extras.py @@ -84,8 +84,12 @@ def test_tcp_framer_transaction_wrong_tid(self): def test_tcp_framer_transaction_wrong_fc(self): """Test a half completed tcp frame transaction.""" msg = b"\x00\x01\x00\x00\x00\x06\xff\x70\x01\x02\x00\x08" - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: self._tcp.handleFrame(msg, 0, 0) + # Unknown FC: framing identity only (no invented function_code). + assert exc_info.value.transaction_id == 1 + assert exc_info.value.dev_id == 0xFF + assert exc_info.value.fcode is None def test_tls_incoming_packet(self): """Framer tls incoming packet.""" diff --git a/test/pdu/test_register_read_messages.py b/test/pdu/test_register_read_messages.py index b6b01e31c..0d04056e4 100644 --- a/test/pdu/test_register_read_messages.py +++ b/test/pdu/test_register_read_messages.py @@ -79,12 +79,10 @@ def test_register_read_response_decode(self): def test_register_read_response_decode_error(self): """Test register read response.""" - reg = ReadHoldingRegistersResponse(count=5, dev_id=9, transaction_id=0x55) + reg = ReadHoldingRegistersResponse(count=5) with pytest.raises(ModbusIOException) as exc_info: reg.decode(b"\x14\x00\x03\x00\x11") assert exc_info.value.fcode == reg.function_code - assert exc_info.value.dev_id == 9 - assert exc_info.value.transaction_id == 0x55 async def test_register_read_requests_count_errors(self, mock_server_context): """This tests that the register request messages. diff --git a/test/server/test_requesthandler.py b/test/server/test_requesthandler.py index 668f60c89..db80a4bc5 100755 --- a/test/server/test_requesthandler.py +++ b/test/server/test_requesthandler.py @@ -55,6 +55,7 @@ async def test_rh_callback_data(self, requesthandler): async def test_rh_callback_data_undecodable_echoes_identity(self, requesthandler): """Undecodable FC exception must echo framing tid/dev_id (#2990).""" + peer = ("192.0.2.1", 5020) with mock.patch( "pymodbus.transaction.TransactionManager.callback_data" ) as cb_data: @@ -65,7 +66,7 @@ async def test_rh_callback_data_undecodable_echoes_identity(self, requesthandler ) cb_data.side_effect = exc data = b"\x00\x0a\x00\x00\x00\x06\x07\x0a\x00\x00\x00\x01" - assert len(data) == requesthandler.callback_data(data, None) + assert len(data) == requesthandler.callback_data(data, peer) requesthandler.pdu_send.assert_called_once() response = requesthandler.pdu_send.call_args.args[0] @@ -75,6 +76,8 @@ async def test_rh_callback_data_undecodable_echoes_identity(self, requesthandler # 0x00 | 0x80 — not the previous hardcoded 0x28 | 0x80 assert response.function_code == 0x80 assert response.exception_code == 0x01 # ILLEGAL_FUNCTION + # UDP peer must be preserved (do not hardcode addr=0). + assert requesthandler.pdu_send.call_args.kwargs.get("addr") == peer async def test_rh_callback_data_undecodable_without_framing_attrs( self, requesthandler diff --git a/test/transaction/test_transaction.py b/test/transaction/test_transaction.py index 16e1d8606..03f05497c 100755 --- a/test/transaction/test_transaction.py +++ b/test/transaction/test_transaction.py @@ -186,18 +186,6 @@ async def test_transaction_data_2(self, use_clc, test): transact.response_future.set_result((1, pdu)) transact.callback_data(packet) - def test_io_exception_from_request_carries_identity(self, use_port): - """Known request identity is attached to client-side IO exceptions.""" - _ = use_port - request = ReadCoilsRequest( - address=117, count=5, dev_id=7, transaction_id=0x1234 - ) - exc = TransactionManager._io_exception_from_request("timeout", request) - assert isinstance(exc, ModbusIOException) - assert exc.fcode == request.function_code - assert exc.dev_id == 7 - assert exc.transaction_id == 0x1234 - @pytest.mark.parametrize("scenario", range(10)) async def test_transaction_execute(self, use_clc, scenario): """Test tracers in disconnect.""" @@ -474,13 +462,18 @@ async def test_sync_transaction_execute(self, use_clc, scenario): ) elif scenario == 3: # wait receive,timeout, no_responses transact.comm_params.timeout_connect = 0.1 - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: transact.sync_execute(False, request) + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id + assert exc_info.value.transaction_id == request.transaction_id elif scenario == 4: # wait receive,timeout, disconnect transact.comm_params.timeout_connect = 0.1 transact.count_until_disconnect = -1 - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: transact.sync_execute(False, request) + assert exc_info.value.fcode == request.function_code + assert exc_info.value.transaction_id == request.transaction_id elif scenario == 5: # wait receive,timeout, no_responses pass transact.comm_params.timeout_connect = 0.1 with pytest.raises(ModbusIOException): @@ -500,8 +493,11 @@ async def test_sync_transaction_execute(self, use_clc, scenario): transact.sync_get_response = mock.Mock(return_value=pdu) # type: ignore[method-assign] transact.pdu_send = mock.Mock() # type: ignore[method-assign] transact.comm_params.timeout_connect = 0.2 - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: transact.sync_execute(False, request) + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id + assert exc_info.value.transaction_id == request.transaction_id elif scenario == 8: # response wrong tid transact.transport = 1 # type: ignore[assignment] pdu = copy.deepcopy(response) @@ -509,8 +505,11 @@ async def test_sync_transaction_execute(self, use_clc, scenario): transact.sync_get_response = mock.Mock(return_value=pdu) # type: ignore[method-assign] transact.pdu_send = mock.Mock() # type: ignore[method-assign] transact.comm_params.timeout_connect = 0.2 - with pytest.raises(ModbusIOException): + with pytest.raises(ModbusIOException) as exc_info: transact.sync_execute(False, request) + assert exc_info.value.fcode == request.function_code + assert exc_info.value.dev_id == request.dev_id + assert exc_info.value.transaction_id == request.transaction_id else: # if scenario == 9 # pdu_send from client transact.transport = 1 # type: ignore[assignment] transact.is_server = True From a5280f1230c8264784db633a21b7b49fabaa28d6 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Tue, 28 Jul 2026 19:48:30 +0530 Subject: [PATCH 4/4] Address review: drop comments and ModbusIOException re-raise Remove narrative comments per maintainer style. Drop the DecodePDU ModbusIOException re-raise (already covered by ModbusException). Keep framing identity on the None path and client request identity helper. Signed-off-by: Sankalp Thakur --- pymodbus/framer/base.py | 10 +--------- pymodbus/pdu/decoders.py | 6 +----- pymodbus/pdu/register_message.py | 2 -- pymodbus/server/requesthandler.py | 5 ----- test/client/test_client_faulty_response.py | 2 -- test/framer/test_extras.py | 1 - test/server/test_requesthandler.py | 4 +--- 7 files changed, 3 insertions(+), 27 deletions(-) diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index b1733dd48..822d47bde 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -87,15 +87,7 @@ def handleFrame( f"ERROR: request ask for transaction_id={exp_tid} but got id={tid}, Skipping." ) continue - try: - pdu = self.decoder.decode(frame_data) - except ModbusIOException as exc: - # Framing ids are authoritative; payload decode never has them yet. - exc.transaction_id = tid - exc.dev_id = dev_id - raise - if pdu is None: - # Unknown/garbage FC: do not invent function_code from noise (#2990). + if (pdu := self.decoder.decode(frame_data)) is None: raise ModbusIOException( "Unable to decode request", transaction_id=tid, diff --git a/pymodbus/pdu/decoders.py b/pymodbus/pdu/decoders.py index 15b940d9f..5a686ef28 100644 --- a/pymodbus/pdu/decoders.py +++ b/pymodbus/pdu/decoders.py @@ -5,7 +5,7 @@ import copy import struct -from ..exceptions import MessageRegisterException, ModbusException, ModbusIOException +from ..exceptions import MessageRegisterException, ModbusException from ..logging import Log from .exceptionresponse import ExceptionResponse from .pdu import ModbusPDU @@ -90,10 +90,6 @@ def decode(self, frame: bytes) -> ModbusPDU | None: str(pdu), ) return pdu - except ModbusIOException: - # Payload decode already attached known identity (e.g. function_code). - # Framer enriches transaction_id / dev_id from the ADU. - raise except (ModbusException, ValueError, IndexError, struct.error) as exc: Log.warning("Unable to decode frame {}", exc) return None diff --git a/pymodbus/pdu/register_message.py b/pymodbus/pdu/register_message.py index f00c16d54..3bd13e3f2 100644 --- a/pymodbus/pdu/register_message.py +++ b/pymodbus/pdu/register_message.py @@ -75,8 +75,6 @@ def decode(self, data: bytes) -> None: """Decode a register response packet.""" self.registers = [] if (data_len := int(data[0])) >= len(data): - # function_code is the class/request FC; framing tid/dev_id are filled - # by FramerBase after DecodePDU re-raises this exception. raise ModbusIOException( f"byte_count {data_len} > length of packet {len(data)}", function_code=self.function_code, diff --git a/pymodbus/server/requesthandler.py b/pymodbus/server/requesthandler.py index efa2cb2c1..ab26b1bb8 100644 --- a/pymodbus/server/requesthandler.py +++ b/pymodbus/server/requesthandler.py @@ -63,11 +63,6 @@ def callback_data(self, data: bytes, addr: tuple | None = None) -> int: try: used_len = super().callback_data(data, addr) except ModbusIOException as exc: - # Undecodable / corrupt PDUs land here. last_pdu is cleared before - # framing runs, so identity comes from the framer exception attrs. - # Prefer a known function_code from payload decode; otherwise 0x00 - # rather than inventing a code from garbage (#2990). - # Prefer the callback addr (UDP peer) — last_addr is still cleared. function_code = 0x00 if exc.fcode is None else exc.fcode response = ExceptionResponse( function_code, diff --git a/test/client/test_client_faulty_response.py b/test/client/test_client_faulty_response.py index 73387f01f..5ee14a31e 100644 --- a/test/client/test_client_faulty_response.py +++ b/test/client/test_client_faulty_response.py @@ -36,8 +36,6 @@ def test_faulty_frame1(self, framer): faulty_frame = b"\x00\x04\x00\x00\x00\x05\x00\x03\x0a\x00\x04" with pytest.raises(ModbusIOException) as exc_info: framer.handleFrame(faulty_frame, 0, 0) - # Known FC with corrupt body: FC survives; framing supplies tid/dev_id. - assert exc_info.value.fcode == 3 assert exc_info.value.transaction_id == 4 assert exc_info.value.dev_id == 0 used_len, pdu = framer.handleFrame(self.good_frame, 0, 0) diff --git a/test/framer/test_extras.py b/test/framer/test_extras.py index 1fa1f6149..642133d88 100755 --- a/test/framer/test_extras.py +++ b/test/framer/test_extras.py @@ -86,7 +86,6 @@ def test_tcp_framer_transaction_wrong_fc(self): msg = b"\x00\x01\x00\x00\x00\x06\xff\x70\x01\x02\x00\x08" with pytest.raises(ModbusIOException) as exc_info: self._tcp.handleFrame(msg, 0, 0) - # Unknown FC: framing identity only (no invented function_code). assert exc_info.value.transaction_id == 1 assert exc_info.value.dev_id == 0xFF assert exc_info.value.fcode is None diff --git a/test/server/test_requesthandler.py b/test/server/test_requesthandler.py index db80a4bc5..4ab50b62c 100755 --- a/test/server/test_requesthandler.py +++ b/test/server/test_requesthandler.py @@ -73,10 +73,8 @@ async def test_rh_callback_data_undecodable_echoes_identity(self, requesthandler assert isinstance(response, ExceptionResponse) assert response.transaction_id == 0x000A assert response.dev_id == 7 - # 0x00 | 0x80 — not the previous hardcoded 0x28 | 0x80 assert response.function_code == 0x80 - assert response.exception_code == 0x01 # ILLEGAL_FUNCTION - # UDP peer must be preserved (do not hardcode addr=0). + assert response.exception_code == 0x01 assert requesthandler.pdu_send.call_args.kwargs.get("addr") == peer async def test_rh_callback_data_undecodable_without_framing_attrs(