Skip to main content

ts_control/tokio/
map_stream.rs

1use alloc::collections::BTreeMap;
2
3use bytes::Bytes;
4use futures_util::Stream;
5use tokio::io::{AsyncRead, AsyncReadExt};
6use ts_control_serde::{MapRequest, MapResponse, PingRequest};
7use ts_http_util::{BytesBody, ClientExt, Http2, ResponseExt};
8use ts_packet::PacketMut;
9use ts_packetfilter as pf;
10use ts_packetfilter_state as pf_state;
11use url::Url;
12
13use crate::{DialPlan, NodeId};
14
15#[derive(Debug, thiserror::Error, Clone, Copy, Eq, PartialEq)]
16pub enum MapStreamError {
17    #[error("serialization error")]
18    SerDe,
19    #[error("unsuccessful HTTP request or upgrade")]
20    Http,
21    #[error("Network error")]
22    NetworkError,
23}
24
25impl From<serde_json::Error> for MapStreamError {
26    fn from(error: serde_json::Error) -> Self {
27        tracing::error!(%error, "serialization error sending map request");
28        MapStreamError::SerDe
29    }
30}
31
32impl From<ts_http_util::Error> for MapStreamError {
33    fn from(error: ts_http_util::Error) -> Self {
34        tracing::error!(%error, "http error sending map request");
35
36        if crate::http_error_is_recoverable(error) {
37            MapStreamError::NetworkError
38        } else {
39            MapStreamError::Http
40        }
41    }
42}
43
44impl From<MapStreamError> for crate::Error {
45    fn from(e: MapStreamError) -> Self {
46        match e {
47            MapStreamError::SerDe => crate::Error::Internal(
48                crate::InternalErrorKind::SerDe,
49                crate::Operation::MapRequest,
50            ),
51            MapStreamError::Http => {
52                crate::Error::Internal(crate::InternalErrorKind::Http, crate::Operation::MapRequest)
53            }
54            MapStreamError::NetworkError => {
55                crate::Error::NetworkError(crate::Operation::MapRequest)
56            }
57        }
58    }
59}
60
61/// An update to the peers recorded in the netmap.
62#[derive(Debug)]
63pub enum PeerUpdate {
64    /// Complete peer state.
65    Full(Vec<crate::Node>),
66
67    /// Delta update to the peer state.
68    Delta {
69        /// Peers added to or changed in the state.
70        upsert: Vec<crate::Node>,
71        /// Peer [`NodeId`]s removed from the state.
72        remove: Vec<NodeId>,
73    },
74}
75
76/// The components of a packet filter update.
77///
78/// These can't be merged into a single map due to the update rules.
79pub type FilterUpdate = (Option<pf::Ruleset>, BTreeMap<String, Option<pf::Ruleset>>);
80
81/// An update to the netmap state produced from a mapresponse.
82#[derive(Debug)]
83pub struct StateUpdate {
84    /// The opaque map-session handle, set only when control assigns one (the first
85    /// [`MapResponse`] of a session). Carried so a reconnect can request stream resumption via
86    /// `MapRequestBuilder::map_session`. `None` on
87    /// responses that don't (re)establish a session.
88    pub session_handle: Option<alloc::string::String>,
89    /// The sequence number of this [`MapResponse`] within its session, or `0` when control omits
90    /// it (e.g. keep-alives). The last non-zero value is what a reconnect resumes after.
91    pub seq: i64,
92    /// New derp map is available.
93    pub derp: Option<crate::DerpMap>,
94    /// New self-node.
95    pub node: Option<crate::Node>,
96    /// Updates to the set of peers in the netmap (a full re-sync or a whole-node delta).
97    pub peer_update: Option<PeerUpdate>,
98    /// Field-level patches to peers already in the netmap (`MapResponse.PeersChangedPatch`). Each
99    /// [`PeerChange`][crate::PeerChange] sets only the fields it carries on the matching node,
100    /// leaving the rest untouched; a patch whose node id is unknown to the current netmap is
101    /// ignored (the wire contract — a patch never creates a node). Control uses these for
102    /// mid-session reachability changes — chiefly a peer's UDP endpoints / home DERP when it
103    /// re-establishes connectivity — so they MUST be applied or the netmap keeps stale endpoints and
104    /// the peer can't re-handshake. A **separate channel** from [`peer_update`](Self::peer_update):
105    /// Go's `controlclient` applies the `Peers*` set first and then `PeersChangedPatch`, so when a
106    /// response carries both they are *both* applied in that order (the consumer applies
107    /// `peer_update` then `peer_patches`). Empty when this response carried no patches.
108    pub peer_patches: Vec<crate::PeerChange>,
109    /// User profiles (`MapResponse.UserProfiles`) carried by this response: the owner identity for
110    /// nodes, keyed by user id. Control sends these incrementally — only new or changed profiles
111    /// per response — so the consumer (the runtime's peer tracker) must **accumulate** them across
112    /// updates, not replace. Empty when this response carried none.
113    pub user_profiles: Vec<crate::UserProfile>,
114    /// Send a ping request.
115    pub ping: Option<PingRequest>,
116    /// Update to the packet filter.
117    pub packetfilter: Option<FilterUpdate>,
118    /// The peer-capability grants retained from this response's packet-filter application rules
119    /// (Go `tailcfg.FilterRule` cap-grants), which the network-rule compile in `packetfilter` drops.
120    /// `Some` exactly when `packetfilter` is `Some` (the same source rules); the consumer keeps
121    /// these for flow-scoped WhoIs (`apitype.WhoIsResponse.CapMap`). Empty `Vec` when the response's
122    /// rules carried no application/cap-grant rule.
123    pub cap_grants: Option<Vec<ts_packetfilter_state::CapGrant>>,
124    /// This URL should be displayed to the user or opened in their browser automatically.
125    pub pop_browser_url: Option<Url>,
126    /// New dial plan sent by control.
127    pub dial_plan: Option<DialPlan>,
128    /// New DNS configuration for the MagicDNS responder. `None` means no change.
129    pub dns_config: Option<crate::DnsConfig>,
130    /// New Tailscale SSH policy pushed by control. `None` means no change in this response;
131    /// `Some` replaces the active policy (an empty rule set means "deny all", fail-closed).
132    pub ssh_policy: Option<crate::SshPolicy>,
133    /// New Tailnet Lock (TKA) status from control (`MapResponse.TKAInfo`). `None` means no change in
134    /// this response; `Some` carries the current authority head + disablement signal.
135    pub tka: Option<crate::TkaStatus>,
136    /// Per-peer online-status flips (`MapResponse.OnlineChange`), keyed by control node [`NodeId`](crate::NodeId).
137    /// The dominant standalone channel for online transitions (control sends these far more often
138    /// than a full [`PeerChange`](crate::PeerChange)). Each entry *sets* `Node::online` to the given
139    /// value; empty when this response carried none.
140    pub online_change: alloc::collections::BTreeMap<crate::NodeId, bool>,
141    /// Per-peer last-seen flips (`MapResponse.PeerSeenChange`), keyed by control node [`NodeId`](crate::NodeId).
142    /// `true` ⇒ the peer was just seen (update last-seen to now); `false` ⇒ the peer is gone
143    /// (mark offline). Empty when this response carried none.
144    pub peer_seen_change: alloc::collections::BTreeMap<crate::NodeId, bool>,
145}
146
147/// Upper bound on a single netmap frame, checked before allocating the read buffer.
148///
149/// The frame length is a `u32` read straight off the (authenticated, ts2021-Noise) control stream;
150/// without a cap, `PacketMut::new(msg_len)` eagerly zero-allocates up to ~4 GiB, so a malformed or
151/// hostile control frame OOMs the client. Every other framed path in the fork bounds before
152/// allocating (DERP 64 KiB, TKA-sync 10 MiB, control-noise `MAX_MESSAGE_SIZE`); this matches that
153/// discipline. 16 MiB is comfortably above any real netmap (Go bounds this similarly) and `compress`
154/// is sent empty, so the on-wire length equals the buffer size with no decompression amplification.
155const MAX_NETMAP_FRAME: u32 = 16 * 1024 * 1024;
156
157/// Long-poll read watchdog: if no frame (not even a keep-alive) arrives within this window, end the
158/// stream so the caller reconnects. Control sends a keep-alive roughly every minute on a streaming
159/// map poll, so silence past this bound means the connection is dead-but-not-closed (a half-open
160/// socket after NAT/firewall state eviction, a silently-dropping middlebox, or a control server
161/// that hung without sending FIN/RST). Without it, `read_u32_le`/`read_exact` await forever and the
162/// node silently stops receiving netmap updates (missed peer/DERP/key-expiry/ACL/TKA changes) with
163/// no reconnect ever attempted. Mirrors Go `controlclient` `direct.go`'s `watchdogTimeout = 120s`.
164const MAP_READ_WATCHDOG: core::time::Duration = core::time::Duration::from_secs(120);
165
166pub fn map_stream(reader: impl AsyncRead + Unpin) -> impl Stream<Item = StateUpdate> {
167    futures_util::stream::unfold(reader, async |mut reader| {
168        // Watchdog the length read: this is where the stream idles between frames, so a silently
169        // dead long poll blocks here. A timeout ends the stream (returns `None`) → reconnect.
170        let msg_len = match tokio::time::timeout(MAP_READ_WATCHDOG, reader.read_u32_le()).await {
171            Ok(res) => res
172                .inspect_err(|e| {
173                    tracing::error!(error = %e, "could not read netmap length");
174                })
175                .ok()?,
176            Err(_elapsed) => {
177                tracing::error!(
178                    watchdog_secs = MAP_READ_WATCHDOG.as_secs(),
179                    "no netmap frame within the keep-alive watchdog; ending stream to reconnect"
180                );
181                return None;
182            }
183        };
184
185        // Bound the frame before allocating: a `u32` length of `0xFFFF_FFFF` would otherwise force a
186        // ~4 GiB zeroed allocation (OOM). End the stream on an over-large frame rather than abort.
187        if msg_len > MAX_NETMAP_FRAME {
188            tracing::error!(
189                ?msg_len,
190                max = MAX_NETMAP_FRAME,
191                "netmap frame too large; ending stream"
192            );
193            return None;
194        }
195
196        let mut buf = PacketMut::new(msg_len as usize);
197        tracing::trace!(?msg_len, "reading netmap");
198
199        // Watchdog the body read too: once the length is in, the body should follow promptly. A
200        // stall here (announced length, body never delivered) is the same dead-connection signal.
201        match tokio::time::timeout(MAP_READ_WATCHDOG, reader.read_exact(buf.as_mut())).await {
202            Ok(res) => res
203                .inspect_err(|e| {
204                    tracing::error!(error = %e, "could not read netmap");
205                })
206                .ok()?,
207            Err(_elapsed) => {
208                tracing::error!(
209                    watchdog_secs = MAP_READ_WATCHDOG.as_secs(),
210                    "netmap body did not arrive within the watchdog; ending stream to reconnect"
211                );
212                return None;
213            }
214        };
215
216        let map_response: MapResponse = serde_json::from_slice(buf.as_ref())
217            .inspect_err(|e| {
218                tracing::error!(error = %e, "deserializing netmap");
219            })
220            .ok()?;
221
222        tracing::trace!(?msg_len, ?map_response);
223
224        let packetfilter = packet_filter(&map_response);
225        let cap_grants = cap_grants(&map_response);
226
227        fn nonempty<T>(x: &Option<Vec<T>>) -> bool {
228            x.as_ref().is_some_and(|x| !x.is_empty())
229        }
230
231        // `peers_changed_patch` carries field-level patches to already-known peers. Go's
232        // `controlclient` applies the whole-node `Peers*` set first and then `PeersChangedPatch`, so
233        // patches are a SEPARATE always-populated channel (`peer_patches`) rather than a third
234        // `peer_update` variant: when a response carries both a full/delta AND patches, the consumer
235        // applies the peer set then the patches, in that order. (Previously patches shared the single
236        // `peer_update` slot and a co-occurring full/delta took precedence, silently dropping them.)
237        let peer_patches: Vec<crate::PeerChange> = map_response
238            .peers_changed_patch
239            .iter()
240            .flatten()
241            .map(crate::PeerChange::from)
242            .collect();
243
244        let peer_update = if let Some(full_map) = map_response.peers {
245            Some(PeerUpdate::Full(full_map.iter().map(Into::into).collect()))
246        } else if nonempty(&map_response.peers_removed) || nonempty(&map_response.peers_changed) {
247            Some(PeerUpdate::Delta {
248                remove: map_response.peers_removed.unwrap_or_default(),
249                upsert: map_response
250                    .peers_changed
251                    .unwrap_or_default()
252                    .iter()
253                    .map(Into::into)
254                    .collect(),
255            })
256        } else {
257            None
258        };
259
260        Some((
261            StateUpdate {
262                session_handle: (!map_response.map_session_handle.is_empty())
263                    .then(|| map_response.map_session_handle.to_owned()),
264                seq: map_response.seq,
265                peer_update,
266                peer_patches,
267                user_profiles: map_response
268                    .user_profiles
269                    .iter()
270                    .map(crate::UserProfile::from)
271                    .collect(),
272                node: map_response.node.as_ref().map(Into::into),
273                derp: map_response
274                    .derp_map
275                    .as_ref()
276                    .map(|x| crate::convert_derp_map(x).collect()),
277                ping: map_response.ping_request,
278                packetfilter,
279                cap_grants,
280                pop_browser_url: map_response.pop_browser_url.and_then(|u| {
281                    u.parse()
282                        .inspect_err(|e| tracing::error!(error = %e, "invalid pop browser url"))
283                        .ok()
284                }),
285                dial_plan: map_response.control_dial_plan.map(Into::into),
286                dns_config: map_response
287                    .dns_config
288                    .as_ref()
289                    .map(crate::DnsConfig::from_serde),
290                ssh_policy: map_response
291                    .ssh_policy
292                    .as_ref()
293                    .map(crate::SshPolicy::from_serde),
294                tka: map_response
295                    .tka_info
296                    .as_ref()
297                    .map(crate::TkaStatus::from_serde),
298                // Online/last-seen delta maps (channels C/D). `NodeId` is the control node id
299                // (`Id`), so these copy across directly. The peer tracker applies them on every
300                // update — including responses that carry NO peer_update — so a standalone online
301                // flip (the common case) isn't lost. (Control sends these on their own, never
302                // alongside a `peers*` set for the same node, so apply-order vs the peer set is moot.)
303                online_change: map_response.online_change.clone(),
304                peer_seen_change: map_response.peer_seen_change.clone(),
305            },
306            reader,
307        ))
308    })
309}
310
311fn packet_filter(map_response: &MapResponse<'_>) -> Option<FilterUpdate> {
312    if map_response.packet_filter.is_none() && map_response.packet_filters.is_empty() {
313        return None;
314    }
315
316    Some((
317        map_response
318            .packet_filter
319            .as_ref()
320            .map(|x| pf_state::rules_to_pf(x).collect()),
321        map_response
322            .packet_filters
323            .iter()
324            .map(|(rule_name, rules)| {
325                (
326                    rule_name.to_string(),
327                    rules
328                        .as_ref()
329                        .map(|x| Some(pf_state::rules_to_pf(x).collect()))
330                        .unwrap_or_default(),
331                )
332            })
333            .collect(),
334    ))
335}
336
337/// Retain the peer-capability grants from the same packet-filter rules [`packet_filter`] compiles —
338/// the application-rule cap-grants that the network-rule compile discards. Collected across the
339/// legacy `packet_filter` and every named `packet_filters` ruleset. `Some` exactly when
340/// [`packet_filter`] is `Some`; an empty `Vec` means the rules carried no cap-grant. Backs
341/// flow-scoped WhoIs.
342fn cap_grants(map_response: &MapResponse<'_>) -> Option<Vec<ts_packetfilter_state::CapGrant>> {
343    if map_response.packet_filter.is_none() && map_response.packet_filters.is_empty() {
344        return None;
345    }
346
347    let mut grants = Vec::new();
348    if let Some(rules) = map_response.packet_filter.as_ref() {
349        grants.extend(pf_state::retain_cap_grants(rules));
350    }
351    for rules in map_response.packet_filters.values().flatten() {
352        grants.extend(pf_state::retain_cap_grants(rules));
353    }
354    Some(grants)
355}
356
357#[tracing::instrument(skip_all, fields(map_url = %url.as_str()))]
358pub async fn send_map_request(
359    map_request: MapRequest<'_>,
360    url: &Url,
361    http2_conn: &Http2<BytesBody>,
362) -> Result<impl AsyncRead + 'static, MapStreamError> {
363    tracing::debug!("sending map request to control server...");
364
365    let body = if cfg!(debug_assertions) {
366        serde_json::to_string_pretty(&map_request)?
367    } else {
368        serde_json::to_string(&map_request)?
369    };
370    tracing::trace!(
371        %body,
372        "sending map request"
373    );
374
375    let resp = http2_conn.post(url, None, Bytes::from(body).into()).await?;
376
377    let status = resp.status();
378    tracing::trace!(?status, "received map response");
379
380    if !status.is_success() {
381        tracing::error!(
382            status = status.as_u16(),
383            "failed to register map updates with unsuccessful HTTP status code"
384        );
385        return Err(MapStreamError::Http);
386    }
387
388    Ok(resp.into_read())
389}
390
391#[cfg(test)]
392mod tests {
393    use alloc::vec::Vec;
394
395    use futures_util::StreamExt;
396
397    use super::*;
398
399    /// Frame each JSON body the way control does: a little-endian u32 length prefix followed by the
400    /// JSON bytes. Returns a single buffer the `map_stream` reader can consume.
401    fn frame(bodies: &[&str]) -> Vec<u8> {
402        let mut buf = Vec::new();
403        for body in bodies {
404            buf.extend_from_slice(&(body.len() as u32).to_le_bytes());
405            buf.extend_from_slice(body.as_bytes());
406        }
407        buf
408    }
409
410    /// An `AsyncRead` that serves `prefix` bytes and then stalls forever (always `Pending`),
411    /// modelling a half-open/silently-dead long-poll connection: bytes flowed, then the peer went
412    /// silent without closing. Used to prove the read watchdog ends the stream instead of hanging.
413    struct StallAfter {
414        prefix: alloc::collections::VecDeque<u8>,
415    }
416
417    impl StallAfter {
418        fn new(prefix: &[u8]) -> Self {
419            Self {
420                prefix: prefix.iter().copied().collect(),
421            }
422        }
423    }
424
425    impl tokio::io::AsyncRead for StallAfter {
426        fn poll_read(
427            mut self: core::pin::Pin<&mut Self>,
428            _cx: &mut core::task::Context<'_>,
429            buf: &mut tokio::io::ReadBuf<'_>,
430        ) -> core::task::Poll<std::io::Result<()>> {
431            if self.prefix.is_empty() {
432                // Drained: stall forever. With a paused test clock the watchdog `timeout` is the
433                // only timer left, so it advances and fires — exactly the dead-connection case.
434                return core::task::Poll::Pending;
435            }
436            while buf.remaining() > 0 {
437                let Some(b) = self.prefix.pop_front() else {
438                    break;
439                };
440                buf.put_slice(&[b]);
441            }
442            core::task::Poll::Ready(Ok(()))
443        }
444    }
445
446    /// A long poll that delivers a frame and then goes silent (no further bytes, no close) must end
447    /// the stream once the read watchdog elapses, so the caller reconnects. Without the watchdog
448    /// the second `next()` would await forever (the node would silently stop getting updates).
449    /// `start_paused` makes the 120s watchdog fire instantly (auto-advanced virtual time).
450    #[tokio::test(start_paused = true)]
451    async fn map_stream_watchdog_ends_stream_on_silent_connection() {
452        let reader = StallAfter::new(&frame(&[r#"{"MapSessionHandle":"sess-1","Seq":1}"#]));
453
454        let mut stream = core::pin::pin!(map_stream(reader));
455
456        // First frame arrives normally.
457        let update = stream.next().await.expect("first frame");
458        assert_eq!(update.seq, 1);
459
460        // The connection then goes silent: the watchdog must end the stream (None), not hang.
461        assert!(
462            stream.next().await.is_none(),
463            "watchdog must end the stream on a silent connection"
464        );
465    }
466
467    /// A connection that never delivers even the first frame must also be bounded by the watchdog
468    /// (the idle-from-the-start case — e.g. control accepted the request then went silent).
469    #[tokio::test(start_paused = true)]
470    async fn map_stream_watchdog_ends_stream_when_no_frame_ever_arrives() {
471        let reader = StallAfter::new(&[]);
472
473        let mut stream = core::pin::pin!(map_stream(reader));
474
475        assert!(
476            stream.next().await.is_none(),
477            "watchdog must end a stream that never produces a frame"
478        );
479    }
480
481    /// A frame whose length prefix arrives but whose body stalls mid-way must be bounded by the
482    /// body-read watchdog (announced length, body never completes — a torn connection).
483    #[tokio::test(start_paused = true)]
484    async fn map_stream_watchdog_ends_stream_on_partial_body() {
485        // 4-byte LE length says 64 bytes follow, but we supply only the prefix + 3 body bytes.
486        let mut bytes = 64u32.to_le_bytes().to_vec();
487        bytes.extend_from_slice(b"abc");
488        let reader = StallAfter::new(&bytes);
489
490        let mut stream = core::pin::pin!(map_stream(reader));
491
492        assert!(
493            stream.next().await.is_none(),
494            "watchdog must end the stream when the body never completes"
495        );
496    }
497
498    #[tokio::test]
499    async fn map_stream_carries_session_handle_and_seq() {
500        let buf = frame(&[r#"{"MapSessionHandle":"sess-xyz","Seq":12}"#]);
501
502        let mut stream = core::pin::pin!(map_stream(&buf[..]));
503        let update = stream.next().await.expect("one update");
504
505        assert_eq!(update.session_handle.as_deref(), Some("sess-xyz"));
506        assert_eq!(update.seq, 12);
507    }
508
509    #[tokio::test]
510    async fn map_stream_empty_handle_maps_to_none() {
511        // A keep-alive-style response with no session handle and seq 0 must surface as None/0 so
512        // the resume cursor is left untouched.
513        let buf = frame(&[r#"{"KeepAlive":true}"#]);
514
515        let mut stream = core::pin::pin!(map_stream(&buf[..]));
516        let update = stream.next().await.expect("one update");
517
518        assert_eq!(update.session_handle, None);
519        assert_eq!(update.seq, 0);
520    }
521
522    #[tokio::test]
523    async fn map_stream_surfaces_peers_changed_patch() {
524        // A response carrying only `PeersChangedPatch` (control's mid-session reachability update)
525        // must surface in `peer_patches`, not be dropped. Regression for the pre-fix code that
526        // logged + discarded these, wedging idle peers that moved (stale endpoints → no re-handshake).
527        let buf = frame(&[r#"{
528            "Seq": 7,
529            "PeersChangedPatch": [
530                { "NodeID": 42, "Endpoints": ["203.0.113.7:41641"], "DERPRegion": 5 }
531            ]
532        }"#]);
533
534        let mut stream = core::pin::pin!(map_stream(&buf[..]));
535        let update = stream.next().await.expect("one update");
536
537        // Patches ride their own channel; with no `Peers*` set there is no `peer_update`.
538        assert!(
539            update.peer_update.is_none(),
540            "no whole-node set in this response"
541        );
542        assert_eq!(update.peer_patches.len(), 1);
543        assert_eq!(update.peer_patches[0].id, 42);
544        assert_eq!(
545            update.peer_patches[0].underlay_addresses.as_deref(),
546            Some(&["203.0.113.7:41641".parse().unwrap()][..])
547        );
548        assert_eq!(
549            update.peer_patches[0].derp_region,
550            Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap()))
551        );
552    }
553
554    #[tokio::test]
555    async fn map_stream_carries_both_delta_and_patch_when_co_occurring() {
556        // Regression for `tsr-5u0`: when a full/delta resync and patches arrive in the SAME response,
557        // BOTH must be surfaced — the resync in `peer_update`, the patches in `peer_patches` — so the
558        // consumer can apply the peer set then the patches (Go's `controlclient` order). The pre-fix
559        // code kept only the resync in the single `peer_update` slot and silently dropped the patch.
560        let buf = frame(&[r#"{
561            "Seq": 8,
562            "PeersChanged": [
563                { "ID": 1, "StableID": "n1", "Name": "a.ts.net.", "User": 1,
564                  "Key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000" }
565            ],
566            "PeersChangedPatch": [ { "NodeID": 1, "DERPRegion": 9 } ]
567        }"#]);
568
569        let mut stream = core::pin::pin!(map_stream(&buf[..]));
570        let update = stream.next().await.expect("one update");
571
572        // The whole-node delta is present...
573        assert!(matches!(update.peer_update, Some(PeerUpdate::Delta { .. })));
574        // ...AND the patch is no longer dropped — it rides `peer_patches` alongside it.
575        assert_eq!(update.peer_patches.len(), 1, "patch must not be dropped");
576        assert_eq!(update.peer_patches[0].id, 1);
577        assert_eq!(
578            update.peer_patches[0].derp_region,
579            Some(ts_derp::RegionId(core::num::NonZeroU32::new(9).unwrap()))
580        );
581    }
582}