From 5bfd3785e86b526627015b8221e0950305a9c69e Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 06:40:08 +0900 Subject: [PATCH 1/4] fix: resolve compatibility test server startup failure The conftest unconditionally passed `-config devcloud.yaml`, but the file doesn't exist in the repo. When `-config` is explicit, main.go uses config.Load() which calls os.Exit(1) on missing file. Since stderr was DEVNULL, the error was invisible and _wait_for_server timed out at 30s. - Only pass `-config devcloud.yaml` when the file exists (fallback to embedded defaults) - Capture stderr and include it in the error message for debugging --- tests/compatibility/conftest.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/compatibility/conftest.py b/tests/compatibility/conftest.py index 38d00a7..3772bae 100644 --- a/tests/compatibility/conftest.py +++ b/tests/compatibility/conftest.py @@ -46,10 +46,14 @@ def devcloud_server(): # collide with stale data. The directory is cleaned up after the session. data_dir = tempfile.mkdtemp(prefix="devcloud-test-") + config_path = os.path.join(project_root, "devcloud.yaml") + if bin_path: - cmd = [bin_path, "-config", "devcloud.yaml"] + cmd = [bin_path] else: - cmd = ["go", "run", "./cmd/devcloud", "-config", "devcloud.yaml"] + cmd = ["go", "run", "./cmd/devcloud"] + if os.path.isfile(config_path): + cmd.extend(["-config", "devcloud.yaml"]) env = os.environ.copy() env["CGO_ENABLED"] = "1" @@ -59,10 +63,20 @@ def devcloud_server(): cmd, cwd=project_root, env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) - _wait_for_server(DEVCLOUD_URL) + try: + _wait_for_server(DEVCLOUD_URL) + except RuntimeError: + proc.kill() + proc.wait() + stderr = proc.stderr.read().decode(errors="replace") + raise RuntimeError( + f"devcloud server did not start within 30s.\n" + f"command: {' '.join(cmd)}\n" + f"stderr:\n{stderr}" + ) from None yield proc proc.send_signal(signal.SIGINT) try: From ad12176f4ec3ccc1bc26fc2b8e8c32cafdca76b2 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 12:16:46 +0900 Subject: [PATCH 2/4] test: add tests for conftest helpers and fix PIPE blocking risk Address code review comments: use DEVNULL for the long-running server process to avoid blocking, only spawn a short-lived PIPE subprocess on failure. Extract _build_devcloud_cmd and _start_server_error helpers with full test coverage. --- tests/compatibility/conftest.py | 58 ++++++++++---- tests/compatibility/test_conftest_helpers.py | 82 ++++++++++++++++++++ 2 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 tests/compatibility/test_conftest_helpers.py diff --git a/tests/compatibility/conftest.py b/tests/compatibility/conftest.py index 3772bae..38ac65b 100644 --- a/tests/compatibility/conftest.py +++ b/tests/compatibility/conftest.py @@ -13,6 +13,44 @@ DEVCLOUD_URL = os.environ.get("DEVCLOUD_URL", f"http://localhost:{DEVCLOUD_PORT}") +def _build_devcloud_cmd(project_root, bin_path): + """Build the devcloud command, conditionally adding -config if devcloud.yaml exists.""" + config_path = os.path.join(project_root, "devcloud.yaml") + + if bin_path: + cmd = [bin_path] + else: + cmd = ["go", "run", "./cmd/devcloud"] + + if os.path.isfile(config_path): + cmd.extend(["-config", "devcloud.yaml"]) + + return cmd + + +def _start_server_error(cmd, project_root, env): + """Re-run server with PIPE to capture stderr, then raise with diagnostic info.""" + debug_proc = subprocess.Popen( + cmd, + cwd=project_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + _wait_for_server(DEVCLOUD_URL, timeout=5) + except RuntimeError: + pass + debug_proc.kill() + debug_proc.wait() + stderr = debug_proc.stderr.read().decode(errors="replace") + raise RuntimeError( + f"devcloud server did not start within 30s.\n" + f"command: {' '.join(cmd)}\n" + f"stderr:\n{stderr}" + ) from None + + def _wait_for_server(url, timeout=30, interval=0.5): """Poll server until it responds or timeout.""" deadline = time.time() + timeout @@ -46,14 +84,7 @@ def devcloud_server(): # collide with stale data. The directory is cleaned up after the session. data_dir = tempfile.mkdtemp(prefix="devcloud-test-") - config_path = os.path.join(project_root, "devcloud.yaml") - - if bin_path: - cmd = [bin_path] - else: - cmd = ["go", "run", "./cmd/devcloud"] - if os.path.isfile(config_path): - cmd.extend(["-config", "devcloud.yaml"]) + cmd = _build_devcloud_cmd(project_root, bin_path) env = os.environ.copy() env["CGO_ENABLED"] = "1" @@ -63,20 +94,15 @@ def devcloud_server(): cmd, cwd=project_root, env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) try: _wait_for_server(DEVCLOUD_URL) except RuntimeError: proc.kill() proc.wait() - stderr = proc.stderr.read().decode(errors="replace") - raise RuntimeError( - f"devcloud server did not start within 30s.\n" - f"command: {' '.join(cmd)}\n" - f"stderr:\n{stderr}" - ) from None + _start_server_error(cmd, project_root, env) yield proc proc.send_signal(signal.SIGINT) try: diff --git a/tests/compatibility/test_conftest_helpers.py b/tests/compatibility/test_conftest_helpers.py new file mode 100644 index 0000000..93acb33 --- /dev/null +++ b/tests/compatibility/test_conftest_helpers.py @@ -0,0 +1,82 @@ +import io + +from tests.compatibility.conftest import _build_devcloud_cmd + + +class TestBuildDevcloudCmd: + def test_includes_config_when_file_exists(self, monkeypatch, tmp_path): + def fake_isfile(path): + return path.endswith("devcloud.yaml") + + monkeypatch.setattr("tests.compatibility.conftest.os.path.isfile", fake_isfile) + + cmd = _build_devcloud_cmd(str(tmp_path), None) + + assert cmd[:3] == ["go", "run", "./cmd/devcloud"] + assert "-config" in cmd + assert "devcloud.yaml" in cmd + + def test_omits_config_when_file_missing(self, monkeypatch, tmp_path): + monkeypatch.setattr( + "tests.compatibility.conftest.os.path.isfile", lambda p: False + ) + + cmd = _build_devcloud_cmd(str(tmp_path), None) + + assert cmd == ["go", "run", "./cmd/devcloud"] + assert "-config" not in cmd + assert "devcloud.yaml" not in cmd + + def test_uses_bin_path_when_provided(self, monkeypatch, tmp_path): + monkeypatch.setattr( + "tests.compatibility.conftest.os.path.isfile", lambda p: False + ) + + cmd = _build_devcloud_cmd(str(tmp_path), "/usr/local/bin/devcloud") + + assert cmd == ["/usr/local/bin/devcloud"] + + def test_bin_path_with_config(self, monkeypatch, tmp_path): + def fake_isfile(path): + return path.endswith("devcloud.yaml") + + monkeypatch.setattr("tests.compatibility.conftest.os.path.isfile", fake_isfile) + + cmd = _build_devcloud_cmd(str(tmp_path), "/usr/local/bin/devcloud") + + assert cmd[0] == "/usr/local/bin/devcloud" + assert "-config" in cmd + + +class TestDevcloudServerErrorHandling: + def test_raises_runtime_error_with_stderr_on_startup_failure(self, monkeypatch): + fake_stderr = io.BytesIO(b"fatal: config file not found\n") + + class FakeProc: + def kill(self): + pass + + def wait(self): + return 1 + + stderr = fake_stderr + + monkeypatch.setattr( + "tests.compatibility.conftest.subprocess.Popen", lambda *a, **kw: FakeProc() + ) + monkeypatch.setattr( + "tests.compatibility.conftest._wait_for_server", + lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("timeout")), + ) + + import pytest + + from tests.compatibility.conftest import _start_server_error + + with pytest.raises(RuntimeError) as exc_info: + _start_server_error(["go", "run", "./cmd/devcloud"], "/tmp/project", {}) + + msg = str(exc_info.value) + assert "fatal: config file not found" in msg + assert "command: go run ./cmd/devcloud" in msg + assert "stderr:" in msg From 9c0fa3b2c67d20c2725c75b3eda5a7303673ffc8 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 12:19:32 +0900 Subject: [PATCH 3/4] fix: add project root to sys.path for CI import resolution --- tests/compatibility/test_conftest_helpers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/compatibility/test_conftest_helpers.py b/tests/compatibility/test_conftest_helpers.py index 93acb33..31c116c 100644 --- a/tests/compatibility/test_conftest_helpers.py +++ b/tests/compatibility/test_conftest_helpers.py @@ -1,6 +1,10 @@ import io +import os +import sys -from tests.compatibility.conftest import _build_devcloud_cmd +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from tests.compatibility.conftest import _build_devcloud_cmd, _start_server_error class TestBuildDevcloudCmd: @@ -71,8 +75,6 @@ def wait(self): import pytest - from tests.compatibility.conftest import _start_server_error - with pytest.raises(RuntimeError) as exc_info: _start_server_error(["go", "run", "./cmd/devcloud"], "/tmp/project", {}) From 0f42751b46bc26dd89b4a2fdd024653378a92d50 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sun, 19 Apr 2026 12:22:39 +0900 Subject: [PATCH 4/4] fix: use pyproject.toml pythonpath instead of sys.path hack --- pyproject.toml | 2 ++ tests/compatibility/test_conftest_helpers.py | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3c32f93 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/tests/compatibility/test_conftest_helpers.py b/tests/compatibility/test_conftest_helpers.py index 31c116c..49840e6 100644 --- a/tests/compatibility/test_conftest_helpers.py +++ b/tests/compatibility/test_conftest_helpers.py @@ -1,8 +1,4 @@ import io -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) from tests.compatibility.conftest import _build_devcloud_cmd, _start_server_error