github/copilot-sdk · error

writer actor has shut down

Error message

writer actor has shut down

What it means

Client::write sends a serialized frame to the dedicated writer actor over a channel. If the writer task has already terminated (connection closed or being torn down), the send fails and the library converts it into a BrokenPipe io::Error so callers see a familiar std::io-style failure rather than a channel-send panic.

Solutions

  1. Check connection liveness and reconnect the client before retrying the write
  2. Ensure Client::stop is not called concurrently with in-flight writes; await pending operations first
  3. Handle BrokenPipe by recreating the Client (transport and writer actor) instead of reusing it
  4. Inspect writer task logs for an earlier underlying I/O error that killed the actor

Example fix

// before
client.write(request).await?; // panics/fails after shutdown
// after
if client.is_running() {
    client.write(request).await?;
} else {
    client = Client::connect(endpoint).await?;
    client.write(request).await?;
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Rust
fn can_write(client: &Client) -> bool { client.is_running() } // or track shutdown state yourself

Try / catch

// Rust
match client.write(frame).await {
    Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => reconnect_and_retry().await?,
    other => other?,
}

Prevention

When it happens

Trigger: Calling any public write/RPC method on a JsonRpc client whose background writer actor has exited — e.g. after the peer closed the connection, after Client::stop, or after the writer task panicked on an underlying I/O error.

Common situations: Sending a request over a socket the server already closed; racing a write against Client::stop during shutdown; long-lived connections dropped by idle timeouts or network interruption.

Related errors


AI-assisted analysis of github/copilot-sdk@cd8cf15dc3 (2026-09-09). Data as JSON: /api/errors/9b4abbdc21012f80. Report an issue: GitHub.

Appendix: source

Thrown at rust/src/jsonrpc.rs:663

    /// # Cancel safety
    ///
    /// **Cancel-safe.** Pre-serializes the body, enqueues it on the writer
    /// actor's command channel, and awaits an ack. Caller cancellation
    /// drops the ack receiver; the actor still completes the frame and
    /// flushes. A partial frame can never appear on the wire.
    pub async fn write<T: serde::Serialize>(&self, message: &T) -> Result<(), Error> {
        let body = serde_json::to_vec(message)?;
        let mut frame = Vec::with_capacity(CONTENT_LENGTH_HEADER.len() + 16 + body.len() + 4);
        frame.extend_from_slice(CONTENT_LENGTH_HEADER.as_bytes());
        frame.extend_from_slice(body.len().to_string().as_bytes());
        frame.extend_from_slice(b"\r\n\r\n");
        frame.extend_from_slice(&body);

        let (ack_tx, ack_rx) = oneshot::channel();
        self.write_tx
            .send(WriteCommand { frame, ack: ack_tx })
            .map_err(|_| {
                Error::from(std::io::Error::new(
                    std::io::ErrorKind::BrokenPipe,
                    "writer actor has shut down",
                ))
            })?;

        match ack_rx.await {
            Ok(Ok(())) => Ok(()),
            Ok(Err(e)) => Err(Error::from(e)),
            Err(_) => Err(Error::from(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "writer actor dropped ack without responding",
            ))),
        }
    }
}

/// RAII guard that removes a pending-request entry from the map if the
/// owning future is dropped before the response arrives. Disarmed on the

View on GitHub (pinned to cd8cf15dc3)