Skip to content

fix: 10 confirmed defects across 7 vendored minqlxtended plugins - #200

Merged
dngrtech merged 5 commits into
mainfrom
feature/minqlxtended-upstream-plugin-fixes
Aug 28, 2026
Merged

fix: 10 confirmed defects across 7 vendored minqlxtended plugins#200
dngrtech merged 5 commits into
mainfrom
feature/minqlxtended-upstream-plugin-fixes

Conversation

@dngrtech

Copy link
Copy Markdown
Owner

Summary

Fixes every finding that survived assessment in a review of all 32 upstream-vendored minqlxtended plugins. Each plugin was reviewed against the minqlx-plugin-review checklist, and every finding was then independently re-verified against source before being accepted — two findings were rejected that way (both claimed a mutation-during-iteration RuntimeError in plugin_manager.py; self.plugins is a .copy() snapshot, so neither was real).

10 findings accepted across 7 files. None critical, one high.

Sev Site Fix
high vpnblock.py:228 _update_cache runs under @minqlxtended.thread and called self.msg() directly at 4 sites — an engine call off the game thread, on every load, every ~12h refresh, and all three error paths. Routed through a _msg_next_frame helper.
med balance.py:333 Roster callbacks re-called add_request from inside a next_frame handler on any mid-fetch roster change, so a shuffle landing mid-fetch could chase its own tail. Bounded by MAX_REFETCH; on giving up it answers with cached data, which self.rating() already degrades safely.
med balance.py:377 !elo/!ratings/!teams/!balance fan out to the ratings API with no gate. Right after handle_new_game clears self.ratings, several players asking at once each spawned a separate thread against the same endpoint. Added a server-wide COMMAND_COOLDOWN — the fetch they trigger is shared, so the flood to guard against is concurrent askers, not one repeat asker.
med balance.py:472 Nine sites read self.game.type_short/state unguarded, while handle_new_game in the same file guards the identical hazard. A command typed during a map change raised AttributeError. Guarded to match.
med balance.py:762 execute_suggestion's @delay(1) stat restore never re-resolved its players. A disconnect in that window threw and skipped the other player's restore too. Each player now restores independently behind its own guard.
med dictionary.py:31 !define is permission=0 and forked an HTTP worker per call with no gate. Per-player cooldown; the tracking map self-prunes.
low leaverban.py:146 handle_game_start counted bots into players_start, inflating games_completed in Redis with no matching games_left. Filtered to match the rest of the file.
low infectedmm.py:64 The delayed warmup callback could fire after g_rrInfected was toggled off or the plugin unloaded. Re-checks gametype and _loaded first.
low ban.py:105 Ban ids came from a zcard read outside the pipeline — two admins banning one player concurrently both read the same count and the second write overwrote the first, while both reported success. Now an atomic INCR, seeded from the existing count via SETNX so ids on existing installs are preserved.
low aliases.py:101 cmd_setnoaliases fell through with a bare return on a failed identifier resolve, where cmd_alias 60 lines up returns Return.STOP_ALL.

Also in this PR

  • Regenerates manifest.json. This additionally corrects branding.py's hash, which was left stale by 5e614d4test_every_manifest_hash_matches_the_file_on_disk was failing on main as a result.
  • Syncs all 7 files into default-minqlxtended/scripts/. They were verified byte-identical to the baseline beforehand. Note that no test pins upstream preset copies to the baseline — test_preset_scripts_match_the_baseline_ports only covers the six QLSM ports — so this seam is currently untested.
  • Records the 8 locally-patched upstream files in the plugin README. The manifest structurally cannot carry this: origin marks lineage, and test_upstream_files_are_marked_upstream requires all 33 upstream names stay "upstream".

Not in this PR

  • vpnblock.py:176 (cmd_bypassvpn) has the same bare-return-on-failed-resolve bug as aliases.py:101. The review did not flag it, so it is left out rather than expanding scope.
  • Migrating the vendored baseline to dngrtech/minqlxtended-plugins and repointing the engine clone in setup_host.yml to dngrtech/minqlxtended. That touches test pins and build config and belongs in its own PR.

