Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions python/sandd/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
146 changes: 145 additions & 1 deletion python/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,145 @@ 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)
assert daemon is None
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
assert daemon.is_busy is False

# 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
assert daemon_during.is_busy is True

# 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
assert daemon_after.is_busy is False

class TestServerStats:
"""Test server statistics with real connections"""

Expand Down Expand Up @@ -400,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(
Expand Down
25 changes: 25 additions & 0 deletions python/tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
10 changes: 10 additions & 0 deletions server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<PyDaemonInfo>> {
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
Expand Down
Loading