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
81 changes: 81 additions & 0 deletions pymodbus.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
2026-09-10 01:22:16,121 DEBUG test_logging:69 test 1
2026-09-10 01:22:16,122 DEBUG test_logging:70 Repeating....
2026-09-10 01:22:16,122 ERROR test_logging:72 get frames
2026-09-10 01:22:16,123 CRITICAL test_logging:73 Repeating....
2026-09-10 01:22:16,123 WARNING test_logging:77 test 2
2026-09-10 01:22:16,124 WARNING test_logging:78 Repeating....
2026-09-10 01:22:16,125 CRITICAL test_logging:81 test 3no
>>>>> send: 0x61 0x62 0x63
>>>>> Repeating....
>>>>> None
2026-09-10 01:22:16,125 CRITICAL test_logging:83 test 3
2026-09-10 01:22:16,125 CRITICAL test_logging:84 Repeating....
2026-09-10 01:22:16,126 ERROR test_logging:89 test 4
2026-09-10 01:22:16,126 ERROR test_logging:90 Repeating....
2026-09-10 01:22:16,127 INFO test_logging:95 test 5
2026-09-10 01:22:16,127 INFO test_logging:96 Repeating....
2026-09-10 01:22:16,594 DEBUG test_logging:136 send: 0x31 0x32 0x33
2026-09-10 01:22:16,859 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:16,860 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,343 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,344 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,466 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,468 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,472 INFO base:92 Server listening.
2026-09-10 01:22:17,472 INFO base:92 Repeating....
2026-09-10 01:22:17,473 INFO base:96 Server graceful shutdown.
2026-09-10 01:22:17,591 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,592 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,710 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,711 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,831 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,832 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,954 WARNING context:65 ModbusDeviceContext, ModbusSequentialDataBlock, ModbusSparseDataBlock are deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:17,955 WARNING context:120 ModbusServerContext is deprecated and will be removed in v4.
Please convert to SimData/SimDevice.
Please read https://pymodbus.readthedocs.io/en/dev/source/upgrade_40.html#convert-to-simdata-simdevice
2026-09-10 01:22:18,811 DEBUG base:75 Processing: 0x0 0x1 0x0 0x0 0x0 0x5 0x1 0x4 0x2 0x0 0x3
2026-09-10 01:22:18,812 DEBUG decoders:86 decoded PDU function_code(4 sub -1) -> ReadInputRegistersResponse(dev_id=0, transaction_id=0, address=0, count=0, bits=[], registers=[3], status=1, retries=0)
2026-09-10 01:22:18,813 DEBUG base:75 Processing: 0x0 0x1 0x0 0x0 0x0 0x5 0x1 0x4 0x2 0x0 0x3
2026-09-10 01:22:18,814 ERROR base:86 ERROR: request ask for transaction_id=2 but got id=1, Skipping.
2026-09-10 01:22:18,815 ERROR transaction:180 No response received after 3 retries, continue with next request
2026-09-10 01:24:38,019 DEBUG test_logging:69 test 1
2026-09-10 01:24:38,021 DEBUG test_logging:70 Repeating....
2026-09-10 01:24:38,021 ERROR test_logging:72 get frames
2026-09-10 01:24:38,027 CRITICAL test_logging:73 Repeating....
2026-09-10 01:24:38,027 WARNING test_logging:77 test 2
2026-09-10 01:24:38,028 WARNING test_logging:78 Repeating....
2026-09-10 01:24:38,028 CRITICAL test_logging:81 test 3no
2026-09-10 01:24:38,028 CRITICAL test_logging:83 test 3
2026-09-10 01:24:38,029 CRITICAL test_logging:84 Repeating....
2026-09-10 01:24:38,030 ERROR test_logging:89 test 4
2026-09-10 01:24:38,030 ERROR test_logging:90 Repeating....
2026-09-10 01:24:38,030 INFO test_logging:95 test 5
2026-09-10 01:24:38,031 INFO test_logging:96 Repeating....
2026-09-10 01:24:38,494 DEBUG test_logging:136 send: 0x31 0x32 0x33
11 changes: 11 additions & 0 deletions pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def __init__(
if is_sync:
return
self.loop = asyncio.get_running_loop()
self.connected_evt = asyncio.Event()
if self.is_server:
if self.comm_params.source_address is not None:
host = self.comm_params.source_address[0]
Expand Down Expand Up @@ -218,11 +219,17 @@ async def connect(self) -> bool:
"""Handle generic connect and call on to specific transport connect."""
Log.debug("Connecting {}", self.comm_params.comm_name)
self.is_closing = False
self.connected_evt.clear()
try:
self.transport, _protocol = await asyncio.wait_for(
self.call_create(),
timeout=self.comm_params.timeout_connect,
)
if self.transport:
await asyncio.wait_for(
self.connected_evt.wait(),
timeout=self.comm_params.timeout_connect,
)
except (asyncio.TimeoutError, OSError) as exc: # pylint: disable=overlapping-except
Log.warning("Failed to connect {}", exc)
return False
Expand Down Expand Up @@ -256,12 +263,16 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
self.transport = transport
self.reset_delay()
self.callback_connected()
self.connected_evt.set()

def connection_lost(self, exc: Exception | None) -> None:
"""Call from asyncio, when the connection is lost or closed.

:param exc: None or an exception object
"""
if not getattr(self, "is_sync", False) and hasattr(self, "connected_evt"):
self.connected_evt.clear()

if not self.transport or self.is_closing:
return
Log.debug("Connection lost {} due to {}", self.comm_params.comm_name, exc)
Expand Down
57 changes: 57 additions & 0 deletions test/client/test_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test client sync."""

import asyncio
import socket
import ssl
from typing import cast
Expand Down Expand Up @@ -610,6 +611,62 @@ async def test_client_connection_made(self):
client.close()
assert rc

async def test_async_connect_state_machine(self):
"""Test async client state-machine hydration after connect."""
client = lib_client.AsyncModbusTcpClient("127.0.0.1")

# Mock create_connection to return transport and protocol but also simulate calling connection_made
transport_mock = mock.AsyncMock()
transport_mock.close = lambda: ()

async def mock_create_connection():
client.ctx.connection_made(transport_mock)
return transport_mock, client.ctx

client.ctx.call_create = mock_create_connection

# Override the normal client connect behavior to ensure we use our mocked create_connection
connected = await client.connect()
assert connected

# This is the crux of the fix: client.connected MUST be true right after await client.connect()
assert client.connected
assert client.ctx.transport is not None
client.close()

async def test_async_connect_race_condition(self):
"""Test that client.connect() correctly waits for connection_made callback even if delayed.

This demonstrates why relying solely on `create_connection`'s return (the old behavior)
results in `client.connected == False` if the callback is delayed by the event loop.
"""
client = lib_client.AsyncModbusTcpClient("127.0.0.1")

transport_mock = mock.AsyncMock()
transport_mock.close = lambda: ()

async def mock_create_connection():
# Artificially delay the firing of protocol.connection_made(transport)
async def delayed_connection_made():
await asyncio.sleep(0.01)
client.ctx.connection_made(transport_mock)

_task = asyncio.create_task(delayed_connection_made()) # noqa: RUF006
return transport_mock, client.ctx

client.ctx.call_create = mock_create_connection

# connect() should await until connection_made fires, rather than just returning immediately
connected = await client.connect()
assert connected

# With the new synchronization logic, await client.connect() successfully waits for the delayed
# connection_made, resulting in client.connected == True.
assert client.connected
assert client.ctx.transport is not None

client.close()

async def test_client_base_async(self):
"""Test modbus base client class."""
async with ModbusBaseClient(
Expand Down
16 changes: 14 additions & 2 deletions test/transport/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ async def test_init_source_addr_none(self, use_clc, dummy_protocol):

async def test_loop_connect(self, client, dummy_protocol):
"""Test properties."""
client.call_create = mock.AsyncMock(return_value=(dummy_protocol(), None))

async def mock_call_create():
prot = dummy_protocol()
client.connection_made(prot)
return (prot, None)

client.call_create = mock.AsyncMock(side_effect=mock_call_create)
assert await client.connect()

async def test_loop_listen(self, server, dummy_protocol):
Expand All @@ -73,7 +79,13 @@ async def test_loop_listen(self, server, dummy_protocol):

async def test_connect_ok(self, client, dummy_protocol):
"""Test properties."""
client.call_create = mock.AsyncMock(return_value=(dummy_protocol(), None))

async def mock_call_create():
prot = dummy_protocol()
client.connection_made(prot)
return (prot, None)

client.call_create = mock.AsyncMock(side_effect=mock_call_create)
assert await client.connect()

async def test_connect_not_ok(self, client, dummy_protocol):
Expand Down
9 changes: 7 additions & 2 deletions test/transport/test_reconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,14 @@ async def test_multi_reconnect_call(self, client):
async def test_reconnect_call_ok(self, client):
"""Test connection_lost()."""
client.loop = asyncio.get_running_loop()
client.call_create = mock.AsyncMock(return_value=(mock.Mock(), mock.Mock()))

async def mock_call_create():
transport = mock.Mock()
client.connection_made(transport)
return (transport, mock.Mock())

client.call_create = mock.AsyncMock(side_effect=mock_call_create)
await client.connect()
client.connection_made(mock.Mock())
client.connection_lost(RuntimeError("Connection lost"))
await asyncio.sleep(client.reconnect_delay_current * 1.8)
assert client.call_create.call_count == 2
Expand Down