Hmbown/CodeWhale · error

LSP outbound channel closed

Error message

LSP outbound channel closed

What it means

send_message could not enqueue a frame because the outbound mpsc receiver is gone: the writer task that drains frames into the server's stdin has exited after a stdin write or flush failure. The server's stdin is no longer reachable - it has exited or its pipe broke. This is the send-time observation of the same dead-server condition that appears as channel-closed on the receive side.

Source

Thrown at crates/tui/src/lsp/client.rs:356

    async fn shutdown(&self) {
        let mut child = self.child.lock().await;
        if let Some(mut c) = child.take() {
            let _ = c.start_kill();
            let _ = c.wait().await;
        }
    }
}

/// Send a JSON value as one Content-Length-framed JSON-RPC message.
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
    let body = serde_json::to_vec(value).context("serialize LSP message")?;
    let header = format!("Content-Length: {}\r\n\r\n", body.len());
    let mut frame = Vec::with_capacity(header.len() + body.len());
    frame.extend_from_slice(header.as_bytes());
    frame.extend_from_slice(&body);
    tx.send(frame)
        .await
        .map_err(|_| anyhow!("LSP outbound channel closed"))?;
    Ok(())
}

/// Background task that drains the outbound queue and writes each frame to
/// the LSP server's stdin. Exits cleanly when the channel closes.
async fn writer_task(mut stdin: tokio::process::ChildStdin, mut rx: mpsc::Receiver<Vec<u8>>) {
    while let Some(frame) = rx.recv().await {
        if stdin.write_all(&frame).await.is_err() {
            break;
        }
        if stdin.flush().await.is_err() {
            break;
        }
    }
}

/// Background task that parses `Content-Length`-framed JSON-RPC frames from
/// the LSP server's stdout. Pushes each parsed JSON value to `tx`. Exits

View on GitHub (pinned to 8880682c63)

Solutions

  1. Treat it as a dead server: respawn the server and re-send the request on the fresh channel
  2. Reproduce by running the server manually and sending the same request sequence
  3. Check the server's stderr/log for why it stopped reading input
  4. Order shutdown so sends during teardown get a clean 'shutting down' signal instead of a closed channel

Example fix

// before: send into a dropped channel
send_message(&self.tx_outbound, &payload).await?; // Err(outbound channel closed)
// after: gate on writer liveness, restart on failure
if self.writer_gone() {
    anyhow::bail!("LSP writer exited; restart the server");
}
Defensive patterns

Strategy: retry

Validate before calling

// Track writer task completion; refuse sends once it has exited
fn writer_alive(writer: &tokio::task::JoinHandle<()>) -> bool {
    !writer.is_finished()
}

Try / catch

// Same bounded-restart shape as channel-closed: one respawn, then fail
if let Err(e) = send_message(&tx, &payload).await {
    if e.to_string().contains("outbound channel closed") {
        client = restart_server().await?; // re-send once on the fresh channel
    } else {
        return Err(e);
    }
}

Prevention

When it happens

Trigger: Server process exits between requests while a new request is being sent; the writer task hits a write/flush error and stops; client shutdown races an in-flight send, dropping the channel first.

Common situations: Crashed or OOM-killed servers, servers exiting on invalid input, teardown sequences that drop the outbound channel while requests are still being issued.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@8880682c63 (2026-08-16). Data as JSON: /api/errors/af77311ed3897277. Report an issue: GitHub.