Skip to content

Replace main with always-masque-relay branch contents - #63

Merged
mickvandijke merged 0 commit into
mainfrom
mick/always-masque-relay-rebased
Apr 22, 2026
Merged

Replace main with always-masque-relay branch contents#63
mickvandijke merged 0 commit into
mainfrom
mick/always-masque-relay-rebased

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Apr 21, 2026

Copy link
Copy Markdown
Member

Summary

This PR supersedes origin/main with the contents of mick/always-masque-relay-rebased. It uses a -s ours merge so the resulting tree is exactly this branch's tree — none of the 26 commits on main that are not in this branch are applied.

Why a -s ours merge

  • Guarantees the 26 commits' changes are not reintroduced (the merge strategy discards the other side's tree entirely).
  • Keeps a single merge commit on the branch instead of 26 individual revert commits.
  • Formally records origin/main as merged, so git tooling treats the branches as reconciled afterward.

Merge instructions

Use "Create a merge commit" when merging this PR. Squash or rebase will discard the -s ours semantics and may reintroduce the superseded changes.

Test plan

  • CI passes on the branch
  • Confirm tree on main after merge matches this branch (git diff main origin/mick/always-masque-relay-rebased is empty)

Greptile Summary

This PR replaces main with the mick/always-masque-relay-rebased branch using a -s ours merge, bringing in a large set of MASQUE relay, BBRv2 congestion control, IP-diversity policy, bounded-channel backpressure, and NAT-traversal improvements. The relay infrastructure is the most significant new surface; two issues need attention before this lands:

  • Double write-lock TOCTOU in close_session (relay_server.rs): the session is marked Closed under the first write lock, released, then removed under a second write lock. Any concurrent caller (e.g. session_count(), cleanup_expired_sessions) observes a phantom closed-but-present session in between, which can cause inflated session counts against max_sessions and spurious double-close attempts.
  • Leaked background tasks in run_stream_forwarding_loop: reader_handle and writer_handle are spawned as detached tasks. When tokio::select! exits early, the JoinHandles are dropped but the tasks keep running, holding their Arc<UdpSocket> clone and preventing FD reclamation for the lifetime of the task.

Confidence Score: 3/5

Two P1 defects in the relay server's session lifecycle need fixes before merging to avoid ghost sessions and FD leaks under load.

The double write-lock TOCTOU in close_session causes incorrect session counts against the max_sessions limit and can confuse cleanup logic. The leaked JoinHandle background tasks in run_stream_forwarding_loop cause UDP socket FDs to linger after session teardown. Both are present-state defects on the hot relay path, not speculative. The rest of the PR (BBRv2, bounded channels, IP policy, NAT traversal) looks well-structured.

src/masque/relay_server.rs — close_session (double lock) and run_stream_forwarding_loop (task leak)

Important Files Changed

Filename Overview
src/masque/relay_server.rs MASQUE relay server with two P1 bugs: double write-lock TOCTOU in close_session and leaked background tasks in run_stream_forwarding_loop; also minor stats counter divergence concern.
src/masque/relay_socket.rs Virtual UDP socket over MASQUE tunnel with well-implemented bounded-channel backpressure, correct TunnelPoller wakeup logic, and GSO segment splitting.
src/masque/ip_policy.rs IP-diversity policy for relay loop prevention; known limitation in unregister_upstream_relay with multiple relays on same IP documented but fragile.
src/congestion/bbr2/mod.rs Vendored BBRv2 from Cloudflare/quiche with proper copyright attribution and scoped clippy allowances for vendored idioms.
src/relay/rate_limiter.rs Token-bucket rate limiter using DashMap for sharded per-address concurrency, replacing previous single-mutex design.
src/connection_strategy.rs Progressive connection strategy state machine (Direct → HolePunch → Relay) with configurable holepunch_enabled flag.

Sequence Diagram

sequenceDiagram
    participant Client as QUIC Client
    participant RS as MasqueRelayServer
    participant RSocket as MasqueRelaySocket
    participant UDP as Bound UDP Socket
    participant Target as Target Peer

    Client->>RS: CONNECT-UDP (handle_connect_request)
    RS->>UDP: bind(INADDR_ANY:0)
    RS->>Client: ConnectUdpResponse(advertised_addr)

    Note over RS: run_stream_forwarding_loop
    RS->>RS: spawn reader_handle (UDP→channel)
    RS->>RS: spawn writer_handle (channel→stream)

    par Direction 2: Client→Target
        Client->>RSocket: length-prefixed frame (stream)
        RSocket->>RS: recv_stream.read_exact
        RS->>UDP: send_to(target)
        UDP->>Target: raw UDP
    and Direction 1: Target→Client
        Target->>UDP: raw UDP
        UDP->>RS: reader_handle.recv_from
        RS->>RSocket: fwd_tx.send(encoded)
        RSocket->>Client: send_stream.write_all (writer_handle)
    end

    Note over RS: On any direction exit
    RS->>RS: close_session(session_id)
    RS--xClient: session cleaned up
