hypha-rpc 0.20.85__tar.gz → 0.20.86__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 (32) hide show
  1. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/PKG-INFO +1 -1
  2. hypha_rpc-0.20.86/hypha_rpc/VERSION +3 -0
  3. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/__init__.py +2 -1
  4. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/rpc.py +55 -29
  5. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/__init__.py +35 -0
  6. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/websocket_client.py +20 -9
  7. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/PKG-INFO +1 -1
  8. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/SOURCES.txt +1 -0
  9. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/pyproject.toml +1 -1
  10. hypha_rpc-0.20.86/tests/test_reconnection_stability.py +1111 -0
  11. hypha_rpc-0.20.85/hypha_rpc/VERSION +0 -3
  12. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/MANIFEST.in +0 -0
  13. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/README.md +0 -0
  14. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/pyodide_sse.py +0 -0
  15. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/pyodide_websocket.py +0 -0
  16. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/sync.py +0 -0
  17. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/launch.py +0 -0
  18. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/mcp.py +0 -0
  19. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/pydantic.py +0 -0
  20. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/schema.py +0 -0
  21. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/serve.py +0 -0
  22. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/webrtc_client.py +0 -0
  23. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/dependency_links.txt +0 -0
  24. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/requires.txt +0 -0
  25. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/top_level.txt +0 -0
  26. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/setup.cfg +0 -0
  27. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_mcp.py +0 -0
  28. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_reconnection_runner.py +0 -0
  29. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_schema.py +0 -0
  30. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_server_compatibility.py +0 -0
  31. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_utils.py +0 -0
  32. {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_websocket_rpc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha_rpc
3
- Version: 0.20.85
3
+ Version: 0.20.86
4
4
  Summary: Hypha RPC client for connecting to Hypha server for data management and AI model serving
5
5
  Author-email: Wei Ouyang <oeway007@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -0,0 +1,3 @@
1
+ {
2
+ "version": "0.20.86"
3
+ }
@@ -7,7 +7,7 @@ import asyncio
7
7
  import sys
8
8
 
9
9
  from .rpc import RPC
10
- from .utils import ObjectProxy
10
+ from .utils import ObjectProxy, run_in_executor
11
11
  from .sync import connect_to_server as connect_to_server_sync
12
12
  from .sync import get_remote_service as get_remote_service_sync
13
13
  from .sync import get_rtc_service as get_rtc_service_sync
@@ -127,4 +127,5 @@ __all__ = [
127
127
  "register_rtc_service_sync",
128
128
  "get_rtc_service_sync",
129
129
  "setup_local_client",
130
+ "run_in_executor",
130
131
  ]
@@ -669,14 +669,25 @@ class RPC(MessageEmitter):
669
669
  registered_count = 0
670
670
  failed_services = []
671
671
 
672
+ # Use timeout for service registration to prevent hanging
673
+ service_registration_timeout = self._method_timeout or 30
674
+
672
675
  for service in list(self._services.values()):
673
676
  try:
674
677
  service_info = self._extract_service_info(service)
675
- await manager.register_service(service_info)
678
+ await asyncio.wait_for(
679
+ manager.register_service(service_info),
680
+ timeout=service_registration_timeout
681
+ )
676
682
  registered_count += 1
677
683
  logger.debug(
678
684
  f"Successfully registered service: {service.get('id', 'unknown')}"
679
685
  )
686
+ except asyncio.TimeoutError:
687
+ failed_services.append(service.get("id", "unknown"))
688
+ logger.error(
689
+ f"Timeout registering service {service.get('id', 'unknown')}"
690
+ )
680
691
  except Exception as service_error:
681
692
  failed_services.append(service.get("id", "unknown"))
682
693
  logger.error(
@@ -707,25 +718,29 @@ class RPC(MessageEmitter):
707
718
  manager_dict = ObjectProxy.toDict(manager)
708
719
  if "subscribe" in manager_dict:
709
720
  logger.debug("Subscribing to client_disconnected events")
710
-
721
+
711
722
  async def handle_client_disconnected(event):
712
723
  client_id = event.get("client")
713
724
  if client_id:
714
725
  logger.debug(f"Client {client_id} disconnected, cleaning up sessions")
715
726
  await self._handle_client_disconnected(client_id)
716
-
717
- # Subscribe to the event topic first
718
- self._client_disconnected_subscription = await manager.subscribe([
719
- "client_disconnected"
720
- ])
721
-
727
+
728
+ # Subscribe to the event topic first with timeout
729
+ self._client_disconnected_subscription = await asyncio.wait_for(
730
+ manager.subscribe(["client_disconnected"]),
731
+ timeout=service_registration_timeout
732
+ )
733
+
722
734
  # Then register the local event handler
723
735
  self.on("client_disconnected", handle_client_disconnected)
724
-
736
+
725
737
  logger.debug("Successfully subscribed to client_disconnected events")
726
738
  else:
727
739
  logger.debug("Manager does not support subscribe method, skipping client_disconnected handling")
728
740
  self._client_disconnected_subscription = None
741
+ except asyncio.TimeoutError:
742
+ logger.warning("Timeout subscribing to client_disconnected events")
743
+ self._client_disconnected_subscription = None
729
744
  except Exception as subscribe_error:
730
745
  logger.warning(f"Failed to subscribe to client_disconnected events: {subscribe_error}")
731
746
  self._client_disconnected_subscription = None
@@ -990,29 +1005,14 @@ class RPC(MessageEmitter):
990
1005
  except Exception as e:
991
1006
  logger.debug(f"Error removing background task: {e}")
992
1007
 
993
- # Unsubscribe from client_disconnected events if subscribed
1008
+ # Remove the local event handler for client_disconnected
1009
+ # Note: Actual unsubscription from server is done in async disconnect() method
994
1010
  if hasattr(self, '_client_disconnected_subscription') and self._client_disconnected_subscription:
995
1011
  try:
996
- # Get the manager service to unsubscribe (non-blocking)
997
- if self._connection and self._connection.manager_id:
998
- async def unsubscribe_async():
999
- try:
1000
- manager = await self.get_remote_service(f"*/{self._connection.manager_id}")
1001
- if hasattr(manager, 'unsubscribe') and callable(manager.unsubscribe):
1002
- if asyncio.iscoroutinefunction(manager.unsubscribe):
1003
- await manager.unsubscribe("client_disconnected")
1004
- else:
1005
- manager.unsubscribe("client_disconnected")
1006
- except Exception as e:
1007
- logger.debug(f"Error unsubscribing from client_disconnected: {e}")
1008
-
1009
- # Create task to run in background
1010
- asyncio.create_task(unsubscribe_async())
1011
-
1012
1012
  # Remove the local event handler
1013
1013
  self.off("client_disconnected")
1014
1014
  except Exception as e:
1015
- logger.debug(f"Error unsubscribing from client_disconnected: {e}")
1015
+ logger.debug(f"Error removing client_disconnected handler: {e}")
1016
1016
 
1017
1017
  # Clear connection reference to break circular references
1018
1018
  if hasattr(self, '_connection'):
@@ -1026,10 +1026,36 @@ class RPC(MessageEmitter):
1026
1026
 
1027
1027
  async def disconnect(self):
1028
1028
  """Disconnect."""
1029
- # Store connection reference before closing
1029
+ # Store connection reference before closing for unsubscribe
1030
1030
  connection = getattr(self, '_connection', None)
1031
+ manager_id = connection.manager_id if connection else None
1032
+
1033
+ # Unsubscribe from client_disconnected events before closing
1034
+ if hasattr(self, '_client_disconnected_subscription') and self._client_disconnected_subscription:
1035
+ try:
1036
+ if connection and manager_id:
1037
+ manager = await asyncio.wait_for(
1038
+ self.get_remote_service(f"*/{manager_id}"),
1039
+ timeout=5.0
1040
+ )
1041
+ if hasattr(manager, 'unsubscribe') and callable(manager.unsubscribe):
1042
+ if asyncio.iscoroutinefunction(manager.unsubscribe):
1043
+ await asyncio.wait_for(
1044
+ manager.unsubscribe("client_disconnected"),
1045
+ timeout=5.0
1046
+ )
1047
+ else:
1048
+ manager.unsubscribe("client_disconnected")
1049
+ logger.debug("Successfully unsubscribed from client_disconnected events")
1050
+ except asyncio.TimeoutError:
1051
+ logger.debug("Timeout unsubscribing from client_disconnected events")
1052
+ except Exception as e:
1053
+ logger.debug(f"Error unsubscribing from client_disconnected: {e}")
1054
+ finally:
1055
+ self._client_disconnected_subscription = None
1056
+
1031
1057
  self.close()
1032
-
1058
+
1033
1059
  # Disconnect the underlying connection if it exists
1034
1060
  if connection:
1035
1061
  try:
@@ -74,6 +74,41 @@ def safe_create_future():
74
74
  return asyncio.Future()
75
75
 
76
76
 
77
+ async def run_in_executor(func, *args, executor=None, **kwargs):
78
+ """
79
+ Run a synchronous CPU-bound function in an executor to avoid blocking the event loop.
80
+
81
+ This is critical for maintaining RPC stability when services perform CPU-intensive
82
+ operations. Blocking the event loop can prevent:
83
+ - Heartbeat messages from being sent/received
84
+ - Reconnection logic from executing
85
+ - Other concurrent RPC calls from progressing
86
+
87
+ Example usage:
88
+ # Instead of:
89
+ result = cpu_intensive_function(data)
90
+
91
+ # Use:
92
+ from hypha_rpc.utils import run_in_executor
93
+ result = await run_in_executor(cpu_intensive_function, data)
94
+
95
+ Args:
96
+ func: The synchronous function to run
97
+ *args: Positional arguments to pass to the function
98
+ executor: Optional concurrent.futures.Executor. If None, uses the default
99
+ ThreadPoolExecutor. For truly CPU-bound tasks, consider using
100
+ a ProcessPoolExecutor.
101
+ **kwargs: Keyword arguments to pass to the function
102
+
103
+ Returns:
104
+ The result of calling func(*args, **kwargs)
105
+ """
106
+ loop = asyncio.get_running_loop()
107
+ if kwargs:
108
+ func = partial(func, **kwargs)
109
+ return await loop.run_in_executor(executor, func, *args)
110
+
111
+
77
112
  # The following code adopted from the munch library,
78
113
  # By Copyright (c) 2010 David Schoonover
79
114
  # We changed the way the keys being generated to cope with
@@ -219,6 +219,12 @@ class WebsocketRPCConnection:
219
219
  except asyncio.CancelledError:
220
220
  # Task was cancelled, cleanup or exit gracefully
221
221
  logger.info("Refresh token task was cancelled.")
222
+ except RuntimeError as e:
223
+ # Handle event loop closed error gracefully
224
+ if "Event loop is closed" in str(e) or "cannot schedule new futures" in str(e):
225
+ logger.debug("Event loop closed during refresh token task")
226
+ else:
227
+ logger.error(f"RuntimeError in refresh token task: {e}")
222
228
  except Exception as exp:
223
229
  logger.error(f"Failed to send refresh token: {exp}")
224
230
 
@@ -427,7 +433,13 @@ class WebsocketRPCConnection:
427
433
  )
428
434
  break
429
435
  except ConnectionAbortedError as e:
430
- logger.warning("Server refuse to reconnect: %s", e)
436
+ logger.warning("Server refused to reconnect: %s", e)
437
+ # Mark as closed and notify the application
438
+ self._closed = True
439
+ if self._handle_disconnected:
440
+ self._handle_disconnected(
441
+ f"Server refused reconnection: {e}"
442
+ )
431
443
  break
432
444
  except (ConnectionRefusedError, OSError) as e:
433
445
  # Network-related errors that might be temporary
@@ -483,19 +495,18 @@ class WebsocketRPCConnection:
483
495
 
484
496
  if retry >= MAX_RETRY and not self._closed:
485
497
  logger.error(
486
- f"Failed to reconnect after {MAX_RETRY} attempts, giving up. Exiting process."
498
+ f"Failed to reconnect after {MAX_RETRY} attempts, giving up."
487
499
  )
500
+ # Mark as closed to prevent further reconnection attempts
501
+ self._closed = True
488
502
  if self._handle_disconnected:
489
503
  self._handle_disconnected(
490
504
  "Max reconnection attempts exceeded"
491
505
  )
492
- # Exit process to prevent stuck event loop
493
- import os
494
-
495
- logger.error(
496
- "Forcing process exit due to unrecoverable connection failure"
497
- )
498
- os._exit(1)
506
+ # Note: We intentionally do NOT call os._exit() here.
507
+ # Instead, we mark the connection as closed and let the
508
+ # application handle the failure through the disconnected
509
+ # handler or by checking connection state.
499
510
 
500
511
  # Create and track the reconnection task
501
512
  reconnect_task = asyncio.create_task(reconnect_with_retry())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha_rpc
3
- Version: 0.20.85
3
+ Version: 0.20.86
4
4
  Summary: Hypha RPC client for connecting to Hypha server for data management and AI model serving
5
5
  Author-email: Wei Ouyang <oeway007@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -23,6 +23,7 @@ hypha_rpc/utils/schema.py
23
23
  hypha_rpc/utils/serve.py
24
24
  tests/test_mcp.py
25
25
  tests/test_reconnection_runner.py
26
+ tests/test_reconnection_stability.py
26
27
  tests/test_schema.py
27
28
  tests/test_server_compatibility.py
28
29
  tests/test_utils.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hypha_rpc"
7
- version = "0.20.85"
7
+ version = "0.20.86"
8
8
  description = "Hypha RPC client for connecting to Hypha server for data management and AI model serving"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"