Affected version: dev-main (2026-07)
Severity: high - a single garbage byte on stdout kills the whole client session
Summary
LspFraming::extractOneMessage() (src/Transport/JsonRpc/LspFraming.php) throws InvalidArgumentException('Missing or invalid Content-Length header') as soon as the receive buffer does not start with a valid frame header. The buffer is never resynced, so after any stray output on the Node bridge stdout, every subsequent sendAndReceive() on that client throws, and the session is unrecoverable.
Observed in practice on CI: after a Chromium renderer crash ("Target crashed"), crash diagnostics ended up interleaved with the framed stream, and each following scenario in the same long-lived process failed with:
InvalidArgumentException: Missing or invalid Content-Length header
in src/Transport/JsonRpc/LspFraming.php:65
#2 ProcessJsonRpcClient->processAndDispatchMessages()
#3 ProcessJsonRpcClient->readProcessOutput()
Anything a dependency prints with console.log (or a native component writing to fd 1) triggers the same permanent failure mode — there is no way to recover the client once it happens.
Reproduction
$frame1 = LspFraming::encode('{"ok":1}');
$frame2 = LspFraming::encode('{"ok":2}');
LspFraming::decode("crash spew\nstack line\n" . $frame1 . 'garbage' . $frame2);
// expected: ['messages' => ['{"ok":1}", '{"ok":2}']]
// actual: throws InvalidArgumentException, losing both messages
Root cause
src/Transport/JsonRpc/LspFraming.php, extractOneMessage() (~line 65): when the header block found via the first \r\n\r\n does not contain a valid Content-Length, the method throws immediately instead of treating the bytes before the next real frame header as noise to be skipped.
Proposed fix
Resync instead of throwing: skip garbage up to the next Content-Length: occurrence; if none is present yet, keep buffering (LSP-based tooling such as vscode-languageclient uses the same skip-to-next-header strategy for robustness).
$contentLength = self::parseContentLength($headers);
if (null === $contentLength) {
- throw new \InvalidArgumentException('Missing or invalid Content-Length header');
+ // resync: skip stray non-LSP output (e.g. browser crash spew) up to the next frame
+ $resyncPos = strpos($buffer, 'Content-Length:', 1);
+ if (false === $resyncPos) {
+ return null;
+ }
+
+ return self::extractOneMessage(substr($buffer, $resyncPos));
}
Test
Feed decode() a buffer with stray non-LSP text before, between, and inside otherwise well-formed frames, and assert every real message is still recovered instead of an exception being thrown.
A PR with this fix and a regression test is ready and will be linked here.
Affected version: dev-main (2026-07)
Severity: high - a single garbage byte on stdout kills the whole client session
Summary
LspFraming::extractOneMessage()(src/Transport/JsonRpc/LspFraming.php) throwsInvalidArgumentException('Missing or invalid Content-Length header')as soon as the receive buffer does not start with a valid frame header. The buffer is never resynced, so after any stray output on the Node bridge stdout, every subsequentsendAndReceive()on that client throws, and the session is unrecoverable.Observed in practice on CI: after a Chromium renderer crash ("Target crashed"), crash diagnostics ended up interleaved with the framed stream, and each following scenario in the same long-lived process failed with:
Anything a dependency prints with
console.log(or a native component writing to fd 1) triggers the same permanent failure mode — there is no way to recover the client once it happens.Reproduction
Root cause
src/Transport/JsonRpc/LspFraming.php,extractOneMessage()(~line 65): when the header block found via the first\r\n\r\ndoes not contain a validContent-Length, the method throws immediately instead of treating the bytes before the next real frame header as noise to be skipped.Proposed fix
Resync instead of throwing: skip garbage up to the next
Content-Length:occurrence; if none is present yet, keep buffering (LSP-based tooling such as vscode-languageclient uses the same skip-to-next-header strategy for robustness).$contentLength = self::parseContentLength($headers); if (null === $contentLength) { - throw new \InvalidArgumentException('Missing or invalid Content-Length header'); + // resync: skip stray non-LSP output (e.g. browser crash spew) up to the next frame + $resyncPos = strpos($buffer, 'Content-Length:', 1); + if (false === $resyncPos) { + return null; + } + + return self::extractOneMessage(substr($buffer, $resyncPos)); }Test
Feed
decode()a buffer with stray non-LSP text before, between, and inside otherwise well-formed frames, and assert every real message is still recovered instead of an exception being thrown.A PR with this fix and a regression test is ready and will be linked here.