testoperations 0.2.0__tar.gz → 0.3.0__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 (45) hide show
  1. {testoperations-0.2.0/src/testoperations.egg-info → testoperations-0.3.0}/PKG-INFO +1 -1
  2. {testoperations-0.2.0 → testoperations-0.3.0}/pyproject.toml +1 -1
  3. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/network_endpoint.py +1 -1
  4. testoperations-0.3.0/src/testoperations/throughput.py +188 -0
  5. {testoperations-0.2.0 → testoperations-0.3.0/src/testoperations.egg-info}/PKG-INFO +1 -1
  6. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/SOURCES.txt +2 -0
  7. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_network_endpoint.py +0 -1
  8. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_sip_phone.py +1 -1
  9. testoperations-0.3.0/tests/test_throughput.py +177 -0
  10. {testoperations-0.2.0 → testoperations-0.3.0}/LICENSE +0 -0
  11. {testoperations-0.2.0 → testoperations-0.3.0}/NOTICE +0 -0
  12. {testoperations-0.2.0 → testoperations-0.3.0}/README.md +0 -0
  13. {testoperations-0.2.0 → testoperations-0.3.0}/setup.cfg +0 -0
  14. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/__init__.py +0 -0
  15. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/device_management.py +0 -0
  16. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/dhcp_client.py +0 -0
  17. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/firewall.py +0 -0
  18. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/homing.py +0 -0
  19. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/http_server.py +0 -0
  20. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/iperf_client.py +0 -0
  21. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/iperf_generator.py +0 -0
  22. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/netem_controller.py +0 -0
  23. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/pcap_capture.py +0 -0
  24. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/py.typed +0 -0
  25. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/sdwan.py +0 -0
  26. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/segmentation.py +0 -0
  27. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/sip_phone.py +0 -0
  28. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/tr069_server.py +0 -0
  29. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/wifi_client.py +0 -0
  30. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/dependency_links.txt +0 -0
  31. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/requires.txt +0 -0
  32. {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/top_level.txt +0 -0
  33. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_device_management.py +0 -0
  34. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_dhcp_client.py +0 -0
  35. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_firewall.py +0 -0
  36. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_homing.py +0 -0
  37. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_http_server.py +0 -0
  38. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_iperf_client.py +0 -0
  39. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_iperf_generator.py +0 -0
  40. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_netem_controller.py +0 -0
  41. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_pcap_capture.py +0 -0
  42. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_sdwan.py +0 -0
  43. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_segmentation.py +0 -0
  44. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_tr069_server.py +0 -0
  45. {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_wifi_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: testoperations
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Framework-agnostic, assertion-free composition functions over testprotocols capabilities.
5
5
  Author-email: Alottabits <rjvisser@alottabits.com>
6
6
  Maintainer-email: Alottabits <rjvisser@alottabits.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "testoperations"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Framework-agnostic, assertion-free composition functions over testprotocols capabilities."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = ["testprotocols>=0.1.0"]
@@ -38,7 +38,7 @@ def wait_for_endpoint_ready(
38
38
  while time.monotonic() < deadline:
39
39
  try:
40
40
  ip = endpoint.get_ipv4_addr()
41
- except Exception as exc: # noqa: BLE001
41
+ except Exception as exc:
42
42
  last_error = exc
43
43
  ip = ""
44
44
  if ip:
@@ -0,0 +1,188 @@
1
+ """Concurrent iperf3 throughput measurement over typed iperf capabilities.
2
+
3
+ Framing owned here (the reason this is a ``testoperations`` function and not a
4
+ step one-liner): start every receiver FIRST (one iperf3 server instance per
5
+ flow, each on its own port), snapshot each receiver log's completed-session
6
+ count, launch all senders together so the flows genuinely overlap, then poll
7
+ each receiver log until a NEW completed session appears and report its
8
+ measured rate. The RECEIVE side is read deliberately: the receiver sits behind
9
+ the device under test, so its rate is what actually crossed that device
10
+ (send-side rates include bytes a shaper may still be queueing or dropping).
11
+
12
+ Assertion-free and stdlib-only: returns facts (per-flow Mbit/s) and raises only
13
+ on operational failures (duplicate ports, a receiver that never produces a
14
+ completed session). Pass/fail thresholds belong to the caller.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import time
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ from testprotocols.iperf_client import IperfClient
26
+ from testprotocols.iperf_server import IperfServer
27
+
28
+ # A finished iperf3 session is flushed to the --logfile when the sender
29
+ # disconnects; allow a grace window after the nominal duration for that flush
30
+ # (and for clock skew between controller and endpoints).
31
+ DEFAULT_RESULT_TIMEOUT_S = 30.0
32
+ _POLL_INTERVAL_S = 1.0
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ThroughputFlow:
37
+ """One measured flow: *sender* pushes TCP to *dest_host*:*port*, *receiver* listens.
38
+
39
+ ``port`` must be unique across the flows of one measurement call — each
40
+ flow gets its own iperf3 server instance (and per-port logfile) on the
41
+ receiving device.
42
+ """
43
+
44
+ sender: IperfClient
45
+ receiver: IperfServer
46
+ dest_host: str
47
+ port: int
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class FlowThroughput:
52
+ """The measured receive-side rate of one flow."""
53
+
54
+ port: int
55
+ mbps: float
56
+
57
+
58
+ def iter_json_docs(text: str) -> list[Any]:
59
+ """Parse the top-level JSON documents concatenated in *text*, in order.
60
+
61
+ iperf3 appends one pretty-printed JSON document per session to its
62
+ ``--logfile``; a restarted server appends to the same per-port file, so a
63
+ log may hold several documents (and a trailing, still-open one while a
64
+ session is running). Documents are extracted with a string-aware brace
65
+ scanner; only complete, parseable documents are returned.
66
+ """
67
+ docs: list[Any] = []
68
+ depth = 0
69
+ start = -1
70
+ in_string = False
71
+ escaped = False
72
+ for i, ch in enumerate(text):
73
+ if in_string:
74
+ if escaped:
75
+ escaped = False
76
+ elif ch == "\\":
77
+ escaped = True
78
+ elif ch == '"':
79
+ in_string = False
80
+ continue
81
+ if ch == '"':
82
+ in_string = True
83
+ elif ch == "{":
84
+ if depth == 0:
85
+ start = i
86
+ depth += 1
87
+ elif ch == "}" and depth > 0:
88
+ depth -= 1
89
+ if depth == 0 and start >= 0:
90
+ try:
91
+ docs.append(json.loads(text[start : i + 1]))
92
+ except ValueError:
93
+ pass
94
+ start = -1
95
+ return docs
96
+
97
+
98
+ def count_sessions(log_text: str) -> int:
99
+ """The number of completed iperf3 session documents in *log_text*."""
100
+ return len(iter_json_docs(log_text))
101
+
102
+
103
+ def last_session_mbps(log_text: str) -> float | None:
104
+ """The receive-side rate (Mbit/s) of the LAST completed session, or ``None``.
105
+
106
+ Prefers ``end.sum_received.bits_per_second`` (TCP receive-side goodput);
107
+ falls back to ``end.sum.bits_per_second`` (UDP sessions). Returns ``None``
108
+ when no completed session carries either summary.
109
+ """
110
+ docs = iter_json_docs(log_text)
111
+ if not docs:
112
+ return None
113
+ end = docs[-1].get("end", {}) if isinstance(docs[-1], dict) else {}
114
+ for key in ("sum_received", "sum"):
115
+ bps = end.get(key, {}).get("bits_per_second")
116
+ if bps is not None:
117
+ return float(bps) / 1e6
118
+ return None
119
+
120
+
121
+ def measure_concurrent_throughput(
122
+ flows: Sequence[ThroughputFlow],
123
+ *,
124
+ duration_s: int = 10,
125
+ result_timeout_s: float = DEFAULT_RESULT_TIMEOUT_S,
126
+ sleep: Callable[[float], None] = time.sleep,
127
+ monotonic: Callable[[], float] = time.monotonic,
128
+ ) -> list[FlowThroughput]:
129
+ """Run all *flows* concurrently for *duration_s*; return per-flow receive rates.
130
+
131
+ Receivers are started before any sender (no connection race), every sender
132
+ is launched before the measurement wait (the flows overlap for the whole
133
+ duration), and both sides are stopped in a ``finally`` so no iperf process
134
+ outlives the call — also on failure. Results are ordered like *flows*.
135
+
136
+ Raises ``ValueError`` on duplicate ports (two flows would share a server
137
+ instance and logfile) and ``RuntimeError`` when a receiver produces no new
138
+ completed session within *duration_s* + *result_timeout_s*.
139
+ """
140
+ ports = [f.port for f in flows]
141
+ if len(set(ports)) != len(ports):
142
+ raise ValueError(f"flow ports must be unique per measurement, got {ports}")
143
+
144
+ started: list[tuple[ThroughputFlow, int, str, int]] = []
145
+ sender_pids: list[tuple[IperfClient, int]] = []
146
+ try:
147
+ for flow in flows:
148
+ receiver_pid, receiver_log = flow.receiver.start_traffic_receiver(flow.port)
149
+ prior_sessions = count_sessions(flow.receiver.get_iperf_logs(receiver_log))
150
+ started.append((flow, receiver_pid, receiver_log, prior_sessions))
151
+
152
+ for flow, _, _, _ in started:
153
+ sender_pid, _ = flow.sender.start_traffic_sender(
154
+ flow.dest_host, flow.port, time=duration_s
155
+ )
156
+ sender_pids.append((flow.sender, sender_pid))
157
+
158
+ sleep(float(duration_s))
159
+
160
+ results: list[FlowThroughput] = []
161
+ deadline = monotonic() + result_timeout_s
162
+ for flow, _, receiver_log, prior_sessions in started:
163
+ while True:
164
+ log_text = flow.receiver.get_iperf_logs(receiver_log)
165
+ if count_sessions(log_text) > prior_sessions:
166
+ mbps = last_session_mbps(log_text)
167
+ if mbps is not None:
168
+ results.append(FlowThroughput(port=flow.port, mbps=mbps))
169
+ break
170
+ if monotonic() >= deadline:
171
+ raise RuntimeError(
172
+ f"iperf receiver on port {flow.port} produced no completed "
173
+ f"session within {result_timeout_s}s after the "
174
+ f"{duration_s}s measurement window"
175
+ )
176
+ sleep(_POLL_INTERVAL_S)
177
+ return results
178
+ finally:
179
+ for sender, sender_pid in sender_pids:
180
+ try:
181
+ sender.stop_traffic(sender_pid)
182
+ except Exception:
183
+ pass
184
+ for flow, receiver_pid, _, _ in started:
185
+ try:
186
+ flow.receiver.stop_traffic(receiver_pid)
187
+ except Exception:
188
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: testoperations
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Framework-agnostic, assertion-free composition functions over testprotocols capabilities.
5
5
  Author-email: Alottabits <rjvisser@alottabits.com>
6
6
  Maintainer-email: Alottabits <rjvisser@alottabits.com>
@@ -17,6 +17,7 @@ src/testoperations/py.typed
17
17
  src/testoperations/sdwan.py
18
18
  src/testoperations/segmentation.py
19
19
  src/testoperations/sip_phone.py
20
+ src/testoperations/throughput.py
20
21
  src/testoperations/tr069_server.py
21
22
  src/testoperations/wifi_client.py
22
23
  src/testoperations.egg-info/PKG-INFO
@@ -37,5 +38,6 @@ tests/test_pcap_capture.py
37
38
  tests/test_sdwan.py
38
39
  tests/test_segmentation.py
39
40
  tests/test_sip_phone.py
41
+ tests/test_throughput.py
40
42
  tests/test_tr069_server.py
41
43
  tests/test_wifi_client.py
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from unittest.mock import MagicMock
6
6
 
7
7
  import pytest
8
-
9
8
  from testoperations.network_endpoint import wait_for_endpoint_ready
10
9
 
11
10
 
@@ -17,7 +17,7 @@ from testoperations.sip_phone import (
17
17
  class TestCallAPhone:
18
18
  def test_calls_off_hook_and_dial(self):
19
19
  caller = MagicMock()
20
-
20
+
21
21
  call_a_phone(caller, "2002")
22
22
 
23
23
  caller.off_hook.assert_called_once_with()
@@ -0,0 +1,177 @@
1
+ """Tests for testoperations.throughput (concurrent iperf3 measurement framing)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import MagicMock
7
+
8
+ import pytest
9
+ from testoperations.throughput import (
10
+ ThroughputFlow,
11
+ count_sessions,
12
+ iter_json_docs,
13
+ last_session_mbps,
14
+ measure_concurrent_throughput,
15
+ )
16
+
17
+
18
+ def _session_doc(rx_bps: float | None = None, sum_bps: float | None = None) -> str:
19
+ end: dict[str, dict[str, float]] = {}
20
+ if rx_bps is not None:
21
+ end["sum_received"] = {"bits_per_second": rx_bps}
22
+ if sum_bps is not None:
23
+ end["sum"] = {"bits_per_second": sum_bps}
24
+ return json.dumps({"start": {"test_start": {}}, "end": end}, indent=2)
25
+
26
+
27
+ # --- JSON log parsing ---------------------------------------------------------
28
+
29
+
30
+ class TestLogParsing:
31
+ def test_iter_json_docs_multiple_documents(self) -> None:
32
+ text = _session_doc(rx_bps=1e6) + "\n" + _session_doc(rx_bps=2e6)
33
+ assert len(iter_json_docs(text)) == 2
34
+
35
+ def test_iter_json_docs_ignores_trailing_incomplete_document(self) -> None:
36
+ text = _session_doc(rx_bps=1e6) + '\n{"start": {"test_start"'
37
+ assert len(iter_json_docs(text)) == 1
38
+
39
+ def test_iter_json_docs_braces_inside_strings_do_not_confuse_scanner(self) -> None:
40
+ text = json.dumps({"note": "brace } in { string", "end": {}})
41
+ docs = iter_json_docs(text)
42
+ assert len(docs) == 1
43
+ assert docs[0]["note"] == "brace } in { string"
44
+
45
+ def test_count_sessions_empty_log(self) -> None:
46
+ assert count_sessions("") == 0
47
+
48
+ def test_last_session_mbps_prefers_receive_side_of_last_doc(self) -> None:
49
+ text = _session_doc(rx_bps=100e6) + "\n" + _session_doc(rx_bps=47.5e6)
50
+ assert last_session_mbps(text) == pytest.approx(47.5)
51
+
52
+ def test_last_session_mbps_falls_back_to_sum(self) -> None:
53
+ assert last_session_mbps(_session_doc(sum_bps=9e6)) == pytest.approx(9.0)
54
+
55
+ def test_last_session_mbps_none_when_no_summary(self) -> None:
56
+ assert last_session_mbps("") is None
57
+ assert last_session_mbps(json.dumps({"end": {}})) is None
58
+
59
+
60
+ # --- measure_concurrent_throughput ---------------------------------------------
61
+
62
+
63
+ def _flow(
64
+ port: int, mbps: float, *, stale_docs: int = 0
65
+ ) -> tuple[ThroughputFlow, MagicMock, MagicMock]:
66
+ """A flow over mocked capabilities; returns (flow, sender_mock, receiver_mock).
67
+
68
+ The fake ``get_iperf_logs`` returns only the *stale* documents until the
69
+ sender has been started, then appends the fresh session — mirroring the
70
+ real sequencing (session completes only after the measurement window).
71
+ """
72
+ stale = "\n".join(_session_doc(rx_bps=1e6) for _ in range(stale_docs))
73
+ fresh = stale + "\n" + _session_doc(rx_bps=mbps * 1e6)
74
+
75
+ sender = MagicMock()
76
+ sender.start_traffic_sender.return_value = (4000 + port, f"/tmp/cl_{port}.log")
77
+
78
+ receiver = MagicMock()
79
+ receiver.start_traffic_receiver.return_value = (5000 + port, f"/tmp/rx_{port}.log")
80
+ receiver.get_iperf_logs.side_effect = lambda _log: (
81
+ fresh if sender.start_traffic_sender.called else stale
82
+ )
83
+ flow = ThroughputFlow(sender=sender, receiver=receiver, dest_host="192.168.32.3", port=port)
84
+ return flow, sender, receiver
85
+
86
+
87
+ class TestMeasureConcurrentThroughput:
88
+ def test_single_flow_returns_receive_rate(self) -> None:
89
+ flow, _, _ = _flow(5301, mbps=47.5)
90
+ results = measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
91
+ assert [r.port for r in results] == [5301]
92
+ assert results[0].mbps == pytest.approx(47.5)
93
+
94
+ def test_receiver_started_before_sender_with_expected_args(self) -> None:
95
+ flow, sender, receiver = _flow(5301, mbps=10.0)
96
+ order: list[str] = []
97
+
98
+ def _rx(port: int) -> tuple[int, str]:
99
+ order.append(f"rx:{port}")
100
+ return (5301, "/tmp/rx.log")
101
+
102
+ def _tx(host: str, port: int, time: int) -> tuple[int, str]:
103
+ order.append(f"tx:{host}:{port}:t={time}")
104
+ return (4001, "/tmp/tx.log")
105
+
106
+ receiver.start_traffic_receiver.side_effect = _rx
107
+ sender.start_traffic_sender.side_effect = _tx
108
+ measure_concurrent_throughput([flow], duration_s=7, sleep=lambda _s: None)
109
+ assert order == ["rx:5301", "tx:192.168.32.3:5301:t=7"]
110
+
111
+ def test_stale_sessions_in_log_are_not_read_as_results(self) -> None:
112
+ # Two stale docs at 1 Mbps sit in the per-port log from an earlier run;
113
+ # the fresh session measures 42 Mbps and must be the one reported.
114
+ flow, _, _ = _flow(5301, mbps=42.0, stale_docs=2)
115
+ results = measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
116
+ assert results[0].mbps == pytest.approx(42.0)
117
+
118
+ def test_concurrent_flows_all_senders_started_before_wait_and_ordered_results(self) -> None:
119
+ flow_a, sender_a, _ = _flow(5301, mbps=9.5)
120
+ flow_b, sender_b, _ = _flow(5302, mbps=11.0)
121
+ sleeps: list[float] = []
122
+ results = measure_concurrent_throughput(
123
+ [flow_a, flow_b], duration_s=10, sleep=sleeps.append
124
+ )
125
+ assert [r.port for r in results] == [5301, 5302]
126
+ assert [r.mbps for r in results] == [pytest.approx(9.5), pytest.approx(11.0)]
127
+ # Both senders launched; the measurement wait happened exactly once.
128
+ sender_a.start_traffic_sender.assert_called_once()
129
+ sender_b.start_traffic_sender.assert_called_once()
130
+ assert sleeps.count(10.0) == 1
131
+
132
+ def test_duplicate_ports_rejected(self) -> None:
133
+ flow_a, _, _ = _flow(5301, mbps=1.0)
134
+ flow_b, _, _ = _flow(5301, mbps=2.0)
135
+ with pytest.raises(ValueError, match="unique"):
136
+ measure_concurrent_throughput([flow_a, flow_b], sleep=lambda _s: None)
137
+
138
+ def test_timeout_when_no_new_session_appears(self) -> None:
139
+ flow, _, receiver = _flow(5301, mbps=42.0)
140
+ receiver.get_iperf_logs.side_effect = lambda _log: "" # never completes
141
+ clock = iter(float(t) for t in range(0, 1000, 5))
142
+ with pytest.raises(RuntimeError, match="port 5301"):
143
+ measure_concurrent_throughput(
144
+ [flow],
145
+ duration_s=10,
146
+ result_timeout_s=20.0,
147
+ sleep=lambda _s: None,
148
+ monotonic=lambda: next(clock),
149
+ )
150
+
151
+ def test_both_sides_stopped_on_success(self) -> None:
152
+ flow, sender, receiver = _flow(5301, mbps=5.0)
153
+ measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
154
+ sender.stop_traffic.assert_called_once_with(4000 + 5301)
155
+ receiver.stop_traffic.assert_called_once_with(5000 + 5301)
156
+
157
+ def test_cleanup_runs_even_when_measurement_times_out(self) -> None:
158
+ flow, sender, receiver = _flow(5301, mbps=5.0)
159
+ receiver.get_iperf_logs.side_effect = lambda _log: ""
160
+ clock = iter(float(t) for t in range(0, 1000, 5))
161
+ with pytest.raises(RuntimeError):
162
+ measure_concurrent_throughput(
163
+ [flow],
164
+ duration_s=10,
165
+ result_timeout_s=15.0,
166
+ sleep=lambda _s: None,
167
+ monotonic=lambda: next(clock),
168
+ )
169
+ sender.stop_traffic.assert_called_once()
170
+ receiver.stop_traffic.assert_called_once()
171
+
172
+ def test_cleanup_swallows_stop_errors(self) -> None:
173
+ flow, sender, receiver = _flow(5301, mbps=5.0)
174
+ sender.stop_traffic.side_effect = RuntimeError("already gone")
175
+ receiver.stop_traffic.side_effect = RuntimeError("already gone")
176
+ results = measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
177
+ assert results[0].mbps == pytest.approx(5.0)
File without changes
File without changes
File without changes
File without changes