Skip to content

fix(server): cap lobby subscribers before AddSubscriber - #2117

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
glorysr1209-png:fix/lobby-subscriber-cap
Jun 4, 2026
Merged

fix(server): cap lobby subscribers before AddSubscriber#2117
matthewevans merged 6 commits into
phase-rs:mainfrom
glorysr1209-png:fix/lobby-subscriber-cap

Conversation

@glorysr1209-png

Copy link
Copy Markdown
Contributor

Closes #2115.

SubscribeLobby now rejects new lobby subscribers once MAX_LOBBY_SUBSCRIBERS (128) is reached, before AddSubscriber pushes the sender.

Real Behavior Proof

  • Subscriber cap enforced in apply_outbounds before subs.push
  • Rejected client receives ServerMessage::Error via its connection queue
  • Unit tests for at-cap rejection

Test plan

  • cargo test -p server-core lobby_subscriber_wire_guard -- --nocapture
  • cargo fmt --all -- --check
  • CI Rust lint + tests

Reject SubscribeLobby once MAX_LOBBY_SUBSCRIBERS is reached to bound
fan-out sender storage and full lobby snapshot work.

Co-authored-by: Cursor <cursoragent@cursor.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a connection capacity guard for lobby subscribers to prevent unbounded memory usage and broadcast fan-out by limiting subscribers to 128. Feedback on the changes points out a critical issue where stale or closed subscriber channels are not pruned before checking the capacity, which can lead to false rejections of new subscribers as inactive connections continue to count toward the limit.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/phase-server/src/main.rs Outdated
Comment on lines 1705 to 1710
let mut subs = lobby_subscribers.lock().await;
if let Err(reason) = guard_lobby_subscriber_capacity(subs.len()) {
let _ = tx.send(ServerMessage::Error { message: reason });
continue;
}
subs.push(tx.clone());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[HIGH] Stale/closed subscribers are not pruned before checking capacity.

Evidence: crates/phase-server/src/main.rs:1705-1710.

Why it matters: If closed subscriber channels are not pruned before checking capacity, stale connections will be counted toward the limit, leading to false rejections of new subscribers.

Suggested fix: Retain only active senders before checking capacity.

                let mut subs = lobby_subscribers.lock().await;
                subs.retain(|s| !s.is_closed());
                if let Err(reason) = guard_lobby_subscriber_capacity(subs.len()) {
                    let _ = tx.send(ServerMessage::Error { message: reason });
                    continue;
                }
                subs.push(tx.clone());

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a server-wide capacity limit of 128 live lobby subscribers to prevent unbounded memory usage and broadcast fan-out, implementing a new validation guard with corresponding unit tests. However, the review identifies a critical state desynchronization issue where checking the capacity in apply_outbounds occurs after the connection state has already committed identity.lobby_subscribed = true, resulting in a "ghost" subscription. The reviewer recommends moving this capacity check to handle_client_message under ClientMessage::SubscribeLobby to safely reject subscriptions before mutating the broker state.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/phase-server/src/main.rs Outdated
Comment on lines 1705 to 1710
let mut subs = lobby_subscribers.lock().await;
if let Err(reason) = guard_lobby_subscriber_capacity(subs.len()) {
let _ = tx.send(ServerMessage::Error { message: reason });
continue;
}
subs.push(tx.clone());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[HIGH] State desynchronization when lobby subscriber capacity is exceeded. Evidence: crates/phase-server/src/main.rs:1706-1709.

Why it matters: apply_outbounds is executed after identity.absorb_conn_state(conn) has already committed identity.lobby_subscribed = true. Rejecting the subscription here leaves the socket identity in a desynchronized "ghost" subscription state where the client and broker believe they are subscribed, but they will never receive any lobby updates.

Suggested fix: Move the capacity check to handle_client_message under ClientMessage::SubscribeLobby so the subscription is rejected before the broker state is mutated and absorbed.

        ClientMessage::SubscribeLobby => {
            let subs_len = lobby_subscribers.lock().await.len();
            if let Err(reason) = guard_lobby_subscriber_capacity(subs_len) {
                let _ = tx.send(ServerMessage::Error { message: reason });
                return;
            }
            dispatch_broker(
                &client_msg,
                lobby,
                lobby_subscribers,
                player_count,
                tx,
                identity,
            )
            .await;
        }
                let mut subs = lobby_subscribers.lock().await;
                subs.push(tx.clone());

Co-authored-by: Cursor <cursoragent@cursor.com>
@matthewevans

Copy link
Copy Markdown
Member

🤖 Architecture Review (automated)

