core: Extract duplicated subprocess spawn/wait boilerplate into a shared helper#3975
core: Extract duplicated subprocess spawn/wait boilerplate into a shared helper#3975rafael-graunke wants to merge 6 commits into
Conversation
|
Bugbot is not enabled for this team, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
run_subprocess, the return type is annotated asOptional[int]for the return code, but the implementation always returns anint; either adjust the annotation tointor make theNonecase explicit if you intend to represent a failure-to-start scenario.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `run_subprocess`, the return type is annotated as `Optional[int]` for the return code, but the implementation always returns an `int`; either adjust the annotation to `int` or make the `None` case explicit if you intend to represent a failure-to-start scenario.
## Individual Comments
### Comment 1
<location path="core/libs/commonwealth/src/commonwealth/utils/general.py" line_range="225-234" />
<code_context>
return _file_is_open_logic_lsof(result.returncode, result.stdout.strip(), result.stderr.strip())
+async def run_subprocess(command: List[str], merge_stderr: bool = False) -> Tuple[Optional[int], bytes, bytes]:
+ """Run a command asynchronously and wait for it to finish.
+
+ Args:
+ command: Command and arguments to execute.
+ merge_stderr: If True, redirect stderr into stdout (mirrors `subprocess.STDOUT`).
+ The returned stderr is empty bytes in this case.
+
+ Returns:
+ Tuple of (returncode, stdout, stderr) as raw bytes. Callers that expect text output
+ are responsible for decoding it themselves.
+ """
+ process = await asyncio.create_subprocess_exec(
+ *command,
+ stdout=asyncio.subprocess.PIPE,
</code_context>
<issue_to_address>
**suggestion:** Return code type should be non-optional to better reflect actual behavior and simplify callers.
Since the function always returns `process.returncode`, `None` should not occur in normal operation. Typing this as `Tuple[int, bytes, bytes]` instead of `Tuple[Optional[int], bytes, bytes]` makes the API more accurate and avoids burdening callers with an impossible `None` case. If you need to represent failures before the process is spawned, consider propagating the exception or modeling that failure mode explicitly rather than using `None` for the return code.
Suggested implementation:
```python
async def run_subprocess(command: List[str], merge_stderr: bool = False) -> Tuple[int, bytes, bytes]:
```
If `Optional[int>` is not used elsewhere in this module, you can remove it from the `from typing import ...` import list to keep the imports clean. No other behavioral changes are required since `process.returncode` is already always an `int` after `communicate()` completes.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| async def run_subprocess(command: List[str], merge_stderr: bool = False) -> Tuple[Optional[int], bytes, bytes]: | ||
| """Run a command asynchronously and wait for it to finish. | ||
|
|
||
| Args: | ||
| command: Command and arguments to execute. | ||
| merge_stderr: If True, redirect stderr into stdout (mirrors `subprocess.STDOUT`). | ||
| The returned stderr is empty bytes in this case. | ||
|
|
||
| Returns: | ||
| Tuple of (returncode, stdout, stderr) as raw bytes. Callers that expect text output |
There was a problem hiding this comment.
suggestion: Return code type should be non-optional to better reflect actual behavior and simplify callers.
Since the function always returns process.returncode, None should not occur in normal operation. Typing this as Tuple[int, bytes, bytes] instead of Tuple[Optional[int], bytes, bytes] makes the API more accurate and avoids burdening callers with an impossible None case. If you need to represent failures before the process is spawned, consider propagating the exception or modeling that failure mode explicitly rather than using None for the return code.
Suggested implementation:
async def run_subprocess(command: List[str], merge_stderr: bool = False) -> Tuple[int, bytes, bytes]:If Optional[int> is not used elsewhere in this module, you can remove it from the from typing import ... import list to keep the imports clean. No other behavioral changes are required since process.returncode is already always an int after communicate() completes.
Coverage gate failing (71.91%, need 75%), not what it looks likeNot that the refactored code is undertested. It's a side effect of how coverage.py scopes files.
This PR's Simplest fix: move Alternative, if you'd rather have real coverage on that file regardless: I wrote a full test suite for the rest of both files locally (55 new tests, 94%/96% file coverage). Happy to bundle it in or split it into a follow-up PR. Let me know which you'd prefer. |
Fixes #3194
Summary
Adds
run_subprocess()tocommonwealth.utils.generaland replaces theasyncio.create_subprocess_exec+communicate()boilerplate that wascopy-pasted across the codebase.
The issue mentions
AbstractRouter.startandhelper/main.py:pingas theplaces to share this with. In practice the duplication is bigger than that:
9 call sites had the identical spawn/wait/decode pattern:
commonwealth/utils/DHCPDiscovery.pyservices/helper/main.py(ping)services/disk_usage/main.py(x2:duscan,disktestspeed test)services/recorder_extractor/main.py(x5:mcap doctor,mcap recover,gst-discoverer-1.0,gst-play-1.0,mcap-foxglove-video-extract)AbstractRouter.startwas deliberately not touched: it spawns a long-livedrouter process, stores the handle on
self._subprocessforexit()andstart_house_keepers()to use afterward, and only callscommunicate()onthe failure path. That's a "supervise a daemon" lifecycle, not "run to
completion and collect output", so forcing it through the new helper would
mean either killing the daemon immediately or leaking the
Processobjectback out, which defeats the point of the abstraction.
Bug fix included
While extracting
recorder_extractor/main.py'sbuild_thumbnail_bytes, foundthat the success path referenced an undefined
stdoutvariable(
if play_proc.returncode != 0 or not stdout:, onlystdout_byteswas everassigned). On success (
returncode == 0) this raisedNameError, sinceoronly short-circuits past it on the failure path. Fixed as part of the
extraction (now checks
stdout_bytes), since the refactor touches that exactline anyway.
Testing
run_subprocess(returncode, stdout/stderr split, andthe
merge_stderrbytes-vs-Nonecase), ran locally against this repo'sasyncio_mode=STRICTpytest config, all pass.disk_usage,recorder_extractor,helper/main.py, orDHCPDiscovery.py. Verified behavior preservation byinspection: each diff only replaces the spawn+communicate block, every
downstream
.decode(...)call and parsing/log line is outside the diff,unchanged. No automated test harness exists for these 4 services to verify
at runtime.
Summary by Sourcery
Introduce a shared async subprocess helper and adopt it across services that previously duplicated spawn/wait logic.
New Features:
Bug Fixes:
Enhancements:
Tests: