gisolate 0.2.16.dev0__tar.gz → 0.2.16.dev1__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.
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/PKG-INFO +1 -1
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/pyproject.toml +1 -1
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/_internal.py +1 -2
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/pubsub.py +44 -32
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_pubsub.py +66 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/uv.lock +1 -1
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.code-review-graph/.gitignore +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.code-review-graph/graph.db +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.envrc +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.gitignore +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/CLAUDE.md +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/LICENSE +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/README.md +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/lefthook.yml +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/__init__.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/_workers.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/bridge.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/hub.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/local.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/proxy.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/subprocess.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/__init__.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/__init__.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/process_proxy.feature +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/serialization.feature +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/subprocess_run.feature +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/thread_local.feature +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_proxy.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_serialization.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_subprocess.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_thread_local.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/conftest.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/helpers.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_bridge.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_hub.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_internal.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_local.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_proxy.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_subprocess.py +0 -0
- {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_workers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gisolate
|
|
3
|
-
Version: 0.2.16.
|
|
3
|
+
Version: 0.2.16.dev1
|
|
4
4
|
Summary: Process isolation for gevent applications — run any object in a clean subprocess, call methods transparently via ZMQ IPC.
|
|
5
5
|
Project-URL: Repository, https://github.com/wy-z/gisolate
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ import contextlib
|
|
|
4
4
|
import io
|
|
5
5
|
import logging
|
|
6
6
|
import pickle
|
|
7
|
-
from typing import Any, Protocol
|
|
7
|
+
from typing import Any, Protocol
|
|
8
8
|
|
|
9
9
|
import dill
|
|
10
10
|
import gevent.monkey
|
|
@@ -44,7 +44,6 @@ class RemoteError(RuntimeError):
|
|
|
44
44
|
# ---------------------------------------------------------------------------
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
@runtime_checkable
|
|
48
47
|
class Serializer(Protocol):
|
|
49
48
|
"""Pluggable serializer protocol: ``dumps(obj) -> bytes`` / ``loads(bytes) -> obj``."""
|
|
50
49
|
|
|
@@ -15,22 +15,9 @@ from ._internal import Serializer, SmartPickle
|
|
|
15
15
|
|
|
16
16
|
log = logging.getLogger(__name__)
|
|
17
17
|
|
|
18
|
-
_TOPIC_ENCODING = "utf-8"
|
|
19
|
-
|
|
20
|
-
# Default high-water mark for PUB send queue. ZMQ drops when exceeded.
|
|
21
|
-
_DEFAULT_SNDHWM = 1000
|
|
22
|
-
|
|
23
18
|
Handler = Callable[[str, Any], Awaitable[None]]
|
|
24
19
|
|
|
25
20
|
|
|
26
|
-
def _encode_topic(topic: str) -> bytes:
|
|
27
|
-
return topic.encode(_TOPIC_ENCODING)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _decode_topic(data: bytes) -> str:
|
|
31
|
-
return data.decode(_TOPIC_ENCODING, errors="replace")
|
|
32
|
-
|
|
33
|
-
|
|
34
21
|
def _safe_close(sock: Any, ctx: Any) -> None:
|
|
35
22
|
"""Best-effort tear down of a ZMQ socket + context. Swallows errors."""
|
|
36
23
|
with contextlib.suppress(Exception):
|
|
@@ -71,7 +58,7 @@ class ProcessPublisher:
|
|
|
71
58
|
address: str,
|
|
72
59
|
*,
|
|
73
60
|
serializer: Serializer = SmartPickle,
|
|
74
|
-
sndhwm: int =
|
|
61
|
+
sndhwm: int = 1000,
|
|
75
62
|
):
|
|
76
63
|
self._addr = address
|
|
77
64
|
self._serializer = serializer
|
|
@@ -82,9 +69,10 @@ class ProcessPublisher:
|
|
|
82
69
|
self._send_lock: Any = None
|
|
83
70
|
|
|
84
71
|
def __del__(self):
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
72
|
+
# Best-effort sync cleanup. Avoid full close() — at GC time the
|
|
73
|
+
# gevent hub may be torn down, so acquiring the send-lock can fail.
|
|
74
|
+
# The ipc file is left on disk (process is exiting anyway).
|
|
75
|
+
_safe_close(getattr(self, "_sock", None), getattr(self, "_ctx", None))
|
|
88
76
|
|
|
89
77
|
def __enter__(self) -> "ProcessPublisher":
|
|
90
78
|
return self.start()
|
|
@@ -141,7 +129,7 @@ class ProcessPublisher:
|
|
|
141
129
|
return
|
|
142
130
|
try:
|
|
143
131
|
self._sock.send_multipart(
|
|
144
|
-
[
|
|
132
|
+
[topic.encode("utf-8"), data], flags=zmq_mod.NOBLOCK
|
|
145
133
|
)
|
|
146
134
|
except zmq_mod.Again:
|
|
147
135
|
# SNDHWM hit — slow subscribers. Drop, matching PUB semantics.
|
|
@@ -254,7 +242,7 @@ class ProcessSubscriber:
|
|
|
254
242
|
sock.connect(self._addr)
|
|
255
243
|
# Re-subscribe to any prefixes registered before start().
|
|
256
244
|
for prefix in self._handlers:
|
|
257
|
-
sock.setsockopt(zmq.SUBSCRIBE,
|
|
245
|
+
sock.setsockopt(zmq.SUBSCRIBE, prefix.encode("utf-8"))
|
|
258
246
|
except Exception:
|
|
259
247
|
_safe_close(sock, ctx)
|
|
260
248
|
raise
|
|
@@ -262,7 +250,10 @@ class ProcessSubscriber:
|
|
|
262
250
|
self._ctx = ctx
|
|
263
251
|
self._sock = sock
|
|
264
252
|
self._started = True
|
|
265
|
-
|
|
253
|
+
# Bind the reader to *this* socket. A close()+start() restart from
|
|
254
|
+
# inside a handler swaps ``self._sock``; the stale reader must not
|
|
255
|
+
# resume against the new socket (would race the new reader's recv).
|
|
256
|
+
self._reader_task = loop.create_task(self._read_loop(sock))
|
|
266
257
|
return self
|
|
267
258
|
|
|
268
259
|
def subscribe(self, topic_prefix: str, handler: Handler) -> None:
|
|
@@ -279,7 +270,7 @@ class ProcessSubscriber:
|
|
|
279
270
|
if new_prefix and self._started:
|
|
280
271
|
import zmq
|
|
281
272
|
|
|
282
|
-
self._sock.setsockopt(zmq.SUBSCRIBE,
|
|
273
|
+
self._sock.setsockopt(zmq.SUBSCRIBE, topic_prefix.encode("utf-8"))
|
|
283
274
|
|
|
284
275
|
def unsubscribe(
|
|
285
276
|
self, topic_prefix: str, handler: Handler | None = None
|
|
@@ -305,7 +296,7 @@ class ProcessSubscriber:
|
|
|
305
296
|
|
|
306
297
|
with contextlib.suppress(Exception):
|
|
307
298
|
self._sock.setsockopt(
|
|
308
|
-
zmq.UNSUBSCRIBE,
|
|
299
|
+
zmq.UNSUBSCRIBE, topic_prefix.encode("utf-8")
|
|
309
300
|
)
|
|
310
301
|
|
|
311
302
|
def _match(self, topic: str) -> list[Handler]:
|
|
@@ -316,22 +307,32 @@ class ProcessSubscriber:
|
|
|
316
307
|
for h in handlers
|
|
317
308
|
]
|
|
318
309
|
|
|
319
|
-
async def _read_loop(self) -> None:
|
|
320
|
-
"""Single reader task: dispatch messages to matched handlers.
|
|
310
|
+
async def _read_loop(self, sock: Any) -> None:
|
|
311
|
+
"""Single reader task: dispatch messages to matched handlers.
|
|
312
|
+
|
|
313
|
+
``sock`` is captured at task creation time so a close+restart cycle
|
|
314
|
+
leaves stale readers pointing at the already-closed socket — their
|
|
315
|
+
recv fails immediately and they exit without touching the new socket.
|
|
316
|
+
"""
|
|
321
317
|
try:
|
|
322
318
|
while True:
|
|
319
|
+
# Check before each recv: close() or a close+start restart
|
|
320
|
+
# may have swapped ``self._sock`` while we were suspended in
|
|
321
|
+
# the previous gather() (pyzmq does not necessarily wake a
|
|
322
|
+
# pending recv future when the socket is closed, so we can't
|
|
323
|
+
# rely solely on recv raising).
|
|
324
|
+
if not self._started or sock is not self._sock:
|
|
325
|
+
return
|
|
323
326
|
try:
|
|
324
|
-
parts = await
|
|
327
|
+
parts = await sock.recv_multipart()
|
|
325
328
|
except Exception:
|
|
326
|
-
|
|
327
|
-
# logging a fake crash.
|
|
328
|
-
if not self._started:
|
|
329
|
+
if not self._started or sock is not self._sock:
|
|
329
330
|
return
|
|
330
331
|
raise
|
|
331
332
|
if len(parts) < 2:
|
|
332
333
|
continue
|
|
333
334
|
topic_bytes, data, *_ = parts
|
|
334
|
-
topic =
|
|
335
|
+
topic = topic_bytes.decode("utf-8", errors="replace")
|
|
335
336
|
handlers = self._match(topic)
|
|
336
337
|
if not handlers:
|
|
337
338
|
continue
|
|
@@ -353,9 +354,9 @@ class ProcessSubscriber:
|
|
|
353
354
|
if isinstance(r, (SystemExit, KeyboardInterrupt)):
|
|
354
355
|
# Honor process-exit intent from a handler.
|
|
355
356
|
raise r
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
):
|
|
357
|
+
# CancelledError is BaseException (not Exception) since
|
|
358
|
+
# 3.8, so ``isinstance(r, Exception)`` naturally skips it.
|
|
359
|
+
if isinstance(r, Exception):
|
|
359
360
|
log.error(
|
|
360
361
|
"subscriber handler raised %s for topic %s",
|
|
361
362
|
type(r).__name__,
|
|
@@ -366,6 +367,17 @@ class ProcessSubscriber:
|
|
|
366
367
|
pass
|
|
367
368
|
except Exception:
|
|
368
369
|
log.exception("subscriber reader task crashed")
|
|
370
|
+
# Reader is dying for an unrelated reason (malformed frame,
|
|
371
|
+
# libzmq error, etc.). Tear our resources down so the
|
|
372
|
+
# subscriber doesn't look alive while silently dropping
|
|
373
|
+
# messages, and so ``start()`` can rebuild from scratch.
|
|
374
|
+
if sock is self._sock and self._started:
|
|
375
|
+
self._started = False
|
|
376
|
+
ctx = self._ctx
|
|
377
|
+
self._sock = None
|
|
378
|
+
self._ctx = None
|
|
379
|
+
self._reader_task = None
|
|
380
|
+
_safe_close(sock, ctx)
|
|
369
381
|
|
|
370
382
|
async def _invoke(self, handler: Handler, topic: str, payload: Any) -> None:
|
|
371
383
|
task = asyncio.current_task()
|
|
@@ -617,6 +617,72 @@ class TestPubSubIntegration:
|
|
|
617
617
|
stop.set()
|
|
618
618
|
thread.join(timeout=3)
|
|
619
619
|
|
|
620
|
+
def test_handler_close_then_restart_no_stale_reader(self):
|
|
621
|
+
"""close+start from inside a handler must not leave a stale reader live.
|
|
622
|
+
|
|
623
|
+
Regression: ``_read_loop`` used to read ``self._sock`` dynamically, so
|
|
624
|
+
after a handler did ``await sub.close(); sub.start()`` the original
|
|
625
|
+
reader resumed against the *new* socket, racing the new reader's
|
|
626
|
+
recv. The reader is now bound to its start-time socket and exits on
|
|
627
|
+
the first failed recv from the closed old socket.
|
|
628
|
+
"""
|
|
629
|
+
addr = _make_addr()
|
|
630
|
+
bucket: list[tuple[str, Any]] = []
|
|
631
|
+
ready = threading.Event()
|
|
632
|
+
stop = threading.Event()
|
|
633
|
+
restarted = threading.Event()
|
|
634
|
+
reader_tasks_snapshot: dict[str, Any] = {}
|
|
635
|
+
|
|
636
|
+
def runner() -> None:
|
|
637
|
+
async def main() -> None:
|
|
638
|
+
sub = ProcessSubscriber(addr)
|
|
639
|
+
|
|
640
|
+
async def handler(topic, payload):
|
|
641
|
+
bucket.append((topic, payload))
|
|
642
|
+
if payload == {"i": 0} and not restarted.is_set():
|
|
643
|
+
old_task = sub._reader_task
|
|
644
|
+
await sub.close()
|
|
645
|
+
sub.start()
|
|
646
|
+
reader_tasks_snapshot["old"] = old_task
|
|
647
|
+
reader_tasks_snapshot["new"] = sub._reader_task
|
|
648
|
+
restarted.set()
|
|
649
|
+
|
|
650
|
+
sub.subscribe("v1.x.", handler)
|
|
651
|
+
sub.start()
|
|
652
|
+
ready.set()
|
|
653
|
+
try:
|
|
654
|
+
while not stop.is_set():
|
|
655
|
+
await asyncio.sleep(0.02)
|
|
656
|
+
finally:
|
|
657
|
+
await sub.close()
|
|
658
|
+
|
|
659
|
+
asyncio.run(main())
|
|
660
|
+
|
|
661
|
+
thread = threading.Thread(target=runner, daemon=True)
|
|
662
|
+
thread.start()
|
|
663
|
+
try:
|
|
664
|
+
assert ready.wait(2.0)
|
|
665
|
+
pub = ProcessPublisher(addr).start()
|
|
666
|
+
try:
|
|
667
|
+
gevent.sleep(0.2)
|
|
668
|
+
pub.publish("v1.x.k", {"i": 0})
|
|
669
|
+
assert _wait_until(restarted.is_set, timeout=3.0)
|
|
670
|
+
# Give the old reader time to wake and (correctly) exit.
|
|
671
|
+
gevent.sleep(0.3)
|
|
672
|
+
old = reader_tasks_snapshot["old"]
|
|
673
|
+
new = reader_tasks_snapshot["new"]
|
|
674
|
+
assert old is not new
|
|
675
|
+
assert old.done(), "stale reader from pre-restart still alive"
|
|
676
|
+
# New reader still receives.
|
|
677
|
+
pub.publish("v1.x.k", {"i": 1})
|
|
678
|
+
assert _wait_until(lambda: len(bucket) >= 2, timeout=3.0)
|
|
679
|
+
assert bucket[1] == ("v1.x.k", {"i": 1})
|
|
680
|
+
finally:
|
|
681
|
+
pub.close()
|
|
682
|
+
finally:
|
|
683
|
+
stop.set()
|
|
684
|
+
thread.join(timeout=3)
|
|
685
|
+
|
|
620
686
|
def test_custom_serializer_end_to_end(self):
|
|
621
687
|
addr = _make_addr()
|
|
622
688
|
bucket: list[tuple[str, Any]] = []
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|