aioads 0.1.0.dev7__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.
Files changed (99) hide show
  1. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/.github/workflows/ci.yml +1 -1
  2. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/.github/workflows/publish.yml +1 -1
  3. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/PKG-INFO +1 -1
  4. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/transport.py +26 -1
  5. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/pyproject.toml +1 -1
  6. aioads-0.1.0.dev8/tests/integration/test_memory.py +192 -0
  7. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_transport.py +63 -0
  8. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/.github/dependabot.yml +0 -0
  9. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/.gitignore +0 -0
  10. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/.vscode/settings.json +0 -0
  11. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/LICENSE +0 -0
  12. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/README.md +0 -0
  13. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ads_client.py +0 -0
  14. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ads_error_codes.py +0 -0
  15. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ads_notifications.py +0 -0
  16. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ads_symbol_cache.py +0 -0
  17. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ads_symbol_parser.py +0 -0
  18. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ams_address.py +0 -0
  19. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ams_header.py +0 -0
  20. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ams_service_port.py +0 -0
  21. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/ams_tcp_header.py +0 -0
  22. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_add_notification.py +0 -0
  23. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_command.py +0 -0
  24. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_delete_notification.py +0 -0
  25. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_read.py +0 -0
  26. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_read_device_info.py +0 -0
  27. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_read_state.py +0 -0
  28. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_read_write.py +0 -0
  29. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_write.py +0 -0
  30. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/ads_write_state.py +0 -0
  31. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/commands/errors.py +0 -0
  32. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/errors.py +0 -0
  33. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_enable_route.py +0 -0
  34. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_function.py +0 -0
  35. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_sum_read.py +0 -0
  36. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_sum_read_write.py +0 -0
  37. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  38. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  39. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
  40. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_table_version.py +0 -0
  41. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_upload.py +0 -0
  42. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/functions/ads_symbol_upload_info.py +0 -0
  43. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/py.typed +0 -0
  44. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/stream.py +0 -0
  45. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/aioads/utils/local_ip.py +0 -0
  46. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/docs/transmission_mode.md +0 -0
  47. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/docs/unittest_style_guide.html +0 -0
  48. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/examples/read_cmd_reuse_mqtt.py +0 -0
  49. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/examples/read_cycles.py +0 -0
  50. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/examples/read_cycles_mqtt.py +0 -0
  51. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/examples/read_multiple.py +0 -0
  52. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/examples/read_multiple_mqtt.py +0 -0
  53. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/examples/read_single.py +0 -0
  54. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/pdm.lock +0 -0
  55. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/__init__.py +0 -0
  56. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/builders.py +0 -0
  57. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/README.md +0 -0
  58. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/__init__.py +0 -0
  59. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/base.py +0 -0
  60. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/config.example.toml +0 -0
  61. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/config.py +0 -0
  62. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/test_connection.py +0 -0
  63. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/test_performance.py +0 -0
  64. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/integration/test_read_symbols.py +0 -0
  65. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/__init__.py +0 -0
  66. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/__init__.py +0 -0
  67. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_add_notification.py +0 -0
  68. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_command.py +0 -0
  69. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_delete_notification.py +0 -0
  70. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read.py +0 -0
  71. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read_device_info.py +0 -0
  72. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read_state.py +0 -0
  73. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_read_write.py +0 -0
  74. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_write.py +0 -0
  75. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_ads_write_state.py +0 -0
  76. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/commands/test_errors.py +0 -0
  77. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/__init__.py +0 -0
  78. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_enable_route.py +0 -0
  79. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_function.py +0 -0
  80. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_sum_read.py +0 -0
  81. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_sum_read_write.py +0 -0
  82. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
  83. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
  84. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +0 -0
  85. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
  86. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
  87. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
  88. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ads_client.py +0 -0
  89. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ads_error_codes.py +0 -0
  90. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ads_notifications.py +0 -0
  91. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ads_symbol_cache.py +0 -0
  92. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ads_symbol_parser.py +0 -0
  93. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ams_address.py +0 -0
  94. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ams_header.py +0 -0
  95. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_ams_tcp_header.py +0 -0
  96. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_errors.py +0 -0
  97. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/test_stream.py +0 -0
  98. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/utils/__init__.py +0 -0
  99. {aioads-0.1.0.dev7 → aioads-0.1.0.dev8}/tests/unit/utils/test_local_ip.py +0 -0
@@ -23,7 +23,7 @@ jobs:
23
23
  python-version: ["3.12", "3.13"]
24
24
 
25
25
  steps:
26
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
27
27
  with:
28
28
  persist-credentials: false
29
29
 
@@ -18,7 +18,7 @@ jobs:
18
18
  name: Build distribution
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22
22
  with:
23
23
  persist-credentials: false
24
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioads
3
- Version: 0.1.0.dev7
3
+ Version: 0.1.0.dev8
4
4
  Summary: An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs
5
5
  Author-email: MkKiefer <102972583+MkKiefer@users.noreply.github.com>
6
6
  License: MIT
@@ -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.dev7"
3
+ version = "0.1.0.dev8"
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" },
@@ -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
+ )
@@ -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