Loading

Comments Outside Diff (2)

  1. src/masque/relay_server.rs, line 1199-1222 (link)

    P1 Double write-lock TOCTOU in close_session

    close_session acquires a write lock to call session.close() (marking the session as Closed), releases the lock, then immediately re-acquires a second write lock to actually remove the session from the map. Between these two critical sections any concurrent reader can observe a Closed-state session that is still present in sessions. Callers like session_count() return an inflated count — which is checked against max_sessions in handle_connect_request — and cleanup_expired_sessions can attempt to re-close the same session. Combine the two operations in a single lock acquisition:

    let client_addr = {
        let mut sessions = self.sessions.write().await;
        let session = sessions
            .get_mut(&session_id)
            .ok_or(RelayError::SessionError {
                session_id: Some(session_id as u32),
                kind: SessionErrorKind::NotFound,
            })?;
    
        let addr = session.client_address();
        session.close();
        sessions.remove(&session_id);  // ← remove in the same lock
        addr
    };
    // Remove from client map and UPnP as before...
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/masque/relay_server.rs
    Line: 1199-1222
    
    Comment:
    **Double write-lock TOCTOU in `close_session`**
    
    `close_session` acquires a write lock to call `session.close()` (marking the session as `Closed`), releases the lock, then immediately re-acquires a second write lock to actually remove the session from the map. Between these two critical sections any concurrent reader can observe a `Closed`-state session that is still present in `sessions`. Callers like `session_count()` return an inflated count — which is checked against `max_sessions` in `handle_connect_request` — and `cleanup_expired_sessions` can attempt to re-close the same session. Combine the two operations in a single lock acquisition:
    
    ```rust
    let client_addr = {
        let mut sessions = self.sessions.write().await;
        let session = sessions
            .get_mut(&session_id)
            .ok_or(RelayError::SessionError {
                session_id: Some(session_id as u32),
                kind: SessionErrorKind::NotFound,
            })?;
    
        let addr = session.client_address();
        session.close();
        sessions.remove(&session_id);  // ← remove in the same lock
        addr
    };
    // Remove from client map and UPnP as before...
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/masque/relay_server.rs, line 496-502 (link)

    P2 Stats counter can diverge from session map

    stats.current_active_sessions() is read (Ordering::Relaxed) and compared to max_sessions, but record_session_terminated uses fetch_sub (also Relaxed). Because active_sessions in MasqueRelayStats is decremented by record_session_terminated, which is called only at the end of close_session after both write locks, there is already a window where current_active_sessions() over-counts active sessions. This compounds with the double-lock TOCTOU noted above. No immediate fix needed beyond the close_session fix, but worth noting that the stats counter and the actual sessions map can diverge if close_session errors between the two write locks (the first lock marks-then-releases, but if ? returned early the stat was never decremented).

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/masque/relay_server.rs
    Line: 496-502
    
    Comment:
    **Stats counter can diverge from session map**
    
    `stats.current_active_sessions()` is read (`Ordering::Relaxed`) and compared to `max_sessions`, but `record_session_terminated` uses `fetch_sub` (also `Relaxed`). Because `active_sessions` in `MasqueRelayStats` is decremented by `record_session_terminated`, which is called only at the end of `close_session` after both write locks, there is already a window where `current_active_sessions()` over-counts active sessions. This compounds with the double-lock TOCTOU noted above. No immediate fix needed beyond the `close_session` fix, but worth noting that the stats counter and the actual `sessions` map can diverge if `close_session` errors between the two write locks (the first lock marks-then-releases, but if `?` returned early the stat was never decremented).
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/masque/relay_server.rs
Line: 1199-1222

Comment:
**Double write-lock TOCTOU in `close_session`**

`close_session` acquires a write lock to call `session.close()` (marking the session as `Closed`), releases the lock, then immediately re-acquires a second write lock to actually remove the session from the map. Between these two critical sections any concurrent reader can observe a `Closed`-state session that is still present in `sessions`. Callers like `session_count()` return an inflated count — which is checked against `max_sessions` in `handle_connect_request` — and `cleanup_expired_sessions` can attempt to re-close the same session. Combine the two operations in a single lock acquisition:

```rust
let client_addr = {
    let mut sessions = self.sessions.write().await;
    let session = sessions
        .get_mut(&session_id)
        .ok_or(RelayError::SessionError {
            session_id: Some(session_id as u32),
            kind: SessionErrorKind::NotFound,
        })?;

    let addr = session.client_address();
    session.close();
    sessions.remove(&session_id);  // ← remove in the same lock
    addr
};
// Remove from client map and UPnP as before...
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/masque/relay_server.rs
Line: 1065-1126

