Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/appliance-pid1/src/dhcp/renew.rs

//! **Keeping the lease alive**: RFC 2131 §4.4.5 as a state machine that is
//! pure over injected time and an injected transport.
//!
//! ```text
//!   BOUND ──T1──▶ RENEWING ──T2──▶ REBINDING ──expiry──▶ REDISCOVERING
//!     ▲   (unicast REQUEST     (broadcast REQUEST      (full DISCOVER→ACK,
//!     │    to the lease's       to 255.255.255.255)     retried forever)
//!     │    server)                                            │
//!     └────────────────── any ACK refreshes ──────────────────┘
//!                    any NAK ⇒ REDISCOVERING immediately
//! ```
//!
//! [`Machine`] holds the timers and transitions and does **no** I/O and no
//! sleeping: `poll(now, transport)` is handed the current second on a clock
//! the caller owns and a [`Transport`] the caller owns, performs at most one
//! transport operation, and answers with when to call again plus what
//! happened. That is what makes every transition — T1 fires, ACK reschedules,
//! T1-silence becomes a T2 broadcast, NAK cuts to re-DISCOVER, expiry —
//! testable in microseconds against a mock, with no network and no sleep. The
//! real driver (`crate::net::dhcp_renewal`) supplies tokio's clock and
//! [`super::WireTransport`]; the tests supply integers and a `Vec` of scripted
//! outcomes.
//!
//! Timer defaults are the RFC's: T1 = 0.5 · lease, T2 = 0.875 · lease, used
//! only when the server named no option 58/59. While RENEWING or REBINDING
//! with no answer, retries back off at half the remaining time to the next
//! boundary with the RFC's 60 s floor (§4.4.5's retransmission rule).
//!
//! # The one deliberate deviation: expiry does not deconfigure
//!
//! RFC 2131 §4.4.5 says that when the lease expires the client "MUST
//! immediately stop using" the address. This machine does not, on purpose,
//! and this paragraph is the record of that decision. The appliance's whole
//! point is serving; its operator is attached over ssh **through this very
//! address**, and deconfiguring it at expiry would kill every session —
//! including the one somebody would use to fix the network — turning "the
//! DHCP server went away" into "the appliance went away". So at expiry the
//! machine moves to REDISCOVERING, re-runs the full handshake forever, and
//! the address is *kept but squatted*: every failed re-DISCOVER cycle emits
//! [`Event::RediscoverFailed`] with `squatting = true`, which the driver turns
//! into a console WARN naming the squat, every cycle, until a server answers.
//! Loud and serving beats silent and dead; the trade-off is honest because it
//! is announced on every retry, not once.

use std::net::Ipv4Addr;

use anyhow::Result;

use super::Lease;
use crate::net::NetPlan;

/// RFC 2131 §4.4.5's floor on the retransmission backoff, seconds.
const RETRY_FLOOR_SECS: u64 = 60;
/// How long to wait between full re-DISCOVER attempts once the lease is gone.
/// Each attempt is itself bounded (~12 s of DISCOVER rounds), so one cycle is
/// roughly a minute of quiet between two loud ones.
const REDISCOVER_RETRY_SECS: u64 = 60;

/// One lease-maintenance operation against the world, bounded by the caller.
///
/// The production implementation is [`super::WireTransport`]; the tests'
/// implementation is a script. Both are *driven* by [`Machine::poll`] — the
/// machine decides when and which, the transport only does.
pub trait Transport {
    /// Send one renewal REQUEST — unicast to `lease.server` when `broadcast`
    /// is false (RENEWING), limited broadcast when true (REBINDING) — and
    /// wait one bounded timeout for the answer.
    fn request(&mut self, lease: &Lease, broadcast: bool) -> Outcome;
    /// Run the full DISCOVER→OFFER→REQUEST→ACK handshake from scratch.
    fn rediscover(&mut self) -> Result<Lease>;
}

/// What one [`Transport::request`] leg came back with.
#[derive(Debug)]
pub enum Outcome {
    /// An ACK that amounted to a lease.
    Ack(Lease),
    /// The server said no; the lease is dead where it stands.
    Nak(Option<String>),
    /// Nobody answered inside the leg's timeout (or the answer was unusable).
    NoReply,
}

