From 44d4b05c8ad7dddf89dde3d4c7e4b03e42fe7384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 4 Nov 2026 11:58:43 +0100 Subject: [PATCH] fix(sdk): don't hang when PostgreSQL inherits pg0's stdio on Linux/macOS Use the temp-file stdio path on every platform, not just Windows. Fixes #45. --- sdk/python/pg0/__init__.py | 52 ++++++++++++++---------------------- sdk/python/tests/test_pg0.py | 18 +++++++++++++ 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/sdk/python/pg0/__init__.py b/sdk/python/pg0/__init__.py index e8493d6..90cb35b 100644 --- a/sdk/python/pg0/__init__.py +++ b/sdk/python/pg0/__init__.py @@ -131,38 +131,26 @@ def _run_pg0(*args: str, check: bool = True) -> subprocess.CompletedProcess: """Run a pg0 command.""" pg0_path = _find_pg0() try: - if sys.platform == "win32": - # On Windows, `pg0 start` spawns PostgreSQL which inherits pg0's - # stdio handles. pg0 exits but PostgreSQL keeps writing to those - # handles, so Python's capture_output=True pipes never see EOF - # and subprocess.run hangs forever. Route stdio through real - # files instead — subprocess.run only waits on the process exit - # code, not on file handles held by grandchildren. - with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f: - rc = subprocess.call( - [pg0_path, *args], - stdout=out_f, - stderr=err_f, - # pg0 is a console executable. Suppress the transient console - # window when it is launched from a GUI Python application. - creationflags=subprocess.CREATE_NO_WINDOW, - ) - out_f.seek(0) - err_f.seek(0) - stdout = out_f.read().decode("utf-8", errors="replace") - stderr = err_f.read().decode("utf-8", errors="replace") - result = subprocess.CompletedProcess( - args=[pg0_path, *args], - returncode=rc, - stdout=stdout, - stderr=stderr, - ) - else: - result = subprocess.run( - [pg0_path, *args], - capture_output=True, - text=True, - ) + # `pg0 start` spawns PostgreSQL, which inherits pg0's stdio. pg0 exits + # but PostgreSQL keeps those handles open, so capture_output=True pipes + # never see EOF and subprocess.run hangs forever (seen on Windows and + # Linux). Route stdio through real files instead: subprocess.call only + # waits for pg0 to exit, not for handles held by grandchildren. + # pg0 is a console executable; on Windows, suppress the transient + # console window when launched from a GUI Python application. + extra = {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} + with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f: + rc = subprocess.call([pg0_path, *args], stdout=out_f, stderr=err_f, **extra) + out_f.seek(0) + err_f.seek(0) + stdout = out_f.read().decode("utf-8", errors="replace") + stderr = err_f.read().decode("utf-8", errors="replace") + result = subprocess.CompletedProcess( + args=[pg0_path, *args], + returncode=rc, + stdout=stdout, + stderr=stderr, + ) if check and result.returncode != 0: stderr = result.stderr.strip() if "already running" in stderr.lower(): diff --git a/sdk/python/tests/test_pg0.py b/sdk/python/tests/test_pg0.py index 05a08ef..f6c22de 100644 --- a/sdk/python/tests/test_pg0.py +++ b/sdk/python/tests/test_pg0.py @@ -46,6 +46,24 @@ def test_windows_runner_hides_console_window(self): assert call.call_args.kwargs["creationflags"] == subprocess.CREATE_NO_WINDOW + @pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX shell script") + def test_runner_does_not_wait_for_children_holding_stdio(self, tmp_path): + """pg0 exits while a child it spawned keeps stdout/stderr open, like the + daemonized PostgreSQL does. The wrapper must return when pg0 exits. + + Regression test for https://github.com/vectorize-io/pg0/issues/45. + """ + fake = tmp_path / "pg0" + fake.write_text("#!/bin/sh\necho started\nsleep 30 &\nexit 0\n") + fake.chmod(0o755) + + begin = time.monotonic() + with patch.object(pg0, "_find_pg0", return_value=str(fake)): + result = pg0._run_pg0("start") + + assert time.monotonic() - begin < 10 + assert result.stdout.strip() == "started" + def test_start_stop(self, clean_instance): """Test starting and stopping Pg0.""" pg = Pg0(name=TEST_NAME, port=TEST_PORT)