Verdict: ✅ Approve w/ nits
Branch vs origin/main: clean

Seam: PASS — Pure transport/wire guard in server-core, mirrors the established *_wire_guard.rs family; zero game logic, no engine/CR surface touched. Correct layer.
Idiomatic: PASS — Near-exact mirror of spectator_wire_guard.rs (const MAX_* + guard_*_capacity(current) -> Result<(), String> + parallel test pair). No bool flags, no string-parsing-for-dispatch, no parser concern.
Value: Covers the class of "unbounded lobby subscriber fan-out / sender storage" DoS, consistent with the existing per-game (32) and per-draft (32) spectator caps. Not a single-card / single-case fix.

Findings

  • [MEDIUM] crates/phase-server/src/main.rs:1693 (new guard call site) — The capacity check runs on raw subs.len() without first pruning is_closed() senders. The established sibling path reserve_game_spectator_slot (main.rs:117) does spectators.retain(|s| !s.is_closed()) before guard_game_spectator_capacity(...). Here, closed-but-not-yet-pruned senders (pruned only on Outbound::RemoveSubscriber, line ~1698, or the 5‑min reaper) count against the cap, so a lobby that churns 128 clients can prematurely reject new subscribers until a remove/reaper event fires. Fix: prune subs.retain(|s| !s.is_closed()) immediately before the guard check, matching the spectator path.
  • [NIT] crates/phase-server/src/main.rs:1693 — No dedup before subs.push. The broker emits AddSubscriber unconditionally on every SubscribeLobby (lobby-broker/src/broker.rs:169) with no same_channel check, so a connection that re-subscribes double-counts against the cap and receives duplicate broadcasts. Pre-existing (not introduced by this PR), but the sibling pattern guards it (main.rs:119, same_channel(tx)); worth aligning while adding the prune above.
  • [NIT] crates/server-core/src/lobby_subscriber_wire_guard.rs:1 — Cap of 128 is plausible but undocumented relative to the 32 spectator caps; a one-line rationale comment (lobby is server-wide vs per-game) would help future tuning.

CR verification: N/A — no game-rule logic in this diff (server transport only), so no CR annotations expected or required.

@matthewevans

Copy link
Copy Markdown
Member

🏛️ Architecture & Idiom Deep-Dive (automated)

Verdict: 🔧 Minor polish
Adversarial idiom pass — does NOT re-check correctness; see the prior architecture-review comment for that.

What's idiomatic here: The new lobby_subscriber_wire_guard.rs is a faithful clone of the established guard_*_capacity house style in spectator_wire_guard.rs — same Result<(), String> shape, same module doc, same current >= MAX form, same accepts-below-cap / rejects-at-cap test pair. Pure validation lives in server-core, the thin call site in phase-server just dispatches and sends a ServerMessage::Error. No bool flags, no string-parsing dispatch, no layer confusion.

Idiom / architecture findings

  • [ARCH] crates/phase-server/src/main.rs:1703-1706 (the AddSubscriber arm) — the seam diverges from the project's own analogous capacity pattern. The spectator path (reserve_game_spectator_slot, main.rs:115-125) prunes then caps: it retain(|s| !s.is_closed()) and dedups via same_channel(tx) before calling guard_game_spectator_capacity(spectators.len()). The new arm caps against subs.len() raw, with no prune and no dedup. Closed senders are only reaped on RemoveSubscriber (:1709); broadcast_to_lobby_subscribers (:1450) doesn't prune either. So a connect/disconnect churn that never delivers a clean RemoveSubscriber accumulates dead senders against the 128 cap and can lock out live subscribers — the exact starvation the spectator path's retain exists to prevent. → fix: mirror the spectator seam — subs.retain(|s| !s.is_closed()); (and, if a client can re-SubscribeLobby, a same_channel(tx) early-return) immediately before the capacity guard, so the cap is enforced on live subscribers. This keeps the two fan-out paths behaviorally consistent rather than one pruning-then-capping and the other capping-raw.

@matthewevans

Copy link
Copy Markdown
Member

🔁 Re-review (upgraded process)

Prior verdict: ✅ Approve w/ nits → Revised: ⚠️ Changes requested
Reconciled with existing reviews: Two HIGH Gemini findings — both CONFIRMED against head, and one was missed entirely by the prior automated pass.

  • Gemini chore: update coverage stats and badges #1 — prune stale senders before cap (HIGH): CONFIRMED. crates/phase-server/src/main.rs:1704-1710 checks subs.len() with no subs.retain(|s| !s.is_closed()) first. The sibling authority reserve_game_spectator_slot (main.rs:110-126) prunes (spectators.retain(|sender| !sender.is_closed())) and dedups (same_channel) before guard_game_spectator_capacity. The prior pass rated this MEDIUM; Gemini's HIGH is the correct floor.
  • Gemini chore: update coverage stats and badges #2 — ghost subscription / state desync (HIGH): CONFIRMED, and the prior automated review missed it entirely. Trace: dispatch_broker_msg (main.rs:1671-1677) calls broker.handle(SubscribeLobby), which sets conn.subscribed = true and returns [AddSubscriber, ToSelf(LobbyUpdate), …] (crates/lobby-broker/src/broker.rs:163-173). Line 1676 identity.absorb_conn_state(conn) commits identity.lobby_subscribed = true before apply_outbounds runs. The new guard then rejects AddSubscriber via continue (line 1708), so tx is never pushed into lobby_subscribers — but the loop still delivers the ToSelf(LobbyUpdate) snapshot. Net: the client receives both an Error and a one-time lobby snapshot, identity.lobby_subscribed == true, yet the socket is absent from the broadcast list and will never receive another ToSubscribers update. Client + broker both believe subscribed; the shell silently isn't.

Missed or re-rated

  • [HIGH] crates/phase-server/src/main.rs:1676 (+ guard at 1706) — State desync: capacity is checked in apply_outbounds, after absorb_conn_state commits lobby_subscribed = true. The sibling spectator path rejects before mutating state — switch_game_spectator_slot returns Result and the call site (main.rs:4173) bails on Err before any commit. The prior pass missed this entirely. Fix per Gemini: perform the capacity check in the SubscribeLobby handler before dispatch_broker_msg/absorb_conn_state, returning early so conn.subscribed is never set and no snapshot is sent.
  • [HIGH→ re-rated from MED] crates/phase-server/src/main.rs:1705 — Cap is computed on un-pruned subs.len(); closed-but-not-yet-removed senders count against the 128 limit (pruning only happens on RemoveSubscriber or the reaper), so a churning lobby falsely rejects new subscribers. The established sibling reserves on a pruned+deduped list. Prior pass rated MEDIUM; Gemini HIGH is the correct floor. Fix: subs.retain(|s| !s.is_closed()) (and ideally a same_channel dedup) immediately before the guard, matching reserve_game_spectator_slot.

Both fixes converge: lift subscriber reservation into a single reserve_lobby_subscriber_slot-style authority (prune + dedup + guard + push, returning Result) invoked in the SubscribeLobby handler before state is absorbed — exactly the spectator pattern this PR otherwise mirrors.

@matthewevans

Copy link
Copy Markdown
Member

Maintainer cleanup pushed on head e1db3b4. What changed:

  • Added a single reserve_lobby_subscriber_slot authority in phase-server that prunes closed senders, dedups same-channel subscriptions, enforces guard_lobby_subscriber_capacity, and pushes atomically.
  • Moved SubscribeLobby reservation before broker dispatch/state absorption so at-cap rejection cannot leave SocketIdentity in a ghost subscribed state.
  • Kept Outbound::AddSubscriber idempotent as a defensive path.
  • Fixed Outbound::RemoveSubscriber to remove the current channel and prune closed senders.
  • Added discriminating phase-server path tests for at-cap rejection, stale-sender pruning, same-channel idempotency, and unsubscribe cleanup.

Local verification:

  • cargo fmt --all
  • git diff --check
  • CARGO_TARGET_DIR=/Users/matt/dev/forge.rs/target cargo test -p server-core lobby_subscriber_wire_guard --lib -- --nocapture
  • CARGO_TARGET_DIR=/Users/matt/dev/forge.rs/target cargo test -p phase-server lobby_subscriber -- --nocapture

Broad validation is left to GitHub CI.

@matthewevans matthewevans added bug Bug fix area:multiplayer Multiplayer/server/lobby (phase-server, server-core) rust Pull requests that update rust code ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow labels Jun 4, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved after maintainer cleanup on head e1db3b4. The subscriber cap now reserves the slot before broker state absorption, prunes stale senders, dedups same-channel subscriptions, fixes unsubscribe cleanup, and GitHub CI is green.

@matthewevans
matthewevans added this pull request to the merge queue Jun 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jun 4, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jun 4, 2026
Merged via the queue into phase-rs:main with commit b36c743 Jun 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow area:multiplayer Multiplayer/server/lobby (phase-server, server-core) bug Bug fix rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SubscribeLobby has no lobby subscriber cap

2 participants