readerboard 0.7.0__tar.gz → 0.7.1__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 (67) hide show
  1. {readerboard-0.7.0/readerboard.egg-info → readerboard-0.7.1}/PKG-INFO +18 -2
  2. {readerboard-0.7.0 → readerboard-0.7.1}/README.md +16 -0
  3. {readerboard-0.7.0 → readerboard-0.7.1}/pyproject.toml +7 -2
  4. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/__init__.py +1 -1
  5. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/transport/serial_link.py +63 -0
  6. {readerboard-0.7.0 → readerboard-0.7.1/readerboard.egg-info}/PKG-INFO +18 -2
  7. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard.egg-info/requires.txt +1 -1
  8. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_transport.py +101 -1
  9. {readerboard-0.7.0 → readerboard-0.7.1}/LICENSE +0 -0
  10. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/__main__.py +0 -0
  11. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/api/__init__.py +0 -0
  12. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/api/app.py +0 -0
  13. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/api/deps.py +0 -0
  14. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/api/errors.py +0 -0
  15. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/api/models.py +0 -0
  16. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/api/routes.py +0 -0
  17. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/config.py +0 -0
  18. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/icons.py +0 -0
  19. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/logging_setup.py +0 -0
  20. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/names.py +0 -0
  21. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/protocol/__init__.py +0 -0
  22. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/protocol/constants.py +0 -0
  23. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/protocol/frames.py +0 -0
  24. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/protocol/markup.py +0 -0
  25. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/protocol/replies.py +0 -0
  26. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/protocol/tokens.py +0 -0
  27. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/py.typed +0 -0
  28. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/services/__init__.py +0 -0
  29. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/services/alerts.py +0 -0
  30. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/services/clock.py +0 -0
  31. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/services/commands.py +0 -0
  32. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/services/registry.py +0 -0
  33. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/sign/__init__.py +0 -0
  34. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/sign/controller.py +0 -0
  35. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/sign/layout.py +0 -0
  36. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/sign/pool.py +0 -0
  37. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/sign/state.py +0 -0
  38. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/transport/__init__.py +0 -0
  39. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/transport/base.py +0 -0
  40. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard/transport/fake.py +0 -0
  41. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard.egg-info/SOURCES.txt +0 -0
  42. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard.egg-info/dependency_links.txt +0 -0
  43. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard.egg-info/entry_points.txt +0 -0
  44. {readerboard-0.7.0 → readerboard-0.7.1}/readerboard.egg-info/top_level.txt +0 -0
  45. {readerboard-0.7.0 → readerboard-0.7.1}/setup.cfg +0 -0
  46. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_alerts.py +0 -0
  47. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_api.py +0 -0
  48. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_clock.py +0 -0
  49. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_component_names.py +0 -0
  50. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_config.py +0 -0
  51. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_constant_values.py +0 -0
  52. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_controller.py +0 -0
  53. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_frames.py +0 -0
  54. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_icons.py +0 -0
  55. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_launch_configurations.py +0 -0
  56. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_logging_setup.py +0 -0
  57. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_markup.py +0 -0
  58. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_open_docs.py +0 -0
  59. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_pictures.py +0 -0
  60. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_pool.py +0 -0
  61. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_registry.py +0 -0
  62. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_replies.py +0 -0
  63. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_run_against_a_sign.py +0 -0
  64. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_run_with_simulator.py +0 -0
  65. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_state.py +0 -0
  66. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_tool_icons.py +0 -0
  67. {readerboard-0.7.0 → readerboard-0.7.1}/tests/test_variables.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync
5
5
  Author: mjaksn
6
6
  License-Expression: MIT
@@ -34,7 +34,7 @@ Requires-Dist: pydantic-settings<3,>=2.15.0
34
34
  Requires-Dist: pyserial<4,>=3.5
35
35
  Requires-Dist: tzdata>=2026.3; sys_platform == "win32"
36
36
  Provides-Extra: dev
37
- Requires-Dist: pytest<10,>=9.1.1; extra == "dev"
37
+ Requires-Dist: pytest<9.2,>=9.1.1; extra == "dev"
38
38
  Requires-Dist: pytest-asyncio<2,>=1.4.0; extra == "dev"
39
39
  Requires-Dist: httpx2<3,>=2.10.0; extra == "dev"
40
40
  Requires-Dist: anyio<4.15,>=4.14.2; extra == "dev"
