test: add subprocess-level transport layer tests#57
Conversation
Add 13 tests that spawn real server subprocesses over stdio pipes, closing the gap between BytesIO-based in-process tests and real pipe behavior. Tests cover multi-message sessions (serve() loop), error recovery mid-session, sync_main() lifecycle, SIGINT handling, large payloads (>80KB), multi-byte UTF-8, and stdout purity verification. The MCPSubprocessClient helper uses a background reader thread with os.read() on the raw fd to avoid the select()+BufferedReader mismatch where BufferedReader greedily consumes pipe data into its internal buffer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: 78d840d616f2
Review Summary by QodoAdd subprocess-level transport layer tests with real pipe I/O
WalkthroughsDescription• Adds 13 subprocess-level transport layer tests with real server processes over stdio pipes • Introduces MCPSubprocessClient helper class for incremental pipe I/O with thread-based reading • Tests cover multi-message sessions, error recovery, lifecycle, and edge cases (large payloads, UTF-8, framing) • Registers @pytest.mark.subprocess marker for selective test execution Diagramflowchart LR
A["Test Infrastructure"] -->|MCPSubprocessClient| B["Multi-Message Sessions"]
A -->|Thread-based Reader| C["Error Recovery"]
A -->|Content-Length Framing| D["Lifecycle Tests"]
B -->|serve() loop| E["13 New Tests"]
C -->|Error Handling| E
D -->|sync_main Entry Point| E
F["Edge Cases"] -->|Large Payloads UTF-8| E
File Changes1. tests/test_subprocess_transport.py
|
Cast json.loads() return to dict[str, Any] and annotate yield-based fixture with Generator return type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: 5344b780b408
Code Review by Qodo
1. Short-read breaks large frames
|
| def test_large_payload_exceeding_pipe_buffer(self, client: MCPSubprocessClient) -> None: | ||
| """Source code >80KB exercises real pipe buffering (pipe buffer ~64KB).""" | ||
| client.send(_make_initialize_request(1)) | ||
| client.receive() | ||
|
|
||
| # Generate >80KB of valid Python source code | ||
| lines = [] | ||
| for i in range(2000): | ||
| lines.append(f"variable_{i} = 'value_{i}' * 10 # padding line {i}") | ||
| large_source = "\n".join(lines) + "\n" | ||
| assert len(large_source.encode("utf-8")) > 80_000 | ||
|
|
||
| client.send(_make_performance_check_request(large_source, request_id=2)) | ||
| resp = client.receive() | ||
| assert resp["id"] == 2 | ||
| assert "result" in resp | ||
| assert resp["result"]["content"][0]["json"]["success"] is True |
There was a problem hiding this comment.
1. Short-read breaks large frames 🐞 Bug ⛯ Reliability
The server reads the request body with a single stdin.read(content_length) and treats any short read as EOF, which can terminate the session mid-message. The new >80KB subprocess test increases the likelihood of triggering this, making CI failures/flakiness likely and indicating a real production transport bug.
Agent Prompt
### Issue description
`WorkshopMCPServer._read_message()` performs a single `stdin.read(content_length)` and treats any short read as EOF. This can break large requests over real pipes and will likely make the new large-payload subprocess test flaky/fail.
### Issue Context
The new subprocess tests send >80KB requests over stdio pipes and expect the server to correctly frame and parse them.
### Fix Focus Areas
- src/workshop_mcp/server.py[117-150]
- tests/test_subprocess_transport.py[433-449]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
✅ Fixed — Server now loops stdin.read() until all content_length bytes are received, preventing short-read EOF on large pipe payloads.
| def start(self) -> None: | ||
| """Spawn server subprocess with stdin/stdout/stderr pipes.""" | ||
| self.proc = subprocess.Popen( | ||
| self.invocation, | ||
| stdin=subprocess.PIPE, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| cwd=str(PROJECT_ROOT), | ||
| ) | ||
| # Start background thread reading raw stdout fd | ||
| self._reader_thread = threading.Thread(target=self._stdout_reader, daemon=True) | ||
| self._reader_thread.start() | ||
|
|
There was a problem hiding this comment.
2. Captured stderr can deadlock 🐞 Bug ⛯ Reliability
The subprocess client captures stderr=PIPE but never drains stderr until after the process exits; if the server logs enough, the child can block on stderr writes and tests can hang indefinitely. This is a classic subprocess pipe deadlock scenario.
Agent Prompt
### Issue description
`MCPSubprocessClient` starts the server with `stderr=subprocess.PIPE` but does not read from stderr until after `wait()`. If the server logs enough, stderr can fill and block the subprocess, hanging tests.
### Issue Context
Server logging is configured to stream to `sys.stderr`. Subprocess tests spawn many servers and assert on stderr content in at least one test.
### Fix Focus Areas
- tests/test_subprocess_transport.py[51-63]
- tests/test_subprocess_transport.py[126-131]
- src/workshop_mcp/server.py[21-26]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
✅ Fixed — Added background stderr reader thread to drain stderr concurrently, preventing pipe deadlock when server logs fill the buffer.
Loop stdin.read() until all content_length bytes are received, preventing EOF on partial pipe reads for large payloads (>64KB). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: c5259406cb2a
Add background stderr reader thread to drain stderr concurrently, preventing classic subprocess pipe deadlock when server logs fill the pipe buffer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: c5259406cb2a
Join stdout and stderr reader threads in close() after process exits, ensuring deterministic behavior for remaining_stdout() and stderr_output assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: c5259406cb2a
Use ensure_ascii=False in send() so non-ASCII characters produce real multibyte UTF-8 bytes on the pipe, exercising decode-boundary handling in the server transport layer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: c5259406cb2a
Qodo Fix SummaryReviewed and addressed all 4 Qodo review issues: ✅ Fixed Issues
⏭️ Deferred IssuesNone — all issues were fixed. ✅ All Tests Pass
Generated by Qodo PR Resolver skill |
Summary
MCPSubprocessClienthelper class with thread-based pipe I/O that avoids theselect()+BufferedReadermismatch@pytest.mark.subprocessmarker for selective execution (poetry run pytest -m subprocess)What's Tested
TestMultiMessageSessionserve()loop, notifications, sequential tool callsTestErrorRecoveryTestEntryPointLifecyclesync_main(), SIGINT, EOF shutdown, stderr loggingTestEdgeCasesTest plan
poetry run pytest tests/test_subprocess_transport.py -v— 13 passed in 1.16spoetry run pytest— 135 total tests pass (122 existing + 13 new) in 1.9spoetry run ruff check tests/test_subprocess_transport.py— all checks passed🤖 Generated with Claude Code