Skip to content

css_unban / css_unmute silently fail on SQLite — RuntimeBinderException swallowed by bare catch in BanManager/MuteManager #284

@playaopindo-dev

Description

@playaopindo-dev

css_unban / css_unmute silently fail on SQLite — every caller path

TL;DR

On SQLite (the default Database.Type in the shipped config), css_unban, !unban, css_unmute, css_ungag, css_unsilence all print their cosmetic success message to the admin but never write to the database. The ban/mute row stays status='ACTIVE', no row is inserted into sa_unbans / sa_unmutes, and the player remains rejected on reconnect. The exception is swallowed by a bare catch { }, so nothing surfaces in any log.

MySQL users do not see this because MySqlConnector materialises an INT column as int; SQLite returns INTEGER PRIMARY KEY AUTOINCREMENT as long, and the C# dynamic binder refuses the implicit longint conversion at runtime.

Reproduced and root-caused against 1.7.9a (eea700b). Fix is two one-line changes; PR follows.

Environment

  • CS2-SimpleAdmin build-1.7.9a (commit eea700b)
  • Database provider: SQLite (default config, Database.Type = "sqlite", file cs2-simpleadmin.sqlite)
  • CounterStrikeSharp current API surface, .NET 8.0
  • Windows dedicated server (Windows 11), but the bug is platform-independent — the root cause is in the C# dynamic binding semantics, not in any native code.

Reproduction

Plant a synthetic ACTIVE ban directly in sa_bans:

INSERT INTO sa_bans
    (player_name, player_steamid, player_ip, admin_steamid, admin_name,
     reason, duration, ends, created, server_id, status)
VALUES
    ('TEST-UNBAN-PROBE', '76561198000000001', NULL, 'CONSOLE', 'Console',
     'repro', 0, NULL, CURRENT_TIMESTAMP, NULL, 'ACTIVE');

Then issue css_unban 76561198000000001 from the server console.

Observed

Console issued command `css_unban 76561198000000001` on server `<name>`
Unbanned player with pattern 76561198000000001.

Expected

  • sa_bans.id=<row> flips to status='UNBANNED', unban_id populated.
  • sa_unbans gets a new row with matching ban_id.
  • The player can reconnect.

Actual

  • sa_bans.id=<row>.status stays 'ACTIVE'.
  • sa_unbans stays empty.
  • The player is still rejected with Your client is not allowed to join this server.
  • No exception in any log (eaten by catch { }).

Caller-format matrix — all four broken identically

Caller Argument format Result
Server console css_unban <SteamID64> cosmetic success, DB unchanged
Server console css_unban <IPv4> cosmetic success, DB unchanged
Server console css_unban <player_name substring> cosmetic success, DB unchanged
In-game chat, admin with @css/unban !unban <SteamID64> cosmetic success, DB unchanged

css_unban <ban_id> (e.g. css_unban 4) is correctly rejected with Too short pattern to search.css_unban is a substring lookup with Length > 1, not an id-based command. That part is by design.

Root cause

After temporarily replacing the bare catch { } at Managers/BanManager.cs::UnbanPlayer with catch (Exception ex) { CS2_SimpleAdmin.Instance?.Logger?.LogError(ex, "UnbanPlayer failed for pattern {Pattern}", playerPattern); }, the SA log immediately surfaces:

[EROR] (plugin:CS2-SimpleAdmin (RELEASE)) UnbanPlayer failed for pattern 76561198000000001
Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
    Cannot implicitly convert type 'long' to 'int'.
    An explicit conversion exists (are you missing a cast?)
   at CallSite.Target(Closure, CallSite, Object)
   at System.Dynamic.UpdateDelegates.UpdateAndExecute1[T0,TRet](CallSite site, T0 arg0)
   at CS2_SimpleAdmin.Managers.BanManager.UnbanPlayer(String playerPattern, String adminSteamId, String reason)

The offending line is Managers/BanManager.cs:334:

foreach (var ban in bansList)
{
    int banId = ban.id;                      // <-- here
    var sqlInsertUnban = databaseProvider.GetInsertUnbanQuery(reason != null);
    var unbanId = await connection.ExecuteScalarAsync<int>(sqlInsertUnban, new { banId, adminId, reason });
    var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery();
    await connection.ExecuteAsync(sqlUpdateBan, new { unbanId, banId });
}

bansList comes from connection.QueryAsync(...) typed as IEnumerable<dynamic>. On SQLite, the id column is INTEGER PRIMARY KEY AUTOINCREMENT; the SQLite native layer hands every INTEGER value back as a 64-bit integer, and Microsoft.Data.Sqlite/Dapper box it as long. The C# dynamic binder's runtime conversion path rejects the implicit longint narrowing and throws RuntimeBinderException BEFORE either the INSERT into sa_unbans or the UPDATE on sa_bans runs. The exception is then caught and discarded by the surrounding catch { } at line 343.

