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.
Files changed (30) hide show
  1. {loopback_singleton-0.1.1/src/loopback_singleton.egg-info → loopback_singleton-0.2.1}/PKG-INFO +12 -11
  2. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/README.md +10 -10
  3. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/pyproject.toml +2 -1
  4. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/daemon.py +32 -8
  5. loopback_singleton-0.2.1/src/loopback_singleton/transport.py +148 -0
  6. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1/src/loopback_singleton.egg-info}/PKG-INFO +12 -11
  7. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/requires.txt +1 -0
  8. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_integration.py +79 -0
  9. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_regressions.py +61 -0
  10. loopback_singleton-0.1.1/src/loopback_singleton/transport.py +0 -42
  11. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/LICENSE +0 -0
  12. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/MANIFEST.in +0 -0
  13. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/setup.cfg +0 -0
  14. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/__init__.py +0 -0
  15. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/api.py +0 -0
  16. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/errors.py +0 -0
  17. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/locking.py +0 -0
  18. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/proxy.py +0 -0
  19. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/py.typed +0 -0
  20. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/runtime.py +0 -0
  21. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/serialization.py +0 -0
  22. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton/version.py +0 -0
  23. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/SOURCES.txt +0 -0
  24. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/dependency_links.txt +0 -0
  25. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/src/loopback_singleton.egg-info/top_level.txt +0 -0
  26. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/fixtures_pkg/__init__.py +0 -0
  27. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/fixtures_pkg/services.py +0 -0
  28. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_api_validation.py +0 -0
  29. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_daemon_factory_resolution.py +0 -0
  30. {loopback_singleton-0.1.1 → loopback_singleton-0.2.1}/tests/test_runtime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopback-singleton
3
- Version: 0.1.1
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
- - Safe-ish concurrent startup with file locking to reduce duplicate daemons.
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
- ### API overview
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
- ### Lifecycle and robustness scenarios
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
- #### Scenario B — Idle shutdown survives stuck clients
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
- #### Scenario C — Private methods are denied by daemon
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
- #### Scenario D — Warm-up without creating a proxy
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
- #### Scenario E — Health check and deterministic shutdown
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
- - Safe-ish concurrent startup with file locking to reduce duplicate daemons.
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
- ### API overview
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
- ### Lifecycle and robustness scenarios
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
- #### Scenario B — Idle shutdown survives stuck clients
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
- #### Scenario C — Private methods are denied by daemon
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
- #### Scenario D — Warm-up without creating a proxy
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
- #### Scenario E — Health check and deterministic shutdown
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.1.1"
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
- try:
119
- hello = recv_message(conn, serializer)
120
- break
121
- except socket.timeout:
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
- try:
134
- msg = recv_message(conn, serializer)
135
- except socket.timeout:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopback-singleton
3
- Version: 0.1.1
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
- - Safe-ish concurrent startup with file locking to reduce duplicate daemons.
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
- ### API overview
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
- ### Lifecycle and robustness scenarios
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
- #### Scenario B — Idle shutdown survives stuck clients
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
- #### Scenario C — Private methods are denied by daemon
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
- #### Scenario D — Warm-up without creating a proxy
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
- #### Scenario E — Health check and deterministic shutdown
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
 
@@ -6,3 +6,4 @@ ruff>=0.4
6
6
  build
7
7
  twine
8
8
  setuptools
9
+ pytest-cov
@@ -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)