pybricks-service 1.0.0b1__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 (44) hide show
  1. pybricks_service-1.0.0b1/LICENSE +21 -0
  2. pybricks_service-1.0.0b1/PKG-INFO +48 -0
  3. pybricks_service-1.0.0b1/README.md +13 -0
  4. pybricks_service-1.0.0b1/pyproject.toml +63 -0
  5. pybricks_service-1.0.0b1/pyproject.toml.orig +51 -0
  6. pybricks_service-1.0.0b1/src/pb_service/__init__.py +13 -0
  7. pybricks_service-1.0.0b1/src/pb_service/__main__.py +7 -0
  8. pybricks_service-1.0.0b1/src/pb_service/backend.py +178 -0
  9. pybricks_service-1.0.0b1/src/pb_service/cli.py +193 -0
  10. pybricks_service-1.0.0b1/src/pb_service/errors.py +106 -0
  11. pybricks_service-1.0.0b1/src/pb_service/hub/__init__.py +4 -0
  12. pybricks_service-1.0.0b1/src/pb_service/hub/ble.py +258 -0
  13. pybricks_service-1.0.0b1/src/pb_service/hub/combined.py +219 -0
  14. pybricks_service-1.0.0b1/src/pb_service/hub/connection.py +583 -0
  15. pybricks_service-1.0.0b1/src/pb_service/hub/events.py +182 -0
  16. pybricks_service-1.0.0b1/src/pb_service/hub/fake.py +498 -0
  17. pybricks_service-1.0.0b1/src/pb_service/hub/manager.py +421 -0
  18. pybricks_service-1.0.0b1/src/pb_service/hub/protocol.py +667 -0
  19. pybricks_service-1.0.0b1/src/pb_service/hub/reconnect.py +72 -0
  20. pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/__init__.py +10 -0
  21. pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/fake.py +234 -0
  22. pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/lazy.py +171 -0
  23. pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/port.py +200 -0
  24. pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/transport.py +238 -0
  25. pybricks_service-1.0.0b1/src/pb_service/hub/selection.py +106 -0
  26. pybricks_service-1.0.0b1/src/pb_service/hub/stream/__init__.py +10 -0
  27. pybricks_service-1.0.0b1/src/pb_service/hub/stream/cobs.py +172 -0
  28. pybricks_service-1.0.0b1/src/pb_service/hub/stream/link.py +289 -0
  29. pybricks_service-1.0.0b1/src/pb_service/hub/stream/pipe.py +45 -0
  30. pybricks_service-1.0.0b1/src/pb_service/hub/stream/wire.py +192 -0
  31. pybricks_service-1.0.0b1/src/pb_service/hub/transport.py +367 -0
  32. pybricks_service-1.0.0b1/src/pb_service/hub/usb/__init__.py +10 -0
  33. pybricks_service-1.0.0b1/src/pb_service/hub/usb/fake.py +319 -0
  34. pybricks_service-1.0.0b1/src/pb_service/hub/usb/port.py +119 -0
  35. pybricks_service-1.0.0b1/src/pb_service/hub/usb/transport.py +297 -0
  36. pybricks_service-1.0.0b1/src/pb_service/methods/__init__.py +19 -0
  37. pybricks_service-1.0.0b1/src/pb_service/methods/hub.py +714 -0
  38. pybricks_service-1.0.0b1/src/pb_service/methods/program.py +335 -0
  39. pybricks_service-1.0.0b1/src/pb_service/methods/registry.py +128 -0
  40. pybricks_service-1.0.0b1/src/pb_service/methods/service.py +135 -0
  41. pybricks_service-1.0.0b1/src/pb_service/methods/stdio.py +337 -0
  42. pybricks_service-1.0.0b1/src/pb_service/py.typed +0 -0
  43. pybricks_service-1.0.0b1/src/pb_service/service.py +271 -0
  44. pybricks_service-1.0.0b1/src/pb_service/session.py +111 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Pybricks Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybricks-service