/// Which conversation produced a refreshed lease — for the log line only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Via {
    /// Unicast REQUEST to the lease's server, before T2.
    Renew,
    /// Broadcast REQUEST, between T2 and expiry.
    Rebind,
    /// A full DISCOVER→ACK from scratch, after a NAK or expiry.
    Rediscover,
}

impl Via {
    /// The RFC's name for the state that got the ACK.
    pub fn as_str(self) -> &'static str {
        match self {
            Via::Renew => "RENEWING",
            Via::Rebind => "REBINDING",
            Via::Rediscover => "REDISCOVERING",
        }
    }
}

/// Something the driver must tell the operator about (and, for a changed
/// plan, apply). Pure data: the machine never logs and never touches netlink.
#[derive(Debug)]
pub enum Event {
    /// An ACK refreshed the lease. `changed` is `Some(plan)` **iff** the
    /// address, prefix or gateway moved — the driver applies it through the
    /// one netlink writer and logs it loudly; `None` (the normal case) means
    /// nothing is re-applied and netlink is never touched.
    Renewed {
        /// Which state's REQUEST the ACK answered.
        via: Via,
        /// The new plan, only when it differs from what is on the interface.
        changed: Option<NetPlan>,
        /// When the next T1 fires, machine-clock seconds.
        renew_at: u64,
        /// When this lease runs out, machine-clock seconds.
        expire_at: u64,
    },
    /// The server refused the renewal; re-DISCOVER starts immediately.
    Nak {
        /// Option 56, the server's stated reason, if it gave one.
        message: Option<String>,
    },
    /// The lease ran out with no replacement. The address is now *squatted*
    /// (kept against the RFC's word — the module doc owns that decision).
    ExpiredSquatting {
        /// When it expired, machine-clock seconds.
        expired_at: u64,
    },
    /// A full re-DISCOVER attempt failed. With `squatting` true this fires
    /// once per retry cycle, forever, and the driver WARNs every time.
    RediscoverFailed {
        /// The handshake's error, rendered.
        error: String,
        /// True once the old lease has expired under us.
        squatting: bool,
    },
}

/// What one [`Machine::poll`] resolved to.
#[derive(Debug)]
pub struct Turn {
    /// The machine-clock second at which to call `poll` again. May be `<=`
    /// now, which means "again immediately" (e.g. right after a NAK).
    pub wake_at: u64,
    /// What happened, in order. Usually empty (a timer that has not fired).
    pub events: Vec<Event>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
    Bound,
    Renewing,
    Rebinding,
    Rediscovering { expired: bool },
}

/// The timers and transitions of §4.4.5, over a caller-owned clock.
///
/// "Machine-clock seconds" throughout are seconds on whatever monotonic clock
/// the caller measures `now` on; the machine only ever compares and adds them.
#[derive(Debug)]
pub struct Machine {
    interface: String,
    lease: Lease,
    /// What is actually on the interface, for change detection:
    /// (address, prefix, gateway).
    applied: (Ipv4Addr, u8, Option<Ipv4Addr>),
    t1_at: u64,
    t2_at: u64,
    expiry_at: u64,
    next_attempt_at: u64,
    state: State,
    /// TEST SEAM: a forced T1 (seconds after each ACK), see
    /// `crate::net::dhcp_t1_override`. `None` in every shipped configuration.
    t1_override: Option<u32>,
}

impl Machine {
    /// A machine for a lease granted at machine-clock second `bound_at`.
    pub fn new(
        interface: String,
        lease: Lease,
        bound_at: u64,
        t1_override: Option<u32>,
    ) -> Machine {
        let applied = (lease.address, lease.prefix, lease.gateway);
        let (t1_at, t2_at, expiry_at) = timers(&lease, bound_at, t1_override);
        Machine {
            interface,
            lease,
            applied,
            t1_at,
            t2_at,
            expiry_at,
            next_attempt_at: t1_at,
            state: State::Bound,
            t1_override,
        }
    }

    /// When the next unicast renewal is due, machine-clock seconds.
    pub fn renew_at(&self) -> u64 {
        self.t1_at
    }

    /// When rebinding (broadcast) would start, machine-clock seconds.
    pub fn rebind_at(&self) -> u64 {
        self.t2_at
    }

    /// When the current lease runs out, machine-clock seconds.
    pub fn expire_at(&self) -> u64 {
        self.expiry_at
    }

