The tasks build the inference path: emsha-backed SHA-256, inferproxy, config, a hand-written HTTP and SSE client, request building, delta assembly, the chat state machine, the thinking cap, the slot gate with retry, the startup self-test and on-device verification. Everything the tasks copy in was checked against a private reference implementation: the gate passes after each task in order, the timing tests pass repeatedly under CPU load, and the reference passes the self-test and all four device checks on straylight. Expected results for the recorded streams were derived by a separate script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5.7 KiB
M2a task 02: inferproxy
Branch: m2a (run git switch m2a; git status --short must be empty, otherwise stop)
Commit subject: Add the inferproxy byte forwarder with its two limits
Goal
loopd will run with no network at all. inferproxy is how it still reaches the model: it
listens on a Unix socket and forwards every byte to one fixed TCP address, and back.
Context
From the design brief: "inferproxy is a forwarder of about 100 lines: one Unix socket in, one
fixed upstream out." and "it forwards bytes and logs nothing". From the spec: it has two limits,
which exist to stop accidents (a retry storm, a loop that spins), and "it never parses what it
forwards".
One exchange looks like this: the client connects, sends a request, and reads until the server closes. Some clients also shut down their sending side when the request is sent. Both must work.
Files
- Copy:
crates/inferproxy/tests/bucket.rs,crates/inferproxy/tests/forward.rs - Modify:
crates/inferproxy/src/lib.rs,crates/inferproxy/src/main.rs,docs/implementer-log.md
Interfaces
Produces, in crates/inferproxy/src/lib.rs:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits { pub max_connections: usize, pub burst: u32, pub per_second: u32 }
impl Default for Limits { /* max_connections 8, burst 10, per_second 2 */ }
/// Holds at most `burst` tokens and gains `per_second` tokens each second.
pub struct TokenBucket { /* private */ }
impl TokenBucket {
pub fn new(burst: u32, per_second: u32, now: std::time::Instant) -> Self; // starts full
pub fn take(&mut self, now: std::time::Instant) -> bool; // one token, if any
}
/// Accepts connections forever. Returns only if `accept` itself fails.
pub fn serve(listener: std::os::unix::net::UnixListener, upstream: String, limits: Limits) -> std::io::Result<()>;
Rules:
TokenBuckettakes the time as an argument and never reads the clock, so it can be tested without sleeping. Use integer arithmetic (count thousandths of a token); partial refills must add up, so ten checks 100 ms apart at 2 per second grant two tokens. Anowearlier than the last one must not panic or underflow (Instant::saturating_duration_since).serve, for each accepted connection, in this order: take a token, else close it at once; check that fewer thanmax_connectionsare open, else close it at once; otherwise count it as open and handle it on a new thread. Print one line to stderr for each refusal, saying which limit. Print nothing else about connections, ever: not addresses, not byte counts, not errors.- Forwarding. Connect to
upstreamover TCP. If that fails, close the client. Copy client to server on one thread and server to client on another.- When the client stops sending (its read returns 0), shut down the write side of the server connection only, and keep copying the server's answer back.
- When the server stops sending, the exchange is over: close both.
- Release the place before closing the client. Decrease the open count before the client's
connection is shut down. A client that has read to the end of the answer may connect again at
once, and must not be refused because the old connection is still being counted. Do this with a
small guard type whose
Dropdecreases the count, so that every path releases it, including the path where the upstream connection fails. mainparses--listen <socket path>and--upstream <host:port>(both required, either order; anything else prints a usage line and exits 2). It removes a socket file left by an earlier run, binds, sets the socket file's mode to 0600, prints one line (inferproxy: <path> -> <upstream>), and callsservewithLimits::default().
API notes (std)
UnixStream::try_clone()andTcpStream::try_clone()give a second handle for the other direction.stream.shutdown(std::net::Shutdown::Write)sends the half-close.std::os::unix::fs::PermissionsExt::from_mode(0o600)withstd::fs::set_permissions.std::sync::atomic::AtomicUsizein anArcis enough for the open count.
Steps
- 1. Copy the tests.
git switch m2a
mkdir -p crates/inferproxy/tests
cp docs/plans/M2a/files/crates/inferproxy/tests/*.rs crates/inferproxy/tests/
Read forward.rs. Its two fake upstreams show the two behaviours rule 3 must handle.
- 2. See the tests fail.
cargo test -p inferproxy. Expected: they do not compile. - 3. Write
lib.rsandmain.rs. Keep the crate's one-line doc comment at the top oflib.rs. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p inferproxy. Expected:bucket6 passed,forward6 passed. Runcargo test -p inferproxy --test forwardten times in a row: it must pass every time. Ifserves_connections_one_after_anotherfails now and then, rule 4 is not met. - 5. Try it by hand. In one terminal:
cargo run -p inferproxy -- --listen /tmp/ip.sock --upstream straylight:11434. In another:curl --unix-socket /tmp/ip.sock http://localhost/health. Expected:{"status":"ok"}. Stop the proxy with Ctrl-C and remove/tmp/ip.sock. If straylight cannot be reached, note it in your log row and carry on; the tests do not need it. - 6. Run the gate.
make gate. Expected last line:gate: ok. - 7. Log and commit.
git add crates/inferproxy docs/implementer-log.md
git commit
Done when
cargo test -p inferproxypasses 12 tests, ten runs in a row;make gateprintsgate: ok.grep -n "eprintln\|println" crates/inferproxy/src/lib.rsshows only the two refusal messages.
Stop and report if
- A test in
forward.rscannot pass without parsing the bytes being forwarded.