Skip to content

fix: recover from panic/cancellation-induced mutex poisoning and leaked transactions - #280

Merged
StefanSteiner merged 3 commits into
tableau:mainfrom
StefanSteiner:fix/engine-pool-panic-resilience
Sep 6, 2026
Merged

fix: recover from panic/cancellation-induced mutex poisoning and leaked transactions#280
StefanSteiner merged 3 commits into
tableau:mainfrom
StefanSteiner:fix/engine-pool-panic-resilience

Conversation

@StefanSteiner

Copy link
Copy Markdown
Contributor

Summary

Two related resilience defects, same failure shape in adjacent layers: a panic or cancellation leaves shared state unusable, and nothing detects it.

#266 — a panicking tool call wedges the MCP server for the process lifetime

HyperMcpServer::with_engine holds a std::sync::MutexGuard<Option<Engine>> across the tool closure it invokes. A panic propagating out of that closure drops the guard mid-unwind and poisons the mutex. ensure_engine had a recovery path for ConnectionLost but none for poisoning, so every subsequent tool call failed with InternalError "Lock poisoned" until the process restarted.

Fix: ensure_engine's engine-lock call sites now go through a new lock_engine_recovering_poison helper that mirrors the existing ConnectionLost recovery shape: on a poisoned lock it discards whatever Engine value the poisoned guard held (a panic may have caught it mid-mutation, so the invariants can't be trusted — rebuilding from scratch is the safe default), clears the poison flag, and falls through to the normal single-flight rebuild path. The next tool call gets a fresh engine instead of a possibly-corrupt one.

This is a different layer from the existing RAII transaction guard (which prevents the SQL-level wedge of an open transaction) — this fixes the server-level wedge of a poisoned Mutex that outlives it.

A regression test drives this through HyperMcpServer::with_engine itself (not Engine directly via TestEngine, which is why the crate's two existing panic-path tests never caught this — they never cross with_engine). Proven red against the pre-fix code (captured "Lock poisoned" panic on the second call), green after the fix.

#263 — a panicked or cancelled async task returns a pooled connection with BEGIN open

AsyncTransaction::drop cannot issue an async ROLLBACK — Rust has no async Drop — so it only warns when dropped without an explicit commit()/rollback(), exactly what happens when a task holding the guard panics or is cancelled. The transaction stays open on the connection when it returns to the pool.

Verified the reported premise empirically against the real engine (it was previously unconfirmed): SELECT 1, the prior default recycle probe (RecycleStrategy::SelectOne), does succeed even inside an open transaction, so it never detects the leak. Also confirmed a nested BEGIN does not error either (Postgres-style warn-and-continue), ruling out "probe via a second BEGIN" as a detection strategy — and confirmed ROLLBACK is both a harmless no-op when nothing is open and a genuine, durable undo when something is (verified via a real INSERT invisible from a second connection after rollback).

Fix: RecycleStrategy::SelectOne now issues an unconditional ROLLBACK instead of SELECT 1 — same one-round-trip cost, since it doubles as the liveness probe (a dead connection fails ROLLBACK exactly as it would fail SELECT 1). Ping, None, and Custom are left unchanged and documented as not discharging a leaked transaction. The sync pool is unaffected because Transaction's Drop rolls back synchronously and therefore cannot leak this way.

A regression test uses a real panicking tokio::spawn task (mirroring the join-error handling already present in ingest.rs/ingest_arrow.rs) that leaves a transaction open on a max_size(1) pool, then proves the next checkout does not observe the leaked row. Proven red against the pre-fix SELECT 1 probe (captured: the leaked row survived, left: 1, right: 0), green after the fix.

Verification

  • cargo fmt --all -- --check: clean
  • cargo clippy -p hyperdb-mcp --all-targets --all-features -- -D warnings: clean
  • cargo clippy -p hyperdb-api --all-targets --all-features -- -D warnings: clean
  • cargo test -p hyperdb-mcp: 611 passed, 0 failed, exit 0
  • cargo test -p hyperdb-api: 630 passed, 0 failed, exit 0
  • hyperdb-mcp/CHANGELOG.md and hyperdb-api/CHANGELOG.md updated under ## [Unreleased] (merged into existing ### Fixed headings, no MD024 duplicates)
  • npx markdownlint-cli2 on both changed changelogs: 0 issues

Closes #266
Closes #263

…bricking the server

`with_engine` holds a `std::sync::MutexGuard<Option<Engine>>` across the
tool closure it invokes. When a tool handler panics, the guard drops
mid-unwind and poisons the mutex per std's default behavior. `ensure_engine`
had no recovery path for poisoning (unlike its existing `ConnectionLost`
recovery), so every subsequent `.lock()` returned `Err`, and every future
tool call failed with `InternalError "Lock poisoned"` until the process
restarted.

`ensure_engine`'s engine-lock call sites now go through
`lock_engine_recovering_poison`, which mirrors the shape of the existing
`ConnectionLost` recovery: on a poisoned lock, it discards whatever `Engine`
the poisoned guard held (a panic may have caught it mid-mutation, so its
invariants can't be trusted), clears the poison flag, and falls through to
the normal single-flight rebuild path. The next tool call gets a fresh
engine instead of a possibly-corrupt one.

Added a regression test that drives the defect through
`HyperMcpServer::with_engine` itself (not `Engine` directly via
`TestEngine`, which is why the crate's existing panic-path tests never
caught this) — proven red against the pre-fix code with captured "Lock
poisoned" output, green after the fix.

Closes tableau#266
`AsyncTransaction::drop` cannot issue an async `ROLLBACK` — Rust has no
async `Drop` — so it only warns when dropped without an explicit
`commit()`/`rollback()`, exactly what happens when a task holding the guard
panics or is cancelled. The transaction stays open on the connection when
it returns to the pool.

Confirmed empirically against the real engine (not merely asserted) that
this is exploitable: `SELECT 1`, the previous default recycle probe
(`RecycleStrategy::SelectOne`), succeeds even inside an open transaction, so
it never detected the leak and the next borrower silently inherited a
connection mid-transaction. Also confirmed a nested `BEGIN` does not error
either (Postgres-style warn-and-continue), so "probe by issuing another
BEGIN" is not a viable detection strategy — and that `ROLLBACK` is both a
harmless no-op when nothing is open and a genuine, durable undo when
something is (verified via a real INSERT that a second connection could not
see afterward).

`SelectOne` now issues an unconditional `ROLLBACK` instead of `SELECT 1` —
same one-round-trip cost, since it doubles as the liveness probe a dead
connection fails identically. `Ping`, `None` and `Custom` are unchanged
(documented as not discharging a leaked transaction); the sync pool is
unaffected because `Transaction`'s `Drop` rolls back synchronously and
therefore cannot leak this way.

Added a regression test using a real panicking `tokio::spawn` task (mirroring
the join-error handling already present in `ingest.rs`/`ingest_arrow.rs`)
that leaves a transaction open on a `max_size(1)` pool, then proves the next
checkout does not observe the leaked row — proven red against the pre-fix
`SELECT 1` probe with captured output (the leaked row survived), green after
the fix.

Closes tableau#263
…e pool fix's mechanism

Review follow-ups on the tableau#266 / tableau#263 resilience work.

The poison recovery stopped one line short of the brick it was written to
prevent. `ensure_engine` still mapped a poisoned `engine_initialization` to
`InternalError "Lock poisoned"`, so a panic in the construction critical
section — `Engine::new*`, attachment replay, the `debug_assert!` — wedged
the server exactly as before. The engine-mutex recovery made that *more*
reachable, not less: it empties the slot, so every later call must construct
and therefore must pass through that lock. It is a `Mutex<()>` held purely
for mutual exclusion, so recovering is unconditionally safe.

The recovery is now a shared free function in `engine.rs`, plus a
`try_lock` sibling, rather than a private method on the server. Every site
that locks the co-owned `Mutex<Option<Engine>>` goes through one of them:

- `build_watcher_pool`, which returned a permanent "Engine lock poisoned"
  where an unattended ingest may see no tool call for hours. It now observes
  the empty slot and returns its existing transient "not initialized" error.
- the watcher's `_table_catalog` upsert, whose `if let Ok(guard)` silently
  skipped bookkeeping for every subsequent file.
- `status`, which conflated poisoned with contended and so answered
  `engine_busy: true` — "retry later" — permanently, with nothing in flight.
  Not in the review; found while auditing the remaining lock sites.

Recovery destroys the ephemeral primary, which was invisible to the client.
Behavior is kept — an `Engine` a panic caught mid-mutation can't be trusted
— but the doc comment, the `warn!` and the changelog now say so.

On the pool side the tests asserted the outcome without pinning the
mechanism. `max_size(1)` does not guarantee connection reuse: deadpool
replaces on any `RecycleError`, and a fresh session cannot see another
session's uncommitted row, so `count == 0` passed either way. Comparing
`async_session_id` across the leak proves the same physical connection was
recycled, and is the suite's only assertion that `ROLLBACK` succeeds on an
idle connection — the premise the one-round-trip cost argument rests on.
A cancelled-task variant backs the changelog's "panicked or cancelled";
against the old `SELECT 1` probe it fails with the row surviving, so
cancellation genuinely leaks rather than being covered by luck.

Also drops a false claim predating the PR: `RecycleStrategy::None` said the
pool still applies a passive `AsyncConnection::is_alive` check. The async
manager never calls it — `None` is a genuine no-op. Only the sync pool
checks. Most misleading for the caller combining `None` with
`AsyncTransaction`.

Tests: mcp lib 133 passed; api pool_tests 16 passed; watcher_tests 9 passed;
fmt, clippy (-D warnings), rustdoc (-D warnings) and markdownlint clean.
Each new test proven red against the reverted fix.

Refs tableau#266
Refs tableau#263
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant