diff --git a/crates/irtt-server/AGENTS.md b/crates/irtt-server/AGENTS.md index be4e8b7..59334f7 100644 --- a/crates/irtt-server/AGENTS.md +++ b/crates/irtt-server/AGENTS.md @@ -202,15 +202,14 @@ Do not rewrite the protocol evidence to match them. The only interoperability constraint is negative — expiry must never be signalled — and silence satisfies it. A zero idle timeout expires a session at the next evaluation; it is not "never expire". -- **Expiry is packet-driven until the runtime exists.** Logical expiry is - timestamp-based, but reclamation happens in a global sweep run after - authentication and before dispatch, so an expired session may stay resident - until the next authenticated, structurally valid request. Nothing observable - turns on it: the table is already bounded, and the sweep runs before capacity - is judged, so a stale session cannot deny an open. Because the sweep is global - it also runs for a no-test open, where upstream was observed not to reclaim. - Do not add a public timer or tick abstraction for this; the Tokio slice may add - scheduled sweeps. +- **Logical expiry is request-exact; physical reclamation also runs on the + runtime's fixed maintenance cadence.** Every authenticated, structurally valid + request runs the global sweep before dispatch, so a request at the deadline + finds the session expired and a stale session cannot deny an open. The Tokio + runtime invokes that same core sweep once per second so dead sessions do not + remain resident when there is no further traffic. This cadence is internal + housekeeping, not protocol policy or public configuration, and the runtime + must not inspect session internals or duplicate the expiry rule. - **The maximum-duration deadline is `first served echo + maximum + 2 s`.** The origin is measured behavior: neither the open, nor a rejected first echo, nor a rate-limited one starts it. The two-second grace matches the measured upstream diff --git a/crates/irtt-server/src/runtime.rs b/crates/irtt-server/src/runtime.rs index dd3a4e6..158196a 100644 --- a/crates/irtt-server/src/runtime.rs +++ b/crates/irtt-server/src/runtime.rs @@ -62,9 +62,10 @@ impl Server { /// Serves datagrams until `shutdown` completes. /// /// Graceful shutdown sends no session-close packets and leaves no hidden - /// receive task behind. Per-packet send failures and short sends drop only - /// that reply; receive failures and internal core failures terminate the - /// loop with an error. + /// receive task behind, even while a reply send is waiting for socket + /// writability. Per-packet send failures and short sends drop only that + /// reply; receive failures and internal core failures terminate the loop + /// with an error. pub async fn run(&mut self, shutdown: F) -> Result<(), ServerRuntimeError> where F: Future, @@ -89,7 +90,15 @@ impl Server { } if let Some(reply) = self.core.handle_datagram(peer, &self.recv_buffer[..len])? { - let sent = self.socket.send_to(&reply, peer).await; + let send = self.socket.send_to(&reply, peer); + tokio::pin!(send); + let sent = loop { + tokio::select! { + _ = &mut shutdown => return Ok(()), + _ = maintenance.tick() => self.core.maintain(), + sent = &mut send => break sent, + } + }; if !matches!(sent, Ok(len) if len == reply.len()) { continue; }