diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ba4ace3..c823923 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@cee97f86b972a3ded3c58d04148362b193ddc518 # v4.38.3 with: languages: ${{ matrix.language }} # Neither language needs a compiled build for CodeQL to extract it. @@ -50,6 +50,6 @@ jobs: queries: security-extended - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@cee97f86b972a3ded3c58d04148362b193ddc518 # v4.38.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 5a56b1d..d7753a4 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -14,6 +14,6 @@ jobs: contents: write # create/update release draft pull-requests: read # read PR titles and labels to generate the notes steps: - - uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7 + - uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e02664a..a6c6aea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,7 +140,7 @@ jobs: cat release_body.md - name: Create GitHub Release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 + uses: softprops/action-gh-release@e598afbe1493e6b1bafb1f389cabb956eab91231 # v3.0.3 with: name: ${{ github.ref_name }} body_path: release_body.md diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3d4a75d..e53db03 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -54,7 +54,7 @@ jobs: sudo apt-get update sudo apt-get install -y gcc-multilib g++-multilib - - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + - uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 - name: Build run: cargo build --target i686-unknown-linux-gnu --verbose @@ -129,10 +129,10 @@ jobs: sudo apt-get update sudo apt-get install -y gcc-multilib g++-multilib - - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + - uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 - name: Install cargo-llvm-cov - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2.87.27 with: tool: cargo-llvm-cov @@ -140,7 +140,7 @@ jobs: run: cargo llvm-cov --target i686-unknown-linux-gnu --lcov --output-path lcov.info - name: Upload coverage artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-lcov path: lcov.info diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4e66a09..b5c1f8a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -30,13 +30,13 @@ jobs: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + uses: ossf/scorecard-action@55891bbd73f2425e97637d96e306fc9d491d0b21 # v2.4.4 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@cee97f86b972a3ded3c58d04148362b193ddc518 # v4.38.3 with: sarif_file: results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d7b77..336669a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project are documented in this file. Format inspired by [Keep a Changelog](https://keepachangelog.com/). Versioning follows [Semantic Versioning](https://semver.org/). Older entries live under [`changelog/`](changelog/). -## [1.4.0] — 2026/10/04 +## [1.4.0] — 2026/10/11 -Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four natives and one constant are new, nothing was removed or renamed, so 1.3.0 gamemodes compile unchanged. **One behaviour did change** — `OnQueryError` used to hand its arguments over in the wrong order, and now hands them over as the forward declares. Read the first entry under Fixed before upgrading if you handle that forward. +Built on rust-samp v3.6.0, now from crates.io. Additive on the Pawn side: four natives and one constant are new, nothing was removed or renamed, so 1.3.0 gamemodes compile unchanged. **One behaviour did change** — `OnQueryError` used to hand its arguments over in the wrong order, and now hands them over as the forward declares. Read the first entry under Fixed before upgrading if you handle that forward. ### Added @@ -35,6 +35,14 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n ### Fixed +- **A burst of queries could kill the server, and could stall the FIFO queue for good.** Submitting 2000 queries in one tick aborted the process with `memory allocation of 4194304 bytes failed`, and the cause is specific to this architecture: a worker thread per query, with the default 8 MiB stack, inside a **32-bit** server. A few hundred live workers exhaust the address space, and the allocation that fails next takes the process with it. + + Two things were wrong, and both are fixed. Workers now get a **256 KiB stack** — a worker opens a connection, runs one statement and builds a cache, it does not recurse — which moved the ceiling from roughly 1700 workers to past 9000 measured. And a refused thread is no longer a panic: `thread::spawn` panics when the OS says no, which the native wrapper caught and turned into a lost submission — lost *including its sequence number*, so every ordered callback queued behind it would have waited forever. The failure is now an ordinary failed result: the sequence is accounted for, `OnQueryError` fires, and the native returns `false`. + + The submission natives also report that refusal honestly now. `mysql_query`, `mysql_pquery`, `mysql_query_file`, `mysql_stmt_execute`, `mysql_stmt_pexecute`, `mysql_transaction_execute` and the ORM CRUD natives returned `true` whenever the connection id was valid; they return what the submission actually did. + + Measured after the fix: 9000 queries submitted in one tick, 9000 callbacks delivered, submission order intact, nothing left in flight. + - **`OnQueryError` delivered its five arguments in reverse.** The forward is declared `OnQueryError(errorid, error[], callback[], query[], connId)`, and a gamemode received the connection id as `errorid`, the query text as `error`, the error message as `query`, and the MySQL code as `connId`. The cause was in the dispatch: `exec_public!` pushes its arguments in reverse — the AMX stack convention — so the order listed in the call is the order the public receives, and the code listed them backwards as if it had to invert them by hand. Present since 1.0.0. **Anyone handling this forward was reading the wrong fields and should check their handler.** (#56) - **Requesting TLS on a loopback host ran in plaintext.** `MYSQL_OPT_SSL = 1` against `127.0.0.1` reported success while `Ssl_cipher` came back empty. The driver's `prefer_socket` defaults to true and, after the handshake, moves a *loopback* connection onto the server's unix socket — where it secures nothing. The two conditions met on the most ordinary local setup there is. Asking for TLS now switches that optimisation off; without TLS it stays, which is where it helps. Verified against MariaDB 11.8: `TLS_AES_256_GCM_SHA384` where there used to be an empty string. @@ -47,10 +55,18 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n ### Changed -- **rust-samp moves from a git tag to crates.io** (`version = "3.5.0"`). This also clears a long-standing oddity: the `v3.5.0` tag declared `version = "3.4.0"` in its own manifest, so the lockfile recorded 3.4.0 pointing at the v3.5.0 tag. The SDK's new `mainthread` module is **not** adopted yet: its `post` takes a closure with no parameters and there is no route to plugin state from a worker thread, so the plugin's own `mpsc` channel stays until `post_with` / `post_with_amx` land. +- **rust-samp moves from a git tag to crates.io, and on to `3.6.0`** — whose `mainthread` queue now carries results back from the worker threads. The move to the registry also clears a long-standing oddity: the `v3.5.0` tag declared `version = "3.4.0"` in its own manifest, so the lockfile recorded 3.4.0 pointing at the v3.5.0 tag. + + **The plugin's own result channels are gone.** A finished query, or a finished Argon2id hash, posts a job that hands the result to the plugin; the SDK drains that queue immediately before `on_tick`, so the dispatch still happens in `on_tick` and nothing arrives a tick later than it used to. The ordering stays ours: jobs run in completion order, so the FIFO sequence buffer is still what puts `mysql_query` callbacks back in submission order. -- **`mysql` 28.0.0 → 28.0.2**, plus a large round of transitive updates. (#38, #45, and the Dependabot group) -- Routine dependency and action bumps: the `github-actions` group in three rounds, `pymdown-extensions` in the docs requirements, and the cargo group — `lru`, `sha1`, `num-bigint`, `wasip2`, `windows-sys` and `derive_utils` among them. (#39, #40, #44–#54) + Handing over and dispatching had to be separate steps, not a matter of taste. `post_with` documents that calling into Pawn from inside the job is not allowed, and the reason applies here exactly: a `public` re-entering one of these natives would take a second `&mut` to the same plugin, since `#[native]` resolves the plugin with `plugin::get` rather than the guarded `plugin::with_instance`. `post_with_amx` splits the two, but hands the script its reply only after the plugin borrow has ended — too late to hold a result cache across the call. Buffering in the job and dispatching in the tick satisfies both. + +- **`mysql` 28.0.0 → 28.0.3**, plus a large round of transitive updates. The 28.0.3 bump is the one that matters: it requires `lru` 0.18, which is how **RUSTSEC-2026-0253 stopped applying** — the fix had been merged upstream since 2026/09/21 and no `cargo update` could reach it while the driver still asked for `0.16`. The lockfile now carries `lru` 0.18.5. (#38, #45, #62, #64) +- Routine dependency and action bumps: the `github-actions` group in five rounds, `pymdown-extensions` and `urllib3` in the docs requirements, and the cargo group — `lru`, `sha1`, `num-bigint`, `wasip2`, `windows-sys`, `derive_utils` and `io-enum` among them. (#39, #40, #44–#54, #61–#66) + +- **A closing sweep over the lockfile and the workflows**, so the release does not ship pins that were already stale when it was tagged. `cargo update` moved ten transitive crates — `cc`, `generic-array`, `powerfmt`, `serde` and its two companions, `twox-hash`, `uuid` and `zeroize`; seven pinned actions were re-pinned to their current releases (`upload-artifact` v7.0.2, `codeql-action` v4.38.3, `scorecard-action` v2.4.4, `install-action` v2.87.27, `action-gh-release` v3.0.3, `rust-cache` v2.9.2, `release-drafter` v7.10.0), SHA and `# vX.Y.Z` comment together, since a comment that drifts from its SHA is worse than no comment. + +- **`argon2` stays on 0.5, deliberately.** 0.6.0 is out and it is a breaking change in exactly the place that must not break quietly: the `std` feature is gone, `SaltString` and `OsRng` moved, `hash_password` changed signature and `PasswordHash` is deprecated. Hashes already written by `mysql_hash_password` have to keep verifying across the bump, and proving that belongs in a pull request of its own rather than riding along with a release. ### CI / tooling @@ -80,12 +96,11 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n - **IPv6 is supported, in brackets.** `[::1]` is parsed and validated as an address; without brackets it is taken as a host name, which usually still connects but validates nothing. (#42) -### Known advisories +### Known advisory -Both are informational, neither is resolvable here, and both are pinned behind the `mysql` crate: +One is left, it is informational, and it is not resolvable here: -- **RUSTSEC-2025-0134** — `rustls-pemfile` is unmaintained. The code that runs is the maintained `rustls-pki-types`; upstream still depends on the wrapper, so there is not even an unreleased fix to wait for. -- **RUSTSEC-2026-0253** — `lru` is unsound in `LruCache::pop` if a key's `Drop` panics. The cache keys here are strings and integers, whose `Drop` does not panic. Upstream merged the bump to `lru` 0.18.2 on 2026/09/21 but has not released it; `mysql` still requires `0.16`, which a `cargo update` cannot cross. +- **RUSTSEC-2025-0134** — `rustls-pemfile` is unmaintained. The code that actually runs is the maintained `rustls-pki-types`; the flagged crate is a thin wrapper over it. The `mysql` crate still depends on the wrapper, including on its own master, so there is not even an unreleased fix to wait for. Dropping it here would mean giving up TLS or forking the driver. ## [1.3.0] — 2026/09/07 diff --git a/Cargo.lock b/Cargo.lock index 5a36e04..bec10cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,9 +127,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "shlex", @@ -176,9 +176,9 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -323,9 +323,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -794,9 +794,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "proc-macro-crate" @@ -924,9 +924,9 @@ dependencies = [ [[package]] name = "rust-samp" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42851d4b9181287c2d563c10252854ab87abd61336f7086feb9f2c6ec9e93320" +checksum = "500793af78fd9784ab8a1fa09fad1b039e0a1dc7e6acfc41af23c7a737c29754" dependencies = [ "fern", "flate2", @@ -939,9 +939,9 @@ dependencies = [ [[package]] name = "rust-samp-codegen" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6c4017948d2be2b498361740bc84baa65ee0565572c8b8a7a06dcf237d064b" +checksum = "a6f90fbcc9557358882648f2f223ec7b11d868049e396c6493797c5a20e2f49e" dependencies = [ "proc-macro2", "quote", @@ -950,9 +950,9 @@ dependencies = [ [[package]] name = "rust-samp-sdk" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf910ae41fe86cd09c57c440c86524f7e0e06b328df47fff836190a7d6597c84" +checksum = "61cce73cc9379ce327b087b57592d7e14741859008cd4df6aed1b6f2be46a365" dependencies = [ "bitflags 2.13.2", ] @@ -1015,9 +1015,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1025,22 +1025,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1272,9 +1272,9 @@ dependencies = [ [[package]] name = "twox-hash" -version = "2.1.4" +version = "2.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" [[package]] name = "typenum" @@ -1314,9 +1314,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "js-sys", "wasm-bindgen", @@ -1593,9 +1593,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 3c2ad8e..cb666df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib", "lib"] [dependencies] # `compression` gzips rotated log archives. It pulls in flate2, which the mysql # crate already brings in via `minimal-rust` — so this costs no new dependency. -samp = { package = "rust-samp", version = "3.5.0", features = ["compression"] } +samp = { package = "rust-samp", version = "3.6.0", features = ["compression"] } # `rustls-tls-ring` is what actually compiles a TLS backend in. Without it the # crate's `make_secure` is a `panic!` stub, so MYSQL_OPT_SSL would abort the # connection instead of encrypting it. `ring` rather than the default diff --git a/docs/queries.md b/docs/queries.md index bb8ae7b..afa3f2c 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -34,7 +34,7 @@ native bool:mysql_query(connId, const query[], const callback[] = "", const form | `format` | string | One char per variadic parameter (`d`/`i` = int, `f` = float, `s` = string) | | `...` | variadic | Values forwarded to the callback in the order they appear | -**Returns:** `true` if the query was queued, `false` if `connId` is unknown. +**Returns:** `true` if the query was queued, `false` if `connId` is unknown or the submission itself failed — the server refusing a new worker thread, which only happens under a burst of thousands in a single tick. A refused submission fires `OnQueryError` with `errorid` `0`, so a FIFO chain never waits on a callback that is not coming. **Ordering guarantee — read this one carefully.** What is ordered is the **delivery of callbacks**, in submission order, even when the underlying queries finish out of order. A slow query holds back the dispatch of later callbacks until it completes. diff --git a/src/natives/orm.rs b/src/natives/orm.rs index a0436a9..8a83814 100644 --- a/src/natives/orm.rs +++ b/src/natives/orm.rs @@ -196,8 +196,7 @@ impl MysqlPlugin { callback_info, conn_id, self.connections.get_auto_reconnect(conn_id), - ); - true + ) } /// orm_apply_cache(orm_id, row = 0) diff --git a/src/natives/query.rs b/src/natives/query.rs index b8e44bd..26f4595 100644 --- a/src/natives/query.rs +++ b/src/natives/query.rs @@ -143,7 +143,7 @@ impl MysqlPlugin { callback_info, req.conn_id, auto_reconnect, - ); + ) } else { self.queries.submit_pquery( pool, @@ -151,10 +151,8 @@ impl MysqlPlugin { callback_info, req.conn_id, auto_reconnect, - ); + ) } - - true } /// Refuses `MYSQL_SYNC` on a native that does not implement it. @@ -388,8 +386,7 @@ impl MysqlPlugin { path )); self.queries - .submit_script(pool, statements, callback_info, conn_id); - true + .submit_script(pool, statements, callback_info, conn_id) } /// mysql_tick() diff --git a/src/natives/stmt.rs b/src/natives/stmt.rs index 0d8ff1a..12fe61e 100644 --- a/src/natives/stmt.rs +++ b/src/natives/stmt.rs @@ -157,7 +157,7 @@ impl MysqlPlugin { callback_info, conn_id, auto_reconnect, - ); + ) } else { self.queries.submit_pprepared( pool, @@ -166,8 +166,7 @@ impl MysqlPlugin { callback_info, conn_id, auto_reconnect, - ); + ) } - true } } diff --git a/src/natives/transaction.rs b/src/natives/transaction.rs index d6e5d89..a47eab7 100644 --- a/src/natives/transaction.rs +++ b/src/natives/transaction.rs @@ -132,7 +132,6 @@ impl MysqlPlugin { }; self.queries - .submit_transaction(pool, tx.steps, callback_info, conn_id); - true + .submit_transaction(pool, tx.steps, callback_info, conn_id) } } diff --git a/src/password.rs b/src/password.rs index 80a2401..4306157 100644 --- a/src/password.rs +++ b/src/password.rs @@ -17,6 +17,7 @@ use argon2::Argon2; use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use crate::plugin::MysqlPlugin; use crate::query::CallbackInfo; /// Why a password operation failed. A fixed set, not a string, so the failure @@ -65,13 +66,13 @@ type Job = (Box PasswordOutcome + Send>, CallbackInfo); /// Runs Argon2id work on a bounded worker pool and collects the results. pub struct PasswordManager { jobs: mpsc::SyncSender, - receiver: mpsc::Receiver, + /// Results handed back by the workers, buffered until the next tick. + ready: Vec, queued: Arc, } impl PasswordManager { pub fn new() -> Self { - let (result_tx, receiver) = mpsc::channel::(); let (jobs, job_rx) = mpsc::sync_channel::(MAX_QUEUED_JOBS); let queued = Arc::new(AtomicU64::new(0)); @@ -86,7 +87,6 @@ impl PasswordManager { for _ in 0..workers { let job_rx = job_rx.clone(); - let result_tx = result_tx.clone(); let queued = queued.clone(); thread::spawn(move || { @@ -107,19 +107,20 @@ impl PasswordManager { let outcome = work(); queued.fetch_sub(1, Ordering::Relaxed); - if result_tx - .send(PasswordResult { outcome, callback }) - .is_err() - { - break; - } + // Same route as a finished query: the main thread takes it + // before `on_tick`, which is where the callback is fired. + samp::mainthread::post_with::(move |plugin| { + plugin + .passwords + .accept(PasswordResult { outcome, callback }); + }); } }); } Self { jobs, - receiver, + ready: Vec::new(), queued, } } @@ -173,8 +174,13 @@ impl PasswordManager { } /// Drains everything finished since the last tick. + /// Takes a finished hash or verification from a worker thread. + pub fn accept(&mut self, result: PasswordResult) { + self.ready.push(result); + } + pub fn poll_results(&mut self) -> Vec { - self.receiver.try_iter().collect() + std::mem::take(&mut self.ready) } } diff --git a/src/query.rs b/src/query.rs index 26c6451..dc29e51 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc; use std::thread; use mysql::Pool; @@ -10,6 +9,8 @@ use crate::cache::CacheEntry; use crate::connection::{ QueryError, attempt_prepared, attempt_query, attempt_script, attempt_transaction, }; +use crate::logger::Logger; +use crate::plugin::MysqlPlugin; /// Parameter type for callback invocation. #[derive(Debug, Clone)] @@ -41,12 +42,23 @@ pub struct QueryResult { /// `pending_ordered` map. Ordered queries use sequences starting at 0 and /// growing by one; parallel results are stored at `u64::MAX` and counting /// downward, so anything `>= PARALLEL_KEY_THRESHOLD` is a parallel entry. +/// Stack given to a query worker. +/// +/// The default is 8 MiB per thread, and the server is a 32-bit process: at a +/// few hundred live workers the address space is gone, an allocation fails and +/// the process aborts. Measured on a 2000-query burst, the server died with +/// "memory allocation of 4194304 bytes failed" well before the thread limit +/// itself was the problem. +/// +/// A worker opens a connection, runs one statement and builds a cache - it +/// does not recurse - so 256 KiB is already generous, and it moves the ceiling +/// from a few hundred workers to thousands. +const WORKER_STACK_BYTES: usize = 256 * 1024; + const PARALLEL_KEY_THRESHOLD: u64 = u64::MAX / 2; /// Manages threaded query execution and result collection. pub struct QueryManager { - sender: mpsc::Sender, - receiver: mpsc::Receiver, next_sequence: u64, pending_ordered: BTreeMap, next_dispatch: u64, @@ -55,10 +67,7 @@ pub struct QueryManager { impl QueryManager { pub fn new() -> Self { - let (sender, receiver) = mpsc::channel(); Self { - sender, - receiver, next_sequence: 0, pending_ordered: BTreeMap::new(), next_dispatch: 0, @@ -80,14 +89,14 @@ impl QueryManager { callback: Option, conn_id: i32, auto_reconnect: bool, - ) { + ) -> bool { let sequence = self.next_sequence; self.next_sequence += 1; - let sender = self.sender.clone(); self.in_flight.fetch_add(1, Ordering::Relaxed); let in_flight = self.in_flight.clone(); - thread::spawn(move || { + let failure_text = query.clone(); + self.spawn_worker(sequence, true, conn_id, failure_text, move || { let result = match attempt_query(&pool, &query, auto_reconnect) { Ok(cache) => QueryResult { cache, @@ -106,9 +115,14 @@ impl QueryManager { sequence, }, }; - let _ = sender.send(result); + // Hand the result to the main thread. The SDK drains this queue + // right before `on_tick`, so the dispatch there sees it in the + // same tick - no latency added by going through the queue. + samp::mainthread::post_with::(move |plugin| { + plugin.queries.accept(result); + }); in_flight.fetch_sub(1, Ordering::Relaxed); - }); + }) } /// Submits a prepared statement. Ordered like [`Self::submit_query`] — the @@ -121,14 +135,14 @@ impl QueryManager { callback: Option, conn_id: i32, auto_reconnect: bool, - ) { + ) -> bool { let sequence = self.next_sequence; self.next_sequence += 1; - let sender = self.sender.clone(); self.in_flight.fetch_add(1, Ordering::Relaxed); let in_flight = self.in_flight.clone(); - thread::spawn(move || { + let failure_text = query.clone(); + self.spawn_worker(sequence, true, conn_id, failure_text, move || { let result = match attempt_prepared(&pool, &query, params, auto_reconnect) { Ok(cache) => QueryResult { cache, @@ -147,9 +161,14 @@ impl QueryManager { sequence, }, }; - let _ = sender.send(result); + // Hand the result to the main thread. The SDK drains this queue + // right before `on_tick`, so the dispatch there sees it in the + // same tick - no latency added by going through the queue. + samp::mainthread::post_with::(move |plugin| { + plugin.queries.accept(result); + }); in_flight.fetch_sub(1, Ordering::Relaxed); - }); + }) } /// Parallel counterpart of [`Self::submit_prepared`] — no ordering @@ -162,12 +181,12 @@ impl QueryManager { callback: Option, conn_id: i32, auto_reconnect: bool, - ) { - let sender = self.sender.clone(); + ) -> bool { self.in_flight.fetch_add(1, Ordering::Relaxed); let in_flight = self.in_flight.clone(); - thread::spawn(move || { + let failure_text = query.clone(); + self.spawn_worker(0, false, conn_id, failure_text, move || { let result = match attempt_prepared(&pool, &query, params, auto_reconnect) { Ok(cache) => QueryResult { cache, @@ -186,9 +205,14 @@ impl QueryManager { sequence: 0, }, }; - let _ = sender.send(result); + // Hand the result to the main thread. The SDK drains this queue + // right before `on_tick`, so the dispatch there sees it in the + // same tick - no latency added by going through the queue. + samp::mainthread::post_with::(move |plugin| { + plugin.queries.accept(result); + }); in_flight.fetch_sub(1, Ordering::Relaxed); - }); + }) } /// Submits a script (several statements from a file), ordered. @@ -198,14 +222,14 @@ impl QueryManager { statements: Vec, callback: Option, conn_id: i32, - ) { + ) -> bool { let sequence = self.next_sequence; self.next_sequence += 1; - let sender = self.sender.clone(); self.in_flight.fetch_add(1, Ordering::Relaxed); let in_flight = self.in_flight.clone(); - thread::spawn(move || { + let failure_text = statements.first().cloned().unwrap_or_default(); + self.spawn_worker(sequence, true, conn_id, failure_text, move || { let result = match attempt_script(&pool, &statements) { Ok(cache) => QueryResult { cache, @@ -224,9 +248,14 @@ impl QueryManager { sequence, }, }; - let _ = sender.send(result); + // Hand the result to the main thread. The SDK drains this queue + // right before `on_tick`, so the dispatch there sees it in the + // same tick - no latency added by going through the queue. + samp::mainthread::post_with::(move |plugin| { + plugin.queries.accept(result); + }); in_flight.fetch_sub(1, Ordering::Relaxed); - }); + }) } /// Submits a transaction batch. Ordered, and never auto-reconnects — see @@ -237,14 +266,14 @@ impl QueryManager { steps: Vec, callback: Option, conn_id: i32, - ) { + ) -> bool { let sequence = self.next_sequence; self.next_sequence += 1; - let sender = self.sender.clone(); self.in_flight.fetch_add(1, Ordering::Relaxed); let in_flight = self.in_flight.clone(); - thread::spawn(move || { + let failure_text = "transaction".to_string(); + self.spawn_worker(sequence, true, conn_id, failure_text, move || { let result = match attempt_transaction(&pool, &steps) { Ok(cache) => QueryResult { cache, @@ -263,9 +292,14 @@ impl QueryManager { sequence, }, }; - let _ = sender.send(result); + // Hand the result to the main thread. The SDK drains this queue + // right before `on_tick`, so the dispatch there sees it in the + // same tick - no latency added by going through the queue. + samp::mainthread::post_with::(move |plugin| { + plugin.queries.accept(result); + }); in_flight.fetch_sub(1, Ordering::Relaxed); - }); + }) } /// Submits a parallel query (no order guarantee — dispatched as soon as complete). @@ -276,12 +310,12 @@ impl QueryManager { callback: Option, conn_id: i32, auto_reconnect: bool, - ) { - let sender = self.sender.clone(); + ) -> bool { self.in_flight.fetch_add(1, Ordering::Relaxed); let in_flight = self.in_flight.clone(); - thread::spawn(move || { + let failure_text = query.clone(); + self.spawn_worker(0, false, conn_id, failure_text, move || { let result = match attempt_query(&pool, &query, auto_reconnect) { Ok(cache) => QueryResult { cache, @@ -300,27 +334,85 @@ impl QueryManager { sequence: 0, }, }; - let _ = sender.send(result); + // Hand the result to the main thread. The SDK drains this queue + // right before `on_tick`, so the dispatch there sees it in the + // same tick - no latency added by going through the queue. + samp::mainthread::post_with::(move |plugin| { + plugin.queries.accept(result); + }); in_flight.fetch_sub(1, Ordering::Relaxed); - }); + }) } /// Collects completed results and returns them in correct dispatch order. /// Ordered queries are buffered and dispatched in FIFO sequence. /// Parallel queries are dispatched immediately. - pub fn poll_results(&mut self) -> Vec { - // Drain all available results from the channel - while let Ok(result) = self.receiver.try_recv() { - if result.ordered { - self.pending_ordered.insert(result.sequence, result); - } else { - // Parallel queries go to pending with a special high sequence - // so they are dispatched after ordered ones in this tick - let key = u64::MAX - u64::try_from(self.pending_ordered.len()).unwrap_or(u64::MAX); - self.pending_ordered.insert(key, result); + /// Starts a worker, or fails the submission cleanly. + /// + /// `thread::spawn` panics when the OS refuses a new thread, and under a + /// burst it does: measured with 2000 queries submitted in one tick, the + /// process ran out of threads partway through. The panic was caught by the + /// native wrapper, but the submission vanished with its sequence number - + /// and an ordered sequence that never arrives stalls every callback queued + /// behind it, permanently. + /// + /// So the failure is turned into an ordinary failed result instead: the + /// sequence is accounted for, `OnQueryError` fires, and the script gets + /// `false` from the native. + fn spawn_worker( + &mut self, + sequence: u64, + ordered: bool, + conn_id: i32, + query_text: String, + job: impl FnOnce() + Send + 'static, + ) -> bool { + match thread::Builder::new() + .stack_size(WORKER_STACK_BYTES) + .spawn(job) + { + Ok(_) => true, + Err(e) => { + self.in_flight.fetch_sub(1, Ordering::Relaxed); + let detail = format!("could not start a worker thread: {e}"); + Logger::error_detail( + "Query not started: the process is out of threads. \ + See logs/mysql.log for details.", + &detail, + ); + self.accept(QueryResult { + cache: CacheEntry::empty(query_text), + callback: None, + conn_id, + error: Some(QueryError { + code: 0, + message: detail, + }), + ordered, + sequence, + }); + false } } + } + /// Takes a finished query from a worker thread. + /// + /// Called on the main thread, from the job the worker posted, so it only + /// buffers: dispatching is `poll_results`, which runs in `on_tick` where + /// invoking a Pawn callback is legal. + pub fn accept(&mut self, result: QueryResult) { + if result.ordered { + self.pending_ordered.insert(result.sequence, result); + } else { + // Parallel results take a high key so they are dispatched after + // the ordered ones waiting in the same tick. + let key = u64::MAX - u64::try_from(self.pending_ordered.len()).unwrap_or(u64::MAX); + self.pending_ordered.insert(key, result); + } + } + + pub fn poll_results(&mut self) -> Vec { let mut ready = Vec::new(); // Dispatch ordered results in sequence @@ -345,12 +437,6 @@ impl QueryManager { ready } - - /// Returns a clone of the sender for testing (inject results without threads). - #[cfg(test)] - pub fn test_sender(&self) -> mpsc::Sender { - self.sender.clone() - } } #[cfg(test)] @@ -384,12 +470,11 @@ mod tests { #[test] fn ordered_results_fifo() { let mut mgr = QueryManager::new(); - let sender = mgr.test_sender(); // Send results out of order: 2, 0, 1 - sender.send(empty_result(2, true)).unwrap(); - sender.send(empty_result(0, true)).unwrap(); - sender.send(empty_result(1, true)).unwrap(); + mgr.accept(empty_result(2, true)); + mgr.accept(empty_result(0, true)); + mgr.accept(empty_result(1, true)); let results = mgr.poll_results(); assert_eq!(results.len(), 3); @@ -401,16 +486,15 @@ mod tests { #[test] fn ordered_results_partial_dispatch() { let mut mgr = QueryManager::new(); - let sender = mgr.test_sender(); // Send sequence 1 but not 0 — should block dispatch - sender.send(empty_result(1, true)).unwrap(); + mgr.accept(empty_result(1, true)); let results = mgr.poll_results(); assert!(results.is_empty()); // sequence 0 is missing // Now send sequence 0 - sender.send(empty_result(0, true)).unwrap(); + mgr.accept(empty_result(0, true)); let results = mgr.poll_results(); assert_eq!(results.len(), 2); // 0 and 1 dispatched @@ -421,10 +505,9 @@ mod tests { #[test] fn parallel_results_dispatch_immediately() { let mut mgr = QueryManager::new(); - let sender = mgr.test_sender(); - sender.send(empty_result(0, false)).unwrap(); - sender.send(empty_result(0, false)).unwrap(); + mgr.accept(empty_result(0, false)); + mgr.accept(empty_result(0, false)); let results = mgr.poll_results(); assert_eq!(results.len(), 2); @@ -433,12 +516,11 @@ mod tests { #[test] fn mixed_ordered_and_parallel() { let mut mgr = QueryManager::new(); - let sender = mgr.test_sender(); // Ordered sequence 0 - sender.send(empty_result(0, true)).unwrap(); + mgr.accept(empty_result(0, true)); // Parallel (no order) - sender.send(empty_result(0, false)).unwrap(); + mgr.accept(empty_result(0, false)); let results = mgr.poll_results(); assert_eq!(results.len(), 2);