Test plan

pytest tests/test_minqlxtended_plugin_baseline.py \
       tests/test_default_minqlxtended_preset.py \
       tests/test_plugin_manifests.py tests/test_plugin_compat.py \
       tests/test_preset_compat.py tests/test_builtin_presets_migration.py \
       tests/test_runtime_default_preset_seeding.py

151 passed. All 7 patched files compile under py_compile.

Full review, including the 2 rejected findings and the reasoning on each: docs/findings/2026-08-27-minqlxtended-upstream-plugin-review.md (gitignored process artifact, local only).

rage added 3 commits August 27, 2026 08:19
Ports the map-author-credit opt-in from the minqlx branding.py so both
runtimes behave consistently: map credit is hidden by default and
only shown when qlx_brandingMapCredit is set to 1.
Fixes every finding that survived assessment in the review recorded at
docs/findings/2026-08-27-minqlxtended-upstream-plugin-review.md, where all 32
upstream-vendored plugins were reviewed against the minqlx-plugin-review
checklist and each finding independently re-verified against source.

vpnblock.py (high) - _update_cache runs under @minqlxtended.thread and called
self.msg() directly at four sites, an engine call off the game thread, on every
load, every ~12h refresh, and all three error paths. Routed through a
_msg_next_frame helper that builds the string on the worker and emits in a frame.

balance.py - four fixes:
  * roster callbacks re-called add_request from inside a next_frame handler on
    any mid-fetch roster change, so a shuffle landing mid-fetch could chase its
    own tail. Bounded by MAX_REFETCH; on giving up it answers with cached data,
    which self.rating() already degrades safely.
  * !elo/!ratings/!teams/!balance each fan out to the ratings API with no gate.
    Straight after handle_new_game clears self.ratings, several players asking
    at once each spawned a separate thread against the same endpoint. Added a
    server-wide COMMAND_COOLDOWN, since the fetch they trigger is shared.
  * nine sites read self.game.type_short/state unguarded while handle_new_game
    in the same file guards the identical hazard; a command typed during a map
    change raised AttributeError. Guarded to match.
  * execute_suggestion's @delay(1) stat restore never re-resolved its players,
    so a disconnect in that window threw and skipped the other player's restore
    too. Each player now restores independently behind its own guard.

ban.py - ban ids came from a zcard read outside the pipeline, so two admins
banning one player concurrently both read the same count and the second write
overwrote the first while both reported success. Now an atomic INCR, seeded
from the existing count via SETNX so ids on existing installs are preserved.

dictionary.py - !define is permission=0 and forked an HTTP worker per call with
no gate. Added a per-player cooldown; the tracking map self-prunes.

infectedmm.py - the delayed warmup callback could fire after g_rrInfected was
toggled off or the plugin unloaded. Re-checks gametype and _loaded first.

leaverban.py - handle_game_start counted bots into players_start, inflating
games_completed in Redis with no matching games_left. Filtered to match the
rest of the file.

aliases.py - cmd_setnoaliases fell through with a bare return on a failed
identifier resolve where cmd_alias returns Return.STOP_ALL.

Also:
  * regenerates manifest.json, which additionally corrects branding.py's hash.
    It was left stale by 5e614d4 and test_every_manifest_hash_matches_the_file
    _on_disk was failing on main as a result.
  * syncs the seven files into default-minqlxtended/scripts/, which were
    verified identical to the baseline beforehand. No test pins upstream preset
    copies to the baseline, only the six QLSM ports.
  * records the eight locally-patched upstream files in the plugin README. The
    manifest cannot carry this: origin marks lineage, and the baseline test
    requires all 33 upstream names stay "upstream".

Tests: test_minqlxtended_plugin_baseline, test_default_minqlxtended_preset,
test_plugin_manifests, test_plugin_compat, test_preset_compat,
test_builtin_presets_migration, test_runtime_default_preset_seeding - 151 passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e9bba0e3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +115 to +117
next_id_key = f"{base_key}:next_id"
self.db.setnx(next_id_key, self.db.zcard(base_key))
ban_id = self.db.incr(next_id_key) - 1

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 Badge Coordinate ban IDs across all ban producers