    /// **The lease as it stands right now** — the one the last ACK granted, or
    /// the boot lease if none has.
    ///
    /// Exists for [`Lease::dns`], which is not part of what "changed" means to
    /// this machine. `Event::Renewed { changed }` is about the *netlink* plan
    /// (address, prefix, gateway) and stays that way: the resolver is a file,
    /// not a route, and giving `changed` a second meaning would make the one
    /// arm that applies a `NetPlan` fire for a reason that has no NetPlan in
    /// it. The driver reads the resolvers off here instead and hands them to
    /// `crate::resolver`, which decides on its own whether anything moved (by
    /// reading the file, not by trusting a flag).
    pub fn lease(&self) -> &Lease {
        &self.lease
    }

    /// Advance to `now`: fire whatever is due, performing **at most one**
    /// transport operation, and say when to come back.
    ///
    /// One operation per poll is a correctness property, not tidiness: a
    /// transport leg blocks (bounded) real time, so `now` is stale the moment
    /// one returns, and a second operation would be scheduled against a clock
    /// reading that is already wrong. The driver re-reads its clock between
    /// polls; the tests advance their integer.
    pub fn poll(&mut self, now: u64, transport: &mut impl Transport) -> Turn {
        let mut events = Vec::new();
        loop {
            match self.state {
                State::Bound => {
                    if now < self.t1_at {
                        return Turn {
                            wake_at: self.t1_at,
                            events,
                        };
                    }
                    self.state = State::Renewing;
                    self.next_attempt_at = now;
                }
                State::Renewing | State::Rebinding => {
                    if now >= self.expiry_at {
                        events.push(Event::ExpiredSquatting {
                            expired_at: self.expiry_at,
                        });
                        self.state = State::Rediscovering { expired: true };
                        self.next_attempt_at = now;
                        continue;
                    }
                    if self.state == State::Renewing && now >= self.t2_at {
                        self.state = State::Rebinding;
                        self.next_attempt_at = now;
                        continue;
                    }
                    let boundary = if self.state == State::Renewing {
                        self.t2_at
                    } else {
                        self.expiry_at
                    };
                    if now < self.next_attempt_at {
                        return Turn {
                            wake_at: self.next_attempt_at.min(boundary),
                            events,
                        };
                    }
                    let broadcast = self.state == State::Rebinding;
                    match transport.request(&self.lease, broadcast) {
                        Outcome::Ack(granted) => {
                            let via = if broadcast { Via::Rebind } else { Via::Renew };
                            events.push(self.refresh(now, granted, via));
                            return Turn {
                                wake_at: self.t1_at,
                                events,
                            };
                        }
                        Outcome::Nak(message) => {
                            events.push(Event::Nak { message });
                            self.state = State::Rediscovering { expired: false };
                            self.next_attempt_at = now;
                            return Turn {
                                wake_at: now,
                                events,
                            };
                        }
                        Outcome::NoReply => {
                            // §4.4.5: half the remaining time to the next
                            // boundary, floored at 60 s. The boundary itself
                            // wins the min so a floor longer than the gap
                            // cannot skip a state.
                            let half = boundary.saturating_sub(now) / 2;
                            self.next_attempt_at = now + half.max(RETRY_FLOOR_SECS);
                            return Turn {
                                wake_at: self.next_attempt_at.min(boundary),
                                events,
                            };
                        }
                    }
                }
                State::Rediscovering { expired } => {
                    if now < self.next_attempt_at {
                        return Turn {
                            wake_at: self.next_attempt_at,
                            events,
                        };
                    }
                    match transport.rediscover() {
                        Ok(granted) => {
                            events.push(self.refresh(now, granted, Via::Rediscover));
                            return Turn {
                                wake_at: self.t1_at,
                                events,
                            };
                        }
                        Err(err) => {
                            events.push(Event::RediscoverFailed {
                                error: format!("{err:#}"),
                                squatting: expired,
                            });
                            self.next_attempt_at = now + REDISCOVER_RETRY_SECS;
                            return Turn {
                                wake_at: self.next_attempt_at,
                                events,
                            };
                        }
                    }
                }
            }
        }
    }