@@ -184,6 +184,22 @@ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
184
184
  probably negotiating telnet rather than passing bytes through, and `rfc2217://`
185
185
  is the form that speaks that.
186
186
 
187
+ An adapter that drops off the network says nothing as it goes. The service asks
188
+ the operating system to give a network link up about a minute after the adapter
189
+ stops answering: on Linux, a minute after the first write it did not
190
+ acknowledge, or, if nothing was written, about a minute after it last answered,
191
+ found by keepalive probes. A platform that does not take one of these options
192
+ keeps its own default for it, which can be much longer. The service itself only
193
+ finds out at its first read or write after the link has gone, and `GET /health`
194
+ shows `"connected": false` from then on. A sign with a variable updated every
195
+ minute is written to that often. One left alone is written to at the refresh,
196
+ every `refresh_interval_seconds`, which is 15 minutes by default, and a refresh
197
+ that lands before the link has gone is buffered like any other write, so on a
198
+ quiet sign `link.connected` can stay true for a refresh interval and a minute
199
+ after the adapter has gone. It is still worth a monitor watching, and needs no
200
+ key, but a ping of the adapter answers within a minute however quiet the sign
201
+ is, and is the better alarm.
202
+
187
203
  ### The API key, and config.local.toml
188
204
 
189
205
  The key is not an argument. A launch configuration is a tracked file and a
@@ -140,6 +140,22 @@ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
140
140
  probably negotiating telnet rather than passing bytes through, and `rfc2217://`
141
141
  is the form that speaks that.
142
142
 
143
+ An adapter that drops off the network says nothing as it goes. The service asks
144
+ the operating system to give a network link up about a minute after the adapter
145
+ stops answering: on Linux, a minute after the first write it did not
146
+ acknowledge, or, if nothing was written, about a minute after it last answered,
147
+ found by keepalive probes. A platform that does not take one of these options
148
+ keeps its own default for it, which can be much longer. The service itself only
149
+ finds out at its first read or write after the link has gone, and `GET /health`
150
+ shows `"connected": false` from then on. A sign with a variable updated every
151
+ minute is written to that often. One left alone is written to at the refresh,
152
+ every `refresh_interval_seconds`, which is 15 minutes by default, and a refresh
153
+ that lands before the link has gone is buffered like any other write, so on a
154
+ quiet sign `link.connected` can stay true for a refresh interval and a minute
155
+ after the adapter has gone. It is still worth a monitor watching, and needs no
156
+ key, but a ping of the adapter answers within a minute however quiet the sign
157
+ is, and is the better alarm.
158
+
143
159
  ### The API key, and config.local.toml
144
160
 
145
161
  The key is not an argument. A launch configuration is a tracked file and a
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readerboard"
7
- version = "0.7.0"
7
+ version = "0.7.1"
8
8
  description = "An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -63,7 +63,12 @@ dependencies = [
63
63
 
64
64
  [project.optional-dependencies]
