Skip to content

Commit bf3b3d5

Browse files
committed
Address CodeRabbit review: prevent encoding/read-failure wedges
- McpServer: encode responses with JSON_INVALID_UTF8_SUBSTITUTE via a new encodeResponse() that never lets a bad encode terminate the STDIN loop (non-UTF-8 bytes from exec() tool output no longer wedge it on the response side); harden debugLog() the same way. - DebugServer::readDbgpFrame(): don't consume the length header until the whole frame is buffered, so a mid-payload read failure leaves the buffer as an intact, resumable frame instead of desyncing later reads. - DebugServer::setBreakpoint(): throw BreakpointException on an empty response (read failure) instead of returning the 'unknown' sentinel and recording a breakpoint that was never set. - CompareRunner worktree test: assert the child exits non-zero so it pins the fatal-error path, not just shutdown-on-exit.
1 parent 7757eee commit bf3b3d5

4 files changed

Lines changed: 77 additions & 8 deletions

File tree

src/DebugServer.php

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,16 @@ private function setBreakpoint(string $filename, int $line, string|null $conditi
11001100

11011101
$response = $this->sendCommand('breakpoint_set', $params);
11021102

1103+
// sendCommand() returns '' on a read failure/connection drop. Without
1104+
// this guard an empty response would slip past the <error> check and
1105+
// the id="..." regex and return the 'unknown' sentinel, recording a
1106+
// breakpoint that was never actually set.
1107+
if ($response === '') {
1108+
throw new BreakpointException(
1109+
"No response from Xdebug when setting breakpoint at {$filename}:{$line}",
1110+
);
1111+
}
1112+
11031113
if (str_contains($response, '<error')) {
11041114
$message = 'Xdebug rejected breakpoint_set';
11051115
if (preg_match('/<message>([^<]+)<\/message>/', $response, $matches)) {
@@ -2554,18 +2564,24 @@ private function readDbgpFrame(Socket $socket): string
25542564
// ASCII digits, so the first NUL is always the header delimiter.
25552565
$nulPos = $this->fillDbgpBufferUntilNul($socket, $timeout);
25562566
$length = (int) substr($this->dbgpReadBuffer, 0, $nulPos);
2557-
$this->dbgpReadBuffer = substr($this->dbgpReadBuffer, $nulPos + 1);
25582567

25592568
if ($length <= 0) {
25602569
throw new RuntimeException("Invalid response length: {$length}");
25612570
}
25622571

2563-
// Need $length data bytes plus the trailing NUL.
2564-
$this->fillDbgpBufferTo($socket, $timeout, $length + 1);
2572+
// Do NOT consume the header until the whole frame (header + NUL +
2573+
// payload + trailing NUL) is buffered. If the payload read fails
2574+
// partway (readTimeout fires, or the connection drops), the buffer
2575+
// must still look like an intact, resumable frame — otherwise the
2576+
// next read would misparse leftover payload bytes as a new length
2577+
// header and desync every subsequent frame on this socket.
2578+
$headerLength = $nulPos + 1;
2579+
$frameLength = $headerLength + $length + 1;
2580+
$this->fillDbgpBufferTo($socket, $timeout, $frameLength);
25652581

2566-
$response = substr($this->dbgpReadBuffer, 0, $length);
2567-
$trailingNull = $this->dbgpReadBuffer[$length];
2568-
$this->dbgpReadBuffer = substr($this->dbgpReadBuffer, $length + 1);
2582+
$response = substr($this->dbgpReadBuffer, $headerLength, $length);
2583+
$trailingNull = $this->dbgpReadBuffer[$headerLength + $length];
2584+
$this->dbgpReadBuffer = substr($this->dbgpReadBuffer, $frameLength);
25692585

25702586
if ($trailingNull !== "\0") {
25712587
$this->log('Warning: Expected trailing NULL byte, got: ' . bin2hex($trailingNull));

src/McpServer.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
use function trim;
5252
use function unlink;
5353

54+
use const JSON_INVALID_UTF8_SUBSTITUTE;
5455
use const JSON_THROW_ON_ERROR;
5556
use const JSON_UNESCAPED_SLASHES;
5657
use const STDIN;
@@ -87,7 +88,10 @@ private function debugLog(string $message, array $data = []): void
8788
'message' => $message,
8889
'data' => $data,
8990
];
90-
error_log('MCP Debug: ' . json_encode($logData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
91+
// Substitute (don't throw on) non-UTF-8 bytes in the logged payload —
92+
// an unset MCP_DEBUG already skips this, but with it set a non-UTF-8
93+
// request line must not be able to wedge the loop from here either.
94+
error_log('MCP Debug: ' . json_encode($logData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
9195
}
9296

9397
private function initializeTools(): void
@@ -276,14 +280,40 @@ public function __invoke(): void
276280
}
277281

278282
$this->debugLog('Sending response', ['id' => $response->id]);
279-
echo json_encode($response, JSON_THROW_ON_ERROR) . "\n";
283+
echo $this->encodeResponse($response) . "\n";
280284
fflush(STDOUT);
281285
}
282286
} catch (Throwable $e) {
283287
error_log('MCP Server Fatal Error: ' . $e->getMessage() . "\nStack trace: " . $e->getTraceAsString());
284288
}
285289
}
286290

291+
/**
292+
* Encode a response for the wire without letting an encoding failure
293+
* terminate the STDIN loop.
294+
*
295+
* Tool results embed the raw output of arbitrary scripts (exec()), which
296+
* may contain non-UTF-8 bytes; those are substituted rather than thrown on,
297+
* so the response is still delivered. On any other, unexpected encoding
298+
* error we emit a protocol-valid error for the same id instead of dropping
299+
* the response — a dropped response would leave the client waiting forever.
300+
*/
301+
private function encodeResponse(JsonRpcResponse $response): string
302+
{
303+
try {
304+
// JSON_INVALID_UTF8_SUBSTITUTE keeps non-UTF-8 tool output from
305+
// throwing; the flag set otherwise matches the previous wire output.
306+
return json_encode($response, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE);
307+
} catch (JsonException $e) {
308+
error_log('MCP Server Error: failed to encode response: ' . $e->getMessage());
309+
310+
return json_encode(
311+
JsonRpcResponse::error($response->id, -32603, 'Failed to encode response'),
312+
JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE,
313+
);
314+
}
315+
}
316+
287317
/**
288318
* Parse and dispatch a single JSON-RPC request line.
289319
*

tests/Integration/CompareRunnerWorktreeTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ public function testWorktreeIsRemovedOnFatalErrorViaShutdownHandler(): void
144144
$worktreePath = trim(implode('', $output));
145145

146146
$this->assertNotSame('', $worktreePath, 'child should print the worktree path before the fatal error');
147+
$this->assertNotSame(0, $exit, 'child must exit non-zero via the fatal error this test exercises');
147148
clearstatcache(true, $worktreePath);
148149
$this->assertDirectoryDoesNotExist(
149150
$worktreePath,

tests/Unit/McpServerTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44

55
namespace Koriym\XdebugMcp\Tests\Unit;
66

7+
use Koriym\XdebugMcp\DTO\GenericResult;
8+
use Koriym\XdebugMcp\DTO\JsonRpcResponse;
79
use Koriym\XdebugMcp\Exceptions\InvalidArgumentException;
810
use Koriym\XdebugMcp\McpServer;
911
use PHPUnit\Framework\TestCase;
1012
use ReflectionClass;
1113
use Throwable;
1214

1315
use function array_column;
16+
use function json_decode;
1417
use function putenv;
1518

19+
use const JSON_THROW_ON_ERROR;
20+
1621
class McpServerTest extends TestCase
1722
{
1823
private McpServer $server;
@@ -122,6 +127,23 @@ public function testHandleLineValidRequestAfterMalformedLineIsNotWedged(): void
122127
$this->assertArrayHasKey('tools', $second['result']);
123128
}
124129

130+
public function testEncodeResponseWithNonUtf8ToolOutputDoesNotThrow(): void
131+
{
132+
// Regression: tool results embed raw exec() output that may contain
133+
// non-UTF-8 bytes. encodeResponse() must not throw (which would
134+
// propagate to __invoke()'s outer catch and wedge the STDIN loop) — the
135+
// bytes are substituted and a valid JSON response is still produced.
136+
$response = JsonRpcResponse::success(7, new GenericResult([
137+
'content' => [['type' => 'text', 'text' => "trace output \xff\xfe not utf-8"]],
138+
]));
139+
140+
$encoded = $this->invokePrivateMethod($this->server, 'encodeResponse', [$response]);
141+
142+
$decoded = json_decode($encoded, true, 512, JSON_THROW_ON_ERROR);
143+
$this->assertSame(7, $decoded['id']);
144+
$this->assertArrayHasKey('result', $decoded);
145+
}
146+
125147
public function testHandleLineNonObjectJsonReturnsInvalidRequest(): void
126148
{
127149
$responseObj = $this->invokePrivateMethod($this->server, 'handleLine', ['42']);

0 commit comments

Comments
 (0)