From 2b556811d1d5a904a4fcb1b2f694315bb2ecbcf9 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 16 Jun 2026 18:30:24 +0800 Subject: [PATCH 1/4] Add get_daemon Signed-off-by: kerthcet --- python/sandd/server.py | 29 ++++++- python/tests/test_integration.py | 141 +++++++++++++++++++++++++++++++ python/tests/test_unit.py | 25 ++++++ server/src/lib.rs | 10 +++ 4 files changed, 203 insertions(+), 2 deletions(-) diff --git a/python/sandd/server.py b/python/sandd/server.py index fed4e3f..2e1f9bb 100644 --- a/python/sandd/server.py +++ b/python/sandd/server.py @@ -242,6 +242,32 @@ def get_stats(self) -> ServerStats: """ return ServerStats(self._server.get_stats()) + def get_daemon(self, daemon_id: str) -> Optional[DaemonInfo]: + """Get daemon by ID + + Args: + daemon_id: Daemon ID to lookup + + Returns: + DaemonInfo if found, None otherwise + + Example: + >>> daemon = server.get_daemon("daemon-1") + >>> if daemon: + ... print(f"Found: {daemon.id}, busy={daemon.is_busy}") + ... else: + ... print("Daemon not found") + """ + info = self._server.get_daemon(daemon_id) + if info is None: + return None + return DaemonInfo( + id=info.id, + version=info.version, + labels=info.labels, + is_busy=info.is_busy, + ) + def _run_interactive(self, session: Session) -> None: """Run session in interactive mode with live terminal @@ -403,8 +429,7 @@ def wait_for_daemon( """ start = time.time() while time.time() - start < timeout: - daemons = self.list_daemons() - if any(d.id == daemon_id for d in daemons): + if self.get_daemon(daemon_id) is not None: return True time.sleep(poll_interval) return False diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py index 96786a5..7d3719b 100644 --- a/python/tests/test_integration.py +++ b/python/tests/test_integration.py @@ -279,6 +279,147 @@ def test_execute_python_script(self, server, daemon_process): assert "Python" in result.stdout +class TestGetDaemon: + """Test get_daemon functionality with real daemons""" + + def test_get_existing_daemon(self, server, daemon_process): + """Test get_daemon returns DaemonInfo for connected daemon""" + daemon_id, _ = daemon_process + + daemon = server.get_daemon(daemon_id) + + assert daemon is not None + assert daemon.id == daemon_id + assert isinstance(daemon.version, str) + assert isinstance(daemon.labels, dict) + assert isinstance(daemon.is_busy, bool) + + def test_get_nonexistent_daemon(self, server, daemon_process): + """Test get_daemon returns None for non-existent daemon""" + _, _ = daemon_process + + result = server.get_daemon("definitely-not-a-real-daemon-id-12345") + assert result is None + + def test_get_daemon_with_labels(self, server, sandd_binary): + """Test get_daemon returns daemon with labels""" + daemon_id = f"test-labeled-daemon-{os.getpid()}" + server_url = f"ws://127.0.0.1:{server.address.split(':')[1]}/ws" + + proc = subprocess.Popen( + [ + sandd_binary, + "--server-url", server_url, + "--daemon-id", daemon_id, + "--label", "env=staging", + "--label", "team=backend", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + try: + assert server.wait_for_daemon(daemon_id, timeout=5.0) + + daemon = server.get_daemon(daemon_id) + assert daemon is not None + assert daemon.id == daemon_id + assert daemon.labels == {"env": "staging", "team": "backend"} + + finally: + proc.kill() + + def test_get_daemon_after_disconnect(self, server, sandd_binary): + """Test get_daemon returns None after daemon disconnects""" + daemon_id = f"test-disconnect-daemon-{os.getpid()}" + server_url = f"ws://127.0.0.1:{server.address.split(':')[1]}/ws" + + proc = subprocess.Popen( + [sandd_binary, "--server-url", server_url, "--daemon-id", daemon_id], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + try: + # Wait for connection + assert server.wait_for_daemon(daemon_id, timeout=5.0) + + # Verify daemon is there + daemon = server.get_daemon(daemon_id) + assert daemon is not None + assert daemon.id == daemon_id + + # Kill the daemon + proc.kill() + proc.wait() + + # Give some time for disconnect to register + time.sleep(0.5) + + # Daemon should no longer be found + daemon = server.get_daemon(daemon_id) + # Note: depending on implementation, it might still be there briefly + # but eventually it should be None or the connection should be marked as dead + + finally: + try: + proc.kill() + except: # noqa: E722 + pass + + def test_get_daemon_multiple_times(self, server, daemon_process): + """Test calling get_daemon multiple times returns consistent results""" + daemon_id, _ = daemon_process + + # Call multiple times + daemon1 = server.get_daemon(daemon_id) + daemon2 = server.get_daemon(daemon_id) + daemon3 = server.get_daemon(daemon_id) + + assert daemon1 is not None + assert daemon2 is not None + assert daemon3 is not None + + # All should have the same ID + assert daemon1.id == daemon2.id == daemon3.id == daemon_id + + def test_get_daemon_busy_state(self, server, daemon_process): + """Test get_daemon reflects busy state""" + daemon_id, _ = daemon_process + + # Check initial state (should not be busy) + daemon = server.get_daemon(daemon_id) + assert daemon is not None + initial_busy = daemon.is_busy + + # Start a long-running command in background + import threading + + def run_long_command(): + try: + server.exec(daemon_id, "sleep 2", timeout=5) + except: # noqa: E722 + pass + + thread = threading.Thread(target=run_long_command) + thread.start() + + # Give command time to start + time.sleep(0.2) + + # Check if daemon is now busy (might be, depending on timing) + daemon_during = server.get_daemon(daemon_id) + assert daemon_during is not None + # Note: is_busy might be True or False depending on exact timing + + # Wait for command to complete + thread.join() + + # Daemon should not be busy anymore + daemon_after = server.get_daemon(daemon_id) + assert daemon_after is not None + + class TestServerStats: """Test server statistics with real connections""" diff --git a/python/tests/test_unit.py b/python/tests/test_unit.py index e7becc7..092c0df 100644 --- a/python/tests/test_unit.py +++ b/python/tests/test_unit.py @@ -123,6 +123,31 @@ def test_download_file_invalid_daemon(self): server.download_file("invalid", "/tmp/test") +class TestGetDaemon: + """Test get_daemon method""" + + def test_returns_none_when_not_found(self): + """Test get_daemon returns None for non-existent daemon""" + server = Server() + result = server.get_daemon("nonexistent-daemon-id") + assert result is None + + def test_returns_none_with_various_ids(self): + """Test get_daemon returns None for various non-existent IDs""" + server = Server() + test_ids = ["test-1", "daemon-123", "prod-worker-5", "invalid"] + for daemon_id in test_ids: + result = server.get_daemon(daemon_id) + assert result is None, f"Expected None for {daemon_id}" + + def test_accepts_string_id(self): + """Test get_daemon accepts string daemon ID""" + server = Server() + # Should not raise any exceptions with valid string input + result = server.get_daemon("some-daemon-id") + assert result is None # Will be None since no daemon connected + + class TestWaitForDaemon: """Test wait_for_daemon method""" diff --git a/server/src/lib.rs b/server/src/lib.rs index f7e9eee..7596daa 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -257,6 +257,16 @@ impl Server { oldest_connection_secs: stats.oldest_connection_secs, }) } + + /// Get daemon by ID (returns None if not found) + fn get_daemon(&self, daemon_id: String) -> PyResult> { + Ok(self.registry.get(&daemon_id).map(|conn| PyDaemonInfo { + id: conn.id.clone(), + version: conn.metadata.version.clone(), + labels: conn.metadata.labels.clone(), + is_busy: conn.is_busy(), + })) + } } /// Session handle From 14bce4695b10cdaa91f2a0066c7fbb093865a443 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 16 Jun 2026 18:38:35 +0800 Subject: [PATCH 2/4] fix lint Signed-off-by: kerthcet --- python/tests/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py index 7d3719b..cf1507a 100644 --- a/python/tests/test_integration.py +++ b/python/tests/test_integration.py @@ -390,7 +390,6 @@ def test_get_daemon_busy_state(self, server, daemon_process): # Check initial state (should not be busy) daemon = server.get_daemon(daemon_id) assert daemon is not None - initial_busy = daemon.is_busy # Start a long-running command in background import threading From 5ed3f73fff05f1b8f225ac081c5b3482ad729d1b Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 16 Jun 2026 19:01:09 +0800 Subject: [PATCH 3/4] add verify to is_busy Signed-off-by: kerthcet --- python/tests/test_integration.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py index cf1507a..a1902b9 100644 --- a/python/tests/test_integration.py +++ b/python/tests/test_integration.py @@ -390,6 +390,7 @@ def test_get_daemon_busy_state(self, server, daemon_process): # Check initial state (should not be busy) daemon = server.get_daemon(daemon_id) assert daemon is not None + assert daemon.is_busy is False # Start a long-running command in background import threading @@ -409,7 +410,7 @@ def run_long_command(): # Check if daemon is now busy (might be, depending on timing) daemon_during = server.get_daemon(daemon_id) assert daemon_during is not None - # Note: is_busy might be True or False depending on exact timing + assert daemon_during.is_busy is True # Wait for command to complete thread.join() @@ -417,7 +418,7 @@ def run_long_command(): # Daemon should not be busy anymore daemon_after = server.get_daemon(daemon_id) assert daemon_after is not None - + assert daemon_after.is_busy is False class TestServerStats: """Test server statistics with real connections""" From 3074bbf2837d182b9ba8c92ac68b3207f9816e2b Mon Sep 17 00:00:00 2001 From: kerthcet Date: Tue, 16 Jun 2026 19:15:03 +0800 Subject: [PATCH 4/4] fix tests Signed-off-by: kerthcet --- python/tests/test_integration.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py index a1902b9..429e1f8 100644 --- a/python/tests/test_integration.py +++ b/python/tests/test_integration.py @@ -358,9 +358,7 @@ def test_get_daemon_after_disconnect(self, server, sandd_binary): # Daemon should no longer be found daemon = server.get_daemon(daemon_id) - # Note: depending on implementation, it might still be there briefly - # but eventually it should be None or the connection should be marked as dead - + assert daemon is None finally: try: proc.kill() @@ -541,7 +539,12 @@ def wait_thread(): assert result_holder["connected"] is True finally: - proc.kill() + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() @pytest.mark.skipif(