When player_info is enabled with qlx_pinfo_ban_deactivated=1, or the preset's kickban plugin is enabled, those plugins still append to the same minqlx:players:<id>:bans sorted set using zcard (player_info.py:397-403, kickban.py:157-176) without advancing :next_id. After a manual ban creates the counter, an automatic ban can take the next zcard ID; the following !ban then gets that same ID from INCR and overwrites the automatic ban's zset member and hash. Make every writer of this ban format use the same atomic allocator.

Useful? React with 👍 / 👎.

Comment on lines +69 to +70
if (not self.is_infected_mastermind_gametype) or (not self._loaded):
return

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 Badge Clear pending mastermind callbacks on plugin unload

When the plugin is unloaded after this delayed callback is scheduled but before it runs while Infected Mastermind remains active, this guard still passes: _loaded is set by _add_hooks and is only cleared by _remove_hooks, but this plugin has no unload handler that calls _remove_hooks. The timer closure can therefore still call add_infected_mastermind_bot after the plugin is gone, which is exactly the unload case this change intends to prevent; cancel the pending timer or verify that this plugin remains registered before adding the bot.

Useful? React with 👍 / 👎.

rage added 2 commits August 27, 2026 23:22
…oad guard

Both findings verified against source before accepting; both were correct.

1. ban id allocation was left inconsistent across writers (P2)

   The INCR allocator in ban.py only covered one of three plugins that write
   the same minqlx:players:<id>:bans zset. player_info.py:398 and the preset's
   kickban.py:163 still derived ids from zcard, and kickban.py's own comment
   already recorded this as a "known limitation shared with ban.py".

   Mixing the two allocators is worse than the original: before, all three were
   consistently racy under concurrency; after, an INCR writer and a zcard writer
   collide deterministically in ordinary mixed use. !ban takes id 0 (counter=1),
   an automatic ban takes zcard=1, the next !ban takes INCR=1 and overwrites it.

   All three now allocate through an identical allocate_ban_id() helper, so the
   fix is whole rather than half-applied. The helper is duplicated per plugin
   because minqlx plugins are standalone modules with no shared import path; its
   docstring names the other two copies so they stay in step.

   voteban.py also derives ids from zcard, but on :votebans -- a different key
   that none of these writers touch -- so it is deliberately left alone.

2. infectedmm's unload guard could not actually detect an unload (P2)

   The guard tested self._loaded, which is only cleared by _remove_hooks, and
   nothing calls that on unload. The gametype half of the check worked, but the
   unload half the comment promised did not: @minqlxtended.delay never cancels a
   pending timer and the closure keeps the instance alive.

   Now checks the engine's own registry -- self.plugins.get(cls) is not self --
   which answers the real question and additionally covers a reload, where a new
   instance has replaced this one.

Preset copies of ban.py, player_info.py and infectedmm.py synced; manifest
regenerated. 93 tests pass across the baseline, preset, manifest and compat
suites.
main was rewritten while this branch was open: the branding.py commit this
branch forked from (5e614d4) was replaced by an equivalent one (e3d6bbe), so
both sides carried their own copy of the same change and every branding.py
conflicted.

The two versions differ only in comment wording -- main's adds an `(e.g. "Till
Merker")` example to the cvar docs -- so main's copy is taken wholesale for
both the baseline and the preset.

Regenerating the manifest afterwards also fixes branding.py's hash, which
e3d6bbe left stale exactly as 5e614d4 had: test_every_manifest_hash_matches_
the_file_on_disk is failing on main right now, and merging this branch is what
corrects it.

139 tests pass across baseline, preset, manifest and compat suites.
@dngrtech
dngrtech merged commit f2094f4 into main Aug 28, 2026
2 checks passed
@dngrtech
dngrtech deleted the feature/minqlxtended-upstream-plugin-fixes branch August 28, 2026 06:37
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