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`. ExitsView on GitHub (pinned to 8880682c63)
Solutions
- Treat it as a dead server: respawn the server and re-send the request on the fresh channel
- Reproduce by running the server manually and sending the same request sequence
- Check the server's stderr/log for why it stopped reading input
- 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
- Mark the client unusable as soon as the writer task finishes
- Log why the writer exited (write vs flush error) before the receiver drops
- Serialize shutdown so sends during teardown get a clean closing error
- Treat server process exit as the authoritative liveness signal
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
- LSP request channel closed
- dsh exited with status {code}
- Failed to run command: {e}
- stdout unavailable
- stderr unavailable
AI-assisted analysis of Hmbown/CodeWhale@8880682c63 (2026-08-16).
Data as JSON: /api/errors/af77311ed3897277.
Report an issue: GitHub.