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 NoneU, 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::ClosedU, the connection closed before the final answer. FrameError::Io(e) with kind WouldBlock or TimedOutU, 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::ToolResponseU, an unexpected message.
  8. PendingApproval with final: trueU, a pending frame marked final.
  9. Result, Failed or Denied with final: falseU, 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 (NoneU, 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)); Noneeprintln!("{}", 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.