Skip to content

Commit a632390

Browse files
committed
Enhance RemoteClient connection logic with exponential backoff and update method documentation; add comprehensive unit tests for RemoteProxy and SimpleRemoteProxy interactions
1 parent c3b4bc8 commit a632390

3 files changed

Lines changed: 242 additions & 16 deletions

File tree

datalab/control/remote.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -753,32 +753,50 @@ def connect(
753753
Args:
754754
port: XML-RPC port to connect to. If not specified,
755755
the port is automatically retrieved from DataLab configuration.
756-
timeout: Timeout in seconds. Defaults to 5.0.
757-
retries: Number of retries. Defaults to 10.
756+
timeout: Maximum time to wait for connection in seconds. Defaults to 5.0.
757+
This is the total maximum wait time, not per retry.
758+
retries: Number of retries. Defaults to 10. This parameter is deprecated
759+
and will be removed in a future version (kept for backward compatibility).
758760
759761
Raises:
760762
ConnectionRefusedError: Unable to connect to DataLab
761763
ValueError: Invalid timeout (must be >= 0.0)
762764
ValueError: Invalid number of retries (must be >= 1)
763765
"""
764766
timeout = 5.0 if timeout is None else timeout
765-
retries = 10 if retries is None else retries
767+
retries = 10 if retries is None else retries # Kept for backward compatibility
766768
if timeout < 0.0:
767769
raise ValueError("timeout must be >= 0.0")
768770
if retries < 1:
769771
raise ValueError("retries must be >= 1")
770-
self.set_port(port)
772+
771773
execenv.print(f"Connecting to DataLab XML-RPC server... [port:{port}] ", end="")
772-
for _index in range(retries):
774+
775+
# Use exponential backoff for more efficient retrying
776+
start_time = time.time()
777+
poll_interval = 0.1 # Start with 100ms
778+
max_poll_interval = 1.0 # Cap at 1 second
779+
780+
while True:
773781
try:
782+
# Try to set the port - this may fail if DataLab hasn't written its
783+
# config file yet, so we retry it in the loop
784+
self.set_port(port)
774785
self.__connect_to_server()
775-
break
776-
except ConnectionRefusedError:
777-
time.sleep(timeout / retries)
778-
else:
779-
execenv.print("KO")
780-
raise ConnectionRefusedError("Unable to connect to DataLab")
781-
execenv.print(f"OK (port: {self.port})")
786+
elapsed = time.time() - start_time
787+
execenv.print(f"OK (port: {self.port}, connected in {elapsed:.1f}s)")
788+
return
789+
except (ConnectionRefusedError, OSError):
790+
# Catch both ConnectionRefusedError and OSError (includes socket errors)
791+
elapsed = time.time() - start_time
792+
if elapsed >= timeout:
793+
execenv.print("KO")
794+
raise ConnectionRefusedError(
795+
f"Unable to connect to DataLab after {elapsed:.1f}s"
796+
)
797+
# Wait before next retry with exponential backoff
798+
time.sleep(poll_interval)
799+
poll_interval = min(poll_interval * 1.5, max_poll_interval)
782800

783801
def disconnect(self) -> None:
784802
"""Disconnect from DataLab XML-RPC server."""

datalab/tests/__init__.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import datalab.config # Loading icons
3636
from datalab.config import MOD_NAME
37+
from datalab.control.proxy import RemoteProxy
3738
from datalab.env import execenv
3839
from datalab.gui.main import DLMainWindow
3940
from datalab.gui.panel.image import ImagePanel
@@ -107,7 +108,7 @@ def is_pid_alive(pid: int) -> bool:
107108
return psutil.pid_exists(pid) and psutil.Process(pid).is_running()
108109

109110

110-
def run_datalab_in_background():
111+
def run_datalab_in_background(wait_until_ready: bool = True) -> None:
111112
"""Run DataLab application as a service.
112113
113114
This function starts the DataLab application in a separate process, ensuring that
@@ -121,8 +122,13 @@ def run_datalab_in_background():
121122
application needs to be running in the background while a client connects to it
122123
and performs various operations.
123124
125+
Args:
126+
wait_until_ready: If True, waits until the DataLab application is ready to
127+
accept connections (default: True). Uses RemoteProxy's built-in retry logic
128+
with extended timeout to handle DataLab startup time.
129+
124130
Raises:
125-
AssertionError: If the DataLab application fails to start
131+
RuntimeError: If the DataLab application fails to start
126132
"""
127133
env = os.environ.copy()
128134
env[execenv.DO_NOT_QUIT_ENV] = "1"
@@ -141,8 +147,26 @@ def run_datalab_in_background():
141147
# error messages or logs that might help you understand why the application failed
142148
# to start.
143149
# If the script is executed within a pytest session, add the `-s` option to pytest.
144-
time.sleep(2)
145-
assert is_pid_alive(proc.pid), "Unable to start DataLab application"
150+
151+
# Give the process a moment to actually start
152+
time.sleep(1)
153+
if not is_pid_alive(proc.pid):
154+
raise RuntimeError("DataLab process terminated immediately after start")
155+
156+
if wait_until_ready:
157+
# Use RemoteProxy's built-in retry mechanism with extended timeout
158+
# DataLab startup: Python imports, Qt init, GUI creation, XML-RPC server
159+
try:
160+
proxy = RemoteProxy(autoconnect=False)
161+
proxy.connect(timeout=30.0) # 30 seconds max for DataLab to be ready
162+
proxy.disconnect()
163+
except ConnectionRefusedError as exc:
164+
if is_pid_alive(proc.pid):
165+
proc.kill()
166+
raise RuntimeError(
167+
"Failed to connect to DataLab application. "
168+
"Process may have started but XML-RPC server is not responding."
169+
) from exc
146170

147171

148172
@contextmanager
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2+
3+
"""
4+
Simple Remote client test
5+
-------------------------
6+
7+
This test covers the simple remote client functionalities as provided by the
8+
`sigima.client` module.
9+
10+
In Sigima, the `sigima.tests.common.client_unit_test` module provides a set of unit
11+
tests for the client functionalities.
12+
13+
The purpose of these tests is to ensure that the client can correctly interact with
14+
the real server, handling various scenarios and edge cases.
15+
16+
The tests include:
17+
18+
- A function comparing the list of methods implemented by the real server
19+
(DataLab's `RemoteServer` class) to those implemented by the stub server
20+
(Sigima's `DataLabStubServer` class).
21+
- A function comparing the list of methods implemented by the full client (DataLab's
22+
`RemoteProxy` class) to those implemented by the simple client (Sigima's
23+
`SimpleRemoteProxy` class).
24+
- A function that simply runs the `sigima.tests.common.client_unit_test` suite after
25+
having launched a real server instance (DataLab application, using the
26+
`datalab.tests.run_datalab_in_background` function), ensuring that this test passes
27+
with the stub server as well as with the real server.
28+
"""
29+
30+
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
31+
# guitest: skip
32+
33+
import inspect
34+
35+
from guidata.env import execenv
36+
from sigima.client.remote import SimpleRemoteProxy
37+
from sigima.client.stub import DataLabStubServer
38+
from sigima.tests.common.client_unit_test import RemoteClientTester
39+
40+
from datalab.control.baseproxy import AbstractDLControl
41+
from datalab.control.proxy import RemoteProxy
42+
from datalab.tests import run_datalab_in_background
43+
44+
45+
def test_compare_server_methods() -> None:
46+
"""Compare methods implemented by RemoteServer vs DataLabStubServer.
47+
48+
This function compares the list of methods implemented by the real server
49+
(DataLab's RemoteServer class) to those implemented by the stub server
50+
(Sigima's DataLabStubServer class).
51+
"""
52+
execenv.print("\n=== Comparing Server Methods ===")
53+
54+
# Get methods from RemoteServer (the real DataLab server)
55+
# RemoteServer implements AbstractDLControl methods
56+
remote_server_methods = set(AbstractDLControl.get_public_methods())
57+
58+
# Get methods from DataLabStubServer (the stub server)
59+
stub_server_methods = {
60+
name
61+
for name, method in inspect.getmembers(DataLabStubServer, inspect.isfunction)
62+
if not name.startswith("_")
63+
}
64+
65+
execenv.print(f"RemoteServer methods: {len(remote_server_methods)}")
66+
execenv.print(f"DataLabStubServer methods: {len(stub_server_methods)}")
67+
68+
# Methods in RemoteServer but not in DataLabStubServer
69+
missing_in_stub = remote_server_methods - stub_server_methods
70+
if missing_in_stub:
71+
execenv.print("\n⚠️ Methods in RemoteServer but missing in DataLabStubServer:")
72+
for method in sorted(missing_in_stub):
73+
execenv.print(f" - {method}")
74+
75+
# Methods in DataLabStubServer but not in RemoteServer
76+
extra_in_stub = stub_server_methods - remote_server_methods
77+
if extra_in_stub:
78+
execenv.print("\n🔍 Methods in DataLabStubServer but not in RemoteServer:")
79+
for method in sorted(extra_in_stub):
80+
execenv.print(f" - {method}")
81+
82+
# Common methods
83+
common_methods = remote_server_methods & stub_server_methods
84+
execenv.print(f"\n✅ Common methods: {len(common_methods)}")
85+
86+
# Assert that stub server implements all required methods
87+
assert not missing_in_stub, (
88+
f"DataLabStubServer is missing {len(missing_in_stub)} methods "
89+
f"implemented by RemoteServer: {sorted(missing_in_stub)}"
90+
)
91+
92+
execenv.print("✨ Server method comparison completed successfully!")
93+
94+
95+
def test_compare_client_methods() -> None:
96+
"""Compare methods implemented by RemoteProxy vs SimpleRemoteProxy.
97+
98+
This function compares the list of methods implemented by the full client
99+
(DataLab's RemoteProxy class) to those implemented by the simple client
100+
(Sigima's SimpleRemoteProxy class).
101+
"""
102+
execenv.print("\n=== Comparing Client Methods ===")
103+
104+
# Get public methods from RemoteProxy (the full client)
105+
remote_proxy_methods = {
106+
name
107+
for name, method in inspect.getmembers(RemoteProxy, inspect.ismethod)
108+
if not name.startswith("_") and callable(method)
109+
}
110+
111+
# Get public methods from SimpleRemoteProxy (the simple client)
112+
simple_proxy_methods = {
113+
name
114+
for name, method in inspect.getmembers(SimpleRemoteProxy, inspect.ismethod)
115+
if not name.startswith("_") and callable(method)
116+
}
117+
118+
execenv.print(f"RemoteProxy methods: {len(remote_proxy_methods)}")
119+
execenv.print(f"SimpleRemoteProxy methods: {len(simple_proxy_methods)}")
120+
121+
# Methods in SimpleRemoteProxy but not in RemoteProxy
122+
extra_in_simple = simple_proxy_methods - remote_proxy_methods
123+
if extra_in_simple:
124+
execenv.print(
125+
"\n🔍 Methods in SimpleRemoteProxy but not in RemoteProxy "
126+
"(expected, as SimpleRemoteProxy is a subset):"
127+
)
128+
for method in sorted(extra_in_simple):
129+
execenv.print(f" - {method}")
130+
131+
# Methods in RemoteProxy but not in SimpleRemoteProxy
132+
missing_in_simple = remote_proxy_methods - simple_proxy_methods
133+
if missing_in_simple:
134+
execenv.print(
135+
"\n📝 Methods in RemoteProxy but not in SimpleRemoteProxy "
136+
"(expected, as SimpleRemoteProxy is simpler):"
137+
)
138+
for method in sorted(missing_in_simple):
139+
execenv.print(f" - {method}")
140+
141+
# Common methods
142+
common_methods = remote_proxy_methods & simple_proxy_methods
143+
execenv.print(f"\n✅ Common methods: {len(common_methods)}")
144+
145+
execenv.print("✨ Client method comparison completed successfully!")
146+
147+
148+
def test_with_real_server() -> None:
149+
"""Run sigima.tests.common.client_unit_test suite with real DataLab server.
150+
151+
This function runs the comprehensive client unit test suite after launching
152+
a real DataLab instance in the background, ensuring that the tests pass
153+
with the real server.
154+
"""
155+
execenv.print("\n=== Testing with Real DataLab Server ===")
156+
157+
# Launch DataLab application in the background
158+
execenv.print("Launching DataLab in background...")
159+
run_datalab_in_background(wait_until_ready=True)
160+
161+
# Import and run the comprehensive test from sigima
162+
execenv.print("Running comprehensive client tests with real server...")
163+
tester = RemoteClientTester()
164+
165+
# Initialize connection to real DataLab server using the existing port
166+
if not tester.init_cdl():
167+
raise ConnectionRefusedError(
168+
"Failed to connect to DataLab server. "
169+
"Make sure DataLab is running and accessible."
170+
)
171+
172+
try:
173+
# Run all tests
174+
tester.run_comprehensive_test()
175+
execenv.print("✨ All tests passed with real DataLab server!")
176+
finally:
177+
# Clean up
178+
tester.close_datalab()
179+
180+
181+
if __name__ == "__main__":
182+
test_compare_server_methods()
183+
test_compare_client_methods()
184+
test_with_real_server()

0 commit comments

Comments
 (0)