    /// An ACK became a lease: restart the timers from `now` and say whether
    /// the plan on the interface has to change.
    fn refresh(&mut self, now: u64, granted: Lease, via: Via) -> Event {
        let (t1_at, t2_at, expiry_at) = timers(&granted, now, self.t1_override);
        self.t1_at = t1_at;
        self.t2_at = t2_at;
        self.expiry_at = expiry_at;
        self.next_attempt_at = t1_at;
        self.state = State::Bound;
        let granted_plan = (granted.address, granted.prefix, granted.gateway);
        let changed = (granted_plan != self.applied).then(|| NetPlan {
            interface: self.interface.clone(),
            address: granted.address,
            prefix: granted.prefix,
            gateway: granted.gateway,
        });
        self.applied = granted_plan;
        self.lease = granted;
        Event::Renewed {
            via,
            changed,
            renew_at: self.t1_at,
            expire_at: self.expiry_at,
        }
    }
}

/// (T1, T2, expiry) as absolute machine-clock seconds, from a lease granted
/// at `bound_at`.
///
/// Server-sent T1/T2 (options 58/59) win; the RFC defaults (0.5 · lease,
/// 0.875 · lease) fill in. Clamped to T1 ≤ T2 ≤ lease so a server that emits
/// nonsense cannot order the states backwards.
fn timers(lease: &Lease, bound_at: u64, t1_override: Option<u32>) -> (u64, u64, u64) {
    let lease_secs = u64::from(lease.lease_secs);
    let t2 = match lease.t2_secs {
        Some(secs) => u64::from(secs).min(lease_secs),
        None => lease_secs * 7 / 8,
    };
    let t1 = match t1_override {
        Some(secs) => u64::from(secs),
        None => match lease.t1_secs {
            Some(secs) => u64::from(secs),
            None => lease_secs / 2,
        },
    }
    .min(t2);
    (bound_at + t1, bound_at + t2, bound_at + lease_secs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyhow::anyhow;
    use std::collections::VecDeque;

    const SERVER: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 2);

    /// A one-hour lease with no server T1/T2: defaults put T1 at 1800,
    /// T2 at 3150, expiry at 3600.
    fn lease() -> Lease {
        Lease {
            address: Ipv4Addr::new(10, 0, 2, 15),
            prefix: 24,
            gateway: Some(SERVER),
            server: SERVER,
            lease_secs: 3600,
            t1_secs: None,
            t2_secs: None,
            host_name: None,
            dns: vec![Ipv4Addr::new(10, 0, 2, 3)],
            routes: Vec::new(),
        }
    }

    fn machine() -> Machine {
        Machine::new("eth0".into(), lease(), 0, None)
    }

    /// The scripted transport: `poll` drives it, it records what it was told
    /// to do, and answers from a queue. `NoReply` / `Err` when the script runs
    /// dry, which is also the shape of a dead network.
    #[derive(Default)]
    struct Script {
        outcomes: VecDeque<Outcome>,
        rediscoveries: VecDeque<Result<Lease>>,
        /// (server unicast would go to, broadcast?) per REQUEST leg.
        requests: Vec<(Ipv4Addr, bool)>,
        rediscover_calls: usize,
    }

    impl Transport for Script {
        fn request(&mut self, lease: &Lease, broadcast: bool) -> Outcome {
            self.requests.push((lease.server, broadcast));
            self.outcomes.pop_front().unwrap_or(Outcome::NoReply)
        }
        fn rediscover(&mut self) -> Result<Lease> {
            self.rediscover_calls += 1;
            self.rediscoveries
                .pop_front()
                .unwrap_or_else(|| Err(anyhow!("no DHCP server answered")))
        }
    }

    // ── BOUND → RENEWING ────────────────────────────────────────────────────

    #[test]
    fn bound_sleeps_to_t1_then_unicasts_a_request_to_the_recorded_server() {
        let mut m = machine();
        let mut t = Script::default();

        // Before T1 nothing may touch the network — a renewal loop that
        // chatters early is a renewal loop that chatters always.
        let turn = m.poll(0, &mut t);
        assert_eq!(turn.wake_at, 1800, "RFC default T1 is 0.5 · lease");
        assert!(turn.events.is_empty());
        assert!(t.requests.is_empty(), "no REQUEST before T1");
        assert_eq!(m.poll(1799, &mut t).wake_at, 1800);
        assert!(t.requests.is_empty(), "still no REQUEST one second early");

        t.outcomes.push_back(Outcome::Ack(lease()));
        m.poll(1800, &mut t);
        assert_eq!(
            t.requests,
            vec![(SERVER, false)],
            "T1 must produce exactly one unicast REQUEST to the lease's server"
        );
    }

    #[test]
    fn an_ack_refreshes_the_timers_and_t1_fires_again_a_lease_later() {
        let mut m = machine();
        let mut t = Script::default();
        t.outcomes.push_back(Outcome::Ack(lease()));
        let turn = m.poll(1800, &mut t);
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::Renewed {
                    via: Via::Renew,
                    changed: None,
                    ..
                }]
            ),
            "an unchanged lease must renew without a plan: {:?}",
            turn.events
        );
        assert_eq!(
            turn.wake_at, 3600,
            "the ACK at 1800 must reschedule T1 a full half-lease later"
        );

        // And the rescheduled T1 must actually fire: this is the assertion
        // the sabotage (refresh not updating t1_at) turns red.
        t.outcomes.push_back(Outcome::Ack(lease()));
        m.poll(3600, &mut t);
        assert_eq!(
            t.requests,
            vec![(SERVER, false), (SERVER, false)],
            "the refreshed T1 must produce the second unicast REQUEST"
        );
    }

    #[test]
    fn server_named_t1_t2_win_over_the_rfc_defaults() {
        let with_options = Lease {
            t1_secs: Some(600),
            t2_secs: Some(1200),
            ..lease()
        };
        let m = Machine::new("eth0".into(), with_options, 100, None);
        assert_eq!(m.renew_at(), 700, "option 58 wins over 0.5 · lease");
        assert_eq!(m.rebind_at(), 1300, "option 59 wins over 0.875 · lease");
        assert_eq!(m.expire_at(), 3700);

        let defaults = machine();
        assert_eq!(defaults.renew_at(), 1800);
        assert_eq!(defaults.rebind_at(), 3150, "0.875 · 3600");
        assert_eq!(defaults.expire_at(), 3600);
    }

    // ── RENEWING → REBINDING ────────────────────────────────────────────────

    #[test]
    fn silence_at_t1_backs_off_then_broadcasts_after_t2() {
        let mut m = machine();
        let mut t = Script::default();

        let turn = m.poll(1800, &mut t); // NoReply (script is empty)
        assert_eq!(t.requests, vec![(SERVER, false)]);
        assert_eq!(
            turn.wake_at,
            1800 + (3150 - 1800) / 2,
            "the retry backs off half the remaining time to T2"
        );

        // Every retry until T2 stays unicast…
        let turn = m.poll(turn.wake_at, &mut t);
        assert!(t.requests.iter().all(|&(_, broadcast)| !broadcast));
        assert!(turn.wake_at <= 3150, "the backoff must never sleep past T2");

        // …and the first poll at T2 goes broadcast.
        m.poll(3150, &mut t);
        let last = t.requests.last().expect("T2 must send a REQUEST");
        assert!(last.1, "past T2 the REQUEST must be broadcast (REBINDING)");
    }

    #[test]
    fn a_rebind_ack_recovers_to_bound() {
        let mut m = machine();
        let mut t = Script::default();
        m.poll(1800, &mut t); // T1, no reply
        t.outcomes.push_back(Outcome::Ack(lease()));
        let turn = m.poll(3150, &mut t); // T2, broadcast, ACK
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::Renewed {
                    via: Via::Rebind,
                    changed: None,
                    ..
                }]
            ),
            "{:?}",
            turn.events
        );
        assert_eq!(
            turn.wake_at,
            3150 + 1800,
            "BOUND again, next T1 rescheduled"
        );
    }

    // ── NAK ─────────────────────────────────────────────────────────────────

    #[test]
    fn a_nak_cuts_straight_to_rediscover() {
        let mut m = machine();
        let mut t = Script::default();
        t.outcomes
            .push_back(Outcome::Nak(Some("lease pool moved".into())));
        let turn = m.poll(1800, &mut t);
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::Nak { message: Some(m) }] if m == "lease pool moved"
            ),
            "{:?}",
            turn.events
        );
        assert_eq!(turn.wake_at, 1800, "a NAK must not wait for expiry");
        assert_eq!(t.rediscover_calls, 0, "one transport op per poll");

        let fresh = Lease {
            address: Ipv4Addr::new(10, 0, 2, 40),
            ..lease()
        };
        t.rediscoveries.push_back(Ok(fresh));
        let turn = m.poll(1800, &mut t);
        assert_eq!(t.rediscover_calls, 1, "the immediate re-poll re-DISCOVERs");
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::Renewed {
                    via: Via::Rediscover,
                    changed: Some(_),
                    ..
                }]
            ),
            "{:?}",
            turn.events
        );
    }

    // ── expiry: the deliberate deviation ────────────────────────────────────

    #[test]
    fn expiry_on_a_dead_network_squats_loudly_and_keeps_asking() {
        let mut m = machine();
        let mut t = Script::default(); // nothing ever answers

        let turn = m.poll(3600, &mut t); // straight to expiry
        assert!(
            matches!(
                turn.events.as_slice(),
                [
                    Event::ExpiredSquatting { expired_at: 3600 },
                    Event::RediscoverFailed {
                        squatting: true,
                        ..
                    }
                ]
            ),
            "expiry must announce the squat AND the failed re-DISCOVER: {:?}",
            turn.events
        );
        assert_eq!(t.rediscover_calls, 1);
        assert_eq!(turn.wake_at, 3600 + REDISCOVER_RETRY_SECS);

        // Every later cycle warns again and asks again — forever, not once.
        let turn = m.poll(turn.wake_at, &mut t);
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::RediscoverFailed {
                    squatting: true,
                    ..
                }]
            ),
            "{:?}",
            turn.events
        );
        assert_eq!(t.rediscover_calls, 2);

        // And when a server finally answers, the squat ends in a normal BOUND.
        t.rediscoveries.push_back(Ok(lease()));
        let turn = m.poll(turn.wake_at, &mut t);
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::Renewed {
                    via: Via::Rediscover,
                    changed: None,
                    ..
                }]
            ),
            "{:?}",
            turn.events
        );
    }

    // ── a lease that moves the address ──────────────────────────────────────

    #[test]
    fn a_changed_lease_emits_the_new_plan_exactly_once() {
        let moved = Lease {
            address: Ipv4Addr::new(192, 168, 7, 20),
            prefix: 16,
            gateway: Some(Ipv4Addr::new(192, 168, 0, 1)),
            ..lease()
        };
        let mut m = machine();
        let mut t = Script::default();

        t.outcomes.push_back(Outcome::Ack(moved.clone()));
        let turn = m.poll(1800, &mut t);
        let [Event::Renewed {
            changed: Some(plan),
            ..
        }] = turn.events.as_slice()
        else {
            panic!("a moved address must emit a plan: {:?}", turn.events);
        };
        assert_eq!(
            *plan,
            NetPlan {
                interface: "eth0".into(),
                address: Ipv4Addr::new(192, 168, 7, 20),
                prefix: 16,
                gateway: Some(Ipv4Addr::new(192, 168, 0, 1)),
            },
            "the plan must be the granted lease, on the machine's interface"
        );

        // The next ACK for the SAME address must not re-emit it: re-applying
        // an unchanged plan every T1 is exactly the netlink churn this field
        // exists to prevent.
        t.outcomes.push_back(Outcome::Ack(moved));
        let turn = m.poll(turn.wake_at, &mut t);
        assert!(
            matches!(
                turn.events.as_slice(),
                [Event::Renewed { changed: None, .. }]
            ),
            "an unchanged renewal re-emitted a plan: {:?}",
            turn.events
        );
    }

    // ── the test seam ───────────────────────────────────────────────────────

    #[test]
    fn the_t1_override_shrinks_only_the_renewal_timer() {
        let mut m = Machine::new("eth0".into(), lease(), 0, Some(20));
        assert_eq!(m.renew_at(), 20, "the override replaces T1");
        assert_eq!(m.rebind_at(), 3150, "…and touches nothing else");
        assert_eq!(m.expire_at(), 3600);

        // It survives a refresh: the seam exists so a live box renews every
        // few seconds, not just once.
        let mut t = Script::default();
        t.outcomes.push_back(Outcome::Ack(lease()));
        let turn = m.poll(20, &mut t);
        assert_eq!(t.requests, vec![(SERVER, false)]);
        assert_eq!(turn.wake_at, 40, "the refreshed T1 is override-sized again");
    }
}