fix: UCI position/search lock race — engine searched the stale position#144
Open
0xflick wants to merge 1 commit into
Open
fix: UCI position/search lock race — engine searched the stale position#1440xflick wants to merge 1 commit into
0xflick wants to merge 1 commit into
Conversation
`cmd_go` spawned a thread that held the `SearchManager` mutex through the whole search *including* the `bestmove` print, and `cmd_position` used `try_lock()`. When the GUI sent the next `position` the instant it read `bestmove`, the just-finished search thread hadn't released the lock yet, so `try_lock` failed, `run_loop` logged the error and moved on — and the next `go` searched the *previous* position, playing a move legal there but illegal in the real one (fastchess: "Illegal move … played by …"). A pure timing race: ~1 in 500 games under LTO+musl+native speed; invisible under ASan or without LTO, which is why it only showed up on the SPRT cluster. Refactor (Stockfish-style): the UCI thread now owns the search state and never shares a lock with a running search. - Drop `Arc<Mutex<SearchManager>>`; `Uci` owns `SearchManager` and the search `JoinHandle` directly. - TT -> `Arc<Table>` (reused lockless across games; `age` -> `AtomicU8`), net -> `Arc<Net>`. - A `go` hands a *snapshot* (cloned position + cloned `Arc`s) to a spawned thread it owns; `stop` (atomic) is the only thing touched on a running search. - `wait_search()` (set stop + join) precedes every state mutation / `go` / `quit`, so a `position` is never dropped and the next `go` always searches the position it was given. - Also fix a latent `hashfull` slice panic for sub-1000-entry tables. Verified on faithful musl+LTO self-play at concurrency 43: 3,214 games, 0 illegal moves (unfixed: ~5-6). Search behaviour unchanged — `cargo run -r -- bench` = 3,302,740 nodes at the same nps; full test suite and clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On the SPRT cluster,
pouncewould occasionally play an illegal move — fastchessIllegal move … played by …, ~1 game in 500, with the search reporting a nonsense line (depth 15+, score cp 0/mate 1, ~30–80 nodes). It never reproduced in normal local testing.Root cause — a UCI position/search lock race.
cmd_gospawns a thread that takes theSearchManagermutex and holds it through the entire search, including theprintln!("bestmove …")(the print is insidethink_with_stop, before the guard drops).cmd_positionusedtry_lock(). So:bestmoveand instantly sends the nextposition fen …;cmd_position'stry_lock()fails;run_looplogsError: Failed to lock search managerand moves on to the next line — the new position was never applied;gosearches the previous position and plays a move that's legal there but illegal in the position fastchess actually has.It's a pure timing race: it lands in the µs window between the
bestmoveprint and the lock release only under fast native codegen — LTO + musl + native speed (~1/500 games); under AddressSanitizer or without LTO the lock frees before fastchess returns, so it never triggers (which is exactly why it only showed up on the cluster).The fix (Stockfish-style threading)
The UCI thread now owns the search state and never shares a lock with a running search:
Arc<Mutex<SearchManager>>—UciownsSearchManagerand the searchJoinHandledirectly. Notry_lockanywhere.Arc<Table>(reused lockless across games — itsunsafe impl Syncis exactly for this;age→AtomicU8), net →Arc<Net>.gohands a snapshot — a cloned position +Arc::clone'd TT/net — to a spawned thread it owns (extracted intosearch_core). The search touches nothing the UCI thread owns;stop(atomic) is the only thingstop/quitflip on it.wait_search()(setstop+ join) precedes every state mutation /go/quit, so apositioncan never be dropped, the nextgoalways searches the position it was given, and no search thread is ever abandoned.Table::hashfull()slice panic for sub-1000-entry tables.bench/datagenare untouched (they keep the synchronousSearchManager::think()).Verification
main(1f9b40f)(P(0 illegal | unfixed rate) ≈ 0.1%.)
Search behaviour is unchanged — pure I/O serialization:
cargo run --release -- bench→Nodes: 3302740(identical fingerprint), same nps;cargo test --all-features→ all pass;cargo clippy --all-targets --all-features -- -D warnings→ clean;ucinewgame/position/go/stopinterleave → cleanbestmoves, no dropped positions, no hang.No throughput cost: hot-path TT access is byte-identical (a relaxed
AtomicU8load is the samemovon x86-64), and the cluster self-play ran at the same games/min as before.Follow-ups (separate, out of scope here)
Move::promotion()(chessmove.rs) andRank::new_unchecked/File::new_unchecked(board.rs)transmutean unchecked byte into a fieldless enum — sound for valid input but UB for out-of-range (e.g.Move::NULL); worth a bounds check for consistency withSquare/Color.🤖 Generated with Claude Code