aioads 0.1.0.dev7__tar.gz → 0.1.0.dev9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/.github/workflows/ci.yml +1 -1
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/.github/workflows/publish.yml +1 -1
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/PKG-INFO +1 -1
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ads_symbol_parser.py +39 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/transport.py +26 -1
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/pyproject.toml +2 -1
- aioads-0.1.0.dev9/tests/integration/test_memory.py +192 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ads_symbol_parser.py +155 -11
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_transport.py +63 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/.github/dependabot.yml +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/.gitignore +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/LICENSE +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/README.md +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ads_client.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ams_service_port.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_add_notification.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/commands/errors.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_sum_read.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/functions/ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/py.typed +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/docs/unittest_style_guide.html +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/pdm.lock +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/__init__.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/builders.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/README.md +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/__init__.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/base.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/config.example.toml +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/config.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/test_connection.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/test_performance.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/integration/test_read_symbols.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/__init__.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/__init__.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_add_notification.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_command.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_read.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_read_state.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_read_write.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_write.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_ads_write_state.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/commands/test_errors.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/__init__.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_enable_route.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_function.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_sum_read.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ads_client.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ads_error_codes.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ads_notifications.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ams_address.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ams_header.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_errors.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/test_stream.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/utils/__init__.py +0 -0
- {aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/utils/test_local_ip.py +0 -0
|
@@ -18,7 +18,7 @@ jobs:
|
|
|
18
18
|
name: Build distribution
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
22
22
|
with:
|
|
23
23
|
persist-credentials: false
|
|
24
24
|
|
|
@@ -134,6 +134,37 @@ class AdsSymbolParser(ISymbolParser):
|
|
|
134
134
|
|
|
135
135
|
def __init__(self, data_types: list[SymbolDataTypeResponse]):
|
|
136
136
|
self._type_lookup = {dt.name: dt for dt in data_types}
|
|
137
|
+
# Resolution of IEC 61131-3 elementary type names to their wire encoding.
|
|
138
|
+
self._elementary_type_names: dict[str, AdsSymbolDataType] = {
|
|
139
|
+
"BOOL": AdsSymbolDataType.BIT,
|
|
140
|
+
"BIT": AdsSymbolDataType.BIT,
|
|
141
|
+
"SINT": AdsSymbolDataType.INT8,
|
|
142
|
+
"USINT": AdsSymbolDataType.UINT8,
|
|
143
|
+
"BYTE": AdsSymbolDataType.UINT8,
|
|
144
|
+
"INT": AdsSymbolDataType.INT16,
|
|
145
|
+
"UINT": AdsSymbolDataType.UINT16,
|
|
146
|
+
"WORD": AdsSymbolDataType.UINT16,
|
|
147
|
+
"DINT": AdsSymbolDataType.INT32,
|
|
148
|
+
"UDINT": AdsSymbolDataType.UINT32,
|
|
149
|
+
"DWORD": AdsSymbolDataType.UINT32,
|
|
150
|
+
"LINT": AdsSymbolDataType.INT64,
|
|
151
|
+
"ULINT": AdsSymbolDataType.UINT64,
|
|
152
|
+
"LWORD": AdsSymbolDataType.UINT64,
|
|
153
|
+
"REAL": AdsSymbolDataType.REAL32,
|
|
154
|
+
"LREAL": AdsSymbolDataType.REAL64,
|
|
155
|
+
"TIME": AdsSymbolDataType.UINT32,
|
|
156
|
+
"TOD": AdsSymbolDataType.UINT32,
|
|
157
|
+
"TIME_OF_DAY": AdsSymbolDataType.UINT32,
|
|
158
|
+
"DATE": AdsSymbolDataType.UINT32,
|
|
159
|
+
"DT": AdsSymbolDataType.UINT32,
|
|
160
|
+
"DATE_AND_TIME": AdsSymbolDataType.UINT32,
|
|
161
|
+
"LTIME": AdsSymbolDataType.UINT64,
|
|
162
|
+
"LTOD": AdsSymbolDataType.UINT64,
|
|
163
|
+
"LTIME_OF_DAY": AdsSymbolDataType.UINT64,
|
|
164
|
+
"LDATE": AdsSymbolDataType.UINT64,
|
|
165
|
+
"LDT": AdsSymbolDataType.UINT64,
|
|
166
|
+
"LDATE_AND_TIME": AdsSymbolDataType.UINT64,
|
|
167
|
+
}
|
|
137
168
|
self._const_type_names: dict[str, Callable[[str, AdsStream], Any]] = {
|
|
138
169
|
"Tc2_System.T_MaxString": lambda type_name, raw_data: raw_data.read(255)
|
|
139
170
|
.decode("latin-1")
|
|
@@ -142,6 +173,7 @@ class AdsSymbolParser(ISymbolParser):
|
|
|
142
173
|
.decode("latin-1")
|
|
143
174
|
.rstrip("\x00"),
|
|
144
175
|
}
|
|
176
|
+
|
|
145
177
|
self._primitive_parser = PrimitiveTypeParser()
|
|
146
178
|
|
|
147
179
|
def update_datatypes(self, data_types: list[SymbolDataTypeResponse]) -> None:
|
|
@@ -265,6 +297,13 @@ class AdsSymbolParser(ISymbolParser):
|
|
|
265
297
|
if data_type != AdsSymbolDataType.BIGTYPE:
|
|
266
298
|
return self._primitive_parser.parse(data_type, type_name, raw_data)
|
|
267
299
|
|
|
300
|
+
# TwinCAT 2 reports BIGTYPE for some elementary types
|
|
301
|
+
# like INT, UINT, REAL, etc. So we need to resolve them by name.
|
|
302
|
+
if type_name in self._elementary_type_names:
|
|
303
|
+
return self._primitive_parser.parse(
|
|
304
|
+
self._elementary_type_names[type_name], type_name, raw_data
|
|
305
|
+
)
|
|
306
|
+
|
|
268
307
|
# Parsing of structured types
|
|
269
308
|
if data_type == AdsSymbolDataType.BIGTYPE:
|
|
270
309
|
return self.parse_struct(type_name, raw_data)
|
|
@@ -211,6 +211,14 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
211
211
|
CONNECT_TIMEOUT = 30.0
|
|
212
212
|
RECONNECT_INITIAL_BACKOFF = 1.0
|
|
213
213
|
RECONNECT_MAX_BACKOFF = 30.0
|
|
214
|
+
RECONNECT_STABILITY_THRESHOLD = 10.0
|
|
215
|
+
"""Seconds a connection must stay up before the reconnect backoff resets.
|
|
216
|
+
|
|
217
|
+
The peer may accept the TCP handshake and close immediately (e.g. the
|
|
218
|
+
Beckhoff single-connection lockout). Such a connect must count as a
|
|
219
|
+
failure for backoff purposes, otherwise the supervisor spins in a
|
|
220
|
+
zero-delay connect/drop loop and pins a CPU core.
|
|
221
|
+
"""
|
|
214
222
|
|
|
215
223
|
# TCP keepalive / liveness tuning.
|
|
216
224
|
# Without these, a half-open connection (peer power-cut, cable pull, NAT
|
|
@@ -409,9 +417,16 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
409
417
|
trigger competing reconnect attempts. Senders fail fast instead (see
|
|
410
418
|
:meth:`request`), which also respects the Beckhoff single-connection
|
|
411
419
|
backoff/lockout behavior.
|
|
420
|
+
|
|
421
|
+
The backoff resets only after a connection stayed up for
|
|
422
|
+
RECONNECT_STABILITY_THRESHOLD seconds, not on connect success: a peer
|
|
423
|
+
that accepts the handshake and closes right away would otherwise reset
|
|
424
|
+
the backoff every cycle and turn this loop into a busy spin.
|
|
412
425
|
"""
|
|
413
426
|
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
427
|
+
loop = asyncio.get_running_loop()
|
|
414
428
|
while self._state is not ConnectionState.CLOSED:
|
|
429
|
+
connected_at = loop.time()
|
|
415
430
|
try:
|
|
416
431
|
await self._read_loop()
|
|
417
432
|
except asyncio.CancelledError: # pylint: disable=try-except-raise
|
|
@@ -426,11 +441,21 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
426
441
|
self._state = ConnectionState.RECONNECTING
|
|
427
442
|
await self._teardown_stream(ConnectionError("Connection lost"))
|
|
428
443
|
|
|
444
|
+
uptime = loop.time() - connected_at
|
|
445
|
+
if uptime >= self.RECONNECT_STABILITY_THRESHOLD:
|
|
446
|
+
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
447
|
+
else:
|
|
448
|
+
self.logger.info(
|
|
449
|
+
"Connection only lasted %.1fs, backing off %.1fs before reconnecting",
|
|
450
|
+
uptime, backoff,
|
|
451
|
+
)
|
|
452
|
+
await asyncio.sleep(backoff + random.uniform(0, backoff))
|
|
453
|
+
backoff = min(backoff * 2, self.RECONNECT_MAX_BACKOFF)
|
|
454
|
+
|
|
429
455
|
while self._state is not ConnectionState.CLOSED:
|
|
430
456
|
try:
|
|
431
457
|
await self._open()
|
|
432
458
|
self.logger.info("Reconnected to the remote")
|
|
433
|
-
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
434
459
|
break
|
|
435
460
|
except asyncio.CancelledError: # pylint: disable=try-except-raise
|
|
436
461
|
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aioads"
|
|
3
|
-
version = "0.1.0.
|
|
3
|
+
version = "0.1.0.dev9"
|
|
4
4
|
description = "An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "MkKiefer", email = "102972583+MkKiefer@users.noreply.github.com" },
|
|
@@ -78,4 +78,5 @@ disable = [
|
|
|
78
78
|
"too-many-locals",
|
|
79
79
|
"too-few-public-methods",
|
|
80
80
|
"broad-exception-caught",
|
|
81
|
+
"too-many-return-statements",
|
|
81
82
|
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests that watch the Python heap for memory leaks.
|
|
3
|
+
|
|
4
|
+
Measurements use `tracemalloc` from the standard library, so only allocations
|
|
5
|
+
made by Python code are counted (which is all of aioads). `gc.collect()` runs
|
|
6
|
+
before every snapshot so the numbers only contain objects that are still
|
|
7
|
+
reachable, not garbage awaiting collection.
|
|
8
|
+
|
|
9
|
+
Reported parts per lifecycle:
|
|
10
|
+
1. Startup cost - connect + datatype upload + first symbol cache fill.
|
|
11
|
+
2. Runtime cost - steady-state growth over many reads (the leak signal)
|
|
12
|
+
plus the transient peak a batch of reads needs on top.
|
|
13
|
+
3. Cleanup residue - what is still allocated after disconnect, relative to
|
|
14
|
+
the pre-connect baseline.
|
|
15
|
+
|
|
16
|
+
The residue of a single lifecycle legitimately contains one-time allocations
|
|
17
|
+
(module-level loggers, enum value caches, interned strings), so the decisive
|
|
18
|
+
leak check is `test_connect_cycles_do_not_leak`: after the first cycle has
|
|
19
|
+
absorbed those one-time costs, further cycles must not grow the heap.
|
|
20
|
+
|
|
21
|
+
Like the performance tests, the budgets below are generous upper bounds, not
|
|
22
|
+
benchmarks. A failure means a leak or a pathological regression, not a small
|
|
23
|
+
change. Adjust the constants when they no longer match the test environment.
|
|
24
|
+
"""
|
|
25
|
+
import gc
|
|
26
|
+
import tracemalloc
|
|
27
|
+
|
|
28
|
+
import unittest
|
|
29
|
+
|
|
30
|
+
from tests.integration.config import CONFIG_PATH
|
|
31
|
+
from tests.integration.config import create_client
|
|
32
|
+
from tests.integration.config import load_config
|
|
33
|
+
|
|
34
|
+
# Upper bound for connect + datatype upload + first cache fill. Depends on the
|
|
35
|
+
# size of the PLC project, so this is a loose sanity bound only.
|
|
36
|
+
STARTUP_MAX_BYTES = 20 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
# Reads executed before measuring, to let lazy one-time allocations
|
|
39
|
+
# (struct caches, buffers growing to working size) settle.
|
|
40
|
+
WARMUP_READS = 25
|
|
41
|
+
|
|
42
|
+
# Reads in the measured window. Each iteration performs one single-symbol read
|
|
43
|
+
# and one multi-symbol read. Steady-state reads must not accumulate memory, so
|
|
44
|
+
# the growth budget is small and independent of the read count.
|
|
45
|
+
MEASURED_READS = 1500
|
|
46
|
+
RUNTIME_GROWTH_MAX_BYTES = 16 * 1024
|
|
47
|
+
|
|
48
|
+
# Transient memory a batch of reads may need on top of the steady state
|
|
49
|
+
# (request/response buffers, parsed values before they are dropped).
|
|
50
|
+
RUNTIME_PEAK_MAX_BYTES = 8 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
# Allowed leftover after disconnecting a single client, relative to the
|
|
53
|
+
# pre-connect baseline. Covers legitimate one-time allocations that persist
|
|
54
|
+
# for the lifetime of the interpreter.
|
|
55
|
+
RESIDUE_MAX_BYTES = 1 * 1024 * 1024
|
|
56
|
+
|
|
57
|
+
# Full connect/read/disconnect cycles in the measured window and their total
|
|
58
|
+
# growth budget. Growth proportional to the cycle count means a real leak.
|
|
59
|
+
MEASURED_CYCLES = 5
|
|
60
|
+
CYCLE_GROWTH_MAX_BYTES = 64 * 1024
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _heap_bytes() -> int:
|
|
64
|
+
"""Collect garbage and return the currently traced heap size."""
|
|
65
|
+
gc.collect()
|
|
66
|
+
return tracemalloc.get_traced_memory()[0]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fmt(num_bytes: int) -> str:
|
|
70
|
+
"""Format a byte count as a signed KiB value for readable reports."""
|
|
71
|
+
return f"{num_bytes / 1024:+,.1f} KiB"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@unittest.skipUnless(
|
|
75
|
+
CONFIG_PATH.exists(),
|
|
76
|
+
"Integration config missing: copy config.example.toml to config.toml",
|
|
77
|
+
)
|
|
78
|
+
class TestMemory(unittest.IsolatedAsyncioTestCase):
|
|
79
|
+
"""
|
|
80
|
+
Memory lifecycle tests for reading symbols by name.
|
|
81
|
+
|
|
82
|
+
Does not use `IntegrationTestCase`: these tests must control the client
|
|
83
|
+
lifecycle themselves to measure connect and disconnect.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
async def asyncSetUp(self) -> None:
|
|
87
|
+
self.config = load_config()
|
|
88
|
+
self.symbol_single = self.config["symbols"]["single"]
|
|
89
|
+
self.symbols_multiple = set(self.config["symbols"]["multiple"])
|
|
90
|
+
# Trigger lazy imports (e.g. MQTT transports) before any baseline is
|
|
91
|
+
# taken, so module-level memory is not attributed to the client.
|
|
92
|
+
create_client(self.config)
|
|
93
|
+
tracemalloc.start()
|
|
94
|
+
|
|
95
|
+
async def asyncTearDown(self) -> None:
|
|
96
|
+
tracemalloc.stop()
|
|
97
|
+
|
|
98
|
+
async def _read_once(self, client) -> None:
|
|
99
|
+
"""One iteration of the read patterns under test."""
|
|
100
|
+
await client.read_symbol_by_name(self.symbol_single)
|
|
101
|
+
await client.read_symbols_by_names(self.symbols_multiple)
|
|
102
|
+
|
|
103
|
+
async def test_read_symbols_memory_lifecycle(self) -> None:
|
|
104
|
+
"""Measure startup cost, runtime growth and cleanup residue."""
|
|
105
|
+
baseline = _heap_bytes()
|
|
106
|
+
|
|
107
|
+
# Part 1: startup - connect (datatype upload) + first cache fill.
|
|
108
|
+
client = create_client(self.config)
|
|
109
|
+
await client.connect()
|
|
110
|
+
await self._read_once(client)
|
|
111
|
+
startup_cost = _heap_bytes() - baseline
|
|
112
|
+
|
|
113
|
+
# Part 2: runtime - steady-state growth and transient peak.
|
|
114
|
+
for _ in range(WARMUP_READS):
|
|
115
|
+
await self._read_once(client)
|
|
116
|
+
before_reads = _heap_bytes()
|
|
117
|
+
tracemalloc.reset_peak()
|
|
118
|
+
for _ in range(MEASURED_READS):
|
|
119
|
+
await self._read_once(client)
|
|
120
|
+
runtime_peak = tracemalloc.get_traced_memory()[1] - before_reads
|
|
121
|
+
runtime_growth = _heap_bytes() - before_reads
|
|
122
|
+
|
|
123
|
+
# Part 3: cleanup - disconnect, drop the client, measure the residue.
|
|
124
|
+
await client.disconnect()
|
|
125
|
+
del client
|
|
126
|
+
residue = _heap_bytes() - baseline
|
|
127
|
+
|
|
128
|
+
print(
|
|
129
|
+
f"\nMemory lifecycle report"
|
|
130
|
+
f"\n 1. startup (connect + first caching): {_fmt(startup_cost)}"
|
|
131
|
+
f"\n 2. runtime growth over {MEASURED_READS} reads: "
|
|
132
|
+
f"{_fmt(runtime_growth)} "
|
|
133
|
+
f"({runtime_growth / MEASURED_READS:+.1f} B/read), "
|
|
134
|
+
f"transient peak {_fmt(runtime_peak)}"
|
|
135
|
+
f"\n 3. cleanup residue vs. baseline: {_fmt(residue)}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
with self.subTest(part="1-startup"):
|
|
139
|
+
self.assertLessEqual(
|
|
140
|
+
startup_cost,
|
|
141
|
+
STARTUP_MAX_BYTES,
|
|
142
|
+
f"Startup allocated {_fmt(startup_cost)}",
|
|
143
|
+
)
|
|
144
|
+
with self.subTest(part="2-runtime"):
|
|
145
|
+
self.assertLessEqual(
|
|
146
|
+
runtime_growth,
|
|
147
|
+
RUNTIME_GROWTH_MAX_BYTES,
|
|
148
|
+
f"{MEASURED_READS} reads grew the heap by {_fmt(runtime_growth)} "
|
|
149
|
+
f"({runtime_growth / MEASURED_READS:+.1f} B/read), "
|
|
150
|
+
f"reads are accumulating memory",
|
|
151
|
+
)
|
|
152
|
+
self.assertLessEqual(
|
|
153
|
+
runtime_peak,
|
|
154
|
+
RUNTIME_PEAK_MAX_BYTES,
|
|
155
|
+
f"Reads transiently needed {_fmt(runtime_peak)} on top of "
|
|
156
|
+
f"the steady state",
|
|
157
|
+
)
|
|
158
|
+
with self.subTest(part="3-cleanup"):
|
|
159
|
+
self.assertLessEqual(
|
|
160
|
+
residue,
|
|
161
|
+
RESIDUE_MAX_BYTES,
|
|
162
|
+
f"{_fmt(residue)} still allocated after disconnect",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def test_connect_cycles_do_not_leak(self) -> None:
|
|
166
|
+
"""Repeated connect/read/disconnect cycles must not grow the heap."""
|
|
167
|
+
async def one_cycle() -> None:
|
|
168
|
+
client = create_client(self.config)
|
|
169
|
+
await client.connect()
|
|
170
|
+
await self._read_once(client)
|
|
171
|
+
await client.disconnect()
|
|
172
|
+
|
|
173
|
+
# The first cycle absorbs one-time allocations that live for the
|
|
174
|
+
# rest of the interpreter (loggers, enum caches, interned strings).
|
|
175
|
+
await one_cycle()
|
|
176
|
+
baseline = _heap_bytes()
|
|
177
|
+
|
|
178
|
+
for _ in range(MEASURED_CYCLES):
|
|
179
|
+
await one_cycle()
|
|
180
|
+
growth = _heap_bytes() - baseline
|
|
181
|
+
|
|
182
|
+
print(
|
|
183
|
+
f"\nConnect cycle report: {MEASURED_CYCLES} cycles grew the heap "
|
|
184
|
+
f"by {_fmt(growth)} ({growth / MEASURED_CYCLES:+,.0f} B/cycle)"
|
|
185
|
+
)
|
|
186
|
+
self.assertLessEqual(
|
|
187
|
+
growth,
|
|
188
|
+
CYCLE_GROWTH_MAX_BYTES,
|
|
189
|
+
f"{MEASURED_CYCLES} connect/read/disconnect cycles grew the heap "
|
|
190
|
+
f"by {_fmt(growth)} ({growth / MEASURED_CYCLES:+,.0f} B/cycle), "
|
|
191
|
+
f"the client lifecycle is leaking",
|
|
192
|
+
)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import struct
|
|
4
4
|
import unittest
|
|
5
5
|
|
|
6
|
+
from parameterized import parameterized
|
|
7
|
+
|
|
6
8
|
from aioads.ads_symbol_parser import AdsSymbolParser, PrimitiveTypeParser
|
|
7
9
|
from aioads.functions.ads_symbol_datatype_by_name import SymbolDataTypeResponse
|
|
8
10
|
from aioads.functions.ads_symbol_info_by_name_ex import (
|
|
@@ -89,7 +91,8 @@ class TestPrimitiveTypeParser(unittest.TestCase):
|
|
|
89
91
|
stream = make_stream(b"Hi\x00\x00\x00\x00")
|
|
90
92
|
|
|
91
93
|
# Act
|
|
92
|
-
result = self.parser.parse(
|
|
94
|
+
result = self.parser.parse(
|
|
95
|
+
AdsSymbolDataType.STRING, "STRING(5)", stream)
|
|
93
96
|
|
|
94
97
|
# Assert
|
|
95
98
|
self.assertEqual(result, "Hi")
|
|
@@ -110,7 +113,8 @@ class TestPrimitiveTypeParser(unittest.TestCase):
|
|
|
110
113
|
stream = make_stream(raw)
|
|
111
114
|
|
|
112
115
|
# Act
|
|
113
|
-
result = self.parser.parse(
|
|
116
|
+
result = self.parser.parse(
|
|
117
|
+
AdsSymbolDataType.WSTRING, "WSTRING(3)", stream)
|
|
114
118
|
|
|
115
119
|
# Assert
|
|
116
120
|
self.assertEqual(result, "Hi")
|
|
@@ -136,7 +140,8 @@ class TestAdsSymbolParser(unittest.TestCase):
|
|
|
136
140
|
stream = make_stream(bytes(range(8)))
|
|
137
141
|
|
|
138
142
|
# Act
|
|
139
|
-
result = self.parser.parse(
|
|
143
|
+
result = self.parser.parse(
|
|
144
|
+
AdsSymbolDataType.BIGTYPE, "REFERENCE TO ST_Foo", stream)
|
|
140
145
|
|
|
141
146
|
# Assert
|
|
142
147
|
self.assertEqual(result, "0001020304050607^")
|
|
@@ -146,7 +151,8 @@ class TestAdsSymbolParser(unittest.TestCase):
|
|
|
146
151
|
stream = make_stream(bytes(range(8)))
|
|
147
152
|
|
|
148
153
|
# Act
|
|
149
|
-
result = self.parser.parse(
|
|
154
|
+
result = self.parser.parse(
|
|
155
|
+
AdsSymbolDataType.BIGTYPE, "POINTER TO ST_Foo", stream)
|
|
150
156
|
|
|
151
157
|
# Assert
|
|
152
158
|
self.assertEqual(result, "0001020304050607*")
|
|
@@ -181,7 +187,8 @@ class TestAdsSymbolParser(unittest.TestCase):
|
|
|
181
187
|
|
|
182
188
|
# Act / Assert
|
|
183
189
|
with self.assertRaises(ValueError) as ctx:
|
|
184
|
-
self.parser.parse(AdsSymbolDataType.INT16,
|
|
190
|
+
self.parser.parse(AdsSymbolDataType.INT16,
|
|
191
|
+
"ARRAY OF nonsense", stream)
|
|
185
192
|
|
|
186
193
|
self.assertIn("Cannot parse array type", str(ctx.exception))
|
|
187
194
|
|
|
@@ -200,14 +207,18 @@ class TestAdsSymbolParser(unittest.TestCase):
|
|
|
200
207
|
|
|
201
208
|
def test_parse_struct_with_sub_items_returns_dict(self) -> None:
|
|
202
209
|
# Arrange
|
|
203
|
-
sub_a = make_datatype(
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
sub_a = make_datatype(
|
|
211
|
+
"a", size=2, data_type=AdsSymbolDataType.INT16, type_name="INT16", offs=0)
|
|
212
|
+
sub_b = make_datatype(
|
|
213
|
+
"b", size=2, data_type=AdsSymbolDataType.INT16, type_name="INT16", offs=2)
|
|
214
|
+
struct_type = make_datatype(
|
|
215
|
+
"ST_Pair", size=4, sub_items=[sub_a, sub_b])
|
|
206
216
|
self.parser.update_datatypes([struct_type])
|
|
207
217
|
stream = make_stream(struct.pack("<hh", 10, 20))
|
|
208
218
|
|
|
209
219
|
# Act
|
|
210
|
-
result = self.parser.parse(
|
|
220
|
+
result = self.parser.parse(
|
|
221
|
+
AdsSymbolDataType.BIGTYPE, "ST_Pair", stream)
|
|
211
222
|
|
|
212
223
|
# Assert
|
|
213
224
|
self.assertEqual(result, {"a": 10, "b": 20})
|
|
@@ -222,18 +233,151 @@ class TestAdsSymbolParser(unittest.TestCase):
|
|
|
222
233
|
|
|
223
234
|
self.assertIn("unknown type name", str(ctx.exception))
|
|
224
235
|
|
|
236
|
+
@parameterized.expand([
|
|
237
|
+
("BOOL", b"\x01", True),
|
|
238
|
+
("INT", struct.pack("<h", -7), -7),
|
|
239
|
+
("WORD", struct.pack("<H", 512), 512),
|
|
240
|
+
("DINT", struct.pack("<i", -100_000), -100_000),
|
|
241
|
+
("LWORD", struct.pack("<Q", 2**40), 2**40),
|
|
242
|
+
("LREAL", struct.pack("<d", 2.5), 2.5),
|
|
243
|
+
])
|
|
244
|
+
def test_parse_bigtype_with_elementary_name_resolves_by_name(
|
|
245
|
+
self, type_name: str, raw: bytes, expected: object
|
|
246
|
+
) -> None:
|
|
247
|
+
"""TwinCAT 2 reports BIGTYPE even for elementary types like INT."""
|
|
248
|
+
# Arrange
|
|
249
|
+
stream = make_stream(raw)
|
|
250
|
+
|
|
251
|
+
# Act
|
|
252
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, type_name, stream)
|
|
253
|
+
|
|
254
|
+
# Assert
|
|
255
|
+
self.assertEqual(result, expected)
|
|
256
|
+
|
|
257
|
+
def test_parse_time_returns_milliseconds(self) -> None:
|
|
258
|
+
# Arrange
|
|
259
|
+
stream = make_stream(struct.pack("<I", 90_500))
|
|
260
|
+
|
|
261
|
+
# Act
|
|
262
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, "TIME", stream)
|
|
263
|
+
|
|
264
|
+
# Assert
|
|
265
|
+
self.assertEqual(result, 90_500)
|
|
266
|
+
|
|
267
|
+
def test_parse_ltime_returns_nanoseconds(self) -> None:
|
|
268
|
+
# Arrange
|
|
269
|
+
stream = make_stream(struct.pack("<Q", 1_500_000_000))
|
|
270
|
+
|
|
271
|
+
# Act
|
|
272
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, "LTIME", stream)
|
|
273
|
+
|
|
274
|
+
# Assert
|
|
275
|
+
self.assertEqual(result, 1_500_000_000)
|
|
276
|
+
|
|
277
|
+
def test_parse_time_of_day_returns_milliseconds_since_midnight(self) -> None:
|
|
278
|
+
# Arrange
|
|
279
|
+
milliseconds_since_midnight = (13 * 3600 + 37 * 60 + 5) * 1000 + 250
|
|
280
|
+
stream = make_stream(struct.pack("<I", milliseconds_since_midnight))
|
|
281
|
+
|
|
282
|
+
# Act
|
|
283
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, "TOD", stream)
|
|
284
|
+
|
|
285
|
+
# Assert
|
|
286
|
+
self.assertEqual(result, milliseconds_since_midnight)
|
|
287
|
+
|
|
288
|
+
def test_parse_date_returns_epoch_seconds(self) -> None:
|
|
289
|
+
# Arrange
|
|
290
|
+
stream = make_stream(struct.pack("<I", 1_752_364_800)) # 2025-07-13
|
|
291
|
+
|
|
292
|
+
# Act
|
|
293
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, "DATE", stream)
|
|
294
|
+
|
|
295
|
+
# Assert
|
|
296
|
+
self.assertEqual(result, 1_752_364_800)
|
|
297
|
+
|
|
298
|
+
def test_parse_date_and_time_returns_epoch_seconds(self) -> None:
|
|
299
|
+
# Arrange
|
|
300
|
+
# 2025-07-13T11:45:06Z
|
|
301
|
+
stream = make_stream(struct.pack("<I", 1_752_407_106))
|
|
302
|
+
|
|
303
|
+
# Act
|
|
304
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, "DT", stream)
|
|
305
|
+
|
|
306
|
+
# Assert
|
|
307
|
+
self.assertEqual(result, 1_752_407_106)
|
|
308
|
+
|
|
309
|
+
@parameterized.expand([
|
|
310
|
+
("LTIME",),
|
|
311
|
+
("LTOD",),
|
|
312
|
+
("LTIME_OF_DAY",),
|
|
313
|
+
("LDATE",),
|
|
314
|
+
("LDT",),
|
|
315
|
+
("LDATE_AND_TIME",),
|
|
316
|
+
])
|
|
317
|
+
def test_parse_long_datetime_types_return_nanoseconds(self, type_name: str) -> None:
|
|
318
|
+
# Arrange
|
|
319
|
+
stream = make_stream(struct.pack("<Q", 1_752_407_106_000_000_000))
|
|
320
|
+
|
|
321
|
+
# Act
|
|
322
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, type_name, stream)
|
|
323
|
+
|
|
324
|
+
# Assert
|
|
325
|
+
self.assertEqual(result, 1_752_407_106_000_000_000)
|
|
326
|
+
|
|
327
|
+
@parameterized.expand([
|
|
328
|
+
("TIME_OF_DAY",),
|
|
329
|
+
("DATE_AND_TIME",),
|
|
330
|
+
])
|
|
331
|
+
def test_parse_long_form_datetime_aliases_return_integer(self, type_name: str) -> None:
|
|
332
|
+
# Arrange
|
|
333
|
+
stream = make_stream(struct.pack("<I", 42_000))
|
|
334
|
+
|
|
335
|
+
# Act
|
|
336
|
+
result = self.parser.parse(AdsSymbolDataType.BIGTYPE, type_name, stream)
|
|
337
|
+
|
|
338
|
+
# Assert
|
|
339
|
+
self.assertEqual(result, 42_000)
|
|
340
|
+
|
|
341
|
+
def test_parse_array_of_time_returns_millisecond_list(self) -> None:
|
|
342
|
+
# Arrange
|
|
343
|
+
stream = make_stream(struct.pack("<II", 1000, 2000))
|
|
344
|
+
|
|
345
|
+
# Act
|
|
346
|
+
result = self.parser.parse(
|
|
347
|
+
AdsSymbolDataType.BIGTYPE, "ARRAY [0..1] OF TIME", stream
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Assert
|
|
351
|
+
self.assertEqual(result, [1000, 2000])
|
|
352
|
+
|
|
353
|
+
def test_parse_struct_with_time_sub_item_returns_millisecond_field(self) -> None:
|
|
354
|
+
# Arrange
|
|
355
|
+
sub_time = make_datatype("duration", size=4, type_name="TIME", offs=0)
|
|
356
|
+
struct_type = make_datatype("ST_Timed", size=4, sub_items=[sub_time])
|
|
357
|
+
self.parser.update_datatypes([struct_type])
|
|
358
|
+
stream = make_stream(struct.pack("<I", 250))
|
|
359
|
+
|
|
360
|
+
# Act
|
|
361
|
+
result = self.parser.parse(
|
|
362
|
+
AdsSymbolDataType.BIGTYPE, "ST_Timed", stream)
|
|
363
|
+
|
|
364
|
+
# Assert
|
|
365
|
+
self.assertEqual(result, {"duration": 250})
|
|
366
|
+
|
|
225
367
|
def test_update_datatypes_registers_new_type_for_lookup(self) -> None:
|
|
226
368
|
# Arrange
|
|
227
369
|
struct_type = make_datatype(
|
|
228
370
|
"ST_Single",
|
|
229
371
|
size=2,
|
|
230
|
-
sub_items=[make_datatype(
|
|
372
|
+
sub_items=[make_datatype(
|
|
373
|
+
"v", size=2, data_type=AdsSymbolDataType.INT16, type_name="INT16")],
|
|
231
374
|
)
|
|
232
375
|
|
|
233
376
|
# Act
|
|
234
377
|
self.parser.update_datatypes([struct_type])
|
|
235
378
|
result = self.parser.parse(
|
|
236
|
-
AdsSymbolDataType.BIGTYPE, "ST_Single", make_stream(
|
|
379
|
+
AdsSymbolDataType.BIGTYPE, "ST_Single", make_stream(
|
|
380
|
+
struct.pack("<h", 42))
|
|
237
381
|
)
|
|
238
382
|
|
|
239
383
|
# Assert
|
|
@@ -217,6 +217,69 @@ class TestAdsTcpTransport(unittest.IsolatedAsyncioTestCase):
|
|
|
217
217
|
self.assertIsInstance(ctx.exception.__cause__, asyncio.IncompleteReadError)
|
|
218
218
|
|
|
219
219
|
|
|
220
|
+
class TestAdsTcpTransportReconnect(unittest.IsolatedAsyncioTestCase):
|
|
221
|
+
"""Reconnect supervision against a peer that accepts and instantly closes.
|
|
222
|
+
|
|
223
|
+
This models a PLC that completes the TCP handshake but rejects the
|
|
224
|
+
connection right away (e.g. the Beckhoff single-connection lockout).
|
|
225
|
+
Every connect *succeeds*, so only the stability-based backoff stands
|
|
226
|
+
between the supervisor and a zero-delay reconnect spin.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
async def asyncSetUp(self) -> None:
|
|
230
|
+
self.accept_count = 0
|
|
231
|
+
|
|
232
|
+
async def accept_and_reject(
|
|
233
|
+
_reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
234
|
+
) -> None:
|
|
235
|
+
self.accept_count += 1
|
|
236
|
+
writer.close()
|
|
237
|
+
|
|
238
|
+
self.server = await asyncio.start_server(
|
|
239
|
+
accept_and_reject, "127.0.0.1", 0
|
|
240
|
+
)
|
|
241
|
+
port = self.server.sockets[0].getsockname()[1]
|
|
242
|
+
|
|
243
|
+
self.transport = AdsTcpTransport(
|
|
244
|
+
src_address=make_ams_address(), ip="127.0.0.1", port=port
|
|
245
|
+
)
|
|
246
|
+
# Shrink the backoff so several reconnect cycles fit into a short
|
|
247
|
+
# observation window without slowing down the suite.
|
|
248
|
+
self.transport.RECONNECT_INITIAL_BACKOFF = 0.05
|
|
249
|
+
self.transport.RECONNECT_MAX_BACKOFF = 0.2
|
|
250
|
+
|
|
251
|
+
async def asyncTearDown(self) -> None:
|
|
252
|
+
await self.transport.disconnect()
|
|
253
|
+
self.server.close()
|
|
254
|
+
await self.server.wait_closed()
|
|
255
|
+
|
|
256
|
+
async def test_reconnect_attempts_stay_bounded_when_peer_rejects_instantly(
|
|
257
|
+
self,
|
|
258
|
+
) -> None:
|
|
259
|
+
# Arrange
|
|
260
|
+
window = 0.5
|
|
261
|
+
# The backoff must sleep at least 0.05 + 0.1 + 0.2 + 0.2 = 0.55s
|
|
262
|
+
# (before jitter) after the initial connect, so at most five accepts
|
|
263
|
+
# fit into the window. Anything near the bound still proves
|
|
264
|
+
# throttling; the unthrottled bug produced thousands per second.
|
|
265
|
+
max_expected_accepts = 6
|
|
266
|
+
|
|
267
|
+
# Act
|
|
268
|
+
await self.transport.connect()
|
|
269
|
+
await asyncio.sleep(window)
|
|
270
|
+
|
|
271
|
+
# Assert
|
|
272
|
+
self.assertLessEqual(
|
|
273
|
+
self.accept_count,
|
|
274
|
+
max_expected_accepts,
|
|
275
|
+
msg=(
|
|
276
|
+
f"Expected at most {max_expected_accepts} connection attempts "
|
|
277
|
+
f"in {window}s but the peer accepted {self.accept_count} — "
|
|
278
|
+
f"the reconnect backoff is not throttling"
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
220
283
|
class TestAdsTcpTransportKeepalive(unittest.TestCase):
|
|
221
284
|
"""TCP keepalive / liveness configuration applied to the active socket."""
|
|
222
285
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_datatype_by_name.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_datatype_upload.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev7 → aioads-0.1.0.dev9}/tests/unit/functions/test_ads_symbol_table_version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|