65
65
  dev = [
66
- "pytest>=9.1.1,<10",
66
+ # pytest 9.2 renamed CallSpec2 and deprecated the old name, which the anyio
67
+ # held below 4.15 further down still imports in its pytest plugin. That is
68
+ # the same collection error again, raised as the plugin loads, so no test
69
+ # runs at all. anyio 4.16 copes with the rename, so this ceiling comes off
70
+ # when the anyio one does.
71
+ "pytest>=9.1.1,<9.2",
67
72
  "pytest-asyncio>=1.4.0,<2",
68
73
  # starlette's TestClient moved to httpx2; plain httpx now raises a
69
74
  # deprecation warning, which this suite is configured to treat as an error.
@@ -7,4 +7,4 @@ and against the release tag, before anything is published. See
7
7
 
8
8
  __all__ = ["__version__"]
9
9
 
10
- __version__ = "0.7.0"
10
+ __version__ = "0.7.1"
@@ -14,11 +14,20 @@ Reconnection is deliberately passive. When the link is down, ``ensure_open``
14
14
  refuses to try again until a backoff window has passed, and ``write`` fails
15
15
  immediately rather than blocking a request behind a device that is not there.
16
16
  Something has to drive the retries; that is the controller's reconnect loop.
17
+
18
+ A link over the network is also told to notice a peer that has gone. An adapter
19
+ that drops off the network says nothing as it goes, and without being told
20
+ otherwise the operating system keeps accepting writes to it: Linux retries an
21
+ unacknowledged one for about a quarter of an hour before reporting anything, and
22
+ an idle connection is never checked at all. Meanwhile ``GET /health`` says the
23
+ link is up and the sign holds whatever it was last sent, which on a sign showing
24
+ the time looks exactly like a working one. See :func:`_notice_a_dead_peer`.
17
25
  """
18
26
 
19
27
  from __future__ import annotations
20
28
 
21
29
  import logging
30
+ import socket
22
31
  import threading
23
32
  import time
24
33
  from collections.abc import Callable
@@ -35,6 +44,20 @@ logger = logging.getLogger(__name__)
35
44
  # lock, and never let the controller reach its deadline.
36
45
  READ_LIMIT_BYTES = 4096
37
46
 
47
+ # How a network link finds out that the adapter at the other end has gone. An
48
+ # idle connection is probed after KEEPALIVE_IDLE_SECONDS of quiet, every
49
+ # KEEPALIVE_INTERVAL_SECONDS after that, and given up after KEEPALIVE_PROBES
50
+ # unanswered probes: about a minute. Probes are only sent while nothing is
51
+ # waiting to be acknowledged, and a write that went out as the adapter vanished
52
+ # is exactly that, so Linux is also told to give up on unacknowledged data after
53
+ # UNACKNOWLEDGED_TIMEOUT_SECONDS rather than its own quarter of an hour. Windows
54
+ # already gives up on a retransmission within a minute, and Python exposes no
55
+ # equivalent of that option there.
56
+ KEEPALIVE_IDLE_SECONDS = 30
57
+ KEEPALIVE_INTERVAL_SECONDS = 10
58
+ KEEPALIVE_PROBES = 3
59
+ UNACKNOWLEDGED_TIMEOUT_SECONDS = 60
60
+
38
61
 
39
62
  class SerialTransport:
40
63
  """A held-open pyserial link with capped exponential backoff on failure."""
@@ -186,6 +209,8 @@ class SerialTransport:
186
209
  self._port = None
187
210
  raise TransportError("could not open %s: %s" % (self._url, err)) from err
188
211
 
212
+ _notice_a_dead_peer(self._port, self._url)
213
+
189
214
  if self._failures:
190
215
  logger.info("link to %s is back after %d failed attempts", self._url, self._failures)
191
216
  else:
@@ -245,3 +270,41 @@ class SerialTransport:
245
270
  # A port that will not close cleanly is still a port we are done
246
271
  # with, and there is nothing useful for a caller to do about it.
247
272
  logger.debug("ignoring error while closing %s", self._url, exc_info=True)
273
+
274
+
275
+ def _notice_a_dead_peer(port: object, url: str) -> None:
276
+ """Tell a network link's socket to find out when the other end has gone.
277
+
278
+ Only ``socket://`` and ``rfc2217://`` links have a socket, and pyserial
279
+ keeps it on a private attribute, so it is looked for rather than assumed: a
280
+ cable and ``loop://`` have none and are left alone. A test pins the
281
+ attribute against the pyserial this is installed with, so that an upgrade
282
+ renaming it fails the suite rather than quietly losing this.
283
+
284
+ Each option is set on its own and a refusal is logged and passed over, so
285
+ that a platform without one of them still opens the link with the rest. An
286
+ option this platform does not define at all is skipped in the same way.
287
+ """
288
+ sock = getattr(port, "_socket", None)
289
+ if not isinstance(sock, socket.socket):
290
+ return
291
+
292
+ # macOS spells the idle time TCP_KEEPALIVE; everywhere else it is
293
+ # TCP_KEEPIDLE. Each is looked up by name because not every platform
294
+ # defines every one of them.
295
+ idle_option = "TCP_KEEPIDLE" if hasattr(socket, "TCP_KEEPIDLE") else "TCP_KEEPALIVE"
296
+ options = [
297
+ (socket.SOL_SOCKET, "SO_KEEPALIVE", 1),
298
+ (socket.IPPROTO_TCP, idle_option, KEEPALIVE_IDLE_SECONDS),
299
+ (socket.IPPROTO_TCP, "TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
300
+ (socket.IPPROTO_TCP, "TCP_KEEPCNT", KEEPALIVE_PROBES),
301
+ (socket.IPPROTO_TCP, "TCP_USER_TIMEOUT", UNACKNOWLEDGED_TIMEOUT_SECONDS * 1000),
302
+ ]
303
+ for level, name, value in options:
304
+ option = getattr(socket, name, None)
305
+ if option is None:
306
+ continue
307
+ try:
308
+ sock.setsockopt(level, option, value)
309
+ except OSError as err:
310
+ logger.debug("could not set %s on the link to %s: %s", name, url, err)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readerboard
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: An HTTP service for BetaBrite and Alpha protocol LED signs: several sources share one sign, with alerts, expiring messages and clock sync
5
5
  Author: mjaksn
6
6
  License-Expression: MIT
@@ -34,7 +34,7 @@ Requires-Dist: pydantic-settings<3,>=2.15.0
34
34
  Requires-Dist: pyserial<4,>=3.5
35
35
  Requires-Dist: tzdata>=2026.3; sys_platform == "win32"
36
36
  Provides-Extra: dev
37
- Requires-Dist: pytest<10,>=9.1.1; extra == "dev"
37
+ Requires-Dist: pytest<9.2,>=9.1.1; extra == "dev"
38
38
  Requires-Dist: pytest-asyncio<2,>=1.4.0; extra == "dev"
39
39
  Requires-Dist: httpx2<3,>=2.10.0; extra == "dev"
40
40
  Requires-Dist: anyio<4.15,>=4.14.2; extra == "dev"
@@ -184,6 +184,22 @@ sign shows nothing or shows rubbish, and the adapter answers on port 23, it is
184
184
  probably negotiating telnet rather than passing bytes through, and `rfc2217://`
185
185
  is the form that speaks that.
186
186
 
187
+ An adapter that drops off the network says nothing as it goes. The service asks
188
+ the operating system to give a network link up about a minute after the adapter
189
+ stops answering: on Linux, a minute after the first write it did not
190
+ acknowledge, or, if nothing was written, about a minute after it last answered,
191
+ found by keepalive probes. A platform that does not take one of these options
192
+ keeps its own default for it, which can be much longer. The service itself only
193
+ finds out at its first read or write after the link has gone, and `GET /health`
194
+ shows `"connected": false` from then on. A sign with a variable updated every
195
+ minute is written to that often. One left alone is written to at the refresh,
196
+ every `refresh_interval_seconds`, which is 15 minutes by default, and a refresh
197
+ that lands before the link has gone is buffered like any other write, so on a
198
+ quiet sign `link.connected` can stay true for a refresh interval and a minute
199
+ after the adapter has gone. It is still worth a monitor watching, and needs no
200
+ key, but a ping of the adapter answers within a minute however quiet the sign
201
+ is, and is the better alarm.
202
+
187
203
  ### The API key, and config.local.toml
188
204
 
189
205
  The key is not an argument. A launch configuration is a tracked file and a
@@ -8,7 +8,7 @@ pyserial<4,>=3.5
8
8
  tzdata>=2026.3
9
9
 
10
10
  [dev]
11
- pytest<10,>=9.1.1
11
+ pytest<9.2,>=9.1.1
12
12
  pytest-asyncio<2,>=1.4.0
13
13
  httpx2<3,>=2.10.0
14
14
  anyio<4.15,>=4.14.2
@@ -11,10 +11,47 @@ import threading
11
11
  import time
12
12
 
13
13
  import pytest
14
+ import serial
15
+ from serial.rfc2217 import PortManager
14
16
 
15
17
  from readerboard.transport.base import Transport, TransportError
16
18
  from readerboard.transport.fake import FakeTransport
17
- from readerboard.transport.serial_link import READ_LIMIT_BYTES, SerialTransport
19
+ from readerboard.transport.serial_link import (
20
+ KEEPALIVE_IDLE_SECONDS,
21
+ KEEPALIVE_INTERVAL_SECONDS,
22
+ KEEPALIVE_PROBES,
23
+ READ_LIMIT_BYTES,
24
+ UNACKNOWLEDGED_TIMEOUT_SECONDS,
25
+ SerialTransport,
26
+ )
27
+
28
+
29
+ def assert_told_to_notice_a_dead_peer(transport: SerialTransport) -> None:
30
+ """Check that an open network link's socket has every option it was given.
31
+
32
+ The socket is a private attribute of pyserial's. The first assertion is the
33
+ one that fails if an upgrade renames it, rather than the link quietly opening
34
+ without any of what follows. An option this platform does not define is not
35
+ set, so it is not checked either.
36
+ """
37
+ sock = transport._port._socket
38
+ assert isinstance(sock, socket.socket)
39
+
40
+ assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) != 0
41
+ # The same choice of name the code makes: macOS calls the idle time
42
+ # TCP_KEEPALIVE, and checking only TCP_KEEPIDLE would check nothing there.
43
+ idle_option = "TCP_KEEPIDLE" if hasattr(socket, "TCP_KEEPIDLE") else "TCP_KEEPALIVE"
44
+ expected = {
45
+ idle_option: KEEPALIVE_IDLE_SECONDS,
46
+ "TCP_KEEPINTVL": KEEPALIVE_INTERVAL_SECONDS,
47
+ "TCP_KEEPCNT": KEEPALIVE_PROBES,
48
+ "TCP_USER_TIMEOUT": UNACKNOWLEDGED_TIMEOUT_SECONDS * 1000,
49
+ }
50
+ for name, value in expected.items():
51
+ option = getattr(socket, name, None)
52
+ if option is None:
53
+ continue
54
+ assert sock.getsockopt(socket.IPPROTO_TCP, option) == value, name
18
55
 
19
56
 
20
57
  class Clock:
@@ -150,6 +187,69 @@ class TestSerialTransportOverASocket:
150
187
  listener.close()
151
188
  server.join(5)
152
189
 
190
+ def test_the_link_is_told_to_notice_an_adapter_that_has_gone(self):
191
+ # An adapter that drops off the network says nothing as it goes. These
192
+ # options are what make the operating system find out, and the link
193
+ # stays reported as up until it does.
194
+ listener = socket.socket()
195
+ listener.bind(("127.0.0.1", 0))
196
+ listener.listen(1)
197
+ transport = SerialTransport("socket://127.0.0.1:%d" % listener.getsockname()[1])
198
+ try:
199
+ transport.ensure_open()
200
+ assert_told_to_notice_a_dead_peer(transport)
201
+ finally:
202
+ transport.close()
203
+ listener.close()
204
+
205
+ # pyserial's RFC 2217 client starts its reader thread with the deprecated
206
+ # setDaemon and setName. They are not ours to fix, and filterwarnings =
207
+ # ["error"] would otherwise fail the open before anything here is checked.
208
+ @pytest.mark.filterwarnings(
209
+ "ignore:set(Daemon|Name)\\(\\) is deprecated:DeprecationWarning"
210
+ )
211
+ def test_an_rfc2217_link_is_told_the_same(self):
212
+ # pyserial's RFC 2217 handler is a separate class from its socket one
213
+ # and keeps its own socket, so it is pinned separately. It will not
214
+ # open against a listener that only accepts: it negotiates first, so
215
+ # the other end is pyserial's own RFC 2217 server, driving a loop://
216
+ # port that stands in for the serial side of an adapter.
217
+ listener = socket.socket()
218
+ listener.bind(("127.0.0.1", 0))
219
+ listener.listen(1)
220
+ done = threading.Event()
221
+
222
+ def serve() -> None:
223
+ conn, _ = listener.accept()
224
+ with conn, serial.serial_for_url("loop://") as port:
225
+ conn.settimeout(0.1)
226
+
227
+ class Connection:
228
+ def write(self, data: bytes) -> None:
229
+ conn.sendall(data)
230
+
231
+ manager = PortManager(port, Connection())
232
+ while not done.is_set():
233
+ try:
234
+ data = conn.recv(1024)
235
+ except TimeoutError:
236
+ continue
237
+ if not data:
238
+ return
239
+ port.write(b"".join(manager.filter(data)))
240
+
241
+ server = threading.Thread(target=serve, daemon=True)
242
+ server.start()
243
+ transport = SerialTransport("rfc2217://127.0.0.1:%d" % listener.getsockname()[1])
244
+ try:
245
+ transport.ensure_open()
246
+ assert_told_to_notice_a_dead_peer(transport)
247
+ finally:
248
+ done.set()
249
+ transport.close()
250
+ listener.close()
251
+ server.join(5)
252
+
153
253
  def test_a_link_that_never_stops_delivering_still_hands_back(self):
154
254
  # Without the cap this read would never return, and the controller
155
255
  # would never reach its deadline.
File without changes
File without changes