Files
kyleandClaude Fable 5.1 76ccc251cd Add M2a plan: thirteen tasks, tests, fake server and recordings
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>
2026-09-17 13:34:11 -07:00

118 lines
5.7 KiB
Markdown

# 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`:
```rust
#[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:
1. **`TokenBucket`** takes 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. A `now` earlier than the
last one must not panic or underflow (`Instant::saturating_duration_since`).
2. **`serve`**, for each accepted connection, in this order: take a token, else close it at once;
check that fewer than `max_connections` are 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.
3. **Forwarding.** Connect to `upstream` over 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.
4. **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 `Drop` decreases the count, so that every path releases it, including
the path where the upstream connection fails.
5. **`main`** parses `--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 calls `serve` with `Limits::default()`.
## API notes (std)
- `UnixStream::try_clone()` and `TcpStream::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)` with `std::fs::set_permissions`.
- `std::sync::atomic::AtomicUsize` in an `Arc` is enough for the open count.
## Steps
- [ ] **1. Copy the tests.**
```sh
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.rs` and `main.rs`.** Keep the crate's one-line doc comment at the top of
`lib.rs`. Run `cargo fmt --all`.
- [ ] **4. See the tests pass.** `cargo test -p inferproxy`. Expected: `bucket` 6 passed, `forward`
6 passed. Run `cargo test -p inferproxy --test forward` ten times in a row: it must pass every
time. If `serves_connections_one_after_another` fails 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.**
```sh
git add crates/inferproxy docs/implementer-log.md
git commit
```
## Done when
- `cargo test -p inferproxy` passes 12 tests, ten runs in a row; `make gate` prints `gate: ok`.
- `grep -n "eprintln\|println" crates/inferproxy/src/lib.rs` shows only the two refusal messages.
## Stop and report if
- A test in `forward.rs` cannot pass without parsing the bytes being forwarded.