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.
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/PKG-INFO +1 -1
- hypha_rpc-0.20.86/hypha_rpc/VERSION +3 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/__init__.py +2 -1
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/rpc.py +55 -29
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/__init__.py +35 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/websocket_client.py +20 -9
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/PKG-INFO +1 -1
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/SOURCES.txt +1 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/pyproject.toml +1 -1
- hypha_rpc-0.20.86/tests/test_reconnection_stability.py +1111 -0
- hypha_rpc-0.20.85/hypha_rpc/VERSION +0 -3
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/MANIFEST.in +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/README.md +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/pyodide_sse.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/pyodide_websocket.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/sync.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/launch.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/mcp.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/pydantic.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/schema.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/utils/serve.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc/webrtc_client.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/dependency_links.txt +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/requires.txt +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/hypha_rpc.egg-info/top_level.txt +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/setup.cfg +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_mcp.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_reconnection_runner.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_schema.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_server_compatibility.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_utils.py +0 -0
- {hypha_rpc-0.20.85 → hypha_rpc-0.20.86}/tests/test_websocket_rpc.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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())
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hypha_rpc"
|
|
7
|
-
version = "0.20.
|
|
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"
|