astrid-runtime/astrid · error

MCP gateway returned an unbound control acknowledgement

Error message

MCP gateway returned an unbound control acknowledgement

What it means

The gateway's control acknowledgement failed binding checks: its protocol version, operation name, or PID didn't match what the client requested (GATEWAY_CONTROL_VERSION, the requested operation, and the ready record's PID). The library treats an unbound ack as untrustworthy and bails rather than acting on it.

Source

Thrown at crates/astrid-cli/src/commands/mcp/lifecycle.rs:679

        .context("failed to write MCP gateway control")?;
    write_half
        .write_all(b"\n")
        .await
        .context("failed to terminate MCP gateway control")?;
    write_half
        .flush()
        .await
        .context("failed to flush MCP gateway control")?;

    let mut reader = BufReader::new(read_half);
    let response = tokio::time::timeout(READY_TIMEOUT, read_bounded_line(&mut reader))
        .await
        .context("timed out waiting for MCP gateway control acknowledgement")??;
    let ack: GatewayControlAck =
        serde_json::from_slice(&response).context("invalid MCP gateway control acknowledgement")?;
    if ack.version != GATEWAY_CONTROL_VERSION || ack.operation != operation || ack.pid != record.pid
    {
        anyhow::bail!("MCP gateway returned an unbound control acknowledgement");
    }
    if !ack.ok {
        anyhow::bail!(
            "MCP gateway rejected {operation:?}: {}",
            ack.error.as_deref().unwrap_or("unknown gateway failure")
        );
    }
    Ok(ack)
}

pub(crate) async fn read_bounded_line<R>(reader: &mut R) -> Result<Vec<u8>>
where
    R: tokio::io::AsyncRead + Unpin,
{
    let mut line = Vec::new();
    for _ in 0..=MAX_CONTROL_BYTES {
        let byte = reader
            .read_u8()

View on GitHub (pinned to affd8760f4)

Solutions

  1. Re-run gateway startup to regenerate a ready record matching the live gateway process
  2. Ensure the astrid CLI and gateway binaries are the same version (reinstall/upgrade both)
  3. Delete stale gateway ready/marker files so the client binds to the correct PID
  4. Reproduce with logging of the ack fields to identify which binding (version/operation/pid) failed

Example fix

// before: assume ack matches
let ack = request_gateway_control(...).await?;
// after: surface the mismatch for diagnosis
match request_gateway_control(...).await {
    Err(e) if e.to_string().contains("unbound control acknowledgement") => {
        // refresh ready record and retry once
        refresh_gateway_ready_record()?;
        request_gateway_control(...).await?;
    },
    other => other?,
}
Defensive patterns

Strategy: retry

Validate before calling

let record = read_gateway_ready()?;
anyhow::ensure!(record.pid != 0, "ready record has no PID; restart gateway");
anyhow::ensure!(
    gateway_version() == GATEWAY_CONTROL_VERSION,
    "CLI/gateway control version mismatch"
);

Type guard

fn ack_is_bound(ack: &GatewayControlAck, op: &str, pid: i32) -> bool {
    ack.version == GATEWAY_CONTROL_VERSION && ack.operation == op && ack.pid == pid
}

Try / catch

match request_gateway_control(...).await {
    Err(e) if e.to_string().contains("unbound control acknowledgement") => {
        refresh_gateway_ready_record()?;
        request_gateway_control(...).await?
    },
    other => other?,
}

Prevention

When it happens

Trigger: request_gateway_control receives an ack where ack.version != GATEWAY_CONTROL_VERSION, ack.operation != operation, or ack.pid != record.pid — typically because a different (old/new) gateway process is answering on the socket.

Common situations: Version mismatch between CLI and installed gateway binary; a new gateway replaced the old one on the same socket mid-request; PID reuse or a stale ready record pointing at the wrong process.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/93467c5b346dea5f. Report an issue: GitHub.