Files
boxmaker/docs/plans/M3a/17-loopd-broker-port.md
T
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on
m3a-ref), each area's check record, and a README with the per-task
table of what each check exposed. The handoff note is done with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:43 -07:00

8.3 KiB

M3a task 17: BrokerPort, [broker] config and the runbook pointers

Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop) Commit subject: Add BrokerPort: loopd asks brokerd for every tool call

Goal

loopd serve sends every tool call to brokerd over broker.sock. Whatever goes wrong there, the model gets a plain failure and the turn goes on. Every fail-closed message loopd prints ends with its entry in docs/runbook.md.

Files

  • Copy: crates/loopd/tests/broker_port.rs, broker_port_bad.rs, pointers.rs, config.rs, device.rs, and crates/loopd/tests/support/broker.rs
  • Create: crates/loopd/src/broker_port.rs (add pub mod broker_port; to lib.rs)
  • Modify: crates/loopd/src/config.rs, main.rs, session.rs, baseline.rs, docs/implementer-log.md

Interfaces

// config.rs: same style as `Loop`. `Config` gains `#[serde(default)] pub broker: Broker`.
#[serde(deny_unknown_fields, default)]
pub struct Broker { pub socket: Option<std::path::PathBuf>, pub timeout_ms: u64 }
// Default: socket None, timeout_ms 120_000. No default path: absent means no broker.

// broker_port.rs
pub const UNAVAILABLE: &str = "the tool broker is unavailable";
pub const NOT_CONFIGURED: &str = "no tool broker is configured";
pub const TOO_LARGE: &str = "the request is too large for the tool broker";
pub const POINTER: &str = "see docs/runbook.md#broker-unavailable";
/// "loopd: {UNAVAILABLE}: {why}; {POINTER}"
pub fn unavailable_line(why: &str) -> String;
/// "loopd: {NOT_CONFIGURED}: every tool call except the core ones will fail; {POINTER}"
pub fn not_configured_line() -> String;

pub struct BrokerPort { /* socket: PathBuf, timeout: Duration, log: Box<dyn Fn(&str) + Send + Sync> */ }
impl BrokerPort {
    pub fn new(socket: PathBuf, timeout: Duration) -> BrokerPort;   // log = |l| eprintln!("{l}")
    pub fn with_log(socket: PathBuf, timeout: Duration, log: Box<dyn Fn(&str) + Send + Sync>) -> BrokerPort;
}
impl ToolPort for BrokerPort { /* below */ }
pub struct NoBroker;       // ToolPort: always Failed { NOT_CONFIGURED }, prints nothing

The protocol

One connection per call. loopd sends one frame: v: PROTOCOL_VERSION, id = request.call.0, final: true, Message::ToolRequest(request.clone()). brokerd answers with frames carrying the same id: optionally one ToolResponse::PendingApproval with final: false, then exactly one Result, Failed or Denied with final: true, and closes.

The timeout is a deadline, not a per-read timeout

A read timeout set once lets a peer that sends one byte now and then hold the turn for ever. Read through this, which allows each read only what is left:

struct Deadline<'a> { stream: &'a UnixStream, until: Instant }
impl std::io::Read for Deadline<'_> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let left = self.until.saturating_duration_since(Instant::now());
        if left.is_zero() {
            return Err(std::io::Error::from(std::io::ErrorKind::TimedOut));
        }
        self.stream.set_read_timeout(Some(left))?;
        let mut stream = self.stream;       // `Read` is implemented for `&UnixStream`
        stream.read(buf)
    }
}

Verified in the std docs of Rust 1.98.1: set_read_timeout and set_write_timeout "An Err is returned if the zero Duration is passed to this method", hence the is_zero check first. A read that times out fails with kind WouldBlock on Linux (TimedOut elsewhere); treat both as a timeout, as http.rs does. Instant::checked_add(Duration) -> Option<Instant>.

  • First deadline: Instant::now().checked_add(timeout), taken before connecting.
  • After a pending frame: wait = expires.unix_millis().saturating_sub(Timestamp::now().unix_millis()), then Instant::now().checked_add(Duration::from_millis(wait)) and .checked_add(timeout) on that. An expires in the past leaves timeout. expires comes from a peer: never add without checked_add.

