Skip to main content

Module ordering

Module ordering 

Source
Expand description

Message ordering, concurrency, and the dispatch loop.

Understanding how agent-client-protocol processes messages is key to writing correct code. This chapter explains the dispatch loop and the ordering guarantees you can rely on.

§The Dispatch Loop

Each connection has a central dispatch loop that processes incoming messages one at a time. When a message arrives, it is passed to your handlers in order until one claims it.

The key property: the dispatch loop waits for each handler to complete before processing the next message. This gives you sequential ordering guarantees within a single connection.

§Ordered Callbacks Hold the Loop

Request and notification callbacks registered with on_receive_request and on_receive_notification run inside the dispatch loop. The loop is blocked until the callback completes.

Registering on_receiving_result or on_receiving_ok_result returns immediately. If registration happens before a peer response is routed during its original dispatch, response handling then holds an ordering barrier until the callback completes. A pending-request failure delivered without an incoming response, or a response routed later, does not carry that barrier.

Session-start helpers are two-phase: on_session_start and on_proxy_session_start perform framework-owned session setup under this ordering guarantee, then invoke the user callback in a spawned task so it can consume later session traffic. No user callback code runs under the session-setup ordering guarantee.

While a callback holds the dispatch loop, this means:

  • No other messages are processed while your callback runs
  • You can safely do setup before “releasing” control back to the loop
  • Messages are processed in the order they arrive

§Deadlock Risk

Because callbacks can hold the dispatch loop, it’s easy to create deadlocks. The most common pattern:

// DEADLOCK: This blocks the loop waiting for a response,
// but the response can't arrive because the loop is blocked!
builder.on_receive_request(async |request: MyRequest, responder, cx| {
    let response = cx.send_request(SomeRequest { ... })
        .block_task()  // <-- Waits for response
        .await?;       // <-- But response can never arrive!
    responder.respond(response)
}, on_receive_request!());

The response can never arrive because the dispatch loop is blocked waiting for your callback to complete.

§block_task vs on_receiving_result

When you send a request, you get a SentRequest with two ways to handle it:

§block_task() - Does not hold dispatch while you process

Use this from a task that already runs outside the dispatch loop, such as a foreground connect_with future or a spawned task:

cx.spawn({
    let cx = cx.clone();
    async move {
        // Safe: we're in a spawned task, not blocking the dispatch loop
        let response = cx.send_request(MyRequest {})
            .block_task()
            .await?;
        // Process response...
        Ok(())
    }
})?;

The dispatch loop continues immediately after delivering the response. Your code receives the response and can take as long as it wants.

§on_receiving_result() - A peer response callback can hold the loop

Use this when you need ordering guarantees:

cx.send_request(MyRequest {})
    .on_receiving_result(async |result| {
        // A timely peer response holds dispatch until this completes
        let response = result?;
        // Do something with response...
        Ok(())
    })?;

Register the callback before a peer response is routed to select this ordered mode. The dispatch loop then waits for your callback before processing the next message. A pending-request failure delivered without an incoming response (such as EOF), a response that was already routed, or one that an interceptor retains and routes after its original dispatch does not carry the barrier.

An ordered callback must not wait for later inbound traffic on the same connection. Spawn that follow-up work and return from the callback so the loop can continue.

§Escaping the Loop: spawn

Use spawn to run work outside the dispatch loop:

builder.on_receive_request(async |request: MyRequest, responder, cx| {
    cx.spawn(async move {
        // This runs outside the loop - other messages may be processed
        let response = cx.send_request(SomeRequest { ... })
            .block_task()
            .await?;
        // ...
        Ok(())
    })?;
    responder.respond(MyResponse { ... })  // Return immediately
}, on_receive_request!());

§Blocking Session Methods

SessionBuilder::run_until does not spawn its caller. It waits for the session response on the current task, so call it only when that task already runs outside the dispatch loop—for example, from the foreground future passed to connect_with or from a task created with spawn. Awaiting it from a message handler deadlocks just like awaiting block_task() there.

cx.build_session_cwd()?
    .block_task()
    .run_until(async |mut session| {
        // Safe: connect_with's foreground future runs outside the dispatch loop
        session.send_prompt("Hello")?;
        let response = session.read_to_string().await?;
        Ok(())
    })
    .await?;

§Summary

PatternBlocks Loop?Use When
on_receive_* callbackYesHandle one incoming message
on_receiving_* callbackFor a timely peer responseBounded response work that needs ordering
on_session_start / on_proxy_session_startSetup onlyInstall session routing, then run session work concurrently
block_task()If awaited in a handlerWait for a response from outside the dispatch loop
spawn(...)NoLong-running work, don’t need ordering
block_task().run_until(...)If called in a handlerSession-scoped work from outside the dispatch loop

§Next Steps