pybricks-core 1.0.0b1__py3-none-any.whl
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.
- pb_core/__init__.py +13 -0
- pb_core/auth.py +90 -0
- pb_core/ipc/__init__.py +66 -0
- pb_core/ipc/address.py +151 -0
- pb_core/ipc/backend/__init__.py +58 -0
- pb_core/ipc/backend/_anyio_listener.py +31 -0
- pb_core/ipc/backend/memory.py +163 -0
- pb_core/ipc/backend/posix.py +118 -0
- pb_core/ipc/backend/provider.py +104 -0
- pb_core/ipc/backend/tcp.py +111 -0
- pb_core/ipc/backend/windows.py +237 -0
- pb_core/ipc/errors.py +55 -0
- pb_core/jsonrpc/__init__.py +60 -0
- pb_core/jsonrpc/errors.py +104 -0
- pb_core/jsonrpc/framing.py +120 -0
- pb_core/jsonrpc/messages.py +271 -0
- pb_core/jsonrpc/peer.py +435 -0
- pb_core/lock.py +161 -0
- pb_core/logging.py +242 -0
- pb_core/modules.py +334 -0
- pb_core/paths.py +185 -0
- pb_core/project.py +344 -0
- pb_core/protocol.py +134 -0
- pb_core/py.typed +0 -0
- pybricks_core-1.0.0b1.dist-info/METADATA +42 -0
- pybricks_core-1.0.0b1.dist-info/RECORD +28 -0
- pybricks_core-1.0.0b1.dist-info/WHEEL +4 -0
- pybricks_core-1.0.0b1.dist-info/licenses/LICENSE +21 -0
pb_core/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Shared building blocks for the Pybricks tool suite.
|
|
5
|
+
|
|
6
|
+
This package holds everything both the service and the client need to agree on:
|
|
7
|
+
the JSON-RPC wire protocol, the per-platform IPC transport, logging setup and
|
|
8
|
+
platform paths. It deliberately depends on neither ``pb_service`` nor ``pb_client``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0b1"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
pb_core/auth.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Shared-secret authentication between the client and the service.
|
|
5
|
+
|
|
6
|
+
The service writes a random token to a file only the current user can read, and
|
|
7
|
+
every client presents it in the mandatory first call. This is the X11
|
|
8
|
+
magic-cookie scheme.
|
|
9
|
+
|
|
10
|
+
It is required on every transport rather than only on TCP. The default
|
|
11
|
+
discretionary access control list on a Windows named pipe grants Everyone read
|
|
12
|
+
access, so without a secret another local user could not issue commands but
|
|
13
|
+
could still watch a hub's output. It is not sufficient on its own for a service
|
|
14
|
+
reachable beyond loopback, where the token crosses the network in clear and
|
|
15
|
+
needs TLS or an SSH tunnel around it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import secrets
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from pb_core import paths
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__all__ = ["TOKEN_BYTES", "load_token", "read_or_create_token"]
|
|
30
|
+
|
|
31
|
+
TOKEN_BYTES = 32
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_or_create_token(path: Path | None = None) -> str:
|
|
35
|
+
"""Return the service's token, creating one if there is none.
|
|
36
|
+
|
|
37
|
+
Called by the service at startup.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Override the cookie file location.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The token as hexadecimal text.
|
|
44
|
+
"""
|
|
45
|
+
cookie = path or paths.cookie_file()
|
|
46
|
+
paths.ensure_private_dir(cookie.parent)
|
|
47
|
+
|
|
48
|
+
if cookie.exists():
|
|
49
|
+
existing = cookie.read_text(encoding="ascii").strip()
|
|
50
|
+
if existing:
|
|
51
|
+
return existing
|
|
52
|
+
|
|
53
|
+
token = secrets.token_hex(TOKEN_BYTES)
|
|
54
|
+
# Create with restrictive permissions from the outset rather than widening
|
|
55
|
+
# then narrowing, which would leave a window where the token is readable.
|
|
56
|
+
descriptor = cookie.open("w", encoding="ascii")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
if sys.platform != "win32":
|
|
60
|
+
import os
|
|
61
|
+
|
|
62
|
+
os.fchmod(descriptor.fileno(), 0o600)
|
|
63
|
+
|
|
64
|
+
descriptor.write(token)
|
|
65
|
+
finally:
|
|
66
|
+
descriptor.close()
|
|
67
|
+
|
|
68
|
+
logger.debug("wrote a new authentication token to %s", cookie)
|
|
69
|
+
return token
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_token(path: Path | None = None) -> str | None:
|
|
73
|
+
"""Return the service's token if it can be read.
|
|
74
|
+
|
|
75
|
+
Called by clients before connecting.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: Override the cookie file location.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The token, or ``None`` if no service has ever run for this user.
|
|
82
|
+
"""
|
|
83
|
+
cookie = path or paths.cookie_file()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
token = cookie.read_text(encoding="ascii").strip()
|
|
87
|
+
except OSError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
return token or None
|
pb_core/ipc/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Transport between the client and the service.
|
|
5
|
+
|
|
6
|
+
The user never types an address: :func:`pb_core.ipc.address.resolve` picks the
|
|
7
|
+
right one for the platform. Everything above this package deals only in
|
|
8
|
+
:class:`anyio.abc.ByteStream`, so neither the service nor the client contains any
|
|
9
|
+
platform-specific code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from contextlib import AbstractAsyncContextManager
|
|
15
|
+
|
|
16
|
+
from anyio.abc import ByteStream
|
|
17
|
+
|
|
18
|
+
from pb_core.ipc.address import ENDPOINT_ENV_VAR, Endpoint, default, resolve
|
|
19
|
+
from pb_core.ipc.backend import IpcBackend, IpcListener, StreamHandler, get_backend
|
|
20
|
+
from pb_core.ipc.errors import AddressInUseError, EndpointError
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ENDPOINT_ENV_VAR",
|
|
24
|
+
"AddressInUseError",
|
|
25
|
+
"Endpoint",
|
|
26
|
+
"EndpointError",
|
|
27
|
+
"IpcBackend",
|
|
28
|
+
"IpcListener",
|
|
29
|
+
"StreamHandler",
|
|
30
|
+
"connect",
|
|
31
|
+
"create_listener",
|
|
32
|
+
"default",
|
|
33
|
+
"get_backend",
|
|
34
|
+
"resolve",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connect(endpoint: Endpoint) -> AbstractAsyncContextManager[ByteStream]:
|
|
39
|
+
"""Connect to the service listening at ``endpoint``.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
endpoint: Where the service is listening.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
An async context manager yielding the connected stream.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ConnectionRefusedError: If nothing is listening. Callers treat this as
|
|
49
|
+
"the service is not running" and may start one.
|
|
50
|
+
"""
|
|
51
|
+
return get_backend(endpoint).connect(endpoint)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_listener(endpoint: Endpoint) -> AbstractAsyncContextManager[IpcListener]:
|
|
55
|
+
"""Listen for clients at ``endpoint``.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
endpoint: Where to listen.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
An async context manager yielding the listener.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
AddressInUseError: If another service already holds the address.
|
|
65
|
+
"""
|
|
66
|
+
return get_backend(endpoint).create_listener(endpoint)
|
pb_core/ipc/address.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Endpoint addressing for the service.
|
|
5
|
+
|
|
6
|
+
An endpoint is written as a URI so that one string can name any transport:
|
|
7
|
+
|
|
8
|
+
* ``unix:///run/user/1000/pybricks/pb-service.sock`` -- AF_UNIX socket (Linux, macOS)
|
|
9
|
+
* ``pipe://pybricks-pb-service-david`` -- Windows named pipe
|
|
10
|
+
* ``tcp://127.0.0.1:47823`` -- TCP, for remote services and for tests
|
|
11
|
+
* ``memory://name`` -- in-process, used by the test suite
|
|
12
|
+
|
|
13
|
+
Users are not expected to type these. :func:`resolve` picks the right default
|
|
14
|
+
for the platform, and ``PB_ENDPOINT`` overrides it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from pb_core import paths
|
|
25
|
+
from pb_core.ipc.errors import EndpointError
|
|
26
|
+
|
|
27
|
+
__all__ = ["ENDPOINT_ENV_VAR", "Endpoint", "Scheme", "default", "resolve"]
|
|
28
|
+
|
|
29
|
+
ENDPOINT_ENV_VAR = "PB_ENDPOINT"
|
|
30
|
+
|
|
31
|
+
Scheme = Literal["unix", "pipe", "tcp", "memory"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Endpoint:
|
|
36
|
+
"""A transport-independent address for the service."""
|
|
37
|
+
|
|
38
|
+
scheme: Scheme
|
|
39
|
+
target: str
|
|
40
|
+
"""Socket path, pipe name, ``host:port``, or in-process key."""
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def parse(cls, uri: str) -> Endpoint:
|
|
44
|
+
"""Parse an endpoint URI.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
uri: A string such as ``tcp://127.0.0.1:47823``.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The parsed endpoint.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
EndpointError: If the URI is malformed or names an unknown
|
|
54
|
+
scheme. It is a :class:`ValueError`.
|
|
55
|
+
"""
|
|
56
|
+
scheme, separator, rest = uri.partition("://")
|
|
57
|
+
if not separator:
|
|
58
|
+
raise EndpointError(f"endpoint is not a URI: {uri!r}")
|
|
59
|
+
|
|
60
|
+
if scheme not in ("unix", "pipe", "tcp", "memory"):
|
|
61
|
+
raise EndpointError(f"unknown endpoint scheme: {scheme!r}")
|
|
62
|
+
|
|
63
|
+
if not rest:
|
|
64
|
+
raise EndpointError(f"endpoint has no target: {uri!r}")
|
|
65
|
+
|
|
66
|
+
if scheme == "tcp":
|
|
67
|
+
host, colon, port = rest.rpartition(":")
|
|
68
|
+
if not colon or not host:
|
|
69
|
+
raise EndpointError(f"tcp endpoint needs host:port: {uri!r}")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
number = int(port)
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise EndpointError(
|
|
75
|
+
f"tcp endpoint port is not a number: {uri!r}"
|
|
76
|
+
) from None
|
|
77
|
+
|
|
78
|
+
if not 0 <= number <= 65535:
|
|
79
|
+
raise EndpointError(f"tcp endpoint port out of range: {uri!r}")
|
|
80
|
+
|
|
81
|
+
return cls(scheme, rest)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def tcp_address(self) -> tuple[str, int]:
|
|
85
|
+
"""Return ``(host, port)`` for a TCP endpoint.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If this is not a TCP endpoint.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
if self.scheme != "tcp":
|
|
92
|
+
raise EndpointError(f"not a tcp endpoint: {self}")
|
|
93
|
+
|
|
94
|
+
host, _, port = self.target.rpartition(":")
|
|
95
|
+
return host, int(port)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_loopback(self) -> bool:
|
|
99
|
+
"""Whether a TCP endpoint is confined to this machine.
|
|
100
|
+
|
|
101
|
+
Non-loopback TCP carries the shared secret over a plaintext socket, so
|
|
102
|
+
callers should require TLS or an SSH tunnel for it.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
if self.scheme != "tcp":
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
host, _ = self.tcp_address
|
|
109
|
+
return host in ("127.0.0.1", "::1", "localhost")
|
|
110
|
+
|
|
111
|
+
def __str__(self) -> str:
|
|
112
|
+
"""Render the endpoint back as a URI."""
|
|
113
|
+
return f"{self.scheme}://{self.target}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def default() -> Endpoint:
|
|
117
|
+
"""Return the platform's default endpoint.
|
|
118
|
+
|
|
119
|
+
Windows uses a named pipe because asyncio cannot serve AF_UNIX sockets on
|
|
120
|
+
the proactor event loop, which the service requires for other reasons.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
if sys.platform == "win32":
|
|
124
|
+
return Endpoint(
|
|
125
|
+
"pipe", paths.windows_pipe_name().removeprefix(r"\.\pipe" + "\\")
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return Endpoint("unix", str(paths.socket_path()))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve(uri: str | None = None) -> Endpoint:
|
|
132
|
+
"""Return the endpoint to use.
|
|
133
|
+
|
|
134
|
+
Precedence is the explicit argument, then ``PB_ENDPOINT``, then the
|
|
135
|
+
platform default.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
uri: An explicit endpoint URI, if the caller has one.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The endpoint to connect to or listen on.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
if uri:
|
|
145
|
+
return Endpoint.parse(uri)
|
|
146
|
+
|
|
147
|
+
from_env = os.environ.get(ENDPOINT_ENV_VAR)
|
|
148
|
+
if from_env:
|
|
149
|
+
return Endpoint.parse(from_env)
|
|
150
|
+
|
|
151
|
+
return default()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Per-transport backends, selected by endpoint scheme."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from pb_core.ipc.address import Endpoint
|
|
11
|
+
from pb_core.ipc.backend.provider import IpcBackend, IpcListener, StreamHandler
|
|
12
|
+
|
|
13
|
+
__all__ = ["IpcBackend", "IpcListener", "StreamHandler", "get_backend"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_backend(endpoint: Endpoint) -> IpcBackend:
|
|
17
|
+
"""Return the backend that implements ``endpoint``.
|
|
18
|
+
|
|
19
|
+
Backends are imported lazily so that, for example, importing the client on
|
|
20
|
+
Linux never touches the Windows pipe module.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
endpoint: The endpoint to be connected to or served.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A backend for that endpoint's scheme.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
NotImplementedError: If the scheme is not supported on this platform.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
if endpoint.scheme == "memory":
|
|
33
|
+
from pb_core.ipc.backend.memory import MemoryBackend
|
|
34
|
+
|
|
35
|
+
return MemoryBackend()
|
|
36
|
+
|
|
37
|
+
if endpoint.scheme == "tcp":
|
|
38
|
+
from pb_core.ipc.backend.tcp import TcpBackend
|
|
39
|
+
|
|
40
|
+
return TcpBackend()
|
|
41
|
+
|
|
42
|
+
if endpoint.scheme == "pipe":
|
|
43
|
+
if sys.platform != "win32":
|
|
44
|
+
raise NotImplementedError("named pipes are only available on Windows")
|
|
45
|
+
|
|
46
|
+
from pb_core.ipc.backend.windows import WindowsPipeBackend
|
|
47
|
+
|
|
48
|
+
return WindowsPipeBackend()
|
|
49
|
+
|
|
50
|
+
if sys.platform == "win32":
|
|
51
|
+
raise NotImplementedError(
|
|
52
|
+
"asyncio cannot serve AF_UNIX sockets on the Windows proactor loop; "
|
|
53
|
+
"use a pipe:// endpoint"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from pb_core.ipc.backend.posix import PosixBackend
|
|
57
|
+
|
|
58
|
+
return PosixBackend()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Adapter presenting an anyio listener through the :class:`IpcListener` API."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, final
|
|
9
|
+
|
|
10
|
+
from anyio.abc import Listener, TaskGroup
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from pb_core.ipc.backend.provider import IpcListener, StreamHandler, owned
|
|
14
|
+
|
|
15
|
+
__all__ = ["AnyioListener"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class AnyioListener(IpcListener):
|
|
20
|
+
"""Wraps :class:`anyio.abc.Listener` so all backends share one interface."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, inner: Listener[Any]) -> None:
|
|
23
|
+
self._inner = inner
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
async def serve(self, handler: StreamHandler, task_group: TaskGroup) -> None:
|
|
27
|
+
await self._inner.serve(owned(handler), task_group)
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
async def aclose(self) -> None:
|
|
31
|
+
await self._inner.aclose()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""An in-process transport, used by the test suite.
|
|
5
|
+
|
|
6
|
+
Running the whole service and client stack in one process with no OS resources
|
|
7
|
+
makes the integration tests fast and identical on every platform, which leaves
|
|
8
|
+
the real per-platform backends needing only a conformance suite.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import AsyncGenerator
|
|
15
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
|
16
|
+
from typing import ClassVar, final
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
from anyio.abc import ByteStream, TaskGroup
|
|
20
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
21
|
+
from anyio.streams.stapled import StapledObjectStream
|
|
22
|
+
from typing_extensions import override
|
|
23
|
+
|
|
24
|
+
from pb_core.ipc.address import Endpoint
|
|
25
|
+
from pb_core.ipc.backend.provider import IpcBackend, IpcListener, StreamHandler, owned
|
|
26
|
+
from pb_core.ipc.errors import AddressInUseError
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
__all__ = ["MemoryBackend"]
|
|
31
|
+
|
|
32
|
+
_BUFFER = 64
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@final
|
|
36
|
+
class _MemoryByteStream(ByteStream):
|
|
37
|
+
"""Adapts an object stream of ``bytes`` to the byte-stream interface.
|
|
38
|
+
|
|
39
|
+
A byte stream must honour ``max_bytes``, so anything left over from a
|
|
40
|
+
received chunk is buffered until the next call.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, inner: StapledObjectStream[bytes]) -> None:
|
|
44
|
+
self._inner = inner
|
|
45
|
+
self._pending = b""
|
|
46
|
+
|
|
47
|
+
@override
|
|
48
|
+
async def receive(self, max_bytes: int = 65536) -> bytes:
|
|
49
|
+
if not self._pending:
|
|
50
|
+
self._pending = await self._inner.receive()
|
|
51
|
+
|
|
52
|
+
chunk, self._pending = self._pending[:max_bytes], self._pending[max_bytes:]
|
|
53
|
+
return chunk
|
|
54
|
+
|
|
55
|
+
@override
|
|
56
|
+
async def send(self, item: bytes) -> None:
|
|
57
|
+
await self._inner.send(item)
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
async def send_eof(self) -> None:
|
|
61
|
+
await self._inner.send_eof()
|
|
62
|
+
|
|
63
|
+
@override
|
|
64
|
+
async def aclose(self) -> None:
|
|
65
|
+
await self._inner.aclose()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pair() -> tuple[ByteStream, ByteStream]:
|
|
69
|
+
"""Create two byte streams connected to each other."""
|
|
70
|
+
a_tx, b_rx = anyio.create_memory_object_stream[bytes](_BUFFER)
|
|
71
|
+
b_tx, a_rx = anyio.create_memory_object_stream[bytes](_BUFFER)
|
|
72
|
+
return (
|
|
73
|
+
_MemoryByteStream(StapledObjectStream(a_tx, a_rx)),
|
|
74
|
+
_MemoryByteStream(StapledObjectStream(b_tx, b_rx)),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@final
|
|
79
|
+
class _MemoryListener(IpcListener):
|
|
80
|
+
"""Accepts connections handed over through a queue."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
key: str,
|
|
85
|
+
tx: MemoryObjectSendStream[ByteStream],
|
|
86
|
+
rx: MemoryObjectReceiveStream[ByteStream],
|
|
87
|
+
) -> None:
|
|
88
|
+
self.key = key
|
|
89
|
+
self.tx = tx
|
|
90
|
+
self._rx = rx
|
|
91
|
+
|
|
92
|
+
@override
|
|
93
|
+
async def serve(self, handler: StreamHandler, task_group: TaskGroup) -> None:
|
|
94
|
+
wrapped = owned(handler)
|
|
95
|
+
|
|
96
|
+
async for stream in self._rx:
|
|
97
|
+
task_group.start_soon(wrapped, stream)
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
async def aclose(self) -> None:
|
|
101
|
+
self.tx.close()
|
|
102
|
+
|
|
103
|
+
# Close anything accepted but never served, or it leaks on shutdown.
|
|
104
|
+
with suppress(anyio.WouldBlock, anyio.EndOfStream, anyio.ClosedResourceError):
|
|
105
|
+
while True:
|
|
106
|
+
await self._rx.receive_nowait().aclose()
|
|
107
|
+
|
|
108
|
+
self._rx.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@final
|
|
112
|
+
class MemoryBackend(IpcBackend):
|
|
113
|
+
"""A process-global registry of in-process listeners keyed by name."""
|
|
114
|
+
|
|
115
|
+
_listeners: ClassVar[dict[str, _MemoryListener]] = {}
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def connect(self, endpoint: Endpoint) -> AbstractAsyncContextManager[ByteStream]:
|
|
119
|
+
return self._do_connect(endpoint)
|
|
120
|
+
|
|
121
|
+
@asynccontextmanager
|
|
122
|
+
async def _do_connect(self, endpoint: Endpoint) -> AsyncGenerator[ByteStream, None]:
|
|
123
|
+
listener = self._listeners.get(endpoint.target)
|
|
124
|
+
if listener is None:
|
|
125
|
+
raise ConnectionRefusedError(f"no listener at {endpoint}")
|
|
126
|
+
|
|
127
|
+
client, server = _pair()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
listener.tx.send_nowait(server)
|
|
131
|
+
except (anyio.WouldBlock, anyio.ClosedResourceError, anyio.BrokenResourceError):
|
|
132
|
+
await client.aclose()
|
|
133
|
+
await server.aclose()
|
|
134
|
+
raise ConnectionRefusedError(
|
|
135
|
+
f"listener at {endpoint} is not accepting"
|
|
136
|
+
) from None
|
|
137
|
+
|
|
138
|
+
logger.debug("memory: connected to %s", endpoint)
|
|
139
|
+
|
|
140
|
+
async with client:
|
|
141
|
+
yield client
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
def create_listener(
|
|
145
|
+
self, endpoint: Endpoint
|
|
146
|
+
) -> AbstractAsyncContextManager[IpcListener]:
|
|
147
|
+
return self._do_listen(endpoint)
|
|
148
|
+
|
|
149
|
+
@asynccontextmanager
|
|
150
|
+
async def _do_listen(self, endpoint: Endpoint) -> AsyncGenerator[IpcListener, None]:
|
|
151
|
+
if endpoint.target in self._listeners:
|
|
152
|
+
raise AddressInUseError(f"address already in use: {endpoint}")
|
|
153
|
+
|
|
154
|
+
tx, rx = anyio.create_memory_object_stream[ByteStream](_BUFFER)
|
|
155
|
+
listener = _MemoryListener(endpoint.target, tx, rx)
|
|
156
|
+
self._listeners[endpoint.target] = listener
|
|
157
|
+
logger.debug("memory: listening on %s", endpoint)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
yield listener
|
|
161
|
+
finally:
|
|
162
|
+
del self._listeners[endpoint.target]
|
|
163
|
+
await listener.aclose()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""AF_UNIX transport for Linux and macOS.
|
|
5
|
+
|
|
6
|
+
anyio supports UNIX sockets natively, so this backend is mostly about getting
|
|
7
|
+
the filesystem details right: a private parent directory, mode ``0600`` on the
|
|
8
|
+
socket itself, and safe removal of a socket left behind by a crashed service.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import AsyncGenerator
|
|
15
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import final
|
|
18
|
+
|
|
19
|
+
import anyio
|
|
20
|
+
import anyio.to_thread
|
|
21
|
+
from anyio.abc import ByteStream
|
|
22
|
+
from typing_extensions import override
|
|
23
|
+
|
|
24
|
+
from pb_core import paths
|
|
25
|
+
from pb_core.ipc.address import Endpoint
|
|
26
|
+
from pb_core.ipc.backend._anyio_listener import AnyioListener
|
|
27
|
+
from pb_core.ipc.backend.provider import IpcBackend, IpcListener
|
|
28
|
+
from pb_core.ipc.errors import AddressInUseError, is_address_in_use
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
__all__ = ["PosixBackend"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@final
|
|
36
|
+
class PosixBackend(IpcBackend):
|
|
37
|
+
"""AF_UNIX sockets."""
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def connect(self, endpoint: Endpoint) -> AbstractAsyncContextManager[ByteStream]:
|
|
41
|
+
return self._connect(endpoint)
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def _connect(self, endpoint: Endpoint) -> AsyncGenerator[ByteStream, None]:
|
|
45
|
+
try:
|
|
46
|
+
stream = await anyio.connect_unix(endpoint.target)
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
# No socket file: the service is not running. Callers may start one.
|
|
49
|
+
raise ConnectionRefusedError(f"no service at {endpoint}") from None
|
|
50
|
+
|
|
51
|
+
logger.debug("unix: connected to %s", endpoint)
|
|
52
|
+
|
|
53
|
+
async with stream:
|
|
54
|
+
yield stream
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def create_listener(
|
|
58
|
+
self, endpoint: Endpoint
|
|
59
|
+
) -> AbstractAsyncContextManager[IpcListener]:
|
|
60
|
+
return self._listen(endpoint)
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def _listen(self, endpoint: Endpoint) -> AsyncGenerator[IpcListener, None]:
|
|
64
|
+
socket = anyio.Path(endpoint.target)
|
|
65
|
+
# Blocking stat/mkdir, so off the event loop thread.
|
|
66
|
+
await anyio.to_thread.run_sync(paths.ensure_private_dir, Path(socket.parent))
|
|
67
|
+
|
|
68
|
+
# A socket file on its own says nothing about whether anyone is behind
|
|
69
|
+
# it, and binding would replace a live service's socket with ours --
|
|
70
|
+
# leaving it running and unreachable. So ask: a connection that is
|
|
71
|
+
# accepted is proof, and anything else means the file is debris from a
|
|
72
|
+
# crash and can go. The service also holds the single-instance lock by
|
|
73
|
+
# this point, which is the real guard; this is what makes the backend
|
|
74
|
+
# honest on its own, and what makes "address already in use" mean the
|
|
75
|
+
# same thing on every transport.
|
|
76
|
+
if await _is_live(endpoint):
|
|
77
|
+
raise AddressInUseError(f"address already in use: {endpoint}")
|
|
78
|
+
|
|
79
|
+
with suppress(FileNotFoundError):
|
|
80
|
+
await socket.unlink()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
inner = await anyio.create_unix_listener(socket, mode=0o600)
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
if is_address_in_use(exc):
|
|
86
|
+
raise AddressInUseError(f"address already in use: {endpoint}") from exc
|
|
87
|
+
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
logger.info("unix: listening on %s", endpoint)
|
|
91
|
+
listener = AnyioListener(inner)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
yield listener
|
|
95
|
+
finally:
|
|
96
|
+
await listener.aclose()
|
|
97
|
+
|
|
98
|
+
with suppress(FileNotFoundError):
|
|
99
|
+
await socket.unlink()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _is_live(endpoint: Endpoint) -> bool:
|
|
103
|
+
"""Whether something is already accepting connections on this socket.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
endpoint: The socket to try.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if a connection was accepted, which only a running service can do.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
stream = await anyio.connect_unix(endpoint.target)
|
|
114
|
+
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
await stream.aclose()
|
|
118
|
+
return True
|