MySQL users do not see this because MySqlConnector materialises a SQL INT column as a CLR int, so the implicit conversion succeeds and the loop completes normally.

Two anti-patterns that make this worse than it had to be

  1. Commands/basebans.cs::OnUnbanCommand calls the cosmetic ReplyToCommand($"Unbanned player with pattern {pattern}.") synchronously, then fires the actual DB work into a fire-and-forget Task.Run(...). The admin sees success at the same instant the work starts, with no propagation path for failures. (Same shape used for OnUnwarnCommand, OnWarnCommand, and several others throughout the file.)
  2. Managers/BanManager.cs::UnbanPlayer ends its try block with a bare catch { } that discards every exception type without any logging. (The try-blocks in BanPlayer, AddBanBySteamid, AddBanByIp all use catch (Exception ex) { CS2_SimpleAdmin._logger?.LogError(...); }, so the inconsistency is purely accidental, not by design.) Same pattern in Managers/MuteManager.cs::UnmutePlayer, where the catch does Console.WriteLine(ex) — which writes to the CS2 console scrollback but never makes it into the rolling SA log file.

Together these guarantee that the bug looks like nothing went wrong, from the admin chat reply down to every log file.

Identical bug, second site

Managers/MuteManager.cs::UnmutePlayer line 266 has the same int muteId = mute.id; pattern and throws the same RuntimeBinderException on SQLite. That means css_unmute, css_ungag, css_unsilence, and their !-aliases all silently fail on SQLite too, for exactly the same reason.

I did not separately repro the mute path because the root cause is identical; mentioning it here so the fix covers both sites in one PR.

Suggested fix (PR follows)

Normalise the conversion path to be provider-agnostic:

int banId  = Convert.ToInt32((object)ban.id);
int muteId = Convert.ToInt32((object)mute.id);

The (object) cast bypasses the dynamic binder so Convert.ToInt32 resolves the well-defined object overload, which handles both int (MySQL) and long (SQLite) without any runtime binding surprises.

I'd also recommend tightening the catch in UnbanPlayer to match the rest of the file (log the exception via CS2_SimpleAdmin.Instance.Logger). My PR includes that one extra hunk; happy to drop it if you prefer to keep the diff minimal.

Testing notes

  • SQLite: verified manually against the synthetic ban above. Pre-fix: sa_bans.status='ACTIVE', sa_unbans empty, RuntimeBinderException in SA log. Post-fix: sa_bans.status='UNBANNED', sa_bans.unban_id populated, sa_unbans row inserted with matching ban_id, no exception. Server: Windows 11 dedicated, CS2 latest, CSS# current.
  • MySQL: not retested by me. The fix preserves the existing MySQL code path semantically — Convert.ToInt32((object)x) on a boxed int returns the same int. Both managers compile clean against net8.0 Release without warnings.

Related prior reports

I searched the tracker before filing this; no prior report names the actual root cause (RuntimeBinderException, longint, Dapper-on-SQLite), but a few existing issues are almost certainly the same family observed from different surfaces:

  • css_unban not working #229 (closed) — "css_unban not working" by @dueruem. SQLite, exact same symptom (css_unban does nothing). Reported in November 2024, closed without a code-level diagnosis. The conversation suggested workarounds ("update plugin", "disable multiacc check", "don't use latest release if you don't need sqlite support") rather than a fix. This PR/issue is the root cause for that report.
  • [BUG?] css_unmute, css_ungag, css_unsilence #142 (closed) — "[BUG?] css_unmute, css_ungag, css_unsilence". Closed as a usage question ("Anything you type is looked up in the database — so you have to enter the exact player nickname or steamid"). With this bug present, even an exact-SteamID lookup against SQLite fails silently for exactly the same reason as css_unban — the user wasn't wrong, the targeting reached the DB but the dynamic binder threw before the write. Almost certainly the same bug, observed on the mute side.
  • OverflowException in CacheManager.InitializeCacheAsync — sa_players_ips.steamid mapped to int (SteamID64 doesn't fit) #283 (open) — "OverflowException in CacheManager.InitializeCacheAsync — sa_players_ips.steamid mapped to int (SteamID64 doesn't fit)". Different surface, same family: Dapper materialising a SQLite 64-bit integer column into a CLR int target. Not fixed by this PR, but worth pulling forward as a related cleanup — both are symptoms of an implicit "SQLite gives you int-width values" assumption that doesn't hold.
  • CS2_SimpleAdmin.Helper.GetValidPlayers() error #275, OverflowException on startup #264 (closed) — earlier OverflowException reports against CacheManager.InitializeCacheAsync, same root-pattern family. Mentioning for context, not as duplicates of this report.

Happy to fold this into a comment on #229 instead of standing up a separate issue if that's preferred; this report exists as a new issue because the root cause is materially new information and the proposed fix touches a second code path (MuteManager) that #229 doesn't mention.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions