From 1f7d4f8e78db3ca7f635a95f79989987b10d568a Mon Sep 17 00:00:00 2001 From: undecuple <125844332+undecuple@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:58:51 -0600 Subject: [PATCH] Relay: a stream leg accepted as the wait ends is used, not left open --- CHANGELOG.md | 1 + server/relay.go | 42 ++++++++++++++++++++--------- server/relay_accept_test.go | 53 +++++++++++++++++++++++++++++++++++++ site/data.json | 3 ++- site/index.html | 1 + 5 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 server/relay_accept_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a37149..949f062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project are documented here. The format follows - **Docs and tooling:** CI compiles the C client (`client/psnr.c`, `client/examples/minimal.c`) with MSVC on Windows and does not run it there; the build instructions in `docs/integrating.md` give the MSVC command. ### Fixed +- **Server:** a relay stream whose accepting side arrived just as the connecting side's 10 second wait ended no longer leaves the accepting connection open. The accepting leg is now used if it had already claimed the stream, and the connection is closed otherwise; before, the accepting player could be told the stream was ready while the connecting player was told it was not. - **Server:** `SET_ROOM_DATA` no longer lets the owner make the room's own join reply too big for one packet: a write to the external or internal blob that would is refused with error 6 and changes nothing, so a member who asks for the room again always gets an answer. Before, such a write was accepted and later joins and re-joins were refused. - **Server:** A search no longer disconnects the player when the rooms' data is large: the reply lists only as many rooms as fit in one packet, and the player pages on from there. - **Server:** A player whose connection stops reading no longer freezes the other players in its room: the server drops it after 10 seconds of a stuck write (`writeTimeout`), so its room mates' requests get their replies and the stuck player is removed from the room. diff --git a/server/relay.go b/server/relay.go index 9584349..00f1a59 100644 --- a/server/relay.go +++ b/server/relay.go @@ -118,6 +118,7 @@ func (s *Server) streamLeg(conn net.Conn, t byte, p []byte) (handedOff bool) { return fail(errNotFound) } if ready() != nil { + st.accept <- nil return false } st.accept <- conn @@ -149,18 +150,13 @@ func (s *Server) streamLeg(conn net.Conn, t byte, p []byte) (handedOff bool) { s.flush([]push{{to, msgStreamOffer, w.b}}) s.log.Debug("relay stream", "stream", st.id, "from", from.onlineID, "to", to.onlineID, "vport", vport) - var other net.Conn - select { - case other = <-st.accept: - case <-s.stopped: - s.mu.Lock() - delete(s.streams, st.id) - s.mu.Unlock() - return false - case <-time.After(streamAcceptTimeout): - s.mu.Lock() - delete(s.streams, st.id) - s.mu.Unlock() + other := s.awaitAccept(st, streamAcceptTimeout) + if other == nil { + select { + case <-s.stopped: + return false + default: + } return fail(errNotFound) } if ready() != nil { @@ -173,6 +169,28 @@ func (s *Server) streamLeg(conn net.Conn, t byte, p []byte) (handedOff bool) { return false } +// awaitAccept waits up to d for the target's leg of st. It returns nil if there is none: the server is stopping, the wait ran out, +// or the accepting side failed. An accepting side that had already taken the stream out of s.streams when the wait ran out is +// waited for (it always sends a leg or nil), so its connection is used and not left behind. +func (s *Server) awaitAccept(st *stream, d time.Duration) net.Conn { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case other := <-st.accept: + return other + case <-s.stopped: + case <-timer.C: + } + s.mu.Lock() + _, waiting := s.streams[st.id] + delete(s.streams, st.id) + s.mu.Unlock() + if waiting { + return nil + } + return <-st.accept +} + // relayWriteTimeout bounds one write of a relayed chunk to a leg: a peer that stops reading cannot hold the leg open. const relayWriteTimeout = 30 * time.Second diff --git a/server/relay_accept_test.go b/server/relay_accept_test.go new file mode 100644 index 0000000..e1a92ec --- /dev/null +++ b/server/relay_accept_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "net" + "testing" + "time" +) + +// A stream's accepting side takes the stream out of s.streams, answers its player and then hands over the connection. If the +// connecting side's wait ran out in between, that connection must still be used, not left in the channel with nobody to close it. + +func TestAwaitAcceptUsesLegClaimedAtTimeout(t *testing.T) { + s := NewServer(10, false) + st := &stream{id: 7, accept: make(chan net.Conn, 1)} + // The stream is no longer in s.streams: the accepting side has claimed it. + ca, sa := tcpPair(t) + _ = ca + go func() { + time.Sleep(50 * time.Millisecond) + st.accept <- sa + }() + if got := s.awaitAccept(st, time.Millisecond); got != sa { + t.Fatalf("awaitAccept returned %v after the wait ran out, want the claimed leg", got) + } +} + +func TestAwaitAcceptClaimedThenFailed(t *testing.T) { + s := NewServer(10, false) + st := &stream{id: 8, accept: make(chan net.Conn, 1)} + go func() { + time.Sleep(20 * time.Millisecond) + st.accept <- nil + }() + if got := s.awaitAccept(st, time.Millisecond); got != nil { + t.Fatalf("awaitAccept returned %v, want nil when the accepting side failed", got) + } +} + +func TestAwaitAcceptTimesOutWhenUnclaimed(t *testing.T) { + s := NewServer(10, false) + st := &stream{id: 9, accept: make(chan net.Conn, 1)} + s.streams[st.id] = st + start := time.Now() + if got := s.awaitAccept(st, 20*time.Millisecond); got != nil { + t.Fatalf("awaitAccept returned %v with no accepting side", got) + } + if time.Since(start) > 2*time.Second { + t.Fatal("awaitAccept did not return after its wait") + } + if s.streams[st.id] != nil { + t.Fatal("the unclaimed stream was left in s.streams") + } +} diff --git a/site/data.json b/site/data.json index 2559d60..f24e0e1 100644 --- a/site/data.json +++ b/site/data.json @@ -1302,6 +1302,7 @@ "**Docs and tooling:** CI compiles the C client (`client/psnr.c`, `client/examples/minimal.c`) with MSVC on Windows and does not run it there; the build instructions in `docs/integrating.md` give the MSVC command." ], "Fixed": [ + "**Server:** a relay stream whose accepting side arrived just as the connecting side's 10 second wait ended no longer leaves the accepting connection open.", "**Server:** `SET_ROOM_DATA` no longer lets the owner make the room's own join reply too big for one packet: a write to the external or internal blob that would is refused with error 6 and changes nothing, so a member ...", "**Server:** A search no longer disconnects the player when the rooms' data is large: the reply lists only as many rooms as fit in one packet, and the player pages on from there.", "**Server:** A player whose connection stops reading no longer freezes the other players in its room: the server drops it after 10 seconds of a stuck write (`writeTimeout`), so its room mates' requests get their replies ...", @@ -1329,7 +1330,7 @@ }, "counts": { "Added": 17, - "Fixed": 11, + "Fixed": 12, "Changed": 10 } }, diff --git a/site/index.html b/site/index.html index 3cd066a..6ceb661 100644 --- a/site/index.html +++ b/site/index.html @@ -360,6 +360,7 @@

Added

Fixed