Intoduce tokio/async-based BFD implementation#795
Conversation
nicolaskagami
left a comment
There was a problem hiding this comment.
(doing a first pass for now focusing on the state machine)
Overall this looks great! This is exactly the place where the sans-io approach with proptesting is really strong. I see you made many of the same decisions I made when working on a similar project.
I agreed with all your TODO-correctness comments. It seems there are a few features missing from our current BFD implementation, so I wonder what our overall plan is for it.
I added a few suggestions which might be a little tiresome but I'd be more than happy to tackle them.
| for mut config in configs { | ||
| // Backwards-compatibility protection: We now reject a detection | ||
| // threshold of 0 (as required by RFC 5880), but it's possible we | ||
| // previously persisted a config with such a threshold before adding the | ||
| // guards that reject them. Bump it up to 1 to ensure we don't panic in | ||
| // the `unwrap_or_else()` below. | ||
| if config.detection_threshold == 0 { | ||
| config.detection_threshold = 1; | ||
| } |
There was a problem hiding this comment.
Ideally we would enforce the NonZero detection_threshold in this input type itself so it's unrepresentable (as per "parse, don't validate").
There was a problem hiding this comment.
100% agreed, but this bubbles out to the persisted state, which I have no context for so didn't want to change as part of this PR. I'll file an issue that we should push NonZeroU8 out to both rdb and the API?
There was a problem hiding this comment.
Oof, ok, so I looked very briefly into this and I think we may have a slightly bigger landmine here than I thought. BfdPeerConfig is part of the versioned HTTP API, so it's defined in the mg-api-types-versions, and we'll follow the standard RFD 619 process for revving that type. If we wanted to change detection_threshold to a NonZeroU8, we'd:
- add a new API version
- add a new
BfdPeerConfigwith the changed field type - add either
FromorTryFromimpls to convert if we get requests from an older client (depending on whether we want to reject requests that have a multiplier of 0, or silently convert them to 1)
That would all work fine at the API level. However, we also persist JSON-ified BfdPeerConfigs on disk, but with no versioning information AFAICT. When we then tried to load them here:
Lines 471 to 484 in dc53171
we'd be trying to deserialize a JSON blob from whatever version was originally persisted as the latest BfdPeerConfig version. Any of these are possible:
- it just works (e.g., if the change is or happens to be wire-compatible; in this case going from
u8toNonZeroU8is wire compatible as long as there are no0values) - it fails (e.g., if we rename fields or add new required fields) - we'd log an error but then skip that config
- it appears to work but silently does the wrong thing (e.g., if we change the semantics of some field in a way that's handled by the
FromorTryFromconversions at the API level but which don't get a chance to run with this "deserialize old blob as new type" path)
We've been burned by this kind of on-disk lack of versioning thing in omicron a few places and have had to go back and add extra infrastructure around on-disk persisted values. Maybe we need to do that here before revving any API types that are also persisted on disk?
There was a problem hiding this comment.
Oh actually maybe all of this is irrelevant - we do persist on disk, but the switch zone has ephemeral storage, so there's not actually a way for us to see an old version disk. I'm not sure why we persist to disk at all, actually - I guess it's for crash recovery (but only if mgd itself crashes - if the sled reboots we don't have any persistence so have to wait for nexus / sled-agent to tell us what to do).
There was a problem hiding this comment.
The on-disk db (sled::Db) is only used for daemon restart/crash recovery, like you said. mgd effectively trusts that there is no need to handle backwards compatibility issues with sled::Db, since all upgrades happen via a wholesale zone replacement that mops up the old filesystem anyway.
There was a problem hiding this comment.
Yeah, makes sense. I think that means I can take out this backcompat shim entirely, right? (We should still push NonZeroU8 out into BfdPeerConfig, but that can happen as a followup.)
There was a problem hiding this comment.
Yeah, I think so. And if we're going to start enforcing the value be non zero (which I think is a good thing!) then we may consider just bumping the API rev as part of this. I'm happy to take that on as a follow-up so you don't have to deal with a bunch of boilerplate and crate org stuff.
There was a problem hiding this comment.
I don't mind bumping the API version; I've done that a bunch in omicron. But this is only one bit of the BFD peer config that's a little suspect - I filed oxidecomputer/omicron#10657 on the omicron side, but it's almost a 1-to-1 port of mgd's type. Should we also consider:
- Should
required_rxeither be au32(to match BFD control packets) or aDuration(since that's what it actually is, then we put the onus of converting to microseconds on mgd internally)? - Should
required_rxenforce any upper or lower bounds (via a newtype or runtime checks)? - Is allowing the caller the ability to specify any local listening
IpAddrright? - Should
get_bfd_peers()return anIdOrdMap<BfdPeerConfig>(keyed by remote address) instead of aVec<BfdPeerConfig>?
There was a problem hiding this comment.
These are all great questions... which I don't have the answer to! I haven't had to dig into the BFD protocol or implementation before, so I think I'll have more opinions as I get further along the review
There was a problem hiding this comment.
de5ab22 bumps the API version to make this param a NonZeroU8. Happy to add more changes pending answers to the other questions, or that can be deferred for later.
| // TODO-correctness What should we do on "overflows an Instant"? That should | ||
| // be _very_ impossible given any reasonable values for `last_recv` (which | ||
| // are always from `Instant::now()` and `recv_timeout`, but someone passing | ||
| // a truly absurdly large `required_min_rx` could maybe cause problems? For | ||
| // now, we'll fall back to a hardcoded, large recv deadline if this addition | ||
| // overflows, but the actual value we pick is a total WAG. | ||
| last_recv | ||
| .checked_add(recv_timeout) | ||
| .unwrap_or_else(|| last_recv + Duration::from_secs(60)) |
There was a problem hiding this comment.
I'm for defaulting to a max duration for recv_timeout. I'd also like to note that this could still panic for unreasonable values of last_recv (near the representational limit).
There was a problem hiding this comment.
I'd also like to note that this could still panic for unreasonable values of last_recv (near the representational limit).
This is true, but in practice last_recv is fully under our control, and is always the result of calling Instant::now() (unlike any of the timeout parameters, which ultimately come from outside, either directly from the BFD peer or indirectly by way of Nexus -> mgd in terms of parameters). I think it's okay to leave the + in here because of that?
I could also drop this in a comment if you think that'd be an improvement. I'm on the fence.
There was a problem hiding this comment.
I think it'd be slightly better to deal with it separately:
- Make sure
recv_timeoutis sensible when it is set ("by construction"). - If the checked add fails you return the maximum representable or something like that. In that case it should only fail if the value was tempered with or we're at the heat-death of the universe so we can return whatever. Not doing this last unchecked add at least doesn't trick us into checking (with our brains) every time we look at it.
There was a problem hiding this comment.
Hmm, I'm not sure how to do either of these with 100% confidence:
recv_timeoutis at least partly under the control of the remote end, at least if we fix the calculation to match what the RFD says it should beInstantdoesn't providesaturating_add()orInstant::MAX, so there's no way (AFAIK) to construct the maximum representable value.
There was a problem hiding this comment.
- We could just define a newtype. IMO the ideal newtype would represent a sensible configuration (which includes this timeout) so that we have a clear place to deal with these misconfiguration errors (where we convert the input type into this newtype). In this case, for example, there's a local strategy for dealing with an unreasonable value but it may still behave erratically elsewhere.
- If an
Instant+ "something reasonable" (as per 1.) overflows then return thatInstant.
| pub fn packet_received( | ||
| &mut self, | ||
| packet: packet::Control, | ||
| now: Instant, | ||
| ) -> PacketReceivedResult { | ||
| self.update_remote_peer_info(&packet); | ||
| self.recv_deadline = next_recv_deadline(&self.local, now); | ||
|
|
||
| if packet.poll() { | ||
| let mut pkt = self.make_packet_to_send(); | ||
| pkt.set_final(); | ||
| self.poll_responses.push_back(pkt); | ||
| } | ||
|
|
||
| match packet.state() { | ||
| packet::State::Peer(peer_state) => { | ||
| self.update_remote_peer_state(peer_state) | ||
| } | ||
| packet::State::Unknown(_) => PacketReceivedResult::UnknownPeerState, | ||
| } | ||
| } |
| // I don't think we support demand mode? But that means this calculation is | ||
| // wrong for async (wrong multiplier, isn't considering remote min tx)? | ||
| let recv_timeout = local | ||
| .required_min_rx | ||
| .saturating_mul(u32::from(local.detection_multiplier.get())); |
There was a problem hiding this comment.
Agreed. According to the RFC, we should use the remote detection mult for async mode, though I admit it feels weird to use the remote one.
In Asynchronous mode, the Detection Time calculated in the local
system is equal to the value of Detect Mult received from the remote
system, multiplied by the agreed transmit interval of the remote
system (the greater of bfd.RequiredMinRxInterval and the last
received Desired Min TX Interval)
| pub fn packet_received( | ||
| &mut self, | ||
| packet: packet::Control, | ||
| now: Instant, | ||
| ) -> PacketReceivedResult { | ||
| self.update_remote_peer_info(&packet); | ||
| self.recv_deadline = next_recv_deadline(&self.local, now); | ||
|
|
||
| if packet.poll() { | ||
| let mut pkt = self.make_packet_to_send(); | ||
| pkt.set_final(); | ||
| self.poll_responses.push_back(pkt); | ||
| } | ||
|
|
||
| match packet.state() { | ||
| packet::State::Peer(peer_state) => { | ||
| self.update_remote_peer_state(peer_state) | ||
| } | ||
| packet::State::Unknown(_) => PacketReceivedResult::UnknownPeerState, | ||
| } | ||
| } |
There was a problem hiding this comment.
An argument could be made here to ignore packets signaling an unknown state. Ideally, that would happen along with a more strongly typed packet input. Your addition of a NonZeroU8 field is a great step in this direction (as per "parse, don't validate") but I don't think we should tackle it in this PR.
| // we can safely `unwrap()` this: we _have_ a reference to the | ||
| // `listener` now, which we got from the map by this same key. | ||
| // we know it's still there. | ||
| let listener = self.listeners.remove(&local_addr).unwrap(); | ||
| Some(ListenerShutdownHandle(listener)) |
There was a problem hiding this comment.
Could we cleanly avoid the unwrap by just replacing this with self.listeners.remove(&local_addr).map(|listener| ListenerShutdownHandle(listener))?
There was a problem hiding this comment.
We could avoid the unwrap like that, but I think it muddies the meaning some; self.listeners.remove(&local_addr).map(|listener| ListenerShutdownHandle(listener)) looks like "local_addr might not be in self.listeners, and that's fine - we'll return None if it's not there", which is not the situation we're in here.
| #[tokio::test(flavor = "multi_thread")] | ||
| async fn exits_when_channel_closed() { | ||
| let (tx, _counters, handle) = | ||
| spawn_egress("127.0.0.1:1".parse().unwrap(), Arc::default()); |
There was a problem hiding this comment.
We have mg_common::ip that does this parse/unwrap in a short macro. Can we use that?
There was a problem hiding this comment.
Sorry, I meant sockaddr since this has a port
There was a problem hiding this comment.
Hmm yeah, still not a fan of this kind of macro, and I think sockaddr / ip aren't quite correct hygiene-wise; if I import them, I get this error:
error: cannot find macro `parse` in this scope
--> bfd-async/src/egress/tests.rs:25:33
|
25 | let sk = bind_egress_socket(sockaddr!("[::1]:0"))
| ^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the macro `sockaddr` (in Nightly builds, run with -Z macro-backtrace for more info)
and have to also import mg_common::parse for them to work. I think they need to use $crate:: when calling another macro? https://doc.rust-lang.org/reference/macros-by-example.html#r-macro.decl.hygiene.crate
There was a problem hiding this comment.
I had no idea that was even a thing... sounds like I've got a little cleanup to do for the macros so we can stop importing mg_common::parse
| else { | ||
| warn!( | ||
| self.log, "unknown peer; dropping"; | ||
| "local" => ?self.socket.local_addr(), |
There was a problem hiding this comment.
What does a prefixed ? do?
| warn!( | ||
| self.log, "unknown peer; dropping"; | ||
| "local" => ?self.socket.local_addr(), | ||
| "peer" => %peer, |
There was a problem hiding this comment.
or a prefixed %? I assume these are slog specific?
There was a problem hiding this comment.
Yeah, the ? prefix is to use Debug formatting, and a % prefix is to use Display formatting. In this case self.socket.local_addr() is a std::io::Result<SocketAddr>, so doesn't impl Display, hence the difference.
https://docs.rs/slog/latest/slog/macro.log.html#fmtdisplay-and-fmtdebug-values
taspelund
left a comment
There was a problem hiding this comment.
Somehow only some of my comments ended up attached to a review, but overall I'd say I'm happy with the changes.
Just going through the codebase, I see a handful of issues that concern me with the implementation, but I don't suspect they were introduced by this PR.
I do plan to run the PR through some adversarial reviews from claude just to see what else I may have overlooked.
| // Bind two peers, then spawn two egress tasks. Send a message to each so | ||
| // our peer can detect the port on which the egress tasks were bound; both | ||
| // should be in the range [49152, ..]. We generally expect to get exactly | ||
| // 49152 and 49153, but we can't assert that: if one or both of those ports | ||
| // are already in use on whatever system this test is running, we'll search | ||
| // beyond that. |
There was a problem hiding this comment.
This is the second time I've seen mention of binding a socket and attempting to record the ephemeral ports. Would this be a good use case for rain's port_file crate?
There was a problem hiding this comment.
I don't think so? IIUC port_file is for when we're spawning a child process that is going to bind to port 0, and we need a wait for it to communicate back to us what port it actually bound. In this case we're binding the sockets in-process, so no need for an out-of-bound communication method.
| #[tokio::test(flavor = "multi_thread")] | ||
| async fn exits_when_channel_closed() { | ||
| let (tx, _counters, handle) = | ||
| spawn_egress("127.0.0.1:1".parse().unwrap(), Arc::default()); |
There was a problem hiding this comment.
I had no idea that was even a thing... sounds like I've got a little cleanup to do for the macros so we can stop importing mg_common::parse
| // transition is to `Down`; we can freely move between `Down`, | ||
| // `AdminDown`, and `Init` without modifying the RIB, and we only go | ||
| // back to "not shut down" if we transition back to `Up`. | ||
| let shutdown = match *self.state_rx.borrow_and_update() { |
There was a problem hiding this comment.
So the way this watch channel works is conceptually similar to a 1-element FIFO where the sender overwrites the element each time they try to write? The reader gets notified when there's been a change to the element, so they check the contents and do some work?
There was a problem hiding this comment.
I think that's right, although I don't really think of it that way. (In particular, there's no "out" portion of "FIFO" - if you call borrow_and_update() twice with no changes, you'll see the same value both times.) It's (literally) a RwLock with some special sauce akin to an async condvar for notifying readers, so I try to think of it like that:
- all the
send_*methods acquire the write lock then notify all readers that a change has occurred (except forsend_if_modified(f), which conditionally notifies the readers depending on whatfreturns) reader.borrow_and_update()acquires the read lock and marks the current value as seenreader.borrow()acquires the read lock and does NOT mark the current value as seen (this is rarely what you want)reader.changed()returns a future that becomes ready whenever the value has changed since the last time this reader calledborrow_and_update()(which means it's ready immediately if the value has already changed or has never been read)
| /// Address to listen on for control messages from the peer. | ||
| pub listen: IpAddr, | ||
| /// Acceptable time between control messages in microseconds. | ||
| pub required_rx: u64, |
There was a problem hiding this comment.
If we're already bumping the API version, any reason we shouldn't convert this to NonZeroU32? Seems like the wire representation for control packets encodes the microseconds as a u32, so I don't see why we should allow users to configure anything greater than u32::MAX.
There was a problem hiding this comment.
I think I'd put this in my "how many API changes do we want to make as part of this PR" question in #795 (comment). E.g., should we make this a u32, or should we go all the way to making it a Duration? What about the other things described in oxidecomputer/omicron#10657, most of which also apply to the MGD types?
My preference is to not try to fix all the API issues in the same PR as introducing this different impl. But I blurred that line by already doing the NonZeroU8 one :(
There was a problem hiding this comment.
That's fair. This was just one that I thought was small in scope and could be included in the API bump we're already doing, I don't have any other API shape changes that I think should include in this PR.
One thing to note: moving to NonZero would supposedly address an issue reported by AI as part of its adversarial review. It claims that a user configuring required_rx as 0 will result in a busy loop... Which I know also wasn't introduced by this PR, but I opportunistically hoped to reduce adding more API revs
There was a problem hiding this comment.
Hmm I'm not sure making the required rx a NonZero type is valid. From RFC 5880:
Required Min RX Interval
This is the minimum interval, in microseconds, between received
BFD Control packets that this system is capable of supporting,
less any jitter applied by the sender (see section 6.8.2). If
this value is zero, the transmitting system does not want the
remote system to send any periodic BFD Control packets.
Us busy looping on zero is certainly wrong, but it sounds like the fix for that is more complex, and not to forbid zero entirely.
There was a problem hiding this comment.
Fair enough. I obviously overlooked that when I was going over RFC 5880, and it clearly says zero is valid. I think it's fine to leave as-is then, and we can follow-up with a fix later.
2493eac to
d3c8f26
Compare
This PR adds a tokio/async-based implementation of BFD. It's heavily based on the existing implementation both in API shape and names, but has some intentional architectural differences (primarily to support pretty extensive unit testing). The non-test code is handwritten, but I leaned pretty heavily on Claude to help with writing tests (although I went over all of them by hand - it has a tendency to write flaky-in-a-not-obvious-way tests when sockets are involved, so hopefully I caught all of those) and used it to compare this implementation against the existing one. The primary motivator for moving to async is fixing #787, which it does: running the reproducer against this implementation results in the
DELETEs returning more or less immediately (under 1ms).The biggest architectural changes are:
send_to()getting stuck or something) used to mean we'd have unbounded memory growth now means we have a lot of dropped packets instead.Some less-architectural-but-still-intentional changes:
NonZeroU8, both because I was concerned about multiplying by 0 and because the RFC says such packetsMUSTbe rejected. We now do so, and also return a 400 if we're asked to configure a session with a threshold of 0. This might be the wrong call and I'm happy to discuss - there was a "panic on startup if we can't re-add all peers" path, so I made that path bump any persisted 0s to 1s, but maybe I should do the same in the API handler instead of returning a 400, in case there are Nexus configs persisted that have a threshold of 0?as u32that wrapped around on large values with.saturating_*()methods).SessionCounters::control_packets_receivedon incoming packets - I think this was just an oversight before?Some things that I left unchanged but I'm not sure are correct:
TODO-correctnesscomments on these with my notes but didn't want to change that behavior as part of a big rework.detection_multipliervalue (also tagged with aTODO-correctnessin the state machine).UdpSocket::send_to(). Could that leave us in a constant loop of closing/opening sockets if sends are failing for some reason not related to the local socket?Upstate, we'll leave behind anexthop_shudownof false for that address? I don't know whether this is okay or gets cleaned up by something else.required_min_rxalso be aNonZerotype? If that gets set to 0 we'll also have a "multiply by 0" in the calculation of the recv deadline.This PR is only the async implementation with no modifications to the main
mgdbinary except the change of detection threshold toNonZeroU8mentioned above. I'll open a second PR that swaps the implementations over.