nautechsystems/nautilus_trader · error · TransportError

Writer task dropped response channel

Error message

Writer task dropped response channel

What it means

When the writer task receives WriterCommand::Update it replies on a oneshot channel with the new connection epoch. If the reply fails, the writer task dropped the responder, meaning it exited between receiving the command and acknowledging the socket swap. The client cannot confirm the writer is now using the new socket, so reconnect_with_outcome fails with this BrokenPipe error rather than transitioning to ACTIVE on an unconfirmed state.

Source

Thrown at crates/network/src/websocket/client.rs:1362

        // back to ACTIVE. Buffered messages stay in the writer task and replay later.
        let (tx, rx) = tokio::sync::oneshot::channel();
        if let Err(e) = self.writer_tx.send(WriterCommand::Update(new_writer, tx)) {
            log::error!("{e}");
            return Err(TransportError::Io(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                format!("Failed to send update command: {e}"),
            )));
        }

        // Wait for writer to confirm it accepted the new socket
        let connection_epoch = match rx.await {
            Ok(connection_epoch) => {
                log::debug!("Writer confirmed socket update: epoch={connection_epoch}");
                connection_epoch
            }
            Err(e) => {
                log::error!("Writer dropped update channel: {e}");
                return Err(TransportError::Io(std::io::Error::new(
                    std::io::ErrorKind::BrokenPipe,
                    "Writer task dropped response channel",
                )));
            }
        };

        // Delay before closing connection
        dst::time::sleep(Duration::from_millis(GRACEFUL_SHUTDOWN_DELAY_MS)).await;

        if ConnectionMode::from_atomic(&self.connection_mode).is_disconnect() {
            log::debug!("Reconnect aborted mid-flight (after delay)");
            return Ok(ReconnectOutcome::Aborted);
        }

        if let Some(read_fence) = self.read_fence.take() {
            read_fence.invalidate();
        }

View on GitHub (pinned to 18893faf8b)

Solutions

  1. Inspect writer task logs for a panic between receiving Update and replying; fix the panicking socket-swap code
  2. Recreate the client (or rerun full reconnect) so a fresh writer task backs the new socket
  3. Ensure the writer task always sends on the oneshot (including on its internal error paths) before exiting
  4. Check for races with client shutdown and serialize disconnect/reconnect paths
Defensive patterns

Strategy: try-catch

Type guard

fn is_broken_pipe(e: &TransportError) -> bool {
    matches!(e, TransportError::Io(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
}

Try / catch

match reconnect_with_outcome().await {
    Err(e) if is_broken_pipe(&e) => {
        // writer dropped the oneshot: treat client as unusable and rebuild
        client = rebuild_client(config).await?;
    }
    r => r?,
}

Prevention

When it happens

Trigger: rx.await returning RecvError because the writer task dropped tx after receiving WriterCommand::Update — typically due to a panic or early return inside the writer loop while processing the update.

Common situations: Writer task panics while constructing the new sink/stream from the new socket; writer task racing shutdown; a bug in the socket-swap path under specific socket states (e.g. already-closed stream).

Related errors


AI-assisted analysis of nautechsystems/nautilus_trader@18893faf8b (2026-09-08). Data as JSON: /api/errors/b4b9a0070cac9f6a. Report an issue: GitHub.