aioads 0.1.0.dev6__tar.gz → 0.1.0.dev8__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.dev6 → aioads-0.1.0.dev8}/.github/workflows/ci.yml +1 -1
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/.github/workflows/publish.yml +1 -1
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/PKG-INFO +1 -1
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/transport.py +78 -3
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/pyproject.toml +1 -1
- aioads-0.1.0.dev8/tests/integration/test_memory.py +192 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_transport.py +132 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/.github/dependabot.yml +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/.gitignore +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/LICENSE +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/README.md +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ads_client.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ads_symbol_parser.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ams_service_port.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_add_notification.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/commands/errors.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_sum_read.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/py.typed +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/docs/unittest_style_guide.html +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/pdm.lock +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/__init__.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/builders.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/README.md +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/__init__.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/base.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/config.example.toml +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/config.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/test_connection.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/test_performance.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/integration/test_read_symbols.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/__init__.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/__init__.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_add_notification.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_command.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read_state.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read_write.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_write.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_write_state.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/commands/test_errors.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/__init__.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_enable_route.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_function.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_sum_read.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ads_client.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ads_error_codes.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ads_notifications.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ads_symbol_parser.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ams_address.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ams_header.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_errors.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/test_stream.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/utils/__init__.py +0 -0
- {aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/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
|
|
|
@@ -7,6 +7,7 @@ import enum
|
|
|
7
7
|
import logging
|
|
8
8
|
import random
|
|
9
9
|
import re
|
|
10
|
+
import socket
|
|
10
11
|
from abc import ABC
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from dataclasses import field
|
|
@@ -210,6 +211,30 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
210
211
|
CONNECT_TIMEOUT = 30.0
|
|
211
212
|
RECONNECT_INITIAL_BACKOFF = 1.0
|
|
212
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
|
+
"""
|
|
222
|
+
|
|
223
|
+
# TCP keepalive / liveness tuning.
|
|
224
|
+
# Without these, a half-open connection (peer power-cut, cable pull, NAT
|
|
225
|
+
# idle-eviction) is never detected: no FIN/RST arrives, so the read loop
|
|
226
|
+
# blocks forever and the supervisor never reconnects. These force the kernel
|
|
227
|
+
# to probe an idle peer and to bound how long unacked writes wait, so a dead
|
|
228
|
+
# link surfaces as a socket error within ~KEEPALIVE_IDLE + KEEPALIVE_INTERVAL
|
|
229
|
+
# * KEEPALIVE_COUNT seconds.
|
|
230
|
+
KEEPALIVE_IDLE = 10
|
|
231
|
+
"""Seconds of idle before the kernel starts sending keepalive probes (Linux: TCP_KEEPIDLE)."""
|
|
232
|
+
KEEPALIVE_INTERVAL = 3
|
|
233
|
+
"""Seconds between keepalive probes (Linux: TCP_KEEPINTVL)."""
|
|
234
|
+
KEEPALIVE_COUNT = 3
|
|
235
|
+
"""Unanswered probes before the connection is considered dead (Linux: TCP_KEEPCNT)."""
|
|
236
|
+
USER_TIMEOUT_MS = 15_000
|
|
237
|
+
"""Milliseconds an unacked write may wait before the socket errors (Linux: TCP_USER_TIMEOUT)."""
|
|
213
238
|
|
|
214
239
|
def __init__(
|
|
215
240
|
self,
|
|
@@ -279,10 +304,43 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
279
304
|
reader, writer = await asyncio.wait_for(
|
|
280
305
|
asyncio.open_connection(self.ip, self.port), self.CONNECT_TIMEOUT
|
|
281
306
|
)
|
|
307
|
+
|
|
308
|
+
self._enable_keepalive(writer)
|
|
282
309
|
async with self._stream_lock:
|
|
283
310
|
self._stream = (reader, writer)
|
|
284
311
|
self._state = ConnectionState.CONNECTED
|
|
285
312
|
|
|
313
|
+
def _enable_keepalive(self, writer: asyncio.StreamWriter) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Enable TCP keepalive so a half-open connection is detected
|
|
316
|
+
even when no FIN/RST is ever sent.
|
|
317
|
+
|
|
318
|
+
The per-option settings beyond SO_KEEPALIVE are platform specific; each
|
|
319
|
+
is applied only when the running platform exposes it, so this stays a
|
|
320
|
+
no-op on systems that lack the knob rather than raising.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
sock: socket.socket | None = writer.get_extra_info("socket")
|
|
324
|
+
if sock is None:
|
|
325
|
+
self.logger.warning(
|
|
326
|
+
"Failed to enable TCP keepalive: StreamWriter has no socket"
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
332
|
+
for opt_name, value in (
|
|
333
|
+
("TCP_KEEPIDLE", self.KEEPALIVE_IDLE),
|
|
334
|
+
("TCP_KEEPINTVL", self.KEEPALIVE_INTERVAL),
|
|
335
|
+
("TCP_KEEPCNT", self.KEEPALIVE_COUNT),
|
|
336
|
+
("TCP_USER_TIMEOUT", self.USER_TIMEOUT_MS),
|
|
337
|
+
):
|
|
338
|
+
opt = getattr(socket, opt_name, None)
|
|
339
|
+
if opt is not None:
|
|
340
|
+
sock.setsockopt(socket.IPPROTO_TCP, opt, value)
|
|
341
|
+
except OSError as e:
|
|
342
|
+
self.logger.warning("Failed to enable TCP keepalive: %s", e)
|
|
343
|
+
|
|
286
344
|
async def _teardown_stream(self, ex: BaseException):
|
|
287
345
|
"""
|
|
288
346
|
Fail all in-flight requests and close the current stream (if any).
|
|
@@ -359,12 +417,19 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
359
417
|
trigger competing reconnect attempts. Senders fail fast instead (see
|
|
360
418
|
:meth:`request`), which also respects the Beckhoff single-connection
|
|
361
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.
|
|
362
425
|
"""
|
|
363
426
|
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
427
|
+
loop = asyncio.get_running_loop()
|
|
364
428
|
while self._state is not ConnectionState.CLOSED:
|
|
429
|
+
connected_at = loop.time()
|
|
365
430
|
try:
|
|
366
431
|
await self._read_loop()
|
|
367
|
-
except asyncio.CancelledError:
|
|
432
|
+
except asyncio.CancelledError: # pylint: disable=try-except-raise
|
|
368
433
|
# Except everything... except a cancellation event
|
|
369
434
|
raise
|
|
370
435
|
except Exception as e:
|
|
@@ -376,13 +441,23 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
376
441
|
self._state = ConnectionState.RECONNECTING
|
|
377
442
|
await self._teardown_stream(ConnectionError("Connection lost"))
|
|
378
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
|
+
|
|
379
455
|
while self._state is not ConnectionState.CLOSED:
|
|
380
456
|
try:
|
|
381
457
|
await self._open()
|
|
382
458
|
self.logger.info("Reconnected to the remote")
|
|
383
|
-
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
384
459
|
break
|
|
385
|
-
except asyncio.CancelledError:
|
|
460
|
+
except asyncio.CancelledError: # pylint: disable=try-except-raise
|
|
386
461
|
raise
|
|
387
462
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
388
463
|
self.logger.info(
|
|
@@ -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
|
+
)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import socket
|
|
5
6
|
import unittest
|
|
6
7
|
from unittest.mock import Mock
|
|
7
8
|
|
|
@@ -216,5 +217,136 @@ class TestAdsTcpTransport(unittest.IsolatedAsyncioTestCase):
|
|
|
216
217
|
self.assertIsInstance(ctx.exception.__cause__, asyncio.IncompleteReadError)
|
|
217
218
|
|
|
218
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
|
+
|
|
283
|
+
class TestAdsTcpTransportKeepalive(unittest.TestCase):
|
|
284
|
+
"""TCP keepalive / liveness configuration applied to the active socket."""
|
|
285
|
+
|
|
286
|
+
def setUp(self) -> None:
|
|
287
|
+
self.transport = AdsTcpTransport(
|
|
288
|
+
src_address=make_ams_address(), ip="127.0.0.1"
|
|
289
|
+
)
|
|
290
|
+
# A real AF_INET TCP socket whose options we can read back. It does not
|
|
291
|
+
# need to be connected to accept these socket options.
|
|
292
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
293
|
+
self.addCleanup(self.sock.close)
|
|
294
|
+
# A StreamWriter exposes its underlying socket via get_extra_info("socket").
|
|
295
|
+
self.writer = Mock()
|
|
296
|
+
self.writer.get_extra_info.return_value = self.sock
|
|
297
|
+
|
|
298
|
+
def test_enable_keepalive_turns_on_so_keepalive(self) -> None:
|
|
299
|
+
# Act
|
|
300
|
+
self.transport._enable_keepalive(self.writer)
|
|
301
|
+
|
|
302
|
+
# Assert
|
|
303
|
+
self.assertEqual(
|
|
304
|
+
self.sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE), 1
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@unittest.skipUnless(
|
|
308
|
+
hasattr(socket, "TCP_KEEPIDLE"), "platform lacks TCP keepalive tuning"
|
|
309
|
+
)
|
|
310
|
+
def test_enable_keepalive_applies_configured_tuning_values(self) -> None:
|
|
311
|
+
# Act
|
|
312
|
+
self.transport._enable_keepalive(self.writer)
|
|
313
|
+
|
|
314
|
+
# Assert — each kernel knob reflects the transport's configured constant
|
|
315
|
+
expected = [
|
|
316
|
+
("TCP_KEEPIDLE", AdsTcpTransport.KEEPALIVE_IDLE),
|
|
317
|
+
("TCP_KEEPINTVL", AdsTcpTransport.KEEPALIVE_INTERVAL),
|
|
318
|
+
("TCP_KEEPCNT", AdsTcpTransport.KEEPALIVE_COUNT),
|
|
319
|
+
("TCP_USER_TIMEOUT", AdsTcpTransport.USER_TIMEOUT_MS),
|
|
320
|
+
]
|
|
321
|
+
for opt_name, value in expected:
|
|
322
|
+
opt = getattr(socket, opt_name, None)
|
|
323
|
+
if opt is None:
|
|
324
|
+
continue
|
|
325
|
+
with self.subTest(option=opt_name):
|
|
326
|
+
self.assertEqual(
|
|
327
|
+
self.sock.getsockopt(socket.IPPROTO_TCP, opt), value
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def test_enable_keepalive_without_socket_does_not_raise(self) -> None:
|
|
331
|
+
# Arrange — a StreamWriter without an underlying socket
|
|
332
|
+
self.writer.get_extra_info.return_value = None
|
|
333
|
+
|
|
334
|
+
# Act / Assert — keepalive is best-effort, so it is skipped quietly
|
|
335
|
+
try:
|
|
336
|
+
self.transport._enable_keepalive(self.writer)
|
|
337
|
+
except Exception as e: # pragma: no cover - failure path
|
|
338
|
+
self.fail(f"_enable_keepalive must not raise when no socket: {e}")
|
|
339
|
+
|
|
340
|
+
def test_enable_keepalive_on_closed_socket_does_not_raise(self) -> None:
|
|
341
|
+
# Arrange — a closed socket makes setsockopt fail with OSError
|
|
342
|
+
self.sock.close()
|
|
343
|
+
|
|
344
|
+
# Act / Assert — keepalive is best-effort, so the error is swallowed
|
|
345
|
+
try:
|
|
346
|
+
self.transport._enable_keepalive(self.writer)
|
|
347
|
+
except OSError:
|
|
348
|
+
self.fail("_enable_keepalive must not propagate socket errors")
|
|
349
|
+
|
|
350
|
+
|
|
219
351
|
if __name__ == "__main__":
|
|
220
352
|
unittest.main()
|
|
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
|
|
File without changes
|
{aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_datatype_by_name.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_datatype_upload.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev6 → aioads-0.1.0.dev8}/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
|
|
File without changes
|