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>
6.0 KiB
M2a task 11: the slot gate, and retry
Branch: m2a (run git switch m2a; git status --short must be empty, otherwise stop)
Commit subject: Add the per-slot gate and chat_with_retry
Goal
Two protections against a pile-up. The gate lets loopd have only one request in flight per
server slot; others wait their turn inside loopd, in order, up to a limit. Retry resends a
request whose server went away, a few times, with growing waits.
Context
The inference server is shared and can be restarted under us. After a restart every conversation
that was waiting would retry at once and queue invisibly on the server. With the gate they queue
in loopd, where the queue is bounded and the caller is told (Queued, Busy). Retrying is safe
because nothing is recorded until a completion is final: a retry sends the same bytes again.
The gate is held for one request, from before it is sent until chat returns. It is the first
code in this milestone that several threads use at once.
Files
- Copy:
crates/loopd/tests/retry.rs - Create:
crates/loopd/src/llama/gate.rs,crates/loopd/src/llama/retry.rs - Modify:
crates/loopd/src/llama/mod.rs,crates/loopd/src/llama/chat.rs,docs/implementer-log.md
Interfaces
crates/loopd/src/llama/gate.rs:
#[derive(Default)]
pub struct SlotGate { /* private: a Mutex around per-slot state, and a Condvar */ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GateFull;
/// The right to have a request in flight on the slot. Dropping it passes the slot on.
pub struct Permit<'a> { /* private */ }
impl SlotGate {
pub fn new() -> Self;
pub fn acquire(&self, slot: u32, max_queue: usize, on_queued: &mut dyn FnMut(usize)) -> Result<Permit<'_>, GateFull>;
}
Gate rules:
- A free slot with nobody waiting is taken at once;
on_queuedis not called. - Otherwise, if
max_queuerequests are already waiting for this slot, returnGateFullat once, without waiting.max_queueof 0 means nobody may wait. - Otherwise join the queue, call
on_queued(ahead)once, whereaheadis the holder (1 if the slot is taken) plus the waiters already in the queue, and wait. - Waiters get the slot in the order they arrived. A
Condvarwakes waiters in no particular order, so give each waiter a ticket, keep the tickets in aVecDeque, and let a woken waiter take the slot only if the slot is free and its ticket is at the front. Usenotify_all. - Slots are independent of each other.
- Dropping the
Permitfrees the slot and wakes the waiters. ImplementDrop. Mutex::lockandCondvar::waitreturnErrif another thread panicked while holding the lock. Do notunwrap: recover the guard withunwrap_or_else(|poisoned| poisoned.into_inner()).
crates/loopd/src/llama/retry.rs:
pub fn is_retryable(e: &InferError) -> bool;
pub fn backoff_ms(schedule: &[u64], retry: u32, jitter: i32) -> u64;
impl Client {
pub fn chat_with_retry(&self, req: &ChatRequest, on_event: &mut dyn FnMut(&ChatEvent)) -> Result<Completion, InferError>;
}
Retry rules:
-
is_retryable, one line per variant, all nine listed so that a new variant is a compile error:Retried: the server went away and may be back Not retried Connect,StreamClosedEarly,Stalled,LoadTimeout,Httpwith status 503Busy,WaitTimeout(it has already waited),ThinkingOverrun,Protocol,Httpwith any other status -
backoff_ms(schedule, retry, jitter):retryis 1 for the first retry (0 is treated as 1). The base isschedule[retry - 1], or the last entry past the end, or 0 for an empty schedule.jitteris clamped to -1000..=1000 and moves the wait by up to a quarter of the base:base + (base / 4) * jitter / 1000. Integer arithmetic; huge values must not overflow (saturating_*,try_from). Examples:([2000, 8000, 30000], 1, 0)is 2000;(…, 1, 1000)is 2500;(…, 1, -1000)is 1500;(…, 2, 500)is 9000;(…, 4, 0)is 30000. -
chat_with_retrycallschat. On an error that is retryable, and while fewer thanretry_attemptsattempts have been made: compute the wait with a jitter that differs from call to call (the sub-second nanoseconds of the system clock, reduced to -1000..=1000, are random enough; do not add a crate). If the time since the first attempt plus the wait is more thanretry_window_ms, return the error. Otherwise emitChatEvent::Retrying { attempt, after_ms, error }(attemptis the number of the attempt about to start, so 2 for the first retry;erroris the error'sDisplaytext), sleep, and try again. When attempts run out, return the last error.
Changes elsewhere:
Clientgets a second field,pub(crate) gate: gate::SlotGate, created inClient::new.chatstarts by acquiring the gate forreq.slotwithcfg.limits.queue_len, passing|ahead| on_event(&ChatEvent::Queued { ahead }).GateFullbecomesInferError::Busy. Bind the permit to a name such as_permitso that it lives untilchatreturns;let _ = …would drop it at once.
Steps
- 1. Copy.
git switch m2a, thencp docs/plans/M2a/files/crates/loopd/tests/retry.rs crates/loopd/tests/ - 2. See the test fail.
cargo test -p loopd --test retry. Expected: it does not compile. - 3. Write
gate.rsandretry.rs, and make the two changes. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p loopd. Expected:retry13 passed, and every earlier test file still passes. Runcargo test -p loopd --test retryten times in a row. - 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
cargo test -p loopd --test retryreports 13 passed, ten runs in a row;make gateprintsgate: ok.gate.rshas nounwrap()and noexpect(.
Stop and report if
waiters_are_served_in_order_one_at_a_timefails now and then. That means rule 4 is not met; if you cannot see why after two attempts, stop.