Kuberwastaken/claurst · error · anyhow::Error

LSP message missing Content-Length header

Error message

LSP message missing Content-Length header

What it means

LSP over stdio requires every message to start with a `Content-Length: <n>` header terminated by a blank line. `read_message` (called from `start`) parses headers until the blank line and, if no valid Content-Length was seen (`content_length == 0`), the stream is not a conforming LSP framing stream, so the client errors out rather than attempting an unbounded body read.

Solutions

  1. Verify the configured command actually speaks LSP over stdio (e.g. run it and check for Content-Length framed output).
  2. Redirect any non-protocol output to stderr: fix wrapper scripts to use `exec` and ensure the server doesn't print logs/diagnostics on stdout (use `--log-file` or stderr flags).
  3. Check the server's stderr for a startup error explaining why it never entered LSP mode (bad root URI, missing initialization).
  4. Confirm the header the server sends matches `Content-Length: <n>` exactly (some misbehaving servers use different casing/spacing — a protocol bug in the server).

Example fix

// before: wrapper pollutes stdout with logs
g(language-server --verbose)  # verbose output goes to stdout

// after
exec language-server --log-file /tmp/ls.log  # stdout carries only LSP frames
Defensive patterns

Strategy: validation

Validate before calling

// smoke-test that the command speaks LSP over stdio before configuring it:
use std::process::{Command, Stdio};
let mut child = Command::new(&server_cmd)
    .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit())
    .spawn()?;
// send a minimal initialize request and expect a Content-Length-framed reply
let req = format!("Content-Length: 2\r\n\r\n{{}}");
use std::io::Write;
child.stdin.take().unwrap().write_all(req.as_bytes())?;
let mut first = String::new();
use std::io::BufRead;
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut first)?;
if !first.starts_with("Content-Length:") {
    anyhow::bail!("`{}` does not emit LSP framing on stdout", server_cmd.display());
}

Type guard

fn is_lsp_framed_output(line: &str) -> bool {
    line.starts_with("Content-Length:")
}

Try / catch

match lsp.start().await {
    Err(e) if e.to_string().contains("missing Content-Length") => {
        eprintln!("`{cmd}` did not speak LSP over stdio. Check it isn't printing logs/errors on stdout: {e:#}");
    }
    other => other?,
}

Prevention

When it happens

Trigger: The server's stdout emitted a header block with no `Content-Length:` line, or an empty/unparseable header value that defaulted content_length to 0 — e.g. the process printed plain-text diagnostics or an error banner on stdout instead of LSP-framed JSON-RPC.

Common situations: Configuring a plain CLI tool (or a server that logs to stdout) as the LSP command; a server that failed startup and printed a human-readable error; a wrapper script contaminating stdout with debug output; a non-LSP binary path in the config.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of Kuberwastaken/claurst@b0637c97ec (2026-09-10). Data as JSON: /api/errors/ad487bb16548672f. Report an issue: GitHub.

Appendix: source

Thrown at src-rust/crates/core/src/lsp.rs:156

    reader: &mut BufReader<ChildStdout>,
) -> anyhow::Result<serde_json::Value> {
    let mut content_length: usize = 0;
    loop {
        let mut line = String::new();
        let n = reader.read_line(&mut line).await?;
        if n == 0 {
            return Err(anyhow::anyhow!("LSP server closed stdout"));
        }
        let trimmed = line.trim_end_matches(['\r', '\n']);
        if trimmed.is_empty() {
            break;
        }
        if let Some(val) = trimmed.strip_prefix("Content-Length: ") {
            content_length = val.trim().parse()?;
        }
    }
    if content_length == 0 {
        return Err(anyhow::anyhow!("LSP message missing Content-Length header"));
    }
    let mut buf = vec![0u8; content_length];
    reader.read_exact(&mut buf).await?;
    Ok(serde_json::from_slice(&buf)?)
}

// ---------------------------------------------------------------------------
// LspClient
// ---------------------------------------------------------------------------

type PendingMap = Arc<DashMap<u64, oneshot::Sender<serde_json::Value>>>;

/// A running LSP client connected to a single server process.
pub struct LspClient {
    pub server_name: String,
    pub server_config: LspServerConfig,
    /// The child process handle; `None` after shutdown.
    process: Option<Child>,

View on GitHub (pinned to b0637c97ec)