What call does. Every exit is listed; there are no others

Exits marked U do the same three things: call log exactly once with unavailable_line(why), return ToolResponse::Failed { message: UNAVAILABLE.to_string() }, and drop the stream. Write one helper for it and use it at every U. Never panic, never return PendingApproval.

  1. First deadline is None → U, why the timeout is too large.
  2. UnixStream::connect(&socket) fails → U, cannot connect to {socket}: {e}.
  3. set_write_timeout(Some(timeout)) fails (it does for a zero timeout) → U, cannot set a timeout: {e}.
  4. write_frame fails with FrameError::TooLarge(_) → return Failed { TOO_LARGE } and do not call log: the broker is fine. Any other write_frame error → U, cannot send the request: {e}.
  5. read_frame(&mut Deadline { .. }) fails. FrameError::Closed → U, the connection closed before the final answer. FrameError::Io(e) with kind WouldBlock or TimedOut → U, no answer in time. Any other error (including TooLarge here) → U, bad frame: {e}.
  6. The frame's id is not the request's → U, an answer for request {got}, not {want}.
  7. msg is Message::Error(e) → U, the broker reported an error: {detail}. Any other message that is not Message::ToolResponse → U, an unexpected message.
  8. PendingApproval with final: true → U, a pending frame marked final.
  9. Result, Failed or Denied with final: false → U, an answer not marked final.
  10. PendingApproval with final: false: if one was already seen → U, a second pending frame. Otherwise compute the new deadline (None → U, an expiry too far away), call on_pending(&Pending { approval, expires }) once, and go back to step 5 with the new deadline.
  11. Result, Failed or Denied with final: true → return it unchanged. Nothing is logged: a denial is not an outage.

Steps 5 to 10 apply to the frame after a pending frame exactly as to the first.

Wiring and pointers

  • main.rs, run_serve: Some(path) → BrokerPort::new(path.clone(), Duration::from_millis(cfg.broker.timeout_ms)); None → eprintln!("{}", not_configured_line()) once, then NoBroker. FakeTools is no longer used by main.rs. A socket that does not exist yet is not a startup error: loopd connects per call.
  • main.rs, run_selftest_check: the failure line becomes selftest: FAILED: {e}; see docs/runbook.md#loopd-selftest-failed. It serves both commands.
  • session.rs: only SessionError::Torn changes, to {path}:{line}: {why}; see docs/runbook.md#session-log-damaged.
  • baseline.rs: add BaselineError::Core(PathBuf, std::io::Error), displayed as {path}: {err}; see docs/runbook.md#core-memory-unreadable, and return it (not Read) for an unreadable memory/core.md. Read stays as it is for system.md.

Steps

  • 1. Copy. git switch m3a, then cp docs/plans/M3a/files/crates/loopd/tests/{broker_port,broker_port_bad,pointers,config,device}.rs crates/loopd/tests/ and cp docs/plans/M3a/files/crates/loopd/tests/support/broker.rs crates/loopd/tests/support/
  • 2. See the tests fail. cargo test -p loopd --test broker_port. Expected: no compile.
  • 3. Write config.rs, then broker_port.rs, then the wiring and pointers. cargo fmt --all.
  • 4. See the tests pass. cargo test -p loopd --test broker_port --test broker_port_bad --test pointers --test config, five times in a row. Expected every time: 15 passed, 2 passed, 7 passed, 11 passed. broker_port takes a few seconds: it waits for real timeouts.
  • 5. Run the gate. make gate. Expected last line: gate: ok.
  • 6. Log and commit. git add crates/loopd docs/implementer-log.md && git commit

Done when

  • Step 4's counts, five runs in a row, and make gate prints gate: ok.
  • You have checked each of the eleven exits above against your code, one by one, and the log's Notes say so.

Stop and report if

  • A test needs the port to keep a connection open between calls, or to retry.
  • the_timeout_is_a_deadline_for_the_frame_not_for_each_read fails although you read through Deadline. Do not loosen it.
  • device.rs does not compile. Do not run it: it needs straylight and is the owner's to run.