From 6377b42bdf7a885bac4a71ef7e6b9b1aba41d1fc Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Mon, 10 Aug 2026 14:01:27 +0200 Subject: [PATCH] Add concurrent close-during-read regression tests for safe close Cover the intermittent close-during-read SIGSEGV (#253/#279) end-to-end against native LMDB with safe close enabled. The existing RefCounter tests stress the counter in isolation (mock env) and the EnvTest safeClose tests are single-threaded, so neither exercises Env.close() racing in-flight reads on other threads through the real Env/Txn/Cursor wiring. Two tests hammer txnRead()/Dbi.get (and a cursor variant) from many threads while another thread races Env.close(); a resource held on the test thread makes the fail-fast EnvInUseException deterministic, and the assertions prove close() never unmaps while readers are live, readers only ever see AlreadyClosedException, and the env closes cleanly once readers stop. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T9DLq5CoaZA9eB6fbgrW4d --- src/test/java/org/lmdbjava/EnvTest.java | 154 ++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/test/java/org/lmdbjava/EnvTest.java b/src/test/java/org/lmdbjava/EnvTest.java index 9ce375e3..14f2938b 100644 --- a/src/test/java/org/lmdbjava/EnvTest.java +++ b/src/test/java/org/lmdbjava/EnvTest.java @@ -33,11 +33,15 @@ import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Random; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -795,4 +799,154 @@ void closeWithOpenCursor() { // can't close the env as we are unable to close the cursor } + + /** + * Regression for the intermittent close-during-read SIGSEGV (lmdbjava#253 / lmdbjava#279). With + * safe close enabled, {@link Env#close()} must never unmap the memory map while another thread is + * still inside a live read transaction; instead it fails fast with {@link Env.EnvInUseException}. + * + *

Unlike the {@code RefCounter} unit tests, this exercises the real {@code Env}/{@code Txn} + * wiring against native LMDB: many threads hammer {@code txnRead()}/{@code Dbi.get} while another + * thread races {@link Env#close()}. On {@code master} (no safe close) this reliably crashes the + * JVM in {@code mdb_txn_renew0}; with safe close the close is rejected while reads are in flight, + * readers only ever observe {@link Env.AlreadyClosedException}, and the env closes cleanly once + * the readers stop. + */ + @Test + void closeDuringConcurrentReads_isRejectedWhileReadersLiveAndSurvives() throws Exception { + final Path dir = tempDir.createTempDir(); + final Env env = + Env.create().setSafeClose().setMaxReaders(64).setMaxDbs(1).open(dir); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + for (int i = 0; i < 32; i++) { + db.put(bb(i), bb(i)); + } + + final int readerCount = 16; + final AtomicBoolean stop = new AtomicBoolean(false); + final AtomicLong reads = new AtomicLong(); + final List unexpected = new CopyOnWriteArrayList<>(); + final List readers = new ArrayList<>(readerCount); + + for (int i = 0; i < readerCount; i++) { + final int seed = i; + final Thread reader = + new Thread( + () -> { + int k = seed; + while (!stop.get()) { + try (Txn txn = env.txnRead()) { + db.get(txn, bb(k & 31)); + reads.incrementAndGet(); + k++; + } catch (final AlreadyClosedException expected) { + return; // benign: env is closing/closed + } catch (final Throwable t) { + unexpected.add(t); + return; + } + } + }, + "reader-" + seed); + reader.setDaemon(true); + reader.start(); + readers.add(reader); + } + + // A transaction held on this thread guarantees the count is non-zero, so the racing close() + // below deterministically fails fast rather than unmapping. The hammer threads meanwhile race + // real native txn begin/renew against that close(). + final Txn heldReader = env.txnRead(); + try { + Thread.sleep(100); // let the reader threads saturate the native read path + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + assertThat(env.isClosed()).isFalse(); // must NOT have unmapped with live readers + } finally { + heldReader.close(); + } + + // Stop the hammer threads and wait for every in-flight transaction to be released. + stop.set(true); + for (final Thread reader : readers) { + reader.join(5_000); + } + + // With no live transactions the env now closes cleanly. + env.close(); + + assertThat(reads.get()).isGreaterThan(0L); + assertThat(unexpected).isEmpty(); + assertThat(env.isClosed()).isTrue(); + } + + /** + * As {@link #closeDuringConcurrentReads_isRejectedWhileReadersLiveAndSurvives()} but the readers + * additionally open a {@link Cursor} on each transaction. Safe close newly tracks cursors as well + * as transactions, so this covers the cursor acquire/release wiring under a concurrent close + * race, which the existing single-threaded cursor test does not. + */ + @Test + void closeDuringConcurrentCursorReads_isRejectedWhileCursorsLiveAndSurvives() throws Exception { + final Path dir = tempDir.createTempDir(); + final Env env = + Env.create().setSafeClose().setMaxReaders(64).setMaxDbs(1).open(dir); + final Dbi db = + env.createDbi().setDbName(DB_1).withDefaultComparator().setDbiFlags(MDB_CREATE).open(); + for (int i = 0; i < 32; i++) { + db.put(bb(i), bb(i)); + } + + final int readerCount = 16; + final AtomicBoolean stop = new AtomicBoolean(false); + final AtomicLong reads = new AtomicLong(); + final List unexpected = new CopyOnWriteArrayList<>(); + final List readers = new ArrayList<>(readerCount); + + for (int i = 0; i < readerCount; i++) { + final Thread reader = + new Thread( + () -> { + while (!stop.get()) { + try (Txn txn = env.txnRead(); + Cursor cursor = db.openCursor(txn)) { + cursor.first(); + reads.incrementAndGet(); + } catch (final AlreadyClosedException expected) { + return; // benign: env is closing/closed + } catch (final Throwable t) { + unexpected.add(t); + return; + } + } + }, + "cursor-reader-" + i); + reader.setDaemon(true); + reader.start(); + readers.add(reader); + } + + // A cursor held on this thread guarantees a non-zero count during the racing close(). + final Txn heldReader = env.txnRead(); + final Cursor heldCursor = db.openCursor(heldReader); + try { + Thread.sleep(100); + Assertions.assertThatThrownBy(env::close).isInstanceOf(Env.EnvInUseException.class); + assertThat(env.isClosed()).isFalse(); + } finally { + heldCursor.close(); + heldReader.close(); + } + + stop.set(true); + for (final Thread reader : readers) { + reader.join(5_000); + } + + env.close(); + + assertThat(reads.get()).isGreaterThan(0L); + assertThat(unexpected).isEmpty(); + assertThat(env.isClosed()).isTrue(); + } }