Skip to content

Commit 243b18a

Browse files
cowork-bot: stream subprocess output in real time via Popen
Switch dispatch from subprocess.run(capture_output=True) to subprocess.Popen with inherited file descriptors. The previous implementation buffered all child stdout/stderr in memory before printing, causing UX lag on long-running tools (deploydiff, schemaforge, configdrift) and potential OOM on large outputs. Popen streams output directly to the parent terminal. Added test_dispatch_streaming.py with regression guards against capture_output=True and stdout=PIPE. Updated existing dispatch tests to mock Popen instead of run.
1 parent b501fc4 commit 243b18a

3 files changed

Lines changed: 121 additions & 18 deletions

File tree

src/devforge/cli.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,13 @@ def dispatch(ctx: typer.Context):
172172
# `--config file.yaml`) reach the underlying CLI instead of being
173173
# rejected by typer as "No such option".
174174
forwarded = list(ctx.args)
175-
result = subprocess.run(
175+
# Stream output in real time via Popen with inherited file descriptors.
176+
# The previous subprocess.run(capture_output=True) buffered all output
177+
# in memory, causing UX lag and potential OOM on large tool output.
178+
proc = subprocess.Popen(
176179
[sys.executable, "-m", module_name] + forwarded,
177-
capture_output=True,
178-
text=True,
179180
)
180-
if result.stdout:
181-
sys.stdout.write(result.stdout)
182-
if result.stderr:
183-
sys.stderr.write(result.stderr)
184-
sys.exit(result.returncode)
181+
sys.exit(proc.wait())
185182

186183
dispatch.__name__ = tool_name
187184
dispatch.__doc__ = f"Run `{pkg}` commands via the {tool_name} subcommand."

tests/test_cli.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,31 +125,35 @@ def test_dispatch_not_installed_shows_install_hint(self, _mock):
125125
assert 'pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[guard]"' in result.stdout
126126

127127
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
128-
@mock.patch("devforge.cli.subprocess.run")
129-
def test_dispatch_installed_tool_runs(self, mock_run, _mock_installed):
128+
@mock.patch("devforge.cli.subprocess.Popen")
129+
def test_dispatch_installed_tool_runs(self, mock_popen, _mock_installed):
130130
"""When a tool is installed, dispatch calls the subprocess."""
131-
mock_run.return_value = mock.MagicMock(returncode=0)
131+
mock_proc = mock.MagicMock()
132+
mock_proc.wait.return_value = 0
133+
mock_popen.return_value = mock_proc
132134
with mock.patch("devforge.cli.sys.exit"):
133135
runner.invoke(app, ["guard"])
134-
mock_run.assert_called_once()
135-
cmd = mock_run.call_args[0][0]
136+
mock_popen.assert_called_once()
137+
cmd = mock_popen.call_args[0][0]
136138
assert "api_contract_guardian" in cmd
137139

138140
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
139-
@mock.patch("devforge.cli.subprocess.run")
140-
def test_dispatch_forwards_tool_flags(self, mock_run, _mock_installed):
141+
@mock.patch("devforge.cli.subprocess.Popen")
142+
def test_dispatch_forwards_tool_flags(self, mock_popen, _mock_installed):
141143
"""Tool flags (e.g. `--config file.yaml`) must reach the underlying CLI.
142144
143145
Regression guard for the silent-failure trap where typer rejected any
144146
argument beginning with `-` as 'No such option' before the tool ran.
145147
With ignore_unknown_options/allow_extra_args, such flags are forwarded
146148
via ctx.args.
147149
"""
148-
mock_run.return_value = mock.MagicMock(returncode=0)
150+
mock_proc = mock.MagicMock()
151+
mock_proc.wait.return_value = 0
152+
mock_popen.return_value = mock_proc
149153
with mock.patch("devforge.cli.sys.exit"):
150154
runner.invoke(app, ["guard", "--config", "x.yaml", "--verbose"])
151-
mock_run.assert_called_once()
152-
cmd = mock_run.call_args[0][0]
155+
mock_popen.assert_called_once()
156+
cmd = mock_popen.call_args[0][0]
153157
# Underlying module is launched...
154158
assert "api_contract_guardian" in cmd
155159
# ...and the tool flags are forwarded, not swallowed by typer.

tests/test_dispatch_streaming.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Regression tests for subprocess output streaming in dispatch.
2+
3+
The dispatch command must stream stdout/stderr in real time rather than
4+
buffering the entire output via capture_output=True. Long-running tools
5+
(deploydiff, schemaforge, configdrift on large datasets) can produce
6+
megabytes of output that should reach the user's terminal immediately.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import subprocess
12+
import sys
13+
from unittest import mock
14+
15+
from typer.testing import CliRunner
16+
17+
from devforge.cli import app
18+
19+
runner = CliRunner()
20+
21+
22+
class TestDispatchStreaming:
23+
"""dispatch must NOT use subprocess.run with capture_output=True.
24+
25+
Real-time streaming requires subprocess.Popen (or subprocess.run with
26+
stdout=None, stderr=None) so the child process inherits the parent's
27+
file descriptors directly.
28+
"""
29+
30+
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
31+
def test_dispatch_does_not_buffer_output(self, _mock_installed):
32+
"""subprocess.run must NOT be called with capture_output=True.
33+
34+
capture_output=True buffers the entire child output in memory before
35+
the parent can write anything. For tools that produce large or
36+
long-running output, this is a UX regression: the user sees nothing
37+
until the tool finishes, and memory usage grows unbounded.
38+
"""
39+
with mock.patch("devforge.cli.subprocess.run") as mock_run:
40+
mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="")
41+
with mock.patch("devforge.cli.sys.exit"):
42+
runner.invoke(app, ["guard", "--help"])
43+
44+
if mock_run.called:
45+
# If subprocess.run is used, it must NOT capture output
46+
call_kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
47+
assert call_kwargs.get("capture_output") is not True, (
48+
"dispatch uses subprocess.run(capture_output=True) which buffers "
49+
"all output. Use subprocess.Popen or stdout=None to stream."
50+
)
51+
assert call_kwargs.get("stdout") is not subprocess.PIPE, (
52+
"dispatch uses stdout=PIPE which buffers output. "
53+
"Use stdout=None to inherit the parent's stdout."
54+
)
55+
56+
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
57+
def test_dispatch_uses_popen_or_inherited_fds(self, _mock_installed):
58+
"""dispatch should use subprocess.Popen for real-time streaming,
59+
or subprocess.run without capture (stdout=None, stderr=None).
60+
"""
61+
with mock.patch("devforge.cli.subprocess.Popen") as mock_popen, \
62+
mock.patch("devforge.cli.subprocess.run") as mock_run:
63+
64+
# Set up Popen mock to simulate a successful run
65+
mock_proc = mock.MagicMock()
66+
mock_proc.wait.return_value = 0
67+
mock_popen.return_value = mock_proc
68+
69+
with mock.patch("devforge.cli.sys.exit"):
70+
runner.invoke(app, ["guard"])
71+
72+
# Either Popen was used (preferred for streaming)
73+
# or subprocess.run was used WITHOUT capture_output
74+
if mock_popen.called:
75+
# Good: Popen streams by default
76+
assert True
77+
elif mock_run.called:
78+
kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
79+
assert kwargs.get("capture_output") is not True
80+
assert kwargs.get("stdout") is not subprocess.PIPE
81+
else:
82+
raise AssertionError(
83+
"Neither subprocess.Popen nor subprocess.run was called"
84+
)
85+
86+
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
87+
def test_dispatch_exit_code_propagates(self, _mock_installed):
88+
"""The child process exit code must propagate to the parent."""
89+
with mock.patch("devforge.cli.subprocess.Popen") as mock_popen:
90+
mock_proc = mock.MagicMock()
91+
mock_proc.wait.return_value = 42
92+
mock_popen.return_value = mock_proc
93+
94+
with mock.patch("devforge.cli.sys.exit") as mock_exit:
95+
runner.invoke(app, ["guard"])
96+
97+
if mock_popen.called:
98+
# sys.exit(42) raises SystemExit; CliRunner catches it and
99+
# may call sys.exit(0) afterward. Check that 42 was among
100+
# the calls rather than asserting exactly one call.
101+
exit_codes = [c.args[0] for c in mock_exit.call_args_list]
102+
assert 42 in exit_codes, f"Expected exit code 42 in {exit_codes}"

0 commit comments

Comments
 (0)