Skip to content

fix: UCI position/search lock race — engine searched the stale position#144

Open
0xflick wants to merge 1 commit into
mainfrom
fix/uci-search-stale-position-race
Open

fix: UCI position/search lock race — engine searched the stale position#144
0xflick wants to merge 1 commit into
mainfrom
fix/uci-search-stale-position-race

Conversation

@0xflick

@0xflick 0xflick commented May 19, 2026

Copy link
Copy Markdown
Owner

The bug

On the SPRT cluster, pounce would occasionally play an illegal move — fastchess Illegal 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_go spawns a thread that takes the SearchManager mutex and holds it through the entire search, including the println!("bestmove …") (the print is inside think_with_stop, before the guard drops). cmd_position used try_lock(). So:

  1. fastchess reads bestmove and instantly sends the next position fen …;
  2. the just-finished search thread hasn't released the lock yet (µs of teardown left) → cmd_position's try_lock() fails;
  3. run_loop logs Error: Failed to lock search manager and moves on to the next line — the new position was never applied;
  4. the next go searches 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 bestmove print 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:

  • Drop Arc<Mutex<SearchManager>>Uci owns SearchManager and the search JoinHandle directly. No try_lock anywhere.
  • TT → Arc<Table> (reused lockless across games — its unsafe impl Sync is exactly for this; ageAtomicU8), net → Arc<Net>.
  • A go hands a snapshot — a cloned position + Arc::clone'd TT/net — to a spawned thread it owns (extracted into search_core). The search touches nothing the UCI thread owns; stop (atomic) is the only thing stop/quit flip on it.
  • wait_search() (set stop + join) precedes every state mutation / go / quit, so a position can never be dropped, the next go always searches the position it was given, and no search thread is ever abandoned.
  • Bonus: fixed a latent Table::hashfull() slice panic for sub-1000-entry tables.

bench/datagen are untouched (they keep the synchronous SearchManager::think()).

Verification

binary musl+LTO self-play @ concurrency 43 illegal moves
main (1f9b40f) ~2,850 games ~5–6
this PR 3,214 games 0

(P(0 illegal | unfixed rate) ≈ 0.1%.)

Search behaviour is unchanged — pure I/O serialization:

  • cargo run --release -- benchNodes: 3302740 (identical fingerprint), same nps;
  • cargo test --all-features → all pass;
  • cargo clippy --all-targets --all-features -- -D warnings → clean;
  • rapid ucinewgame/position/go/stop interleave → clean bestmoves, no dropped positions, no hang.

No throughput cost: hot-path TT access is byte-identical (a relaxed AtomicU8 load is the same mov on 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) and Rank::new_unchecked/File::new_unchecked (board.rs) transmute an 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 with Square/Color.

🤖 Generated with Claude Code

`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>
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