3
+ Version: 1.0.0b1
4
+ Summary: The Pybricks service: manages hub connections
5
+ Keywords: pybricks,lego,micropython,bluetooth,robotics
6
+ Author: The Pybricks Authors
7
+ Author-email: The Pybricks Authors <dev@pybricks.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: pybricks-core>=1.0.0b1
18
+ Requires-Dist: anyio>=4.14
19
+ Requires-Dist: bleak>=1.1.0
20
+ Requires-Dist: packaging>=22
21
+ Requires-Dist: serialx>=1.9
22
+ Requires-Dist: aio-rfcomm>=1.1
23
+ Requires-Dist: semver>=3
24
+ Requires-Dist: typing-extensions>=4.15
25
+ Maintainer: David Lechner
26
+ Maintainer-email: David Lechner <david@pybricks.com>
27
+ Requires-Python: >=3.11
28
+ Project-URL: homepage, https://pybricks.com
29
+ Project-URL: repository, https://github.com/pybricks/pybricks-tools
30
+ Project-URL: documentation, https://pybricks.com/pybricks-tools/design/
31
+ Project-URL: Changelog, https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-service/CHANGELOG.md
32
+ Project-URL: Support, https://github.com/pybricks/support/discussions
33
+ Project-URL: Issues, https://github.com/pybricks/support/issues
34
+ Description-Content-Type: text/markdown
35
+
36
+ # pybricks-service
37
+
38
+ The background service behind the [Pybricks](https://pybricks.com) tools. It
39
+ owns the Bluetooth connection to a hub and outlives any single command, which
40
+ is what lets one `pb run` follow another without waiting to reconnect.
41
+
42
+ It also fans a hub's output out to several clients at once, so an interactive
43
+ session and a program watching the same hub do not get in each other's way.
44
+
45
+ **You do not install this yourself.** It comes with `pybricks-cli`, and it
46
+ starts when something needs it and stops when nothing has for a while.
47
+
48
+ > **Status:** beta. Commands and APIs may still change before 1.0.0.
@@ -0,0 +1,13 @@
1
+ # pybricks-service
2
+
3
+ The background service behind the [Pybricks](https://pybricks.com) tools. It
4
+ owns the Bluetooth connection to a hub and outlives any single command, which
5
+ is what lets one `pb run` follow another without waiting to reconnect.
6
+
7
+ It also fans a hub's output out to several clients at once, so an interactive
8
+ session and a program watching the same hub do not get in each other's way.
9
+
10
+ **You do not install this yourself.** It comes with `pybricks-cli`, and it
11
+ starts when something needs it and stops when nothing has for a while.
12
+
13
+ > **Status:** beta. Commands and APIs may still change before 1.0.0.
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "pybricks-service"
3
+ version = "1.0.0b1"
4
+ description = "The Pybricks service: manages hub connections"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ keywords = [
10
+ "pybricks",
11
+ "lego",
12
+ "micropython",
13
+ "bluetooth",
14
+ "robotics",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Education",
20
+ "Natural Language :: English",
21
+ "Operating System :: OS Independent",
22
+ "Topic :: Software Development :: Embedded Systems",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "pybricks-core>=1.0.0b1",
27
+ "anyio>=4.14",
28
+ "bleak>=1.1.0",
29
+ "packaging>=22",
30
+ "serialx>=1.9",
31
+ "aio-rfcomm>=1.1",
32
+ "semver>=3",
33
+ "typing-extensions>=4.15",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "The Pybricks Authors"
38
+ email = "dev@pybricks.com"
39
+
40
+ [[project.maintainers]]
41
+ name = "David Lechner"
42
+ email = "david@pybricks.com"
43
+
44
+ [project.scripts]
45
+ pb-service = "pb_service.cli:main"
46
+
47
+ [project.urls]
48
+ homepage = "https://pybricks.com"
49
+ repository = "https://github.com/pybricks/pybricks-tools"
50
+ documentation = "https://pybricks.com/pybricks-tools/design/"
51
+ Changelog = "https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-service/CHANGELOG.md"
52
+ Support = "https://github.com/pybricks/support/discussions"
53
+ Issues = "https://github.com/pybricks/support/issues"
54
+
55
+ [build-system]
56
+ requires = ["uv_build>=0.12.7,<0.13.0"]
57
+ build-backend = "uv_build"
58
+
59
+ [tool.uv.build-backend]
60
+ module-name = "pb_service"
61
+
62
+ [tool.uv.sources.pybricks-core]
63
+ workspace = true
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "pybricks-service"
3
+ version = "1.0.0b1"
4
+ description = "The Pybricks service: manages hub connections"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "The Pybricks Authors", email = "dev@pybricks.com" }]
9
+ maintainers = [{ name = "David Lechner", email = "david@pybricks.com" }]
10
+ requires-python = ">=3.11"
11
+ keywords = ["pybricks", "lego", "micropython", "bluetooth", "robotics"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Intended Audience :: Education",
16
+ "Natural Language :: English",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Embedded Systems",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "pybricks-core>=1.0.0b1",
23
+ "anyio>=4.14",
24
+ "bleak>=1.1.0",
25
+ "packaging>=22",
26
+ "serialx>=1.9",
27
+ "aio-rfcomm>=1.1",
28
+ "semver>=3",
29
+ "typing-extensions>=4.15",
30
+ ]
31
+
32
+ [project.scripts]
33
+ pb-service = "pb_service.cli:main"
34
+
35
+ [project.urls]
36
+ homepage = "https://pybricks.com"
37
+ repository = "https://github.com/pybricks/pybricks-tools"
38
+ documentation = "https://pybricks.com/pybricks-tools/design/"
39
+ "Changelog" = "https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-service/CHANGELOG.md"
40
+ "Support" = "https://github.com/pybricks/support/discussions"
41
+ "Issues" = "https://github.com/pybricks/support/issues"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.12.7,<0.13.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.uv.build-backend]
48
+ module-name = "pb_service"
49
+
50
+ [tool.uv.sources]
51
+ pybricks-core = { workspace = true }
@@ -0,0 +1,13 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The Pybricks service.
5
+
6
+ Owns every hub connection and outlives any single CLI invocation, so a hub
7
+ stays connected between commands and several clients can talk to one hub at
8
+ once.
9
+ """
10
+
11
+ __version__ = "1.0.0b1"
12
+
13
+ __all__ = ["__version__"]
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ from pb_service.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,178 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Choosing how the service reaches hubs.
5
+
6
+ Imported lazily so that a service running against the simulated backend, as the
7
+ tests and CI do, never loads the Bluetooth stack at all.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from collections.abc import AsyncGenerator
15
+ from contextlib import asynccontextmanager
16
+ from typing import TYPE_CHECKING, cast
17
+
18
+ import anyio
19
+
20
+ from pb_service.hub.transport import HubTransport
21
+
22
+ if TYPE_CHECKING:
23
+ from aio_rfcomm import Bluetooth
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ __all__ = [
28
+ "HUB_BACKEND_ENV_VAR",
29
+ "PERMISSION_NAME",
30
+ "get_hub_transport",
31
+ "open_hub_transport",
32
+ ]
33
+
34
+ HUB_BACKEND_ENV_VAR = "PB_HUB_BACKEND"
35
+ """Selects the transport.
36
+
37
+ Left alone, the service uses every transport together, which is what lets a hub
38
+ be found whichever way it happens to be reachable. Set it to ``usb``, ``ble`` or
39
+ ``rfcomm`` to use only one, or to ``fake`` for a simulated hub.
40
+ """
41
+
42
+
43
+ PERMISSION_NAME = "Pybricks RFCOMM Service"
44
+ """What macOS calls this service when it asks about Bluetooth.
45
+
46
+ Shown in the permission dialog and under Privacy & Security, and what the
47
+ answer is remembered against, so changing it asks every user again. pb-cli
48
+ repeats it in what it tells the user, and a test holds the two together.
49
+
50
+ Not plain "Pybricks": among the other entries in Privacy & Security that
51
+ reads as the Pybricks app or Pybricks Code, and says nothing about what is
52
+ asking or why. This is the part of pb-service that reaches EV3 and NXT hubs
53
+ over Bluetooth Classic, and only that part is asked about: BLE is bleak's,
54
+ inside the service itself.
55
+ """
56
+
57
+
58
+ def _usb() -> HubTransport:
59
+ from pb_service.hub.usb import UsbHubTransport
60
+
61
+ return UsbHubTransport()
62
+
63
+
64
+ def _ble() -> HubTransport:
65
+ from pb_service.hub.ble import BleHubTransport
66
+
67
+ return BleHubTransport()
68
+
69
+
70
+ def _rfcomm(bluetooth: Bluetooth | None) -> tuple[HubTransport, ...]:
71
+ if bluetooth is None:
72
+ return ()
73
+
74
+ from pb_service.hub.rfcomm import RfcommHubTransport
75
+
76
+ return (RfcommHubTransport(bluetooth),)
77
+
78
+
79
+ def _backend() -> str:
80
+ return os.environ.get(HUB_BACKEND_ENV_VAR, "").lower()
81
+
82
+
83
+ def get_hub_transport(bluetooth: Bluetooth | None = None) -> HubTransport:
84
+ """Return the transport this service should use.
85
+
86
+ Every import here is inside the branch that needs it, so a service running
87
+ against the simulated backend loads neither the Bluetooth stack nor the
88
+ serial one, and a machine using only one of the real transports does not
89
+ pay for the other.
90
+
91
+ Returns:
92
+ Always a :class:`~pb_service.hub.combined.CombinedTransport`, holding
93
+ USB and Bluetooth or whichever single transport ``PB_HUB_BACKEND``
94
+ names. Always, even for one transport, so that choosing between
95
+ transports and honouring a request for a particular one are implemented
96
+ in exactly one place rather than once per backend. The simulated backend
97
+ is what lets the whole service be exercised on a CI runner with no radio
98
+ and nothing plugged in.
99
+
100
+ Args:
101
+ bluetooth: Bluetooth Classic, already open, or None to leave RFCOMM
102
+ out. :func:`open_hub_transport` opens it when it is wanted.
103
+ """
104
+ from pb_service.hub.combined import CombinedTransport
105
+
106
+ backend = _backend()
107
+
108
+ if backend == "fake":
109
+ from pb_service.hub.fake import FakeHub, FakeHubTransport
110
+
111
+ logger.warning("using the simulated hub backend; no real hub will be found")
112
+ return CombinedTransport(
113
+ FakeHubTransport(
114
+ FakeHub(name="Pybricks Hub", address="00:11:22:33:44:55"),
115
+ FakeHub(name="Spare Hub", address="66:77:88:99:AA:BB"),
116
+ )
117
+ )
118
+
119
+ if backend == "usb":
120
+ return CombinedTransport(_usb())
121
+
122
+ if backend == "ble":
123
+ return CombinedTransport(_ble())
124
+
125
+ if backend == "rfcomm":
126
+ return CombinedTransport(*_rfcomm(bluetooth))
127
+
128
+ if backend:
129
+ logger.warning(
130
+ "unknown %s=%r; using every transport", HUB_BACKEND_ENV_VAR, backend
131
+ )
132
+
133
+ # In TRANSPORT_PREFERENCE order, which is what makes USB the one chosen when
134
+ # a hub is reachable both ways. A test holds the two together.
135
+ return CombinedTransport(_usb(), _ble(), *_rfcomm(bluetooth))
136
+
137
+
138
+ @asynccontextmanager
139
+ async def open_hub_transport() -> AsyncGenerator[HubTransport, None]:
140
+ """Open the transport this service should use, for as long as the block lasts.
141
+
142
+ Bluetooth Classic is held open in this scope when RFCOMM is in use, and
143
+ nowhere else: aio-rfcomm allows it open once per process, and everything
144
+ opened through it stops working when its block ends, so it has to outlive
145
+ every hub. It is opened the first time RFCOMM needs it, not here, so the
146
+ service never waits on it to start (see
147
+ :mod:`pb_service.hub.rfcomm.lazy`). If it cannot be opened, RFCOMM
148
+ reports Bluetooth as unusable and the other transports carry on.
149
+
150
+ Yields:
151
+ What :func:`get_hub_transport` returns.
152
+ """
153
+ backend = _backend()
154
+
155
+ if backend in ("fake", "usb", "ble"):
156
+ yield get_hub_transport()
157
+ return
158
+
159
+ import aio_rfcomm.macos
160
+
161
+ from pb_service.hub.rfcomm.lazy import LazyBluetooth
162
+
163
+ # macOS asks the responsible process for Bluetooth permission, and a
164
+ # service started from a command line has no application to speak for it,
165
+ # so without this it is refused rather than asked. The answer is
166
+ # remembered under the name, so changing it asks the user again.
167
+ aio_rfcomm.macos.prompt_under_name(
168
+ name=PERMISSION_NAME, reason="to connect to LEGO hubs over Bluetooth"
169
+ )
170
+
171
+ async with anyio.create_task_group() as tg:
172
+ # Cast: it offers only the calls the RFCOMM transport makes.
173
+ bluetooth = cast("Bluetooth", LazyBluetooth(tg))
174
+
175
+ try:
176
+ yield get_hub_transport(bluetooth)
177
+ finally:
178
+ tg.cancel_scope.cancel()
@@ -0,0 +1,193 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The service's command line.
5
+
6
+ Plain argparse rather than the CLI framework used by ``pb``: the service must not
7
+ depend on the presentation layer, and it is not a program people interact with
8
+ directly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import logging
15
+ import os
16
+ import signal
17
+ import sys
18
+ from typing import Any
19
+
20
+ import anyio
21
+
22
+ # Imported for the side effect: importing a method module registers its RPCs,
23
+ # so nothing here reads the names. Said on each line, because a comment is
24
+ # not something a linter can read.
25
+ import pb_service.methods.hub # pyright: ignore[reportUnusedImport]
26
+ import pb_service.methods.program # pyright: ignore[reportUnusedImport]
27
+ import pb_service.methods.service # pyright: ignore[reportUnusedImport]
28
+ import pb_service.methods.stdio # noqa: F401 # pyright: ignore[reportUnusedImport]
29
+ from pb_core import ipc, lock
30
+ from pb_core.ipc import AddressInUseError
31
+ from pb_core.logging import setup_logging
32
+ from pb_core.paths import log_file
33
+ from pb_service import __version__
34
+ from pb_service import service as service_module
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ __all__ = ["main"]
39
+
40
+ EXIT_OK = 0
41
+ EXIT_ERROR = 1
42
+ EXIT_ALREADY_RUNNING = 3
43
+
44
+ _LEVELS = {
45
+ "trace": 5,
46
+ "debug": logging.DEBUG,
47
+ "info": logging.INFO,
48
+ "warning": logging.WARNING,
49
+ "error": logging.ERROR,
50
+ }
51
+
52
+
53
+ def _parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(
55
+ prog="pb-service",
56
+ description="The Pybricks service. Normally started automatically.",
57
+ )
58
+ parser.add_argument(
59
+ "--version", action="version", version=f"pb-service {__version__}"
60
+ )
61
+ parser.add_argument(
62
+ "--endpoint",
63
+ help="Where to listen, for example tcp://127.0.0.1:47823. "
64
+ "Defaults to the platform's socket or named pipe.",
65
+ )
66
+ parser.add_argument(
67
+ "--foreground",
68
+ action="store_true",
69
+ help="Log to stderr instead of only to the log file.",
70
+ )
71
+ parser.add_argument(
72
+ "--log-level",
73
+ choices=sorted(_LEVELS),
74
+ default="info",
75
+ help="Verbosity (default: info).",
76
+ )
77
+ parser.add_argument(
78
+ "--idle-timeout",
79
+ type=float,
80
+ default=service_module.DEFAULT_IDLE_TIMEOUT,
81
+ help="Seconds with no clients and no hubs before exiting. "
82
+ "Use 0 to run until stopped.",
83
+ )
84
+ return parser
85
+
86
+
87
+ async def _watch_signals(service: service_module.Service) -> None:
88
+ """Turn termination signals into an orderly shutdown.
89
+
90
+ On Windows ``anyio.open_signal_receiver`` is unavailable, because the
91
+ proactor loop has no ``add_signal_handler``. A plain handler that pokes the
92
+ loop is used instead, and it needs nothing else to make it prompt: asyncio
93
+ sets the signal wakeup socket, so the byte written by the C-level handler
94
+ -- from the thread Windows spawns to deliver a console control event --
95
+ completes the loop's own overlapped read and the wait returns at once.
96
+ Measured on Windows 11 against a loop parked in ``sleep_forever``: the
97
+ handler ran 0.7 ms after the event was sent, with nothing else waking the
98
+ loop.
99
+ """
100
+
101
+ if sys.platform == "win32":
102
+ import asyncio
103
+
104
+ loop = asyncio.get_running_loop()
105
+ event = anyio.Event()
106
+
107
+ def handler(signum: int, frame: Any) -> None:
108
+ loop.call_soon_threadsafe(event.set)
109
+
110
+ previous = [
111
+ (sig, signal.signal(sig, handler))
112
+ for sig in (signal.SIGINT, signal.SIGBREAK)
113
+ ]
114
+
115
+ try:
116
+ await event.wait()
117
+ service.request_shutdown("signal")
118
+ finally:
119
+ for sig, old in previous:
120
+ signal.signal(sig, old)
121
+
122
+ return
123
+
124
+ with anyio.open_signal_receiver(signal.SIGINT, signal.SIGTERM) as signals:
125
+ async for _ in signals:
126
+ service.request_shutdown("signal")
127
+ return
128
+
129
+
130
+ async def _serve(args: argparse.Namespace) -> int:
131
+ endpoint = ipc.resolve(args.endpoint)
132
+ service_module.log_banner(endpoint, __version__)
133
+
134
+ try:
135
+ async with service_module.run_service(
136
+ endpoint, idle_timeout=args.idle_timeout
137
+ ) as service:
138
+ async with anyio.create_task_group() as tg:
139
+ tg.start_soon(_watch_signals, service)
140
+ await service.wait_for_shutdown()
141
+ tg.cancel_scope.cancel()
142
+
143
+ logger.info("shutting down: %s", service.shutdown_reason)
144
+ except AddressInUseError:
145
+ # The common case is two clients racing to start a service. One wins,
146
+ # and both clients then connect to it, so this is not an error.
147
+ logger.info("another service is already listening on %s", endpoint)
148
+ return EXIT_ALREADY_RUNNING
149
+
150
+ return EXIT_OK
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> int:
154
+ """Run the service.
155
+
156
+ Args:
157
+ argv: Command line arguments, defaulting to ``sys.argv``.
158
+
159
+ Returns:
160
+ The process exit code.
161
+ """
162
+ args = _parser().parse_args(argv)
163
+ level = _LEVELS[args.log_level]
164
+
165
+ setup_logging(
166
+ level=level,
167
+ style="plain" if args.foreground else "file",
168
+ log_file=None if args.foreground else log_file(),
169
+ )
170
+
171
+ # The first thing written, before anything that could fail or block.
172
+ # A process that gets no further than here still leaves evidence that
173
+ # it existed, which is exactly what a leftover service with nothing in
174
+ # the log did not.
175
+ logger.info(
176
+ "pb-service starting (pid=%d, argv=%s)",
177
+ os.getpid(),
178
+ argv if argv is not None else sys.argv[1:],
179
+ )
180
+
181
+ try:
182
+ with lock.single_instance():
183
+ return anyio.run(_serve, args, backend="asyncio")
184
+ except lock.AlreadyRunningError:
185
+ logger.info("another service already holds the lock; exiting")
186
+ return EXIT_ALREADY_RUNNING
187
+ except Exception:
188
+ logger.exception("service failed")
189
+ return EXIT_ERROR
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())