loopback-singleton 0.1.1__tar.gz → 0.2.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.
- {loopback_singleton-0.1.1/src/loopback_singleton.egg-info → loopback_singleton-0.2.1}/PKG-INFO +12 -11
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/README.md +10 -10
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/pyproject.toml +2 -1
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/daemon.py +32 -8
- loopback_singleton-0.2.1/src/loopback_singleton/transport.py +148 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1/src/loopback_singleton.egg-info}/PKG-INFO +12 -11
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/requires.txt +1 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_integration.py +79 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_regressions.py +61 -0
- loopback_singleton-0.1.1/src/loopback_singleton/transport.py +0 -42
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/LICENSE +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/MANIFEST.in +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/setup.cfg +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/__init__.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/api.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/errors.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/locking.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/proxy.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/py.typed +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/runtime.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/serialization.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/version.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/SOURCES.txt +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/dependency_links.txt +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/top_level.txt +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/fixtures_pkg/__init__.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/fixtures_pkg/services.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_api_validation.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_daemon_factory_resolution.py +0 -0
- {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_runtime.py +0 -0
{loopback_singleton-0.1.1/src/loopback_singleton.egg-info → loopback_singleton-0.2.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loopback-singleton
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Process-external local singleton via loopback daemon
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/TovarnovM/loopback_singleton
|
|
@@ -16,6 +16,7 @@ Requires-Dist: ruff>=0.4; extra == "dev"
|
|
|
16
16
|
Requires-Dist: build; extra == "dev"
|
|
17
17
|
Requires-Dist: twine; extra == "dev"
|
|
18
18
|
Requires-Dist: setuptools; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
20
|
Dynamic: license-file
|
|
20
21
|
|
|
21
22
|
# loopback-singleton
|
|
@@ -31,7 +32,7 @@ Current release: `0.1.1`.
|
|
|
31
32
|
### What works today
|
|
32
33
|
|
|
33
34
|
- Local singleton daemon auto-start on first use.
|
|
34
|
-
-
|
|
35
|
+
- Concurrent startup coordination with file locking to reduce duplicate daemons.
|
|
35
36
|
- Authenticated handshake (shared token in runtime dir) between client and daemon.
|
|
36
37
|
- Sequential method execution on the singleton object (single executor queue).
|
|
37
38
|
- Idle TTL auto-shutdown for daemon cleanup.
|
|
@@ -85,7 +86,7 @@ with svc.proxy() as obj:
|
|
|
85
86
|
print(obj.inc())
|
|
86
87
|
```
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
## API overview
|
|
89
90
|
|
|
90
91
|
```python
|
|
91
92
|
local_singleton(
|
|
@@ -125,10 +126,9 @@ svc.shutdown()
|
|
|
125
126
|
4. Daemon binds ephemeral loopback TCP port, writes runtime metadata, and serves requests.
|
|
126
127
|
5. Each `CALL` request is executed sequentially against one in-memory object instance.
|
|
127
128
|
|
|
129
|
+
## Lifecycle and robustness scenarios
|
|
128
130
|
|
|
129
|
-
###
|
|
130
|
-
|
|
131
|
-
#### Scenario A — Oversized payload fails fast, daemon remains healthy
|
|
131
|
+
### Scenario A — Oversized payload fails fast, daemon remains healthy
|
|
132
132
|
|
|
133
133
|
```python
|
|
134
134
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -138,11 +138,11 @@ with svc.proxy() as p:
|
|
|
138
138
|
|
|
139
139
|
Large frames are capped (16 MiB by default). Oversized frames are rejected with a clear protocol/connection error, and the daemon keeps serving other clients.
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
### Scenario B — Idle shutdown survives stuck clients
|
|
142
142
|
|
|
143
143
|
Daemon client handlers use bounded socket read timeouts, so an idle/stuck TCP client cannot block daemon shutdown forever.
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
### Scenario C — Private methods are denied by daemon
|
|
146
146
|
|
|
147
147
|
```python
|
|
148
148
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -152,7 +152,7 @@ with svc.proxy() as p:
|
|
|
152
152
|
|
|
153
153
|
Even if a client bypasses proxy-side checks, daemon-side policy rejects `CALL` for methods starting with `_`.
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
### Scenario D — Warm-up without creating a proxy
|
|
156
156
|
|
|
157
157
|
```python
|
|
158
158
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -161,7 +161,7 @@ svc.ensure_started()
|
|
|
161
161
|
|
|
162
162
|
This starts (or verifies) the daemon and completes handshake without creating a `Proxy`.
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
### Scenario E — Health check and deterministic shutdown
|
|
165
165
|
|
|
166
166
|
```python
|
|
167
167
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -209,9 +209,10 @@ If startup repeatedly fails due to stale metadata, stop clients and remove the d
|
|
|
209
209
|
|
|
210
210
|
## Development
|
|
211
211
|
|
|
212
|
-
Run tests:
|
|
212
|
+
Run checks and tests:
|
|
213
213
|
|
|
214
214
|
```bash
|
|
215
|
+
ruff check .
|
|
215
216
|
pytest -q
|
|
216
217
|
```
|
|
217
218
|
|
|
@@ -11,7 +11,7 @@ Current release: `0.1.1`.
|
|
|
11
11
|
### What works today
|
|
12
12
|
|
|
13
13
|
- Local singleton daemon auto-start on first use.
|
|
14
|
-
-
|
|
14
|
+
- Concurrent startup coordination with file locking to reduce duplicate daemons.
|
|
15
15
|
- Authenticated handshake (shared token in runtime dir) between client and daemon.
|
|
16
16
|
- Sequential method execution on the singleton object (single executor queue).
|
|
17
17
|
- Idle TTL auto-shutdown for daemon cleanup.
|
|
@@ -65,7 +65,7 @@ with svc.proxy() as obj:
|
|
|
65
65
|
print(obj.inc())
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
## API overview
|
|
69
69
|
|
|
70
70
|
```python
|
|
71
71
|
local_singleton(
|
|
@@ -105,10 +105,9 @@ svc.shutdown()
|
|
|
105
105
|
4. Daemon binds ephemeral loopback TCP port, writes runtime metadata, and serves requests.
|
|
106
106
|
5. Each `CALL` request is executed sequentially against one in-memory object instance.
|
|
107
107
|
|
|
108
|
+
## Lifecycle and robustness scenarios
|
|
108
109
|
|
|
109
|
-
###
|
|
110
|
-
|
|
111
|
-
#### Scenario A — Oversized payload fails fast, daemon remains healthy
|
|
110
|
+
### Scenario A — Oversized payload fails fast, daemon remains healthy
|
|
112
111
|
|
|
113
112
|
```python
|
|
114
113
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -118,11 +117,11 @@ with svc.proxy() as p:
|
|
|
118
117
|
|
|
119
118
|
Large frames are capped (16 MiB by default). Oversized frames are rejected with a clear protocol/connection error, and the daemon keeps serving other clients.
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
### Scenario B — Idle shutdown survives stuck clients
|
|
122
121
|
|
|
123
122
|
Daemon client handlers use bounded socket read timeouts, so an idle/stuck TCP client cannot block daemon shutdown forever.
|
|
124
123
|
|
|
125
|
-
|
|
124
|
+
### Scenario C — Private methods are denied by daemon
|
|
126
125
|
|
|
127
126
|
```python
|
|
128
127
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -132,7 +131,7 @@ with svc.proxy() as p:
|
|
|
132
131
|
|
|
133
132
|
Even if a client bypasses proxy-side checks, daemon-side policy rejects `CALL` for methods starting with `_`.
|
|
134
133
|
|
|
135
|
-
|
|
134
|
+
### Scenario D — Warm-up without creating a proxy
|
|
136
135
|
|
|
137
136
|
```python
|
|
138
137
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -141,7 +140,7 @@ svc.ensure_started()
|
|
|
141
140
|
|
|
142
141
|
This starts (or verifies) the daemon and completes handshake without creating a `Proxy`.
|
|
143
142
|
|
|
144
|
-
|
|
143
|
+
### Scenario E — Health check and deterministic shutdown
|
|
145
144
|
|
|
146
145
|
```python
|
|
147
146
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -189,9 +188,10 @@ If startup repeatedly fails due to stale metadata, stop clients and remove the d
|
|
|
189
188
|
|
|
190
189
|
## Development
|
|
191
190
|
|
|
192
|
-
Run tests:
|
|
191
|
+
Run checks and tests:
|
|
193
192
|
|
|
194
193
|
```bash
|
|
194
|
+
ruff check .
|
|
195
195
|
pytest -q
|
|
196
196
|
```
|
|
197
197
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "loopback-singleton"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Process-external local singleton via loopback daemon"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -20,6 +20,7 @@ dev = [
|
|
|
20
20
|
"build",
|
|
21
21
|
"twine",
|
|
22
22
|
"setuptools",
|
|
23
|
+
"pytest-cov",
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
[tool.setuptools]
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import importlib
|
|
7
7
|
import queue
|
|
8
|
+
import select
|
|
8
9
|
import socket
|
|
9
10
|
import threading
|
|
10
11
|
import time
|
|
@@ -15,7 +16,7 @@ from typing import Any
|
|
|
15
16
|
from .errors import ProtocolError
|
|
16
17
|
from .runtime import get_runtime_paths, remove_runtime, write_runtime
|
|
17
18
|
from .serialization import get_serializer
|
|
18
|
-
from .transport import recv_message, send_message
|
|
19
|
+
from .transport import recv_message, recv_message_timeout, send_message
|
|
19
20
|
from .version import PROTOCOL_VERSION
|
|
20
21
|
|
|
21
22
|
|
|
@@ -28,6 +29,7 @@ class ExecItem:
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
CLIENT_RECV_TIMEOUT = 0.5
|
|
32
|
+
MAX_STALLED_PARTIAL_WINDOWS = 3
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def _resolve_factory(factory_import: str):
|
|
@@ -111,15 +113,30 @@ def run_daemon(name: str, factory: str, idle_ttl: float, serializer_name: str, s
|
|
|
111
113
|
|
|
112
114
|
def handle_client(conn: socket.socket) -> None:
|
|
113
115
|
nonlocal ever_connected
|
|
116
|
+
|
|
117
|
+
def _socket_may_have_buffered_data() -> bool:
|
|
118
|
+
try:
|
|
119
|
+
readable, _, _ = select.select([conn], [], [], 0)
|
|
120
|
+
except OSError:
|
|
121
|
+
return True
|
|
122
|
+
return bool(readable)
|
|
123
|
+
|
|
114
124
|
mark_connected(+1)
|
|
115
125
|
conn.settimeout(CLIENT_RECV_TIMEOUT)
|
|
116
126
|
try:
|
|
127
|
+
stalled_partial_windows = 0
|
|
117
128
|
while not shutting_down.is_set():
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
hello = recv_message_timeout(conn, serializer, CLIENT_RECV_TIMEOUT)
|
|
130
|
+
if hello is None:
|
|
131
|
+
if _socket_may_have_buffered_data():
|
|
132
|
+
stalled_partial_windows += 1
|
|
133
|
+
if stalled_partial_windows >= MAX_STALLED_PARTIAL_WINDOWS:
|
|
134
|
+
return
|
|
135
|
+
else:
|
|
136
|
+
stalled_partial_windows = 0
|
|
122
137
|
continue
|
|
138
|
+
stalled_partial_windows = 0
|
|
139
|
+
break
|
|
123
140
|
else:
|
|
124
141
|
return
|
|
125
142
|
|
|
@@ -129,11 +146,18 @@ def run_daemon(name: str, factory: str, idle_ttl: float, serializer_name: str, s
|
|
|
129
146
|
with active_lock:
|
|
130
147
|
ever_connected = True
|
|
131
148
|
send_message(conn, ("OK", runtime_info["pid"], {"serializer": serializer_name}), serializer)
|
|
149
|
+
stalled_partial_windows = 0
|
|
132
150
|
while not shutting_down.is_set():
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
151
|
+
msg = recv_message_timeout(conn, serializer, CLIENT_RECV_TIMEOUT)
|
|
152
|
+
if msg is None:
|
|
153
|
+
if _socket_may_have_buffered_data():
|
|
154
|
+
stalled_partial_windows += 1
|
|
155
|
+
if stalled_partial_windows >= MAX_STALLED_PARTIAL_WINDOWS:
|
|
156
|
+
return
|
|
157
|
+
else:
|
|
158
|
+
stalled_partial_windows = 0
|
|
136
159
|
continue
|
|
160
|
+
stalled_partial_windows = 0
|
|
137
161
|
kind = msg[0]
|
|
138
162
|
if kind == "PING":
|
|
139
163
|
with active_lock:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""TCP framing and message transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
import time
|
|
8
|
+
import select
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import ProtocolError
|
|
12
|
+
from .serialization import PickleSerializer
|
|
13
|
+
|
|
14
|
+
_LEN_STRUCT = struct.Struct("!I")
|
|
15
|
+
MAX_FRAME_BYTES = 16 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
_HAS_POLL = hasattr(select, "poll") and hasattr(select, "POLLHUP") and hasattr(select, "POLLERR")
|
|
18
|
+
if _HAS_POLL:
|
|
19
|
+
_POLL_HUP_FLAGS = select.POLLHUP | select.POLLERR
|
|
20
|
+
if hasattr(select, "POLLRDHUP"):
|
|
21
|
+
_POLL_HUP_FLAGS |= select.POLLRDHUP
|
|
22
|
+
else:
|
|
23
|
+
_POLL_HUP_FLAGS = 0
|
|
24
|
+
|
|
25
|
+
_PARTIAL_FRAME_WAIT_INTERVAL = 0.01
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
29
|
+
chunks = bytearray()
|
|
30
|
+
while len(chunks) < n:
|
|
31
|
+
chunk = sock.recv(n - len(chunks))
|
|
32
|
+
if not chunk:
|
|
33
|
+
raise ConnectionError("Socket closed while receiving")
|
|
34
|
+
chunks.extend(chunk)
|
|
35
|
+
return bytes(chunks)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def send_message(sock: socket.socket, obj: Any, serializer: PickleSerializer) -> None:
|
|
39
|
+
payload = serializer.dumps(obj)
|
|
40
|
+
sock.sendall(_LEN_STRUCT.pack(len(payload)))
|
|
41
|
+
sock.sendall(payload)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def recv_message(sock: socket.socket, serializer: PickleSerializer) -> Any:
|
|
45
|
+
raw_len = _recv_exact(sock, _LEN_STRUCT.size)
|
|
46
|
+
(payload_len,) = _LEN_STRUCT.unpack(raw_len)
|
|
47
|
+
if payload_len < 0:
|
|
48
|
+
raise ProtocolError(f"Invalid frame length: {payload_len}")
|
|
49
|
+
if payload_len > MAX_FRAME_BYTES:
|
|
50
|
+
raise ProtocolError(
|
|
51
|
+
f"Frame too large: {payload_len} bytes exceeds max {MAX_FRAME_BYTES} bytes"
|
|
52
|
+
)
|
|
53
|
+
payload = _recv_exact(sock, payload_len)
|
|
54
|
+
return serializer.loads(payload)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def recv_message_timeout(
|
|
58
|
+
sock: socket.socket, serializer: PickleSerializer, timeout: float
|
|
59
|
+
) -> Any | None:
|
|
60
|
+
"""Receive a full framed message within timeout without consuming partial frames.
|
|
61
|
+
|
|
62
|
+
Returns None if no complete frame becomes available before timeout.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
deadline = time.monotonic() + timeout
|
|
66
|
+
while True:
|
|
67
|
+
remaining = deadline - time.monotonic()
|
|
68
|
+
if remaining <= 0:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
readable, _, _ = select.select([sock], [], [], remaining)
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise ConnectionError("Socket closed while receiving") from exc
|
|
75
|
+
if not readable:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
raw_len = sock.recv(_LEN_STRUCT.size, socket.MSG_PEEK)
|
|
80
|
+
except socket.timeout:
|
|
81
|
+
continue
|
|
82
|
+
if not raw_len:
|
|
83
|
+
raise ConnectionError("Socket closed while receiving")
|
|
84
|
+
if len(raw_len) < _LEN_STRUCT.size:
|
|
85
|
+
if _peer_disconnected(sock):
|
|
86
|
+
raise ConnectionError("Socket closed while receiving")
|
|
87
|
+
time.sleep(min(_PARTIAL_FRAME_WAIT_INTERVAL, remaining))
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
(payload_len,) = _LEN_STRUCT.unpack(raw_len)
|
|
91
|
+
if payload_len < 0:
|
|
92
|
+
raise ProtocolError(f"Invalid frame length: {payload_len}")
|
|
93
|
+
if payload_len > MAX_FRAME_BYTES:
|
|
94
|
+
raise ProtocolError(
|
|
95
|
+
f"Frame too large: {payload_len} bytes exceeds max {MAX_FRAME_BYTES} bytes"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
frame_len = _LEN_STRUCT.size + payload_len
|
|
99
|
+
try:
|
|
100
|
+
frame = sock.recv(frame_len, socket.MSG_PEEK)
|
|
101
|
+
except socket.timeout:
|
|
102
|
+
continue
|
|
103
|
+
if not frame:
|
|
104
|
+
raise ConnectionError("Socket closed while receiving")
|
|
105
|
+
if len(frame) < frame_len:
|
|
106
|
+
if _peer_disconnected(sock):
|
|
107
|
+
raise ConnectionError("Socket closed while receiving")
|
|
108
|
+
time.sleep(min(_PARTIAL_FRAME_WAIT_INTERVAL, remaining))
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
return recv_message(sock, serializer)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _peer_disconnected(sock: socket.socket) -> bool:
|
|
115
|
+
if _HAS_POLL:
|
|
116
|
+
try:
|
|
117
|
+
poller = select.poll()
|
|
118
|
+
poller.register(sock, _POLL_HUP_FLAGS)
|
|
119
|
+
return any(event & _POLL_HUP_FLAGS for _, event in poller.poll(0))
|
|
120
|
+
except OSError:
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
# Windows and other platforms may not expose poll/POLLHUP.
|
|
124
|
+
# Fall back to a non-blocking MSG_PEEK probe so closed peers are still
|
|
125
|
+
# detected when only a partial frame is buffered.
|
|
126
|
+
if not hasattr(sock, "recv"):
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
original_timeout = None
|
|
130
|
+
if hasattr(sock, "gettimeout"):
|
|
131
|
+
original_timeout = sock.gettimeout()
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
if hasattr(sock, "setblocking"):
|
|
135
|
+
sock.setblocking(False)
|
|
136
|
+
try:
|
|
137
|
+
chunk = sock.recv(1, socket.MSG_PEEK)
|
|
138
|
+
except (BlockingIOError, InterruptedError):
|
|
139
|
+
return False
|
|
140
|
+
except OSError:
|
|
141
|
+
return True
|
|
142
|
+
return chunk == b""
|
|
143
|
+
finally:
|
|
144
|
+
if original_timeout is not None and hasattr(sock, "settimeout"):
|
|
145
|
+
try:
|
|
146
|
+
sock.settimeout(original_timeout)
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
{loopback_singleton-0.1.1 → loopback_singleton-0.2.1/src/loopback_singleton.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loopback-singleton
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Process-external local singleton via loopback daemon
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/TovarnovM/loopback_singleton
|
|
@@ -16,6 +16,7 @@ Requires-Dist: ruff>=0.4; extra == "dev"
|
|
|
16
16
|
Requires-Dist: build; extra == "dev"
|
|
17
17
|
Requires-Dist: twine; extra == "dev"
|
|
18
18
|
Requires-Dist: setuptools; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
20
|
Dynamic: license-file
|
|
20
21
|
|
|
21
22
|
# loopback-singleton
|
|
@@ -31,7 +32,7 @@ Current release: `0.1.1`.
|
|
|
31
32
|
### What works today
|
|
32
33
|
|
|
33
34
|
- Local singleton daemon auto-start on first use.
|
|
34
|
-
-
|
|
35
|
+
- Concurrent startup coordination with file locking to reduce duplicate daemons.
|
|
35
36
|
- Authenticated handshake (shared token in runtime dir) between client and daemon.
|
|
36
37
|
- Sequential method execution on the singleton object (single executor queue).
|
|
37
38
|
- Idle TTL auto-shutdown for daemon cleanup.
|
|
@@ -85,7 +86,7 @@ with svc.proxy() as obj:
|
|
|
85
86
|
print(obj.inc())
|
|
86
87
|
```
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
## API overview
|
|
89
90
|
|
|
90
91
|
```python
|
|
91
92
|
local_singleton(
|
|
@@ -125,10 +126,9 @@ svc.shutdown()
|
|
|
125
126
|
4. Daemon binds ephemeral loopback TCP port, writes runtime metadata, and serves requests.
|
|
126
127
|
5. Each `CALL` request is executed sequentially against one in-memory object instance.
|
|
127
128
|
|
|
129
|
+
## Lifecycle and robustness scenarios
|
|
128
130
|
|
|
129
|
-
###
|
|
130
|
-
|
|
131
|
-
#### Scenario A — Oversized payload fails fast, daemon remains healthy
|
|
131
|
+
### Scenario A — Oversized payload fails fast, daemon remains healthy
|
|
132
132
|
|
|
133
133
|
```python
|
|
134
134
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -138,11 +138,11 @@ with svc.proxy() as p:
|
|
|
138
138
|
|
|
139
139
|
Large frames are capped (16 MiB by default). Oversized frames are rejected with a clear protocol/connection error, and the daemon keeps serving other clients.
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
### Scenario B — Idle shutdown survives stuck clients
|
|
142
142
|
|
|
143
143
|
Daemon client handlers use bounded socket read timeouts, so an idle/stuck TCP client cannot block daemon shutdown forever.
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
### Scenario C — Private methods are denied by daemon
|
|
146
146
|
|
|
147
147
|
```python
|
|
148
148
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -152,7 +152,7 @@ with svc.proxy() as p:
|
|
|
152
152
|
|
|
153
153
|
Even if a client bypasses proxy-side checks, daemon-side policy rejects `CALL` for methods starting with `_`.
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
### Scenario D — Warm-up without creating a proxy
|
|
156
156
|
|
|
157
157
|
```python
|
|
158
158
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -161,7 +161,7 @@ svc.ensure_started()
|
|
|
161
161
|
|
|
162
162
|
This starts (or verifies) the daemon and completes handshake without creating a `Proxy`.
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
### Scenario E — Health check and deterministic shutdown
|
|
165
165
|
|
|
166
166
|
```python
|
|
167
167
|
svc = local_singleton("svc", factory="mypkg.m:MyObj")
|
|
@@ -209,9 +209,10 @@ If startup repeatedly fails due to stale metadata, stop clients and remove the d
|
|
|
209
209
|
|
|
210
210
|
## Development
|
|
211
211
|
|
|
212
|
-
Run tests:
|
|
212
|
+
Run checks and tests:
|
|
213
213
|
|
|
214
214
|
```bash
|
|
215
|
+
ruff check .
|
|
215
216
|
pytest -q
|
|
216
217
|
```
|
|
217
218
|
|
|
@@ -203,6 +203,58 @@ def test_idle_shutdown_with_stuck_client_connection() -> None:
|
|
|
203
203
|
remove_runtime(get_runtime_paths(name))
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
def test_partial_frame_then_peer_close_does_not_block_idle_shutdown() -> None:
|
|
207
|
+
name = f"partial-close-{uuid.uuid4().hex}"
|
|
208
|
+
svc = local_singleton(name=name, factory=FACTORY, idle_ttl=0.5)
|
|
209
|
+
|
|
210
|
+
svc.ensure_started()
|
|
211
|
+
runtime_path = get_runtime_paths(name).runtime_file
|
|
212
|
+
runtime = pickle.loads(runtime_path.read_bytes())
|
|
213
|
+
token = ensure_auth_token(get_runtime_paths(name))
|
|
214
|
+
serializer = get_serializer("pickle")
|
|
215
|
+
|
|
216
|
+
with socket.create_connection((runtime["host"], runtime["port"]), timeout=2.0) as sock:
|
|
217
|
+
send_message(sock, ("HELLO", PROTOCOL_VERSION, token), serializer)
|
|
218
|
+
assert recv_message(sock, serializer)[0] == "OK"
|
|
219
|
+
|
|
220
|
+
payload = serializer.dumps(("PING",))
|
|
221
|
+
frame = struct.pack("!I", len(payload)) + payload
|
|
222
|
+
sock.sendall(frame[:1])
|
|
223
|
+
|
|
224
|
+
deadline = time.time() + 4.0
|
|
225
|
+
while time.time() < deadline and runtime_path.exists():
|
|
226
|
+
time.sleep(0.05)
|
|
227
|
+
|
|
228
|
+
assert not runtime_path.exists()
|
|
229
|
+
remove_runtime(get_runtime_paths(name))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_partial_frame_stall_is_disconnected_after_retries() -> None:
|
|
233
|
+
name = f"partial-stall-{uuid.uuid4().hex}"
|
|
234
|
+
svc = local_singleton(name=name, factory=FACTORY, idle_ttl=0.5)
|
|
235
|
+
|
|
236
|
+
svc.ensure_started()
|
|
237
|
+
runtime_path = get_runtime_paths(name).runtime_file
|
|
238
|
+
runtime = pickle.loads(runtime_path.read_bytes())
|
|
239
|
+
token = ensure_auth_token(get_runtime_paths(name))
|
|
240
|
+
serializer = get_serializer("pickle")
|
|
241
|
+
|
|
242
|
+
with socket.create_connection((runtime["host"], runtime["port"]), timeout=2.0) as sock:
|
|
243
|
+
send_message(sock, ("HELLO", PROTOCOL_VERSION, token), serializer)
|
|
244
|
+
assert recv_message(sock, serializer)[0] == "OK"
|
|
245
|
+
|
|
246
|
+
payload = serializer.dumps(("PING",))
|
|
247
|
+
frame = struct.pack("!I", len(payload)) + payload
|
|
248
|
+
sock.sendall(frame[:1])
|
|
249
|
+
time.sleep(2.0)
|
|
250
|
+
|
|
251
|
+
deadline = time.time() + 4.0
|
|
252
|
+
while time.time() < deadline and runtime_path.exists():
|
|
253
|
+
time.sleep(0.05)
|
|
254
|
+
|
|
255
|
+
assert not runtime_path.exists()
|
|
256
|
+
remove_runtime(get_runtime_paths(name))
|
|
257
|
+
|
|
206
258
|
def test_private_method_call_denied_server_side() -> None:
|
|
207
259
|
name = f"private-denied-{uuid.uuid4().hex}"
|
|
208
260
|
svc = local_singleton(name=name, factory=FACTORY, idle_ttl=2.0)
|
|
@@ -245,3 +297,30 @@ def test_service_ensure_started_ping_shutdown_lifecycle() -> None:
|
|
|
245
297
|
second_info = svc.ping()
|
|
246
298
|
assert second_info["pid"] != first_pid
|
|
247
299
|
remove_runtime(get_runtime_paths(name))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_chunked_ping_frame_survives_recv_timeout_window() -> None:
|
|
303
|
+
name = f"chunked-ping-{uuid.uuid4().hex}"
|
|
304
|
+
svc = local_singleton(name=name, factory=FACTORY, idle_ttl=2.0)
|
|
305
|
+
|
|
306
|
+
svc.ensure_started()
|
|
307
|
+
runtime = pickle.loads(get_runtime_paths(name).runtime_file.read_bytes())
|
|
308
|
+
token = ensure_auth_token(get_runtime_paths(name))
|
|
309
|
+
serializer = get_serializer("pickle")
|
|
310
|
+
|
|
311
|
+
with socket.create_connection((runtime["host"], runtime["port"]), timeout=2.0) as sock:
|
|
312
|
+
send_message(sock, ("HELLO", PROTOCOL_VERSION, token), serializer)
|
|
313
|
+
assert recv_message(sock, serializer)[0] == "OK"
|
|
314
|
+
|
|
315
|
+
payload = serializer.dumps(("PING",))
|
|
316
|
+
frame = struct.pack("!I", len(payload)) + payload
|
|
317
|
+
split_at = len(frame) // 2
|
|
318
|
+
sock.sendall(frame[:split_at])
|
|
319
|
+
time.sleep(0.7)
|
|
320
|
+
sock.sendall(frame[split_at:])
|
|
321
|
+
|
|
322
|
+
status, payload = recv_message(sock, serializer)
|
|
323
|
+
assert status == "OK"
|
|
324
|
+
assert payload["pid"] == runtime["pid"]
|
|
325
|
+
|
|
326
|
+
remove_runtime(get_runtime_paths(name))
|
|
@@ -8,6 +8,7 @@ import sys
|
|
|
8
8
|
import time
|
|
9
9
|
import uuid
|
|
10
10
|
import stat
|
|
11
|
+
import importlib
|
|
11
12
|
|
|
12
13
|
import pytest
|
|
13
14
|
from pathlib import Path
|
|
@@ -263,6 +264,66 @@ def test_ensure_auth_token_uses_fallback_when_xdg_runtime_unusable(monkeypatch,
|
|
|
263
264
|
finally:
|
|
264
265
|
blocked.chmod(0o700)
|
|
265
266
|
|
|
267
|
+
|
|
268
|
+
def test_transport_imports_without_poll_support(monkeypatch) -> None:
|
|
269
|
+
import loopback_singleton.transport as transport
|
|
270
|
+
|
|
271
|
+
monkeypatch.delattr(transport.select, "poll", raising=False)
|
|
272
|
+
monkeypatch.delattr(transport.select, "POLLHUP", raising=False)
|
|
273
|
+
monkeypatch.delattr(transport.select, "POLLERR", raising=False)
|
|
274
|
+
|
|
275
|
+
reloaded = importlib.reload(transport)
|
|
276
|
+
assert reloaded._HAS_POLL is False
|
|
277
|
+
assert reloaded._peer_disconnected(object()) is False
|
|
278
|
+
|
|
279
|
+
importlib.reload(transport)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_recv_message_timeout_sleeps_when_partial_frame_buffered(monkeypatch) -> None:
|
|
283
|
+
import loopback_singleton.transport as transport
|
|
284
|
+
|
|
285
|
+
class _Sock:
|
|
286
|
+
def recv(self, _n: int, _flags: int = 0) -> bytes:
|
|
287
|
+
return b"\x00\x01"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
sock = _Sock()
|
|
291
|
+
serializer = get_serializer("pickle")
|
|
292
|
+
sleep_calls: list[float] = []
|
|
293
|
+
|
|
294
|
+
def fake_select(_r, _w, _x, _timeout):
|
|
295
|
+
return ([sock], [], [])
|
|
296
|
+
|
|
297
|
+
def fake_sleep(seconds: float) -> None:
|
|
298
|
+
sleep_calls.append(seconds)
|
|
299
|
+
raise RuntimeError("stop")
|
|
300
|
+
|
|
301
|
+
monkeypatch.setattr(transport.select, "select", fake_select)
|
|
302
|
+
monkeypatch.setattr(transport.time, "sleep", fake_sleep)
|
|
303
|
+
monkeypatch.setattr(transport, "_peer_disconnected", lambda _sock: False)
|
|
304
|
+
|
|
305
|
+
with pytest.raises(RuntimeError, match="stop"):
|
|
306
|
+
transport.recv_message_timeout(sock, serializer, timeout=0.1)
|
|
307
|
+
|
|
308
|
+
assert sleep_calls
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_recv_message_timeout_treats_select_oserror_as_disconnect(monkeypatch) -> None:
|
|
312
|
+
import loopback_singleton.transport as transport
|
|
313
|
+
|
|
314
|
+
class _Sock:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
serializer = get_serializer("pickle")
|
|
318
|
+
|
|
319
|
+
def fake_select(_r, _w, _x, _timeout):
|
|
320
|
+
raise OSError("bad file descriptor")
|
|
321
|
+
|
|
322
|
+
monkeypatch.setattr(transport.select, "select", fake_select)
|
|
323
|
+
|
|
324
|
+
with pytest.raises(ConnectionError, match="Socket closed while receiving"):
|
|
325
|
+
transport.recv_message_timeout(_Sock(), serializer, timeout=0.1)
|
|
326
|
+
|
|
266
327
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX-only metadata corruption regression")
|
|
267
328
|
def test_corrupt_runtime_metadata_is_treated_as_missing_and_recovers(monkeypatch, tmp_path: Path) -> None:
|
|
268
329
|
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
"""TCP framing and message transport."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import socket
|
|
6
|
-
import struct
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from .errors import ProtocolError
|
|
10
|
-
from .serialization import PickleSerializer
|
|
11
|
-
|
|
12
|
-
_LEN_STRUCT = struct.Struct("!I")
|
|
13
|
-
MAX_FRAME_BYTES = 16 * 1024 * 1024
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
17
|
-
chunks = bytearray()
|
|
18
|
-
while len(chunks) < n:
|
|
19
|
-
chunk = sock.recv(n - len(chunks))
|
|
20
|
-
if not chunk:
|
|
21
|
-
raise ConnectionError("Socket closed while receiving")
|
|
22
|
-
chunks.extend(chunk)
|
|
23
|
-
return bytes(chunks)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def send_message(sock: socket.socket, obj: Any, serializer: PickleSerializer) -> None:
|
|
27
|
-
payload = serializer.dumps(obj)
|
|
28
|
-
sock.sendall(_LEN_STRUCT.pack(len(payload)))
|
|
29
|
-
sock.sendall(payload)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def recv_message(sock: socket.socket, serializer: PickleSerializer) -> Any:
|
|
33
|
-
raw_len = _recv_exact(sock, _LEN_STRUCT.size)
|
|
34
|
-
(payload_len,) = _LEN_STRUCT.unpack(raw_len)
|
|
35
|
-
if payload_len < 0:
|
|
36
|
-
raise ProtocolError(f"Invalid frame length: {payload_len}")
|
|
37
|
-
if payload_len > MAX_FRAME_BYTES:
|
|
38
|
-
raise ProtocolError(
|
|
39
|
-
f"Frame too large: {payload_len} bytes exceeds max {MAX_FRAME_BYTES} bytes"
|
|
40
|
-
)
|
|
41
|
-
payload = _recv_exact(sock, payload_len)
|
|
42
|
-
return serializer.loads(payload)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
{loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_daemon_factory_resolution.py
RENAMED
|
File without changes
|
|
File without changes
|