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.
Files changed (40) hide show
  1. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/PKG-INFO +1 -1
  2. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/pyproject.toml +1 -1
  3. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/_internal.py +1 -2
  4. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/pubsub.py +44 -32
  5. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_pubsub.py +66 -0
  6. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/uv.lock +1 -1
  7. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.code-review-graph/.gitignore +0 -0
  8. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.code-review-graph/graph.db +0 -0
  9. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.envrc +0 -0
  10. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/.gitignore +0 -0
  11. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/CLAUDE.md +0 -0
  12. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/LICENSE +0 -0
  13. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/README.md +0 -0
  14. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/lefthook.yml +0 -0
  15. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/__init__.py +0 -0
  16. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/_workers.py +0 -0
  17. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/bridge.py +0 -0
  18. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/hub.py +0 -0
  19. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/local.py +0 -0
  20. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/proxy.py +0 -0
  21. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/src/gisolate/subprocess.py +0 -0
  22. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/__init__.py +0 -0
  23. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/__init__.py +0 -0
  24. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/process_proxy.feature +0 -0
  25. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/serialization.feature +0 -0
  26. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/subprocess_run.feature +0 -0
  27. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/features/thread_local.feature +0 -0
  28. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_proxy.py +0 -0
  29. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_serialization.py +0 -0
  30. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_subprocess.py +0 -0
  31. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/bdd/test_thread_local.py +0 -0
  32. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/conftest.py +0 -0
  33. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/helpers.py +0 -0
  34. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_bridge.py +0 -0
  35. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_hub.py +0 -0
  36. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_internal.py +0 -0
  37. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_local.py +0 -0
  38. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_proxy.py +0 -0
  39. {gisolate-0.2.16.dev0 → gisolate-0.2.16.dev1}/tests/test_subprocess.py +0 -0
  40. {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.dev0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gisolate"
3
- version = "0.2.16.dev0"
3
+ version = "0.2.16.dev1"
4
4
  description = "Process isolation for gevent applications — run any object in a clean subprocess, call methods transparently via ZMQ IPC."
5
5
  readme = "README.md"
6
6
  license = "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, runtime_checkable
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 = _DEFAULT_SNDHWM,
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
- with contextlib.suppress(Exception):
86
- if getattr(self, "_started", False):
87
- self.close()
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
- [_encode_topic(topic), data], flags=zmq_mod.NOBLOCK
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, _encode_topic(prefix))
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
- self._reader_task = loop.create_task(self._read_loop())
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, _encode_topic(topic_prefix))
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, _encode_topic(topic_prefix)
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 self._sock.recv_multipart()
327
+ parts = await sock.recv_multipart()
325
328
  except Exception:
326
- # close() torpedoed the socket; exit cleanly without
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 = _decode_topic(topic_bytes)
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
- if isinstance(r, BaseException) and not isinstance(
357
- r, asyncio.CancelledError
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]] = []
@@ -126,7 +126,7 @@ wheels = [
126
126
 
127
127
  [[package]]
128
128
  name = "gisolate"
129
- version = "0.2.15"
129
+ version = "0.2.16.dev0"
130
130
  source = { editable = "." }
131
131
  dependencies = [
132
132
  { name = "dill" },
File without changes
File without changes
File without changes
File without changes