From 81dbbf5bb30af888f3376b8c8224c2c217c9aac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Le=C3=B3n?= <58715544+JavierLeon9966@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:04:41 -0300 Subject: [PATCH 01/24] Close notifiers after successfully dialing to prevent deadlock on signals that won't be received --- dial.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dial.go b/dial.go index 978e84a..298d8b0 100644 --- a/dial.go +++ b/dial.go @@ -98,11 +98,7 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig // Signals may be received very early when signaling an offer with local candidates. signals, stop := d.notifySignals(networkID, signaling) - defer func() { - if err != nil { - stop() - } - }() + defer stop() // Encode an offer using the local parameters! dtlsParams.Role = webrtc.DTLSRoleServer @@ -172,7 +168,9 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig return nil, fmt.Errorf("parse offer: %w", err) } - go d.handleConn(ctx, c, signals) + connCtx, cancel := context.WithCancel(ctx) + defer cancel() + go d.handleConn(connCtx, c, signals) select { case <-ctx.Done(): From d9aeb971773228d937fa3f100ba64596f0ec0ff9 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:08:09 +0900 Subject: [PATCH 02/24] conn.go: Add background context to Conn --- conn.go | 91 ++++++++++++++++++++++++++++++++++------------------- dial.go | 2 +- listener.go | 2 +- 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/conn.go b/conn.go index 9734fb9..2990d7d 100644 --- a/conn.go +++ b/conn.go @@ -1,6 +1,7 @@ package nethernet import ( + "context" "errors" "fmt" "io" @@ -59,14 +60,20 @@ type Conn struct { // that are expected to both open during negotiating a new Conn. channels [messageReliabilityCapacity]*dataChannel - once sync.Once // Ensures closure occur only once - closed chan struct{} // Notifies that a Conn has been closed. + // once ensures that the Conn is closed only once. + once sync.Once log *slog.Logger local Addr id uint64 networkID string + + // ctx is the background context associated with the Conn. + ctx context.Context + // cancel is the function used to cancel the ctx. It is called + // by close + cancel context.CancelCauseFunc } // Read receives a message from the 'ReliableDataChannel'. The bytes of the message data are copied to @@ -88,8 +95,8 @@ func (conn *Conn) Read(b []byte) (n int, err error) { // returned if the Conn has been closed by [Conn.Close]. func (conn *Conn) Receive(r MessageReliability) ([]byte, error) { select { - case <-conn.closed: - return nil, net.ErrClosed + case <-conn.ctx.Done(): + return nil, context.Cause(conn.ctx) case pk := <-conn.channels[r].packets: return pk, nil } @@ -116,6 +123,13 @@ func (conn *Conn) DisableEncryption() bool { return true } +// Context returns the background context associated with the Conn. +// The returned context is canceled when the Conn is no longer usable. +// Its cancellation cause describes the reason the Conn was closed. +func (conn *Conn) Context() context.Context { + return conn.ctx +} + // Write writes the data into the 'ReliableDataChannel'. If the data exceeds 10000 bytes, it is split into // multiple segments. An error may be returned while writing a segment or if the Conn has been closed by [Conn.Close]. func (conn *Conn) Write(b []byte) (n int, err error) { @@ -127,8 +141,8 @@ func (conn *Conn) Write(b []byte) (n int, err error) { // returned while writing one or more segments or the Conn has been closed by [Conn.Close]. func (conn *Conn) Send(data []byte, reliability MessageReliability) (n int, err error) { select { - case <-conn.closed: - return 0, net.ErrClosed + case <-conn.ctx.Done(): + return 0, context.Cause(conn.ctx) default: if reliability == MessageReliabilityUnreliable && len(data) > maxMessageSize { return 0, fmt.Errorf("data larger than %d (received: %d) cannot be sent over UnreliableDataChannel", maxMessageSize, len(data)) @@ -228,17 +242,20 @@ func (conn *Conn) remoteAddr() *Addr { } } -// Close closes the 'ReliableDataChannel' and 'UnreliableDataChannel', then closes the SCTP, DTLS, -// and ICE transports of the Conn. An error may be returned using [errors.Join], which contains -// non-nil errors encountered during closure. -func (conn *Conn) Close() (err error) { +// close closes the data channels associated with reliability parameters, then closes each transport +// of the Conn. It also cancels the background context with the provided cause so that may be returned +// by current-blocking methods such as [Conn.Read]. +func (conn *Conn) close(cause error) (err error) { conn.once.Do(func() { - close(conn.closed) + if cause != nil { + conn.log.Error("connection is closing with a cause", slog.Any("cause", err)) + } + conn.cancel(cause) conn.negotiator.handleClose(conn) for r := range messageReliabilityCapacity { - if conn.channels[r] != nil { - err = errors.Join(err, conn.channels[r].Close()) + if ch := conn.channels[r]; ch != nil { + err = errors.Join(err, ch.Close()) } } @@ -252,9 +269,16 @@ func (conn *Conn) Close() (err error) { return err } -// handleTransports handles incoming messages from the 'ReliableDataChannel' and ensures -// closure of its two data channels, as well as ICE, DTLS, and SCTP transports when any of -// them are closed by the remote connection. +// Close closes the 'ReliableDataChannel' and 'UnreliableDataChannel', then closes the SCTP, DTLS, +// and ICE transports of the Conn. An error may be returned using [errors.Join], which contains +// non-nil errors encountered during closure. +func (conn *Conn) Close() (err error) { + return conn.close(net.ErrClosed) +} + +// handleTransports registers handlers for all underlying transports and data channels +// associated with the Conn. It also ensures that the Conn is closed if an unrecoverable +// error has occurred in any of the underlying transports and data channels. func (conn *Conn) handleTransports() { for r := MessageReliability(0); r < messageReliabilityCapacity; r++ { ch := conn.channels[r] @@ -265,41 +289,42 @@ func (conn *Conn) handleTransports() { slog.String("label", ch.Label())) return } - conn.log.Error("error handling remote message", - slog.String("label", ch.Label()), - slog.Any("error", err), - ) + // Receiving an invalid or incomplete message is considered unrecoverable + // as segmented packets cannot be completed. Closing the connection also + // helps mitigate malformed or malicious input from a peer. + // The DataChannel invokes this callback while holding an internal lock, + // so the connection is closed in a goroutine to avoid deadlock. + go conn.close(fmt.Errorf("nethernet: handle message in %s: %w", ch.Label(), err)) } }) ch.OnClose(func() { - _ = conn.Close() + _ = conn.close(fmt.Errorf("nethernet: data channel %q closed by remote peer", ch.Label())) }) } conn.sctp.OnDataChannelOpened(func(channel *webrtc.DataChannel) { - conn.log.Error("connection was not expected to open a data channel after connection it is fully established", slog.String("label", channel.Label())) - _ = conn.Close() + _ = conn.close(fmt.Errorf("nethernet: data channel %q was unexpectedly opened by remote peer after connection was established", channel.Label())) }) conn.ice.OnConnectionStateChange(func(state webrtc.ICETransportState) { switch state { case webrtc.ICETransportStateClosed, webrtc.ICETransportStateDisconnected, webrtc.ICETransportStateFailed: - // This handler function itself is holding the lock, call Close in a goroutine. - go conn.Close() // We need to make sure that all transports has been closed + // This handler function itself is holding the lock, call Close in a goroutine to avoid deadlock. + go conn.close(fmt.Errorf("nethernet: ICE transport entered unrecoverable state: %s", state)) default: } }) conn.dtls.OnStateChange(func(state webrtc.DTLSTransportState) { switch state { case webrtc.DTLSTransportStateClosed, webrtc.DTLSTransportStateFailed: - // This handler function itself is holding the lock, call Close in a goroutine. - go conn.Close() // We need to make sure that all transports has been closed + // This handler function itself is holding the lock, call Close in a goroutine to avoid deadlock. + go conn.close(fmt.Errorf("nethernet: DTLS transport entered unrecoverable state: %s", state)) default: } }) conn.sctp.OnClose(func(err error) { - // This handler function itself is holding the lock, call Close in a goroutine. - go conn.Close() // We need to make sure that all transports has been closed + // This handler function itself is holding the lock, call Close in a goroutine to avoid deadlock. + go conn.close(fmt.Errorf("nethernet: SCTP transport closed: %w", err)) }) } @@ -349,7 +374,7 @@ func (conn *Conn) handleSignal(signal *Signal) error { if err != nil { return fmt.Errorf("parse error code: %w", err) } - conn.log.Error("connection failed with error", slog.Uint64("code", code)) + conn.close(fmt.Errorf("nethernet: remote peer notified connection failure (code: %d)", code)) if err := conn.Close(); err != nil { return fmt.Errorf("close: %w", err) } @@ -520,7 +545,7 @@ func (desc description) connectionRole(role webrtc.DTLSRole) sdp.ConnectionRole // negotiator (caller) must establish each transport after creating a Conn when a first ICE // candidate has been signaled from the remote connection. func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc.SCTPTransport, id uint64, networkID string, local Addr, n negotiator) *Conn { - return &Conn{ + c := &Conn{ ice: ice, dtls: dtls, sctp: sctp, @@ -529,8 +554,6 @@ func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc. negotiator: n, - closed: make(chan struct{}), - log: n.log().With(slog.Group("connection", slog.Uint64("id", id), slog.String("networkID", networkID), @@ -541,6 +564,8 @@ func newConn(ice *webrtc.ICETransport, dtls *webrtc.DTLSTransport, sctp *webrtc. id: id, networkID: networkID, } + c.ctx, c.cancel = context.WithCancelCause(context.Background()) + return c } type negotiator interface { diff --git a/dial.go b/dial.go index 978e84a..3451859 100644 --- a/dial.go +++ b/dial.go @@ -286,7 +286,7 @@ func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Sign select { case <-ctx.Done(): return - case <-conn.closed: + case <-conn.ctx.Done(): return case signal, ok := <-signals: if !ok { diff --git a/listener.go b/listener.go index 1516705..8021464 100644 --- a/listener.go +++ b/listener.go @@ -415,7 +415,7 @@ func (l *Listener) handleConn(conn *Conn, d *description) { err = ctx.Err() case <-l.closed: err = net.ErrClosed - case <-conn.closed: + case <-conn.ctx.Done(): return case <-conn.candidateReceived: conn.log.Debug("received first candidate") From 241017bac6041e7ed5fd7daaff4099d16b7153ef Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:28:46 +0900 Subject: [PATCH 03/24] conn.go: Clarify documentation for cancel field --- conn.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conn.go b/conn.go index 2990d7d..036bb9f 100644 --- a/conn.go +++ b/conn.go @@ -71,8 +71,8 @@ type Conn struct { // ctx is the background context associated with the Conn. ctx context.Context - // cancel is the function used to cancel the ctx. It is called - // by close + // cancel is the function used to cancel the ctx with a cause. + // It is called by close and must not be called elsewhere. cancel context.CancelCauseFunc } From 7747edbc9626022c78795eb805ff9a2417dcb100 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:34:35 +0900 Subject: [PATCH 04/24] conn.go: Log the correct error on connection closure --- conn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conn.go b/conn.go index 036bb9f..3ceb7a3 100644 --- a/conn.go +++ b/conn.go @@ -248,7 +248,7 @@ func (conn *Conn) remoteAddr() *Addr { func (conn *Conn) close(cause error) (err error) { conn.once.Do(func() { if cause != nil { - conn.log.Error("connection is closing with a cause", slog.Any("cause", err)) + conn.log.Debug("connection is closing with a cause", slog.Any("cause", cause)) } conn.cancel(cause) conn.negotiator.handleClose(conn) From 6c9731bfef29517a26ce97c2667ebd77d008592a Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:47:19 +0900 Subject: [PATCH 05/24] conn.go: SCTP transport may report a nil error on closure --- conn.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/conn.go b/conn.go index 3ceb7a3..2884821 100644 --- a/conn.go +++ b/conn.go @@ -323,8 +323,14 @@ func (conn *Conn) handleTransports() { } }) conn.sctp.OnClose(func(err error) { + var e error + if err != nil { + e = fmt.Errorf("nethernet: SCTP transport closed: %w", err) + } else { + e = errors.New("nethernet: SCTP transport closed") + } // This handler function itself is holding the lock, call Close in a goroutine to avoid deadlock. - go conn.close(fmt.Errorf("nethernet: SCTP transport closed: %w", err)) + go conn.close(e) }) } From 3f32b5e450951b5c1a69f4a61d7b4c3d60e7a059 Mon Sep 17 00:00:00 2001 From: lactyy <92302002+lactyy@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:53:51 +0900 Subject: [PATCH 06/24] conn.go: Align field docs --- conn.go | 7 ++++--- listener.go | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/conn.go b/conn.go index 2884821..3799e6c 100644 --- a/conn.go +++ b/conn.go @@ -47,9 +47,10 @@ type Conn struct { candidateReceived chan struct{} // candidates includes all [webrtc.ICECandidate] signaled from the remote connection. - // New candidates are appended atomically to the slice. - candidates []webrtc.ICECandidate - candidatesMu sync.Mutex // Guards candidates + // New candidates are appended atomically to the slice. It is guarded by candidatesMu. + candidates []webrtc.ICECandidate + // candidatesMu guards candidates from concurrent read-write access. + candidatesMu sync.Mutex // negotiator is either Listener or Dialer that the Conn has been negotiated through. negotiator negotiator diff --git a/listener.go b/listener.go index 8021464..6568ad0 100644 --- a/listener.go +++ b/listener.go @@ -83,7 +83,8 @@ type Listener struct { signaling Signaling networkID string - id uint64 // used for identifying Listener with an uint64. + // id is the numerical identifier for the Listener. + id uint64 connections sync.Map From 379cbdbc609ab4f6f2fa8aec2386f6ee3e8041fa Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 00:24:09 -0500 Subject: [PATCH 07/24] fix: few issues --- conn.go | 3 +-- dial.go | 10 ++++++---- discovery/listener.go | 27 +++++++++++++++++++++------ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/conn.go b/conn.go index 3799e6c..34f8ead 100644 --- a/conn.go +++ b/conn.go @@ -381,8 +381,7 @@ func (conn *Conn) handleSignal(signal *Signal) error { if err != nil { return fmt.Errorf("parse error code: %w", err) } - conn.close(fmt.Errorf("nethernet: remote peer notified connection failure (code: %d)", code)) - if err := conn.Close(); err != nil { + if err := conn.close(fmt.Errorf("nethernet: remote peer notified connection failure (code: %d)", code)); err != nil { return fmt.Errorf("close: %w", err) } default: diff --git a/dial.go b/dial.go index 92097cb..c145a66 100644 --- a/dial.go +++ b/dial.go @@ -98,7 +98,11 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig // Signals may be received very early when signaling an offer with local candidates. signals, stop := d.notifySignals(networkID, signaling) - defer stop() + defer func() { + if err != nil { + stop() + } + }() // Encode an offer using the local parameters! dtlsParams.Role = webrtc.DTLSRoleServer @@ -168,9 +172,7 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig return nil, fmt.Errorf("parse offer: %w", err) } - connCtx, cancel := context.WithCancel(ctx) - defer cancel() - go d.handleConn(connCtx, c, signals) + go d.handleConn(c.Context(), c, signals) select { case <-ctx.Done(): diff --git a/discovery/listener.go b/discovery/listener.go index fd1519c..1c0a24a 100644 --- a/discovery/listener.go +++ b/discovery/listener.go @@ -128,8 +128,9 @@ type Listener struct { // notifier holds a buffered input channel and a caller-provided output // channel for relaying incoming signals to a [nethernet.Listener]. type notifier struct { - in chan *nethernet.Signal - out chan<- *nethernet.Signal + in chan *nethernet.Signal + out chan<- *nethernet.Signal + stop chan struct{} } // Signal sends a NetherNet signal to the corresponding address for the network ID. @@ -169,8 +170,9 @@ func (l *Listener) Notify(signals chan<- *nethernet.Signal) (stop func()) { i := l.notifyCount n := notifier{ // Buffer notifications so packet handling never blocks under lock. - in: make(chan *nethernet.Signal, 64), - out: signals, + in: make(chan *nethernet.Signal, 64), + out: signals, + stop: make(chan struct{}), } l.notifiers[i] = n l.notifyCount++ @@ -178,8 +180,20 @@ func (l *Listener) Notify(signals chan<- *nethernet.Signal) (stop func()) { go func() { defer close(signals) - for sig := range n.in { - n.out <- sig + for { + select { + case <-n.stop: + return + case sig, ok := <-n.in: + if !ok { + return + } + select { + case <-n.stop: + return + case n.out <- sig: + } + } } }() @@ -204,6 +218,7 @@ func (l *Listener) stop(i uint32) { return } delete(l.notifiers, i) + close(n.stop) close(n.in) } From ae9ff8a078fd7a9aaedb112afc95aefb21043f3e Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 00:50:10 -0500 Subject: [PATCH 08/24] feat: ciphertext length guard --- discovery/crypto.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discovery/crypto.go b/discovery/crypto.go index 40745e6..7435aae 100644 --- a/discovery/crypto.go +++ b/discovery/crypto.go @@ -30,6 +30,9 @@ func decrypt(src []byte) ([]byte, error) { if err != nil { return nil, fmt.Errorf("make block: %w", err) } + if len(src) == 0 || len(src)%block.BlockSize() != 0 { + return nil, fmt.Errorf("invalid ciphertext length: %d", len(src)) + } mode := ecb.NewECBDecrypter(block) dst := make([]byte, len(src)) mode.CryptBlocks(dst, src) From 28a29f7f6931735abd1eb1d4782273d19ffd8b9e Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 00:53:41 -0500 Subject: [PATCH 09/24] fix: create conn earlier in dial.go --- dial.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/dial.go b/dial.go index c145a66..bd2d1b6 100644 --- a/dial.go +++ b/dial.go @@ -103,6 +103,19 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig stop() } }() + c := newConn(ice, dtls, sctp, d.ConnectionID, networkID, Addr{ + NetworkID: signaling.NetworkID(), + ConnectionID: d.ConnectionID, + Candidates: candidates, + }, dialerConn{ + Dialer: d, + stop: stop, + }) + defer func() { + if err != nil { + _ = c.Close() + } + }() // Encode an offer using the local parameters! dtlsParams.Role = webrtc.DTLSRoleServer @@ -133,19 +146,6 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig } } - c := newConn(ice, dtls, sctp, d.ConnectionID, networkID, Addr{ - NetworkID: signaling.NetworkID(), - ConnectionID: d.ConnectionID, - Candidates: candidates, - }, dialerConn{ - Dialer: d, - stop: stop, - }) - defer func() { - if err != nil { - _ = c.Close() - } - }() for { select { case <-ctx.Done(): From e766b588ea6eacaba5672971568f8aeb1b5b8d08 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 01:04:42 -0500 Subject: [PATCH 10/24] feat: defensive checks for message reliability --- conn.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conn.go b/conn.go index 34f8ead..a523bad 100644 --- a/conn.go +++ b/conn.go @@ -95,6 +95,9 @@ func (conn *Conn) Read(b []byte) (n int, err error) { // received from the data channel responsible for the MessageReliability. An error may be // returned if the Conn has been closed by [Conn.Close]. func (conn *Conn) Receive(r MessageReliability) ([]byte, error) { + if r >= messageReliabilityCapacity { + return nil, fmt.Errorf("invalid message reliability: %d", r) + } select { case <-conn.ctx.Done(): return nil, context.Cause(conn.ctx) @@ -145,6 +148,9 @@ func (conn *Conn) Send(data []byte, reliability MessageReliability) (n int, err case <-conn.ctx.Done(): return 0, context.Cause(conn.ctx) default: + if reliability >= messageReliabilityCapacity { + return 0, fmt.Errorf("invalid message reliability: %d", reliability) + } if reliability == MessageReliabilityUnreliable && len(data) > maxMessageSize { return 0, fmt.Errorf("data larger than %d (received: %d) cannot be sent over UnreliableDataChannel", maxMessageSize, len(data)) } From 45380e3a0f92f61082320c2db981acf1302049da Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 01:05:34 -0500 Subject: [PATCH 11/24] fix: incorrect segment count check --- message.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/message.go b/message.go index c5f73aa..71b9957 100644 --- a/message.go +++ b/message.go @@ -154,7 +154,7 @@ func (c *dataChannel) handleMessage(b []byte) error { return fmt.Errorf("parse: %w", err) } - if c.reliability == MessageReliabilityUnreliable && msg.segments > 1 { + if c.reliability == MessageReliabilityUnreliable && msg.segments > 0 { return fmt.Errorf("unexpected segment count on UnreliableDataChannel: %d", msg.segments) } From a043c9b92c7fa73ebc8886d60e009fd26421f5a9 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 01:05:58 -0500 Subject: [PATCH 12/24] feat: check recipient id matches --- discovery/listener.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discovery/listener.go b/discovery/listener.go index 1c0a24a..b69f952 100644 --- a/discovery/listener.go +++ b/discovery/listener.go @@ -339,6 +339,9 @@ func (l *Listener) handleResponse(pk *ResponsePacket, senderID uint64) error { // handleMessage handles a MessagePacket sent from the remote NetherNet network. // It discards the packet if the Data is "Ping", otherwise decodes them as a [nethernet.Signal]. func (l *Listener) handleMessage(pk *MessagePacket, senderID uint64) error { + if pk.RecipientID != l.conf.NetworkID { + return nil + } if pk.Data == "Ping" { return nil } From 05d2a6fef2769e323e1adfff775b1299744540e5 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 01:09:38 -0500 Subject: [PATCH 13/24] fix(listener): create conn earlier to trigger Close() so transports are cleaned up on early return --- listener.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/listener.go b/listener.go index 6568ad0..7e64bb0 100644 --- a/listener.go +++ b/listener.go @@ -295,9 +295,20 @@ func (l *Listener) handleOffer(signal *Signal) error { if len(dtlsParams.Fingerprints) == 0 { return wrapSignalError(errors.New("local DTLS parameters has no fingerprints"), ErrorCodeFailedToCreateAnswer) } - sctpCapabilities := sctp.GetCapabilities() + + c := newConn(ice, dtls, sctp, signal.ConnectionID, signal.NetworkID, Addr{ + NetworkID: l.networkID, + Candidates: candidates, + }, l) + established := false + defer func() { + if !established { + _ = c.Close() + } + }() // Encode an answer using the local parameters! + sctpCapabilities := sctp.GetCapabilities() answer, err := description{ ice: iceParams, dtls: dtlsParams, @@ -328,13 +339,9 @@ func (l *Listener) handleOffer(signal *Signal) error { } } - c := newConn(ice, dtls, sctp, signal.ConnectionID, signal.NetworkID, Addr{ - NetworkID: l.networkID, - Candidates: candidates, - }, l) - l.connections.Store(c.remoteAddr().String(), c) go l.handleConn(c, desc) + established = true return nil } From 12fe3fb810b7e6a477dc23c3f07489c458aea039 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Fri, 20 Feb 2026 01:16:26 -0500 Subject: [PATCH 14/24] refactor: remove redundant context parameter, method already checks conn.ctx.Done() --- dial.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/dial.go b/dial.go index bd2d1b6..74d0823 100644 --- a/dial.go +++ b/dial.go @@ -172,7 +172,7 @@ func (d Dialer) DialContext(ctx context.Context, networkID string, signaling Sig return nil, fmt.Errorf("parse offer: %w", err) } - go d.handleConn(c.Context(), c, signals) + go d.handleConn(c, signals) select { case <-ctx.Done(): @@ -279,13 +279,10 @@ func (d Dialer) startTransports(ctx context.Context, conn *Conn, desc *descripti } // handleConn handles incoming Signals signaled from the remote connection and calls Conn.handleSignal -// to handle them within the Conn. The [context.Context] is used to return immediately when it has been -// canceled or exceeded the deadline. -func (d Dialer) handleConn(ctx context.Context, conn *Conn, signals <-chan *Signal) { +// to handle them within the Conn. It returns when the Conn context is canceled. +func (d Dialer) handleConn(conn *Conn, signals <-chan *Signal) { for { select { - case <-ctx.Done(): - return case <-conn.ctx.Done(): return case signal, ok := <-signals: From daa13c266962e980dae15fbfc1311226c58be718 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:33:35 -0400 Subject: [PATCH 15/24] Fix discovery packet validation and signaling edge cases --- .gitignore | 4 +- conn.go | 23 +++-- conn_test.go | 36 ++++++++ dial.go | 23 +++-- dial_test.go | 91 +++++++++++++++++++ discovery/listener.go | 26 ++++-- discovery/listener_address_test.go | 45 ++++++++++ discovery/listener_cache_test.go | 32 +++++++ discovery/packet.go | 19 +++- discovery/packet_test.go | 139 +++++++++++++++++++++++++++++ discovery/server_data.go | 15 +++- discovery/server_data_test.go | 94 +++++++++++++++++++ signal.go | 6 ++ signal_test.go | 35 ++++++++ 14 files changed, 560 insertions(+), 28 deletions(-) create mode 100644 dial_test.go create mode 100644 discovery/listener_address_test.go create mode 100644 discovery/listener_cache_test.go create mode 100644 discovery/packet_test.go create mode 100644 discovery/server_data_test.go create mode 100644 signal_test.go diff --git a/.gitignore b/.gitignore index d957fc9..37b8144 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,6 @@ fabric.properties # Editor-based Rest Client .idea/httpRequests -.vscode \ No newline at end of file +.vscode + +.clawpatch \ No newline at end of file diff --git a/conn.go b/conn.go index 27f3026..7cd233f 100644 --- a/conn.go +++ b/conn.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "log/slog" "math" "math/rand/v2" @@ -69,6 +68,9 @@ type Conn struct { // channelsMu guards channels from concurrent read-write access during startup and closure. channelsMu sync.RWMutex + readMu sync.Mutex + readBuf []byte + // once ensures that the Conn is closed only once. once sync.Once @@ -90,13 +92,20 @@ type Conn struct { // Read receives a message from the 'ReliableDataChannel'. The bytes of the message data are copied to // the given data. An error may be returned if the Conn has been closed by [Conn.Close]. func (conn *Conn) Read(b []byte) (n int, err error) { - pk, err := conn.Receive(MessageReliabilityReliable) - if err != nil { - return n, err + conn.readMu.Lock() + defer conn.readMu.Unlock() + + if len(conn.readBuf) == 0 { + pk, err := conn.Receive(MessageReliabilityReliable) + if err != nil { + return n, err + } + conn.readBuf = pk } - n = copy(b, pk) - if n < len(pk) { - return n, io.ErrShortBuffer + n = copy(b, conn.readBuf) + conn.readBuf = conn.readBuf[n:] + if len(conn.readBuf) == 0 { + conn.readBuf = nil } return n, nil } diff --git a/conn_test.go b/conn_test.go index 93bd81a..8ea8613 100644 --- a/conn_test.go +++ b/conn_test.go @@ -1,6 +1,7 @@ package nethernet import ( + "context" "errors" "net" "testing" @@ -25,3 +26,38 @@ func TestClosedWriteError(t *testing.T) { } }) } + +func TestConnReadKeepsRemainderWhenBufferIsShort(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + + conn := &Conn{ctx: ctx} + packets := make(chan []byte, 1) + conn.storeChannel(MessageReliabilityReliable, &dataChannel{packets: packets}) + packets <- []byte("hello") + + b := make([]byte, 2) + n, err := conn.Read(b) + if err != nil { + t.Fatalf("first Read() error = %v, want nil", err) + } + if got := string(b[:n]); got != "he" { + t.Fatalf("first Read() = %q, want %q", got, "he") + } + + n, err = conn.Read(b) + if err != nil { + t.Fatalf("second Read() error = %v, want nil", err) + } + if got := string(b[:n]); got != "ll" { + t.Fatalf("second Read() = %q, want %q", got, "ll") + } + + n, err = conn.Read(b) + if err != nil { + t.Fatalf("third Read() error = %v, want nil", err) + } + if got := string(b[:n]); got != "o" { + t.Fatalf("third Read() = %q, want %q", got, "o") + } +} diff --git a/dial.go b/dial.go index 5923dac..b6ae5ce 100644 --- a/dial.go +++ b/dial.go @@ -9,6 +9,7 @@ import ( "net" "strconv" "sync" + "time" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" @@ -220,14 +221,24 @@ func (d dialerConn) log() *slog.Logger { // signalError sends a SignalTypeError to the remote connection using the // provided [Signaling] implementation, remote network ID, and error code. func (d Dialer) signalError(signaling Signaling, networkID string, code int) { - _ = signaling.Signal(context.Background(), &Signal{ - Type: SignalTypeError, - Data: strconv.Itoa(code), - ConnectionID: d.ConnectionID, - NetworkID: networkID, - }) + go func() { + parent := signaling.Context() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, signalErrorTimeout) + defer cancel() + _ = signaling.Signal(ctx, &Signal{ + Type: SignalTypeError, + Data: strconv.Itoa(code), + ConnectionID: d.ConnectionID, + NetworkID: networkID, + }) + }() } +const signalErrorTimeout = time.Second * 2 + // startTransports starts the ICE transport as [webrtc.ICERoleControlling], // then starts DTLS and SCTP using the parameters from the remote description. // After SCTP is established, it creates the 'ReliableDataChannel' and diff --git a/dial_test.go b/dial_test.go new file mode 100644 index 0000000..3cbb7c5 --- /dev/null +++ b/dial_test.go @@ -0,0 +1,91 @@ +package nethernet + +import ( + "context" + "errors" + "net" + "sync" + "testing" + "time" +) + +func TestDialContextDoesNotWaitIndefinitelyForErrorSignal(t *testing.T) { + signaling := newBlockingErrorSignaling("client") + + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*20) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := (Dialer{}).DialContext(ctx, "server", signaling) + done <- err + }() + + select { + case err := <-done: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("DialContext() error = %v, want context deadline exceeded", err) + } + case <-time.After(time.Millisecond * 250): + t.Fatal("DialContext() did not return promptly after its context deadline") + } + + select { + case <-signaling.errorSignalStarted: + case <-time.After(time.Second): + t.Fatal("DialContext() did not attempt to signal the timeout error") + } +} + +type blockingErrorSignaling struct { + id string + + ctx context.Context + cancel context.CancelCauseFunc + + once sync.Once + errorSignalStarted chan struct{} +} + +func newBlockingErrorSignaling(id string) *blockingErrorSignaling { + ctx, cancel := context.WithCancelCause(context.Background()) + return &blockingErrorSignaling{ + id: id, + ctx: ctx, + cancel: cancel, + errorSignalStarted: make(chan struct{}), + } +} + +func (s *blockingErrorSignaling) Signal(ctx context.Context, signal *Signal) error { + if signal.Type != SignalTypeError { + return nil + } + s.once.Do(func() { + close(s.errorSignalStarted) + }) + <-ctx.Done() + return ctx.Err() +} + +func (*blockingErrorSignaling) Notify(chan<- *Signal) func() { + return func() {} +} + +func (s *blockingErrorSignaling) Context() context.Context { + return s.ctx +} + +func (*blockingErrorSignaling) Credentials(context.Context) (*Credentials, error) { + return nil, nil +} + +func (s *blockingErrorSignaling) NetworkID() string { + return s.id +} + +func (*blockingErrorSignaling) PongData([]byte) {} + +func (s *blockingErrorSignaling) close() { + s.cancel(net.ErrClosed) +} diff --git a/discovery/listener.go b/discovery/listener.go index 60843be..70f0e51 100644 --- a/discovery/listener.go +++ b/discovery/listener.go @@ -8,7 +8,6 @@ import ( "maps" "math/rand" "net" - "net/netip" "strconv" "strings" "sync" @@ -42,9 +41,8 @@ func (conf ListenConfig) Listen(addr string) (*Listener, error) { if conf.NetworkID == 0 { conf.NetworkID = rand.Uint64() } - addrPort, err := netip.ParseAddrPort(addr) - if err != nil { - return nil, fmt.Errorf("parse address: %w", err) + if addr == "" { + addr = ":0" } // We hardcode network protocol for "udp" as it always expects UDP packets to be received. conn, err := net.ListenPacket("udp", addr) @@ -52,7 +50,13 @@ func (conf ListenConfig) Listen(addr string) (*Listener, error) { return nil, err } - if conf.BroadcastAddress == nil && addrPort.Port() != DefaultPort { + localAddr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("unexpected local address type %T", conn.LocalAddr()) + } + + if conf.BroadcastAddress == nil && localAddr.Port != DefaultPort { // If the port for the address is 7551, it means no applications are listening on this network // and server discovery using limited broadcast on net.IPv4bcast is not meaningful. conf.BroadcastAddress = &net.UDPAddr{ @@ -289,10 +293,10 @@ func (l *Listener) handlePacket(data []byte, addr net.Addr) error { if !ok { a = address{ networkID: senderID, - addr: addr, } } - // Update or set the timestamp for expiring them in deleteInactiveAddresses. + // Update or set the address and timestamp for expiring them in deleteInactiveAddresses. + a.addr = addr a.t = time.Now() l.addresses[senderID] = a l.addressesMu.Unlock() @@ -369,7 +373,13 @@ func (l *Listener) handleMessage(pk *MessagePacket, senderID uint64) error { // ServerData stores the ServerData for responding to the clients broadcasting // RequestPacket with a ResponsePacket containing the binary representation. func (l *Listener) ServerData(d *ServerData) { - b, _ := d.MarshalBinary() + b, err := d.MarshalBinary() + if err != nil { + if l.conf.Log != nil { + l.conf.Log.Error("error marshaling server data", slog.Any("error", err)) + } + return + } l.pongData.Store(&b) } diff --git a/discovery/listener_address_test.go b/discovery/listener_address_test.go new file mode 100644 index 0000000..92f08e6 --- /dev/null +++ b/discovery/listener_address_test.go @@ -0,0 +1,45 @@ +package discovery + +import ( + "net" + "testing" +) + +func TestListenAcceptsClientAddressForms(t *testing.T) { + for _, tt := range []struct { + name string + addr string + }{ + {name: "empty", addr: ""}, + {name: "portZero", addr: ":0"}, + } { + t.Run(tt.name, func(t *testing.T) { + l, err := (ListenConfig{}).Listen(tt.addr) + if err != nil { + t.Fatalf("Listen(%q): %v", tt.addr, err) + } + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + + localAddr, ok := l.conn.LocalAddr().(*net.UDPAddr) + if !ok { + t.Fatalf("local address = %T, want *net.UDPAddr", l.conn.LocalAddr()) + } + if localAddr.Port == DefaultPort { + t.Skipf("port-zero bind selected default port %d", DefaultPort) + } + if l.conf.BroadcastAddress == nil { + t.Fatal("BroadcastAddress is nil") + } + if !l.conf.BroadcastAddress.IP.Equal(net.IPv4bcast) { + t.Fatalf("BroadcastAddress.IP = %v, want %v", l.conf.BroadcastAddress.IP, net.IPv4bcast) + } + if l.conf.BroadcastAddress.Port != DefaultPort { + t.Fatalf("BroadcastAddress.Port = %d, want %d", l.conf.BroadcastAddress.Port, DefaultPort) + } + }) + } +} diff --git a/discovery/listener_cache_test.go b/discovery/listener_cache_test.go new file mode 100644 index 0000000..2d05c4f --- /dev/null +++ b/discovery/listener_cache_test.go @@ -0,0 +1,32 @@ +package discovery + +import ( + "net" + "testing" +) + +func TestHandlePacketUpdatesAddressForKnownSender(t *testing.T) { + const ( + localID uint64 = 1 + senderID uint64 = 2 + ) + l := &Listener{ + conf: ListenConfig{NetworkID: localID}, + addresses: make(map[uint64]address), + } + packet := Marshal(&MessagePacket{RecipientID: localID, Data: "Ping"}, senderID) + firstAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 19132} + secondAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 19133} + + if err := l.handlePacket(packet, firstAddr); err != nil { + t.Fatalf("handlePacket(firstAddr): %v", err) + } + if err := l.handlePacket(packet, secondAddr); err != nil { + t.Fatalf("handlePacket(secondAddr): %v", err) + } + + got := l.addresses[senderID] + if got.addr != secondAddr { + t.Fatalf("cached addr = %v, want %v", got.addr, secondAddr) + } +} diff --git a/discovery/packet.go b/discovery/packet.go index aa58ec0..bc7271d 100644 --- a/discovery/packet.go +++ b/discovery/packet.go @@ -67,6 +67,9 @@ func Unmarshal(b []byte) (Packet, uint64, error) { if err := binary.Read(buf, binary.LittleEndian, &length); err != nil { return nil, 0, fmt.Errorf("read length: %w", err) } + if remaining := buf.Len(); int(length) != remaining { + return nil, 0, fmt.Errorf("invalid packet length: %d, remaining %d", length, remaining) + } h := &Header{} if err := h.Read(buf); err != nil { return nil, 0, fmt.Errorf("read header: %w", err) @@ -98,11 +101,16 @@ func readBytes[L ~uint32 | ~uint8](r io.Reader) ([]byte, error) { if err := binary.Read(r, binary.LittleEndian, &length); err != nil { return nil, fmt.Errorf("read length: %w", err) } - b := make([]byte, length) - if n, err := r.Read(b); err != nil { + length64 := uint64(length) + if length64 > maxPacketPayloadLength { + return nil, fmt.Errorf("invalid length: %d, max %d", length, maxPacketPayloadLength) + } + if l, ok := r.(interface{ Len() int }); ok && length64 > uint64(l.Len()) { + return nil, fmt.Errorf("invalid length: %d, remaining %d", length, l.Len()) + } + b := make([]byte, int(length64)) + if _, err := io.ReadFull(r, b); err != nil { return nil, err - } else if n != int(length) { - return nil, fmt.Errorf("invalid length: %d, expected %d", n, length) } return b, nil } @@ -114,6 +122,9 @@ func writeBytes[L ~uint32 | ~uint8](w io.Writer, b []byte) { } const ( + // maxPacketPayloadLength is 65,535 bytes, matching the uint16 length prefix. + maxPacketPayloadLength = 1<<16 - 1 + IDRequestPacket uint16 = iota IDResponsePacket IDMessagePacket diff --git a/discovery/packet_test.go b/discovery/packet_test.go new file mode 100644 index 0000000..faab3ec --- /dev/null +++ b/discovery/packet_test.go @@ -0,0 +1,139 @@ +package discovery + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "strings" + "testing" +) + +func TestMarshalUnmarshalRoundTripsDiscoveryPackets(t *testing.T) { + const senderID uint64 = 42 + + tests := []struct { + name string + packet Packet + assert func(testing.TB, Packet) + }{ + { + name: "request", + packet: &RequestPacket{}, + assert: func(t testing.TB, got Packet) { + t.Helper() + if _, ok := got.(*RequestPacket); !ok { + t.Fatalf("packet = %T, want *RequestPacket", got) + } + }, + }, + { + name: "response", + packet: &ResponsePacket{ApplicationData: []byte{0, 1, 2, 0xff}}, + assert: func(t testing.TB, got Packet) { + t.Helper() + response, ok := got.(*ResponsePacket) + if !ok { + t.Fatalf("packet = %T, want *ResponsePacket", got) + } + if want := []byte{0, 1, 2, 0xff}; !bytes.Equal(response.ApplicationData, want) { + t.Fatalf("ApplicationData = %v, want %v", response.ApplicationData, want) + } + }, + }, + { + name: "message", + packet: &MessagePacket{RecipientID: 99, Data: "CONNECTREQUEST 7 payload"}, + assert: func(t testing.TB, got Packet) { + t.Helper() + message, ok := got.(*MessagePacket) + if !ok { + t.Fatalf("packet = %T, want *MessagePacket", got) + } + if message.RecipientID != 99 { + t.Fatalf("RecipientID = %d, want 99", message.RecipientID) + } + if message.Data != "CONNECTREQUEST 7 payload" { + t.Fatalf("Data = %q, want %q", message.Data, "CONNECTREQUEST 7 payload") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotSenderID, err := Unmarshal(Marshal(tt.packet, senderID)) + if err != nil { + t.Fatalf("Unmarshal(Marshal()) error = %v, want nil", err) + } + if gotSenderID != senderID { + t.Fatalf("sender ID = %d, want %d", gotSenderID, senderID) + } + tt.assert(t, got) + }) + } +} + +func TestUnmarshalRejectsInvalidPacketLength(t *testing.T) { + body := &bytes.Buffer{} + (&Header{PacketID: IDRequestPacket, SenderID: 1}).Write(body) + + _, _, err := Unmarshal(sealPayload(append( + binary.LittleEndian.AppendUint16(nil, uint16(body.Len()+1)), + body.Bytes()..., + ))) + if err == nil || !strings.Contains(err.Error(), "invalid packet length") { + t.Fatalf("Unmarshal() error = %v, want invalid packet length", err) + } +} + +func TestUnmarshalRejectsOversizedNestedLengths(t *testing.T) { + const oversizedLength = uint32(maxPacketPayloadLength) + + tests := []struct { + name string + packetID uint16 + write func(*bytes.Buffer) + }{ + { + name: "message data", + packetID: IDMessagePacket, + write: func(body *bytes.Buffer) { + _ = binary.Write(body, binary.LittleEndian, uint64(2)) + _ = binary.Write(body, binary.LittleEndian, oversizedLength) + }, + }, + { + name: "response application data", + packetID: IDResponsePacket, + write: func(body *bytes.Buffer) { + _ = binary.Write(body, binary.LittleEndian, oversizedLength) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := Unmarshal(rawPacket(tt.packetID, tt.write)) + if err == nil || !strings.Contains(err.Error(), "invalid length") { + t.Fatalf("Unmarshal() error = %v, want invalid length", err) + } + }) + } +} + +func rawPacket(packetID uint16, write func(*bytes.Buffer)) []byte { + body := &bytes.Buffer{} + (&Header{PacketID: packetID, SenderID: 1}).Write(body) + write(body) + return sealPayload(append( + binary.LittleEndian.AppendUint16(nil, uint16(body.Len())), + body.Bytes()..., + )) +} + +func sealPayload(payload []byte) []byte { + hash := hmac.New(sha256.New, key[:]) + hash.Write(payload) + return append(hash.Sum(nil), encrypt(payload)...) +} diff --git a/discovery/server_data.go b/discovery/server_data.go index 8ede236..5106728 100644 --- a/discovery/server_data.go +++ b/discovery/server_data.go @@ -44,10 +44,18 @@ type ServerData struct { // MarshalBinary ... func (d *ServerData) MarshalBinary() ([]byte, error) { buf := &bytes.Buffer{} + serverName := []byte(d.ServerName) + if len(serverName) > maxServerDataNameLength { + return nil, fmt.Errorf("server name length %d exceeds %d byte limit", len(serverName), maxServerDataNameLength) + } + levelName := []byte(d.LevelName) + if len(levelName) > maxServerDataNameLength { + return nil, fmt.Errorf("level name length %d exceeds %d byte limit", len(levelName), maxServerDataNameLength) + } _ = binary.Write(buf, binary.LittleEndian, version) - writeBytes[uint8](buf, []byte(d.ServerName)) - writeBytes[uint8](buf, []byte(d.LevelName)) + writeBytes[uint8](buf, serverName) + writeBytes[uint8](buf, levelName) _ = binary.Write(buf, binary.LittleEndian, d.GameType<<1) _ = binary.Write(buf, binary.LittleEndian, d.PlayerCount) _ = binary.Write(buf, binary.LittleEndian, d.MaxPlayerCount) @@ -116,3 +124,6 @@ func (d *ServerData) UnmarshalBinary(data []byte) error { // version is the current version of ServerData as supported by the `discovery` package. const version uint8 = 4 + +// maxServerDataNameLength is 255 bytes, matching the uint8 string length prefix. +const maxServerDataNameLength = 1<<8 - 1 diff --git a/discovery/server_data_test.go b/discovery/server_data_test.go new file mode 100644 index 0000000..6d9b596 --- /dev/null +++ b/discovery/server_data_test.go @@ -0,0 +1,94 @@ +package discovery + +import ( + "strings" + "testing" +) + +func TestServerDataMarshalBinaryNameLengthBoundary(t *testing.T) { + tests := []struct { + name string + data *ServerData + }{ + { + name: "server name", + data: testServerData(strings.Repeat("s", maxServerDataNameLength), "world"), + }, + { + name: "level name", + data: testServerData("server", strings.Repeat("l", maxServerDataNameLength)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := tt.data.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary() error = %v, want nil", err) + } + + var got ServerData + if err := got.UnmarshalBinary(b); err != nil { + t.Fatalf("UnmarshalBinary() error = %v, want nil", err) + } + if got.ServerName != tt.data.ServerName { + t.Fatalf("ServerName = %q, want %q", got.ServerName, tt.data.ServerName) + } + if got.LevelName != tt.data.LevelName { + t.Fatalf("LevelName = %q, want %q", got.LevelName, tt.data.LevelName) + } + }) + } +} + +func TestServerDataMarshalBinaryRejectsOverlongNames(t *testing.T) { + tests := []struct { + name string + data *ServerData + }{ + { + name: "server name", + data: testServerData(strings.Repeat("s", maxServerDataNameLength+1), "world"), + }, + { + name: "level name", + data: testServerData("server", strings.Repeat("l", maxServerDataNameLength+1)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := tt.data.MarshalBinary(); err == nil { + t.Fatal("MarshalBinary() error = nil, want overlong name error") + } + }) + } +} + +func TestListenerServerDataDoesNotReplacePongDataOnMarshalError(t *testing.T) { + l := &Listener{} + l.ServerData(testServerData("server", "world")) + + before := l.pongData.Load() + if before == nil { + t.Fatal("pongData was not set by valid server data") + } + + l.ServerData(testServerData(strings.Repeat("s", maxServerDataNameLength+1), "world")) + after := l.pongData.Load() + if after != before { + t.Fatal("pongData was replaced after MarshalBinary error") + } +} + +func testServerData(serverName, levelName string) *ServerData { + return &ServerData{ + ServerName: serverName, + LevelName: levelName, + GameType: 2, + PlayerCount: 1, + MaxPlayerCount: 8, + TransportLayer: 2, + ConnectionType: 4, + } +} diff --git a/signal.go b/signal.go index 59535f9..fdd2b5b 100644 --- a/signal.go +++ b/signal.go @@ -145,6 +145,12 @@ func formatICECandidate(id int, candidate webrtc.ICECandidate, iceParams webrtc. b.WriteString(strconv.FormatUint(uint64(candidate.RelatedPort), 10)) b.WriteByte(' ') } + if candidate.Protocol == webrtc.ICEProtocolTCP && candidate.TCPType != "" { + b.WriteString("tcptype") + b.WriteByte(' ') + b.WriteString(candidate.TCPType) + b.WriteByte(' ') + } b.WriteString("generation") b.WriteByte(' ') b.WriteByte('0') diff --git a/signal_test.go b/signal_test.go new file mode 100644 index 0000000..2252c0c --- /dev/null +++ b/signal_test.go @@ -0,0 +1,35 @@ +package nethernet + +import ( + "strings" + "testing" + + "github.com/pion/webrtc/v4" +) + +func TestFormatICECandidatePreservesTCPType(t *testing.T) { + const tcpType = "passive" + candidate := webrtc.ICECandidate{ + Foundation: "tcp", + Priority: 1234, + Address: "192.0.2.1", + Protocol: webrtc.ICEProtocolTCP, + Port: 9, + Component: 1, + Typ: webrtc.ICECandidateTypeHost, + TCPType: tcpType, + } + + formatted := formatICECandidate(7, candidate, webrtc.ICEParameters{UsernameFragment: "ufrag"}) + if !strings.Contains(formatted, " tcptype "+tcpType+" ") { + t.Fatalf("formatted candidate = %q, want tcptype %q", formatted, tcpType) + } + + got, err := parseRemoteCandidate(formatted) + if err != nil { + t.Fatalf("parseRemoteCandidate() error = %v, want nil", err) + } + if got.TCPType != tcpType { + t.Fatalf("TCPType = %q, want %q", got.TCPType, tcpType) + } +} From 32e74c03bb0a1b48b77adadf0508a566352cbb84 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:35:19 -0400 Subject: [PATCH 16/24] remove signal_test.go --- signal_test.go | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 signal_test.go diff --git a/signal_test.go b/signal_test.go deleted file mode 100644 index 2252c0c..0000000 --- a/signal_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package nethernet - -import ( - "strings" - "testing" - - "github.com/pion/webrtc/v4" -) - -func TestFormatICECandidatePreservesTCPType(t *testing.T) { - const tcpType = "passive" - candidate := webrtc.ICECandidate{ - Foundation: "tcp", - Priority: 1234, - Address: "192.0.2.1", - Protocol: webrtc.ICEProtocolTCP, - Port: 9, - Component: 1, - Typ: webrtc.ICECandidateTypeHost, - TCPType: tcpType, - } - - formatted := formatICECandidate(7, candidate, webrtc.ICEParameters{UsernameFragment: "ufrag"}) - if !strings.Contains(formatted, " tcptype "+tcpType+" ") { - t.Fatalf("formatted candidate = %q, want tcptype %q", formatted, tcpType) - } - - got, err := parseRemoteCandidate(formatted) - if err != nil { - t.Fatalf("parseRemoteCandidate() error = %v, want nil", err) - } - if got.TCPType != tcpType { - t.Fatalf("TCPType = %q, want %q", got.TCPType, tcpType) - } -} From 7e1878840110fe56054a4d9a0fcec0d34b99a522 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:38:55 -0400 Subject: [PATCH 17/24] trim tests --- discovery/listener_address_test.go | 45 --------------------- discovery/listener_cache_test.go | 32 --------------- discovery/packet_test.go | 65 ------------------------------ 3 files changed, 142 deletions(-) delete mode 100644 discovery/listener_address_test.go delete mode 100644 discovery/listener_cache_test.go diff --git a/discovery/listener_address_test.go b/discovery/listener_address_test.go deleted file mode 100644 index 92f08e6..0000000 --- a/discovery/listener_address_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package discovery - -import ( - "net" - "testing" -) - -func TestListenAcceptsClientAddressForms(t *testing.T) { - for _, tt := range []struct { - name string - addr string - }{ - {name: "empty", addr: ""}, - {name: "portZero", addr: ":0"}, - } { - t.Run(tt.name, func(t *testing.T) { - l, err := (ListenConfig{}).Listen(tt.addr) - if err != nil { - t.Fatalf("Listen(%q): %v", tt.addr, err) - } - t.Cleanup(func() { - if err := l.Close(); err != nil { - t.Errorf("Close: %v", err) - } - }) - - localAddr, ok := l.conn.LocalAddr().(*net.UDPAddr) - if !ok { - t.Fatalf("local address = %T, want *net.UDPAddr", l.conn.LocalAddr()) - } - if localAddr.Port == DefaultPort { - t.Skipf("port-zero bind selected default port %d", DefaultPort) - } - if l.conf.BroadcastAddress == nil { - t.Fatal("BroadcastAddress is nil") - } - if !l.conf.BroadcastAddress.IP.Equal(net.IPv4bcast) { - t.Fatalf("BroadcastAddress.IP = %v, want %v", l.conf.BroadcastAddress.IP, net.IPv4bcast) - } - if l.conf.BroadcastAddress.Port != DefaultPort { - t.Fatalf("BroadcastAddress.Port = %d, want %d", l.conf.BroadcastAddress.Port, DefaultPort) - } - }) - } -} diff --git a/discovery/listener_cache_test.go b/discovery/listener_cache_test.go deleted file mode 100644 index 2d05c4f..0000000 --- a/discovery/listener_cache_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package discovery - -import ( - "net" - "testing" -) - -func TestHandlePacketUpdatesAddressForKnownSender(t *testing.T) { - const ( - localID uint64 = 1 - senderID uint64 = 2 - ) - l := &Listener{ - conf: ListenConfig{NetworkID: localID}, - addresses: make(map[uint64]address), - } - packet := Marshal(&MessagePacket{RecipientID: localID, Data: "Ping"}, senderID) - firstAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 19132} - secondAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 19133} - - if err := l.handlePacket(packet, firstAddr); err != nil { - t.Fatalf("handlePacket(firstAddr): %v", err) - } - if err := l.handlePacket(packet, secondAddr); err != nil { - t.Fatalf("handlePacket(secondAddr): %v", err) - } - - got := l.addresses[senderID] - if got.addr != secondAddr { - t.Fatalf("cached addr = %v, want %v", got.addr, secondAddr) - } -} diff --git a/discovery/packet_test.go b/discovery/packet_test.go index faab3ec..5b78ba2 100644 --- a/discovery/packet_test.go +++ b/discovery/packet_test.go @@ -9,71 +9,6 @@ import ( "testing" ) -func TestMarshalUnmarshalRoundTripsDiscoveryPackets(t *testing.T) { - const senderID uint64 = 42 - - tests := []struct { - name string - packet Packet - assert func(testing.TB, Packet) - }{ - { - name: "request", - packet: &RequestPacket{}, - assert: func(t testing.TB, got Packet) { - t.Helper() - if _, ok := got.(*RequestPacket); !ok { - t.Fatalf("packet = %T, want *RequestPacket", got) - } - }, - }, - { - name: "response", - packet: &ResponsePacket{ApplicationData: []byte{0, 1, 2, 0xff}}, - assert: func(t testing.TB, got Packet) { - t.Helper() - response, ok := got.(*ResponsePacket) - if !ok { - t.Fatalf("packet = %T, want *ResponsePacket", got) - } - if want := []byte{0, 1, 2, 0xff}; !bytes.Equal(response.ApplicationData, want) { - t.Fatalf("ApplicationData = %v, want %v", response.ApplicationData, want) - } - }, - }, - { - name: "message", - packet: &MessagePacket{RecipientID: 99, Data: "CONNECTREQUEST 7 payload"}, - assert: func(t testing.TB, got Packet) { - t.Helper() - message, ok := got.(*MessagePacket) - if !ok { - t.Fatalf("packet = %T, want *MessagePacket", got) - } - if message.RecipientID != 99 { - t.Fatalf("RecipientID = %d, want 99", message.RecipientID) - } - if message.Data != "CONNECTREQUEST 7 payload" { - t.Fatalf("Data = %q, want %q", message.Data, "CONNECTREQUEST 7 payload") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, gotSenderID, err := Unmarshal(Marshal(tt.packet, senderID)) - if err != nil { - t.Fatalf("Unmarshal(Marshal()) error = %v, want nil", err) - } - if gotSenderID != senderID { - t.Fatalf("sender ID = %d, want %d", gotSenderID, senderID) - } - tt.assert(t, got) - }) - } -} - func TestUnmarshalRejectsInvalidPacketLength(t *testing.T) { body := &bytes.Buffer{} (&Header{PacketID: IDRequestPacket, SenderID: 1}).Write(body) From 71dc99e5f9135eddf12ea09c210db50530816140 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:39:48 -0400 Subject: [PATCH 18/24] simplify --- dial.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/dial.go b/dial.go index b6ae5ce..6075f36 100644 --- a/dial.go +++ b/dial.go @@ -222,11 +222,7 @@ func (d dialerConn) log() *slog.Logger { // provided [Signaling] implementation, remote network ID, and error code. func (d Dialer) signalError(signaling Signaling, networkID string, code int) { go func() { - parent := signaling.Context() - if parent == nil { - parent = context.Background() - } - ctx, cancel := context.WithTimeout(parent, signalErrorTimeout) + ctx, cancel := context.WithTimeout(signaling.Context(), signalErrorTimeout) defer cancel() _ = signaling.Signal(ctx, &Signal{ Type: SignalTypeError, From 02b8e4a865c95f9d376bf99b2b03c7d2bf361399 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:42:30 -0400 Subject: [PATCH 19/24] Clarify discovery length handling Use the widest actual discovery length prefix type instead of widening to uint64 when bounding nested packet reads. Constraint: readBytes only supports uint8 and uint32 length prefixes. Confidence: high Scope-risk: narrow Tested: go test -count=1 ./... --- discovery/packet.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/discovery/packet.go b/discovery/packet.go index bc7271d..ac3a581 100644 --- a/discovery/packet.go +++ b/discovery/packet.go @@ -101,14 +101,14 @@ func readBytes[L ~uint32 | ~uint8](r io.Reader) ([]byte, error) { if err := binary.Read(r, binary.LittleEndian, &length); err != nil { return nil, fmt.Errorf("read length: %w", err) } - length64 := uint64(length) - if length64 > maxPacketPayloadLength { - return nil, fmt.Errorf("invalid length: %d, max %d", length, maxPacketPayloadLength) + n := uint32(length) + if n > maxPacketPayloadLength { + return nil, fmt.Errorf("invalid length: %d, max %d", n, maxPacketPayloadLength) } - if l, ok := r.(interface{ Len() int }); ok && length64 > uint64(l.Len()) { - return nil, fmt.Errorf("invalid length: %d, remaining %d", length, l.Len()) + if l, ok := r.(interface{ Len() int }); ok && n > uint32(l.Len()) { + return nil, fmt.Errorf("invalid length: %d, remaining %d", n, l.Len()) } - b := make([]byte, int(length64)) + b := make([]byte, n) if _, err := io.ReadFull(r, b); err != nil { return nil, err } From 6a2c796a14790d3d1d96572d534efdd7ababc6e4 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:52:12 -0400 Subject: [PATCH 20/24] Trim server data regression tests Keep only the overlong-name rejection coverage and remove broad boundary/internal listener-state checks to keep the upstream PR focused. Constraint: upstream review should see tests tied directly to non-obvious behavior changes. Confidence: high Scope-risk: narrow Tested: go test -count=1 ./... --- discovery/server_data_test.go | 52 ----------------------------------- 1 file changed, 52 deletions(-) diff --git a/discovery/server_data_test.go b/discovery/server_data_test.go index 6d9b596..9f609b5 100644 --- a/discovery/server_data_test.go +++ b/discovery/server_data_test.go @@ -5,42 +5,6 @@ import ( "testing" ) -func TestServerDataMarshalBinaryNameLengthBoundary(t *testing.T) { - tests := []struct { - name string - data *ServerData - }{ - { - name: "server name", - data: testServerData(strings.Repeat("s", maxServerDataNameLength), "world"), - }, - { - name: "level name", - data: testServerData("server", strings.Repeat("l", maxServerDataNameLength)), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b, err := tt.data.MarshalBinary() - if err != nil { - t.Fatalf("MarshalBinary() error = %v, want nil", err) - } - - var got ServerData - if err := got.UnmarshalBinary(b); err != nil { - t.Fatalf("UnmarshalBinary() error = %v, want nil", err) - } - if got.ServerName != tt.data.ServerName { - t.Fatalf("ServerName = %q, want %q", got.ServerName, tt.data.ServerName) - } - if got.LevelName != tt.data.LevelName { - t.Fatalf("LevelName = %q, want %q", got.LevelName, tt.data.LevelName) - } - }) - } -} - func TestServerDataMarshalBinaryRejectsOverlongNames(t *testing.T) { tests := []struct { name string @@ -65,22 +29,6 @@ func TestServerDataMarshalBinaryRejectsOverlongNames(t *testing.T) { } } -func TestListenerServerDataDoesNotReplacePongDataOnMarshalError(t *testing.T) { - l := &Listener{} - l.ServerData(testServerData("server", "world")) - - before := l.pongData.Load() - if before == nil { - t.Fatal("pongData was not set by valid server data") - } - - l.ServerData(testServerData(strings.Repeat("s", maxServerDataNameLength+1), "world")) - after := l.pongData.Load() - if after != before { - t.Fatal("pongData was replaced after MarshalBinary error") - } -} - func testServerData(serverName, levelName string) *ServerData { return &ServerData{ ServerName: serverName, From 6dc03344dd25641763460aebf9a8274c50aefc02 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:54:52 -0400 Subject: [PATCH 21/24] Remove unused dial test helper Staticcheck flagged the test-only close helper as unused after trimming the signaling fake. Constraint: GitHub workflow runs staticcheck ./.... Confidence: high Scope-risk: narrow Tested: go vet ./... Tested: staticcheck ./... Tested: go test -count=1 ./... --- dial_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dial_test.go b/dial_test.go index 3cbb7c5..e42972e 100644 --- a/dial_test.go +++ b/dial_test.go @@ -3,7 +3,6 @@ package nethernet import ( "context" "errors" - "net" "sync" "testing" "time" @@ -85,7 +84,3 @@ func (s *blockingErrorSignaling) NetworkID() string { } func (*blockingErrorSignaling) PongData([]byte) {} - -func (s *blockingErrorSignaling) close() { - s.cancel(net.ErrClosed) -} From b08c0fc4cca9806fed5277a8fd06596ea547beb4 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 00:56:52 -0400 Subject: [PATCH 22/24] Drop TCP candidate formatting change Bedrock NetherNet disables TCP ICE candidates, so preserving TCP candidate type is outside the PR's compatibility target. Constraint: reviewer cited Mojang NetherNet onboarding docs: TCP Candidate Policy is disabled and connectivity uses UDP. Rejected: keep tcptype formatting | unnecessary for Bedrock NetherNet and adds review surface. Confidence: high Scope-risk: narrow Tested: go vet ./... Tested: staticcheck ./... Tested: go test -count=1 ./... --- signal.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/signal.go b/signal.go index fdd2b5b..59535f9 100644 --- a/signal.go +++ b/signal.go @@ -145,12 +145,6 @@ func formatICECandidate(id int, candidate webrtc.ICECandidate, iceParams webrtc. b.WriteString(strconv.FormatUint(uint64(candidate.RelatedPort), 10)) b.WriteByte(' ') } - if candidate.Protocol == webrtc.ICEProtocolTCP && candidate.TCPType != "" { - b.WriteString("tcptype") - b.WriteByte(' ') - b.WriteString(candidate.TCPType) - b.WriteByte(' ') - } b.WriteString("generation") b.WriteByte(' ') b.WriteByte('0') From b12bb295230785c0f60277147a822ed3f2927c80 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 01:07:50 -0400 Subject: [PATCH 23/24] Organize listener tests by scope Keep the live discovery listener test behind the manual tag with an explicit filename, and place default listener regressions in a unit test file. Constraint: PR review should distinguish manual live-network coverage from default unit coverage. Confidence: high Scope-risk: narrow Tested: go vet ./... Tested: staticcheck ./... Tested: go test -count=1 ./... --- ...listener_test.go => listener_live_test.go} | 0 discovery/listener_unit_test.go | 71 +++++++++++++++++++ 2 files changed, 71 insertions(+) rename discovery/{listener_test.go => listener_live_test.go} (100%) create mode 100644 discovery/listener_unit_test.go diff --git a/discovery/listener_test.go b/discovery/listener_live_test.go similarity index 100% rename from discovery/listener_test.go rename to discovery/listener_live_test.go diff --git a/discovery/listener_unit_test.go b/discovery/listener_unit_test.go new file mode 100644 index 0000000..cd1ba33 --- /dev/null +++ b/discovery/listener_unit_test.go @@ -0,0 +1,71 @@ +package discovery + +import ( + "net" + "testing" +) + +func TestListenAcceptsClientAddressForms(t *testing.T) { + for _, tt := range []struct { + name string + addr string + }{ + {name: "empty", addr: ""}, + {name: "portZero", addr: ":0"}, + } { + t.Run(tt.name, func(t *testing.T) { + l, err := (ListenConfig{}).Listen(tt.addr) + if err != nil { + t.Fatalf("Listen(%q): %v", tt.addr, err) + } + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + + localAddr, ok := l.conn.LocalAddr().(*net.UDPAddr) + if !ok { + t.Fatalf("local address = %T, want *net.UDPAddr", l.conn.LocalAddr()) + } + if localAddr.Port == DefaultPort { + t.Skipf("port-zero bind selected default port %d", DefaultPort) + } + if l.conf.BroadcastAddress == nil { + t.Fatal("BroadcastAddress is nil") + } + if !l.conf.BroadcastAddress.IP.Equal(net.IPv4bcast) { + t.Fatalf("BroadcastAddress.IP = %v, want %v", l.conf.BroadcastAddress.IP, net.IPv4bcast) + } + if l.conf.BroadcastAddress.Port != DefaultPort { + t.Fatalf("BroadcastAddress.Port = %d, want %d", l.conf.BroadcastAddress.Port, DefaultPort) + } + }) + } +} + +func TestHandlePacketUpdatesAddressForKnownSender(t *testing.T) { + const ( + localID uint64 = 1 + senderID uint64 = 2 + ) + l := &Listener{ + conf: ListenConfig{NetworkID: localID}, + addresses: make(map[uint64]address), + } + packet := Marshal(&MessagePacket{RecipientID: localID, Data: "Ping"}, senderID) + firstAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 19132} + secondAddr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 19133} + + if err := l.handlePacket(packet, firstAddr); err != nil { + t.Fatalf("handlePacket(firstAddr): %v", err) + } + if err := l.handlePacket(packet, secondAddr); err != nil { + t.Fatalf("handlePacket(secondAddr): %v", err) + } + + got := l.addresses[senderID] + if got.addr != secondAddr { + t.Fatalf("cached addr = %v, want %v", got.addr, secondAddr) + } +} From bc08c94d07326a8abd6e39156835f5e6f9cd42a5 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 4 Jun 2026 01:09:37 -0400 Subject: [PATCH 24/24] Rename listener test files Use the maintainer-preferred layout: keep the manual listener flow as an example-style test file and put default listener coverage in listener_test.go. Constraint: preserve branch history without force-pushing. Confidence: high Scope-risk: narrow Tested: go vet ./... Tested: staticcheck ./... Tested: go test -count=1 ./... --- discovery/{listener_live_test.go => example_listener_test.go} | 0 discovery/{listener_unit_test.go => listener_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename discovery/{listener_live_test.go => example_listener_test.go} (100%) rename discovery/{listener_unit_test.go => listener_test.go} (100%) diff --git a/discovery/listener_live_test.go b/discovery/example_listener_test.go similarity index 100% rename from discovery/listener_live_test.go rename to discovery/example_listener_test.go diff --git a/discovery/listener_unit_test.go b/discovery/listener_test.go similarity index 100% rename from discovery/listener_unit_test.go rename to discovery/listener_test.go