Skip to content

Stray non-LSP output on the bridge stdout permanently poisons the framed stream #105

Description

@poman

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions