Skip to content

Fix #1311: cache + debounce A2S queries on the public servers page - #1329

Merged
rumblefrog merged 1 commit into
mainfrom
fix/issue-1311-server-requery-cache
May 10, 2026
Merged

rumblefrog merged 1 commit into
mainfrom
fix/issue-1311-server-requery-cache

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Fixes #1311.

Summary

  • Server-side cache: New Sbpp\Servers\SourceQueryCache::fetch($ip, $port) wraps every public server-query handler (servers.host_players / host_property / host_players_list / players) so back-to-back calls coalesce into ONE A2S GetInfo + GetPlayers UDP probe per (ip, port) per ~30s window. On-disk under SB_CACHE/srvquery/<sha1>.json, atomic tempfile + rename() writes mirroring system.check_version's release cache. Both success and failure paths cache so an unreachable host costs ONE probe per window, not one per request. The cache is user-agnostic; per-caller fields (is_owner, can_ban, trunchostname) are stamped on top by the handler after the fetch returns.
  • Client-side debounce: The per-tile Re-query button ([data-testid="server-refresh"] in page_servers.tpl) now starts disabled and only re-enables after the bootstrap probe settles, mirroring the pre-existing toggle button gate. The JS-side tile.__sbppLoading flag short-circuits clicks that race the disabled attribute settling in the DOM.
  • Documentation: _register.php documents the intentional public=true reach on the servers.host_* family plus why the cache is what makes it safe; AGENTS.md adds the Sbpp\Servers\SourceQueryCache row in both the namespace table and the "Where to find what" index; ARCHITECTURE.md adds the Servers/ directory row + a "Server query cache" section under the Web panel chapter.

Test plan

Gate Result
./sbpp.sh phpstan pass (0 errors)
./sbpp.sh test pass (411 tests, 1848 assertions, 1 pre-existing deprecation unrelated to this PR)
./sbpp.sh test --filter='SourceQueryCache|HostPlayers' pass (12 tests, 99 assertions — covers cache hit / negative cache / TTL expiry / atomic write / invalidation, plus handler-shape assertions for host_players coalescing + negative cache)
./sbpp.sh ts-check pass
./sbpp.sh composer api-contract regenerated (docblock-only diff for api_servers_host_players)
./sbpp.sh e2e specs/flows/server-refresh-debounce.spec.ts pass — new spec asserts five rapid clicks coalesce into a single servers.host_players POST and the refresh button is server-rendered disabled until the bootstrap probe lands
./sbpp.sh e2e specs/smoke/servers.spec.ts pass — existing servers smoke spec still green

The new E2E spec opts out of the mobile-chromium project (file-level comment explains: contract is browser-shape-agnostic, and the second project's worker would race truncate-and-reseed against the shared sourcebans_e2e schema on the local-default workers: undefined; CI's workers: 1 would be safe but the file-local skip keeps the test deterministic everywhere).

…#1311)

The public servers page (`?p=servers`) and its per-tile Re-query button
fanned one anonymous-callable A2S `GetInfo + GetPlayers` UDP probe out
to each configured `:prefix_servers` row on every panel hit and every
button click. A hand-mash of the refresh button — or a `for` loop
hitting `?p=servers` — translated 1:1 to A2S queries leaving the panel
host. The dispatcher's only friction was CSRF, which anonymous browsers
get for free off the public chrome.

Server-side: every public server-query handler (`servers.host_players`
/ `host_property` / `host_players_list` / `players`) now goes through
`Sbpp\Servers\SourceQueryCache::fetch($ip, $port)` — an on-disk cache
keyed by `(ip, port)` with a ~30s window. Mirrors the
`system.check_version` cache shape (atomic tempfile + `rename()`).
Both success and failure paths cache so an unreachable host costs ONE
probe per window, not one per request. The cache is user-agnostic; the
handler stamps per-caller fields (`is_owner`, `can_ban`, `trunchostname`)
on top after the fetch.

Client-side: the per-tile Re-query button (`[data-testid="server-refresh"]`)
now starts `disabled` and only re-enables after the bootstrap probe
settles, mirroring the pre-existing toggle button gate. The JS-side
`tile.__sbppLoading` flag short-circuits clicks that race the
`disabled` attribute settling in the DOM.

Tests: `web/tests/integration/SourceQueryCacheTest.php` covers the
cache shape (hit / miss / negative cache / TTL expiry / atomic write /
invalidation) via the test-only probe override; new handler-shape
assertions in `web/tests/api/ServersTest.php` verify rapid repeat
calls coalesce and unreachable servers are negative-cached. Playwright
spec `web/tests/e2e/specs/flows/server-refresh-debounce.spec.ts`
asserts five rapid clicks coalesce into a single XHR.

Docs: `_register.php` documents the intentional `public=true` reach
on the `servers.host_*` family + why the cache is what makes it safe;
`AGENTS.md` and `ARCHITECTURE.md` add the Servers/ subsystem row.

Fixes #1311
@rumblefrog
rumblefrog added this pull request to the merge queue May 10, 2026
Merged via the queue into main with commit 37c2ceb May 10, 2026
5 checks passed
@rumblefrog
rumblefrog deleted the fix/issue-1311-server-requery-cache branch May 10, 2026 22:26
rumblefrog added a commit that referenced this pull request May 10, 2026
Two test-only fixes surfaced when the PR rebased on top of #1320 +
#1329 landing in main:

- `BanListIpColumnTest::renderBanList` (from #1320) constructs a
  `BanListView` with positional + named arguments. This PR adds a 28th
  constructor parameter (`is_advanced_search_open`) which the test
  must now pass — default it to `false` so the IP-column suite stays
  exercising the column-render contract it was written for, not the
  disclosure state.
- `ServerMapImageRenderTest::testHandlerStillEmitsMapimgField` (from
  #1326) asserted a literal `"'mapimg'   => GetMapImage("` substring
  in `web/api/handlers/servers.php`. #1329's cache + debounce rewrite
  shifted the surrounding column alignment to 5 spaces, breaking the
  brittle match without changing the contract the test guards (the
  field is still emitted). Switch to a regex that tolerates any
  whitespace between the key and `=>` so a future alignment shift
  doesn't silently re-trigger the same false positive.
rumblefrog added a commit that referenced this pull request May 10, 2026
Two test-only fixes surfaced when the PR rebased on top of #1320 +

- `BanListIpColumnTest::renderBanList` (from #1320) constructs a
  `BanListView` with positional + named arguments. This PR adds a 28th
  constructor parameter (`is_advanced_search_open`) which the test
  must now pass — default it to `false` so the IP-column suite stays
  exercising the column-render contract it was written for, not the
  disclosure state.
- `ServerMapImageRenderTest::testHandlerStillEmitsMapimgField` (from
  #1326) asserted a literal `"'mapimg'   => GetMapImage("` substring
  in `web/api/handlers/servers.php`. #1329's cache + debounce rewrite
  shifted the surrounding column alignment to 5 spaces, breaking the
  brittle match without changing the contract the test guards (the
  field is still emitted). Switch to a regex that tolerates any
  whitespace between the key and `=>` so a future alignment shift
  doesn't silently re-trigger the same false positive.
Rushaway pushed a commit to srcdslab/sourcebans-pp that referenced this pull request May 15, 2026
…ban-meta + Re-apply + collapsible adv search) (sbpp#1330)

* fix(banlist+commslist): restore unban-meta line + Re-apply + collapsible advanced search (sbpp#1315)

The v2.0 redesign collapsed the sourcebans.js-driven legacy public lists
into Smarty-only `page_bans.tpl` / `page_comms.tpl` and dropped three
power-user surfaces along the way. This PR restores them.

  1. Inline unban-meta line on admin-lifted rows. The reason cell on the
     desktop table emits "Unbanned by <admin>: <reason>"
     (`[data-testid="ban-unban-meta"]`) when `state == 'unbanned'`;
     mobile cards mirror with the `-mobile` suffix. Same shape on the
     commslist for `state == 'unmuted'` — higher priority there because
     no drawer fallback exists. Both are gated on `!$hideadminname` so
     anonymous viewers under a hidden-admins config don't get the admin
     name leaked. Read-side render only — no overlap with sbpp#1301 / sbpp#1323.
  2. Re-apply (Reban) icon affordance for expired / unbanned rows on
     the banlist desktop, gated on `ADMIN_OWNER | ADMIN_ADD_BAN`.
     Deep-links the smart-default `?p=admin&c=bans&section=add-ban&rebanid=<bid>`
     URL the existing `BansPrepareReban` JSON action already
     pre-populates. Mobile parity deferred — drawer is canonical.
  3. Advanced-search disclosure on both lists. Default-collapsed
     `<details class="filters-details">` wrapping the legacy multi-
     criterion search form (loaded via `{load_template
     file="admin.bans.search"}`). Auto-opens on a post-submit
     `?advType=&advSearch=` paint via a new
     `BanListView::$is_advanced_search_open` /
     `CommsListView::$is_advanced_search_open` boolean. Reuses the
     `.filters-details` chrome sbpp#1303 introduced for admin-admins
     (CSS rules ship in this PR; will dedupe harmlessly when sbpp#1318
     merges).

Tests:

  - PHPUnit: `PublicBanListRegressionTest` (7 cases — disclosure
    default-closed, auto-open, unban-meta render, Re-apply gating
    + URL shape, banlist + commslist parity). RunInSeparateProcess
    per method to dodge the page-handler `setPostKey()` redeclare
    trap, mirroring `Php82DeprecationsTest`'s pattern.
  - Playwright: `flows/public-banlist-regressions.spec.ts` (4 cases
    across chromium + mobile-chromium — disclosure default-closed,
    click-to-open, post-submit auto-open via URL navigation;
    closed-state axe coverage). Re-apply / unban-meta DB-state
    assertions live in the PHPUnit test where seeding is cleaner.

AGENTS.md "Where to find what" gains three rows for the new patterns.

* fix(tests): update integration tests after rebase on main

Two test-only fixes surfaced when the PR rebased on top of sbpp#1320 +

- `BanListIpColumnTest::renderBanList` (from sbpp#1320) constructs a
  `BanListView` with positional + named arguments. This PR adds a 28th
  constructor parameter (`is_advanced_search_open`) which the test
  must now pass — default it to `false` so the IP-column suite stays
  exercising the column-render contract it was written for, not the
  disclosure state.
- `ServerMapImageRenderTest::testHandlerStillEmitsMapimgField` (from
  sbpp#1326) asserted a literal `"'mapimg'   => GetMapImage("` substring
  in `web/api/handlers/servers.php`. sbpp#1329's cache + debounce rewrite
  shifted the surrounding column alignment to 5 spaces, breaking the
  brittle match without changing the contract the test guards (the
  field is still emitted). Switch to a regex that tolerates any
  whitespace between the key and `=>` so a future alignment shift
  doesn't silently re-trigger the same false positive.

* fix(tests): login as admin in BanListIpColumnTest hideplayerips=false case

sbpp#1315's `<details class="filters-details">` disclosure on the public
ban list `{load_template file="admin.bans.search"}`s the legacy
advanced-search partial. The partial's backing `admin.bans.search.php`
re-derives `hideplayerips` / `hideadminname` from
`Config::getBool('banlist.hideplayerips') && !$userbank->is_admin()`
and `Renderer::render`s the result into Smarty, overwriting whatever
the parent BanListView had assigned. In production this is a no-op
because the parent (`page.banlist.php`) computes the SAME formula —
both halves converge. But `BanListIpColumnTest` hand-crafts
`BanListView::$hideplayerips` to exercise the column-gating contract
in isolation, so the partial's overwrite collapses the test's
fixture-controlled value down to whatever the test environment's
Config + $userbank happen to evaluate to.

Fix by logging in as admin in the two test methods that pass
`hideplayerips: false`. With `is_admin()=true`, the partial's
formula reduces to `(…) && !true = false`, matching the View's
hand-crafted `false`. The suppressed-state test
(`hideplayerips: true`) stays anonymous so the formula reduces to
`Config && true` — `true` under `data.sql`'s
`banlist.hideplayerips=1` default — also matching the View's input.

The test pre-dates sbpp#1315; it was written against the v2.0 template
that didn't yet `{load_template}` the partial, so the parent's
assigns were never overwritten. The new disclosure brings the
partial back into the render tree and the test has to maintain
the production invariant ("BanListView's hideplayerips matches
AdminBansSearchView's recomputed value") explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant