feat: stdio server spawn-test (issue #2)
Add spawn_test() to bcc_core — spawns a stdio server for up to 3 s, captures stderr, and reports ok/exited/crashed/not_found. Key design decisions driven by real MCP server behaviour: - stdin=PIPE (never written): servers block on JSON-RPC input and stay alive, so "still running after timeout" reliably signals a healthy start. stdin=DEVNULL would send EOF, causing well-behaved servers to exit 0 and be misclassified as "exited". - Command resolved via shutil.which(augmented_path()) before Popen so subprocess PATH resolution is unambiguous across platforms. - start_new_session=True on POSIX + os.killpg on timeout: kills the whole process group, not just the launcher (npx, uvx), which would otherwise orphan the actual node/python grandchild process. - stdout=DEVNULL: draining a PIPE we don't read would deadlock at ~64 KB. - stderr drained in a daemon thread, capped at 4 KB. GUI: SpawnTester(QThread) wraps spawn_test; "Test launch" button in ServerEditor dep row (stdio only, visible when command resolves ok/warn). Result colours match the existing dep-status palette (green/amber/red). Stderr appended to the diagnostics panel if it is open. 7 new unit tests cover all outcomes and the stdin-open regression guard. Ran python bcc.py locally: button appears for stdio servers whose command resolves, is hidden for remote servers and missing-command servers. Closes #2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
proper test functions with tmp_path/monkeypatch fixtures)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -238,3 +239,67 @@ def test_dep_check_marks_remote():
|
||||
def test_dep_check_sees_through_shell_wrapper():
|
||||
r = c.check_dependency({"command": "cmd", "args": ["/c", "definitely-not-real-xyz", "foo"]})
|
||||
assert "definitely-not-real-xyz" in r["label"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7. Spawn test
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_spawn_test_ok_still_running():
|
||||
# A process that sleeps longer than the timeout → "ok" (server started)
|
||||
r = c.spawn_test(
|
||||
{"command": sys.executable, "args": ["-c", "import time; time.sleep(60)"]}, timeout=0.3
|
||||
)
|
||||
assert r["outcome"] == "ok"
|
||||
assert r["returncode"] is None
|
||||
|
||||
|
||||
def test_spawn_test_crashed():
|
||||
# A process that exits with a non-zero code immediately
|
||||
r = c.spawn_test(
|
||||
{"command": sys.executable, "args": ["-c", "raise SystemExit(1)"]}, timeout=0.3
|
||||
)
|
||||
assert r["outcome"] == "crashed"
|
||||
assert r["returncode"] == 1
|
||||
|
||||
|
||||
def test_spawn_test_exited_cleanly():
|
||||
# A process that exits 0 before timeout (unusual for a server)
|
||||
r = c.spawn_test({"command": sys.executable, "args": ["-c", "pass"]}, timeout=0.5)
|
||||
assert r["outcome"] == "exited"
|
||||
assert r["returncode"] == 0
|
||||
|
||||
|
||||
def test_spawn_test_not_found():
|
||||
r = c.spawn_test({"command": "definitely-not-a-real-binary-xyz"}, timeout=0.3)
|
||||
assert r["outcome"] == "not_found"
|
||||
|
||||
|
||||
def test_spawn_test_remote_not_applicable():
|
||||
r = c.spawn_test({"url": "https://example.com/mcp"}, timeout=0.3)
|
||||
assert r["outcome"] == "not_applicable"
|
||||
|
||||
|
||||
def test_spawn_test_stderr_captured():
|
||||
# A process that writes to stderr before crashing
|
||||
r = c.spawn_test(
|
||||
{
|
||||
"command": sys.executable,
|
||||
"args": ["-c", "import sys; sys.stderr.write('test-err\\n'); sys.exit(2)"],
|
||||
},
|
||||
timeout=0.5,
|
||||
)
|
||||
assert r["outcome"] == "crashed"
|
||||
assert "test-err" in r["stderr"]
|
||||
|
||||
|
||||
def test_spawn_test_stdin_not_closed():
|
||||
# A process that blocks reading stdin should stay alive (outcome=ok), not exit 0.
|
||||
# This guards against the regression where stdin=DEVNULL sends EOF and a server
|
||||
# that reads stdin exits cleanly, being misclassified as "exited" instead of "ok".
|
||||
r = c.spawn_test(
|
||||
{"command": sys.executable, "args": ["-c", "import sys; sys.stdin.read()"]},
|
||||
timeout=0.3,
|
||||
)
|
||||
assert r["outcome"] == "ok", f"expected ok (blocking on stdin), got {r['outcome']}"
|
||||
|
||||
Reference in New Issue
Block a user