Skip to content

Commit f002aff

Browse files
Fix cross-test pollution from engineered homeservers re-using the same server names (fix TestPartialStateJoin flakes) (#880)
### What problem are we trying to solve? This PR is tackling this problem: > Many of the faster joins test flakes are due to the homeserver under test failing to contact Complement homeservers after they have been torn down. When this happens, subsequent tests can fail if they use a Complement homeserver that happens to have the same `hostname:port` as one which the homeserver under test has previously marked as offline. > > *-- #626 This problem occurs because as an [optimization](#471) we try to share the `deployment` across many tests. To be clear, as a dumb-simple solution, if we created a new `deployment` for each test, we wouldn't run into this issue (but then the tests would be "slow" again). ### What does this PR do? Spawning from #878 (comment) where I originally thought we could prevent stray requests to a server just from the federation request [authentication](https://spec.matrix.org/v1.18/server-server-api/#authentication) but turns out, it's already doing the right thing. The only thing we can do is compare the `destination` to our server name which [`VerifyHTTPRequest`](https://github.com/matrix-org/gomatrixserverlib/blob/c9c468727353c4835523349dc7c202e5de9a2e37/fclient/request.go#L216-L227) is already doing. And since the crux of this whole thing is that two Complement engineered servers can use the same recycled port and have identical server names (`host.docker.internal:port`), it doesn't help us here. The rest of the public key stuff that is part of federation requests is just for the origin server to sign itself with, for authenticity. > `destination`: [Added in v1.3] the server name of the receiving server. This is the same as the `destination` field from the JSON described in step 1. For compatibility with older servers, recipients should accept requests without this parameter, but MUST always send it. If this property is included, but the value does not match the receiving server’s name, the receiving server must deny the request with an HTTP status code 401 Unauthorized. > > *-- https://spec.matrix.org/v1.18/server-server-api/#authentication* Instead, this PR takes an alternative route where we instead never re-use the same `server_name` for a Complement engineered homeserver. This means that even if the real homeserver reaches out to the torn-down engineered homeservers, it just hits 'connection refused' instead of a live, unrelated server. No cross-test pollution. In the context of `tests/msc3902/federation_room_join_partial_state_test.go`, it means the engineered homeserver will never see a [`t.Errorf("Received unexpected PDU", ...)`](https://github.com/matrix-org/complement/blob/bc2b638b11f5b0a068e39ae51f7ac2782070d14f/tests/msc3902/federation_room_join_partial_state_test.go#L77C7-L77C40) problem. ### How do we currently deal with this? Currently, we try to workaround the stray request problem by making sure the engineered homeserver leaves any rooms it shares with the real homeserver. This means the real homeserver has no logical reason to contact the other server but it's still technically possible that it might reach out because of some queued federation traffic, etc. I find this approach/pattern pretty bad (especially in the way it's currently implemented). See the "Fix" explanation on #878 for more info. See all of the logic around [`partialStateJoinResult.Destroy`](https://github.com/matrix-org/complement/blob/bc2b638b11f5b0a068e39ae51f7ac2782070d14f/tests/msc3902/federation_room_join_partial_state_test.go#L4399-L4428) and [`WithWaitForLeave`](https://github.com/matrix-org/complement/blob/bc2b638b11f5b0a068e39ae51f7ac2782070d14f/tests/msc3902/federation_room_join_partial_state_test.go#L126-L162) to see how messy this gets and all of the logic that we get to remove because of this new approach.
1 parent 589dd56 commit f002aff

1 file changed

Lines changed: 83 additions & 5 deletions

File tree

federation/server.go

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -528,18 +528,96 @@ func (s *Server) Mux() *mux.Router {
528528
return s.mux
529529
}
530530

531-
// Listen for federation server requests - call the returned function to gracefully close the server.
531+
// Keep track of the ports that we've previously used so that we never use the same port
532+
// (and therefore the same `server_name`) to two different servers.
533+
//
534+
// A `Server` is identified over federation solely by its `server_name` (which looks
535+
// like `hostname:port` for these Complement engineered homeservers). When the OS recycles a
536+
// freed port, a new Server could otherwise get a `server_name` that is identical to a
537+
// previously torn-down one.
538+
//
539+
// To explain an actual situation where this becomes a problem: A real homeserver under
540+
// test (that is participating in a room with the now-dead engineered homeserver) might
541+
// still try to reach the dead server, but since the `server_name` is the same, it's now
542+
// hitting the new server unexpectedly (cross-test pollution).
543+
//
544+
// This particularly happens when you try to share a `deployment` across many tests and
545+
// then each test creates a engineered homeservers to interact against.
546+
//
547+
// Retiring each port for the lifetime of the process keeps stray requests pointed at a
548+
// dead port (connection refused) instead of a live, unrelated server.
549+
var (
550+
// Use a mutex so only one thread can advance `lastUsedPort` at a time. We don't want
551+
// multiple threads clobbering `lastUsedPort`.
552+
lastUsedPortMu sync.Mutex
553+
// Start at 1024 (1023 + 1) to avoid the priviged ports used by the system
554+
//
555+
// Since we sequentially try each port, we just need to keep track of the last one we tried
556+
lastUsedPort = 1023
557+
)
558+
559+
// listenOnUnusedPort listens on an unused port that no other federation `Server` has
560+
// used before in this process.
561+
func listenOnUnusedPort(t ct.TestLike) net.Listener {
562+
lastUsedPortMu.Lock()
563+
defer lastUsedPortMu.Unlock()
564+
565+
// We use this sequential port scan strategy over guess and check with an OS-assigned
566+
// port (by using `:0`) as it's more efficient. The OS may recycle and re-use freed
567+
// ports meaning we could regress to O(n^2) behavior trying to search for each new
568+
// port we want to find.
569+
//
570+
// Using `:0` means an unused port is automatically picked for us (could be random,
571+
// could be the next sequential unused port, we don't know). Ideally, we could ask for
572+
// the next unused port after X to avoid a bunch of work. When using `:0`, the
573+
// pathological case that is O(n^2) is if OS hands back next lowest unused port
574+
// sequentially which would mean we would have to probe and hold each listener until
575+
// we finally got something new.
576+
577+
// Try the whole port range (untested but it's probably fast to do so)
578+
max_attempts := 65535
579+
var lastErr error
580+
for i := 0; i < max_attempts; i++ {
581+
port := lastUsedPort + 1
582+
if port > 65535 {
583+
// If this ever becomes a problem, we can namespace used ports by `deployment` since
584+
// that has to be passed into `NewServer(...)` anyway and the whole point of this is
585+
// that a homeserver from the `deployment` doesn't try to reach out to a previous
586+
// engineered homeserver it knows about.
587+
//
588+
// As another alternative, we could also wrap-around to the beginning of the port
589+
// range again although that is slightly unsound.
590+
ct.Fatalf(
591+
t, "listenOnUnusedPort: could not find an unused port in the entire port range (0 - 65535). "+
592+
"(see comment here if you run into this). Last error: %s", lastErr,
593+
)
594+
}
595+
596+
// Check port availability
597+
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
598+
lastUsedPort = port
599+
if err != nil {
600+
lastErr = err
601+
// Port unavailable, skip
602+
continue
603+
}
604+
605+
return ln
606+
}
607+
// Since we try the entire port range, we don't really expect to get here but we have
608+
// it in case there is a programming error above
609+
ct.Fatalf(t, "listenOnUnusedPort: Programming error")
610+
return nil
611+
}
612+
532613
func (s *Server) Listen() (cancel func()) {
533614
if s.listening {
534615
return
535616
}
536617
var wg sync.WaitGroup
537618
wg.Add(1)
538619

539-
ln, err := net.Listen("tcp", ":0") //nolint
540-
if err != nil {
541-
ct.Fatalf(s.t, "ListenFederationServer: net.Listen failed: %s", err)
542-
}
620+
ln := listenOnUnusedPort(s.t)
543621
port := ln.Addr().(*net.TCPAddr).Port
544622
s.serverName = spec.ServerName(fmt.Sprintf("%s:%d", s.serverName, port))
545623
s.listening = true

0 commit comments

Comments
 (0)