Comment:
**Leaked background tasks after `select!` exits in `run_stream_forwarding_loop`**

`reader_handle` and `writer_handle` are spawned detached tasks. When `tokio::select!` terminates early (e.g. the Direction-2 branch exits), the `JoinHandle`s are **dropped** but the tasks continue running — dropping a `JoinHandle` in Tokio does not abort the spawned task. After `close_session` releases the session's `Arc<UdpSocket>`, `reader_handle` still holds its own `Arc` clone and keeps draining UDP traffic. This prevents the OS from reclaiming the socket file-descriptor until the reader task finishes naturally, which may require an I/O error that never comes. Consider aborting the handles explicitly when select! exits:

```rust
tokio::select! {
    _ = &mut reader_handle => { writer_handle.abort(); },
    _ = &mut writer_handle => { reader_handle.abort(); },
    _ = async { /* Direction 2 */ } => {
        reader_handle.abort();
        writer_handle.abort();
    },
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/masque/ip_policy.rs
Line: 152-154

Comment:
**`unregister_upstream_relay` silently drops other relays on the same IP**

The comment on lines 148-153 acknowledges this: "If multiple upstream relays share the same IP (unusual but possible over different ports) this removes the IP entirely." However the recommended remediation — "callers should register after removal if any remain" — is fragile and error-prone. In practice, a node could have two active relay sessions to two different ports on the same public IP (e.g., two relay servers behind the same NAT). Deregistering one would unblock clients from that IP for the other, violating the upstream-IP policy. A reference-counted or multi-value set would be safer here.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/masque/relay_server.rs
Line: 496-502

Comment:
**Stats counter can diverge from session map**

`stats.current_active_sessions()` is read (`Ordering::Relaxed`) and compared to `max_sessions`, but `record_session_terminated` uses `fetch_sub` (also `Relaxed`). Because `active_sessions` in `MasqueRelayStats` is decremented by `record_session_terminated`, which is called only at the end of `close_session` after both write locks, there is already a window where `current_active_sessions()` over-counts active sessions. This compounds with the double-lock TOCTOU noted above. No immediate fix needed beyond the `close_session` fix, but worth noting that the stats counter and the actual `sessions` map can diverge if `close_session` errors between the two write locks (the first lock marks-then-releases, but if `?` returned early the stat was never decremented).

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "merge: supersede origin/main — keep bran..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Comment on lines +1065 to +1126
let (fwd_tx, mut fwd_rx) =
tokio::sync::mpsc::channel::<Bytes>(RELAY_FORWARD_CHANNEL_CAPACITY);

// Reader: UDP socket → channel (never blocked by stream writes)
let reader_handle = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
match socket.recv_from(&mut buf).await {
Ok((len, source)) => {
let payload = Bytes::copy_from_slice(&buf[..len]);
tracing::trace!(
session_id, source = %source, len,
"Stream relay: received UDP from target"
);
let datagram =
UncompressedDatagram::new(VarInt::from_u32(0), source, payload);
let encoded = datagram.encode();
stats.record_bytes(encoded.len() as u64);
stats.record_datagram();
if fwd_tx.send(encoded).await.is_err() {
break; // writer closed
}
}
Err(e) => {
tracing::debug!(session_id, error = %e, "UDP recv error");
break;
}
}
}
});

tokio::select! {
// TODO: Rate limiting — check_rate_limit should be called in both
// directions to enforce the per-session bandwidth_limit from
// RelaySessionConfig. Currently the stream path bypasses rate
// limiting entirely. Requires passing the session's rate limiter
// into this loop.
//
// Direction 1: UDP → Stream (target → relay → client)
_ = async {
let mut buf = vec![0u8; 65536];
loop {
match socket.recv_from(&mut buf).await {
Ok((len, source)) => {
let payload = Bytes::copy_from_slice(&buf[..len]);
tracing::trace!(
session_id, source = %source, len,
"Stream relay: received UDP from target"
);

let datagram = UncompressedDatagram::new(
VarInt::from_u32(0), source, payload,
);
let encoded = datagram.encode();

// Write length-prefixed frame to stream
let frame_len = encoded.len() as u32;
if let Err(e) = send_stream.write_all(&frame_len.to_be_bytes()).await {
tracing::debug!(session_id, error = %e, "Stream write error (length)");
break;
}
if let Err(e) = send_stream.write_all(&encoded).await {
tracing::debug!(session_id, error = %e, "Stream write error (data)");
break;
}
// Writer: channel → QUIC stream (paced by stream flow control)
let writer_handle = tokio::spawn(async move {
let mut keepalive = tokio::time::interval(RELAY_KEEPALIVE_INTERVAL);
keepalive.tick().await; // skip immediate first tick

stats.record_bytes(encoded.len() as u64);
stats.record_datagram();
loop {
tokio::select! {
item = fwd_rx.recv() => {
let Some(encoded) = item else { break };
let frame_len = encoded.len() as u32;
if let Err(e) = send_stream.write_all(&frame_len.to_be_bytes()).await {
tracing::debug!(session_id, error = %e, "Stream write error (length)");
break;
}
Err(e) => {
tracing::debug!(session_id, error = %e, "UDP recv error");
if let Err(e) = send_stream.write_all(&encoded).await {
tracing::debug!(session_id, error = %e, "Stream write error (data)");
break;
}
}
_ = keepalive.tick() => {
if let Err(e) = send_stream.write_all(&keepalive_bytes).await {
tracing::debug!(session_id, error = %e, "Keepalive write error");
break;
}
}
}
} => {},
}
});

tokio::select! {
_ = reader_handle => {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Leaked background tasks after select! exits in run_stream_forwarding_loop

reader_handle and writer_handle are spawned detached tasks. When tokio::select! terminates early (e.g. the Direction-2 branch exits), the JoinHandles are dropped but the tasks continue running — dropping a JoinHandle in Tokio does not abort the spawned task. After close_session releases the session's Arc<UdpSocket>, reader_handle still holds its own Arc clone and keeps draining UDP traffic. This prevents the OS from reclaiming the socket file-descriptor until the reader task finishes naturally, which may require an I/O error that never comes. Consider aborting the handles explicitly when select! exits:

tokio::select! {
    _ = &mut reader_handle => { writer_handle.abort(); },
    _ = &mut writer_handle => { reader_handle.abort(); },
    _ = async { /* Direction 2 */ } => {
        reader_handle.abort();
        writer_handle.abort();
    },
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/masque/relay_server.rs
Line: 1065-1126

Comment:
**Leaked background tasks after `select!` exits in `run_stream_forwarding_loop`**

`reader_handle` and `writer_handle` are spawned detached tasks. When `tokio::select!` terminates early (e.g. the Direction-2 branch exits), the `JoinHandle`s are **dropped** but the tasks continue running — dropping a `JoinHandle` in Tokio does not abort the spawned task. After `close_session` releases the session's `Arc<UdpSocket>`, `reader_handle` still holds its own `Arc` clone and keeps draining UDP traffic. This prevents the OS from reclaiming the socket file-descriptor until the reader task finishes naturally, which may require an I/O error that never comes. Consider aborting the handles explicitly when select! exits:

```rust
tokio::select! {
    _ = &mut reader_handle => { writer_handle.abort(); },
    _ = &mut writer_handle => { reader_handle.abort(); },
    _ = async { /* Direction 2 */ } => {
        reader_handle.abort();
        writer_handle.abort();
    },
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/masque/ip_policy.rs
Comment on lines +152 to +154
pub fn unregister_upstream_relay(&self, relay: SocketAddr) {
self.upstream_relay_ips.write().remove(&relay.ip());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 unregister_upstream_relay silently drops other relays on the same IP

The comment on lines 148-153 acknowledges this: "If multiple upstream relays share the same IP (unusual but possible over different ports) this removes the IP entirely." However the recommended remediation — "callers should register after removal if any remain" — is fragile and error-prone. In practice, a node could have two active relay sessions to two different ports on the same public IP (e.g., two relay servers behind the same NAT). Deregistering one would unblock clients from that IP for the other, violating the upstream-IP policy. A reference-counted or multi-value set would be safer here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/masque/ip_policy.rs
Line: 152-154

Comment:
**`unregister_upstream_relay` silently drops other relays on the same IP**

The comment on lines 148-153 acknowledges this: "If multiple upstream relays share the same IP (unusual but possible over different ports) this removes the IP entirely." However the recommended remediation — "callers should register after removal if any remain" — is fragile and error-prone. In practice, a node could have two active relay sessions to two different ports on the same public IP (e.g., two relay servers behind the same NAT). Deregistering one would unblock clients from that IP for the other, violating the upstream-IP policy. A reference-counted or multi-value set would be safer here.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

Performance Comparison

Benchmark Baseline Current Change Status

Summary

Configuration

  • Regression threshold: >10% slower
  • Improvement threshold: >10% faster
  • Measurements: Mean execution time

@mickvandijke
mickvandijke force-pushed the mick/always-masque-relay-rebased branch from 87f92d0 to ec16340 Compare April 21, 2026 19:48
@mickvandijke
mickvandijke merged commit ec16340 into main Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant