Conversation
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.
Fix: constructor failures in LightningCursor / LightningTransaction crash the finalizer thread
Problem
LightningCursorandLightningTransactionare bothIDisposabletypes with afinalizer (
~LightningCursor()/~LightningTransaction()) that callsDispose(false). Their constructors call a native LMDB function and then call.ThrowOnError()on the result:If the native call fails,
ThrowOnError()throws before the fields below it areassigned. By that point the object has already been allocated and — because it
has a finalizer — is already finalizer-registered by the runtime, even though the
constructor never returns and the caller never receives a reference to it. The
object becomes eligible for collection as soon as the failed constructor call's
stack frame is gone, and its finalizer will still run later, operating on
partially-initialized state:
LightningCursor.Dispose(bool)starts withDatabase.Environment.IsOpened.If construction failed,
Databasewas never assigned and isnull, so thefinalizer throws a
NullReferenceException.LightningTransaction.Dispose(bool)starts withif (!Environment.IsOpened) throw new InvalidOperationException("A transaction must be disposed before closing the environment");.Environmentis assignedearlier in the constructor, so this doesn't NRE, but if the environment has
since been closed (a very plausible sequence — the failed
BeginTransactioncall is usually followed by disposing the environment), the finalizer throws
that
InvalidOperationExceptioninstead.An exception thrown on the finalizer thread is unhandled by definition — there is
no application code on that call stack that could catch it — and terminates the
process. In this specific case the practical symptom is that a
try/catcharounda failed
CreateCursor/BeginTransactioncall looks like it handled the errorcorrectly, but the process later aborts anyway (or hangs, if a test host is
waiting on a process that has already died on its finalizer thread) once the GC
happens to collect the discarded object.
This was found in a downstream project's test suite, where it appeared as
process exit code 134 during otherwise-unrelated test runs (or a hang, if the
test runner has GC/finalization interleaved with output capture).
Reproduction
Two independent repros trigger the same class of bug through the two affected
constructors:
LightningCursor: open a small-MapSizeenvironment, fill a writetransaction until a
PutreturnsMDB_MAP_FULL(poisoning the transaction),then call
CreateCursoron that poisoned transaction.mdb_cursor_openreturns
MDB_BAD_TXN,ThrowOnError()throws, and the half-builtLightningCursoris left finalizer-registered withDatabase == null.Forcing
GC.Collect(); GC.WaitForPendingFinalizers();afterward crashes theprocess.
LightningTransaction: open an environment withMaxReaders = 1, beginone read-only transaction to hold the only reader slot, then attempt a second
read-only
BeginTransaction.mdb_txn_beginreturnsMDB_READERS_FULL,ThrowOnError()throws, and the half-builtLightningTransactionis leftfinalizer-registered. Disposing the environment and forcing
GC.Collect(); GC.WaitForPendingFinalizers();afterward crashes the process,because the finalizer's
Environment.IsOpenedcheck now fails.Both are exercised by the included tests (see below), run in a child process
because an unhandled exception on the finalizer thread is fatal to the process
that hits it — there's no way to assert on it in-process other than observing
the whole process die.
Root cause
The constructors allocate an object with a finalizer, make a native call that can
fail, and only assign the rest of the object's fields — including the fields the
finalizer's
Dispose(false)path depends on — after that call succeeds. Onfailure, the exception propagates out of the constructor, but the object is
already subject to finalization with those fields left at their default
null/unassigned values, which the finalizer logic does not guard against.
Notably,
LightningEnvironment's two constructors already guard against exactlythis: if
EnvironmentConfiguration.Configurethrows aftermdb_env_createsucceeds, they close the native handle and call
GC.SuppressFinalize(this)before rethrowing, with a comment explaining why:
LightningCursorandLightningTransactionnever received the same treatment.Fix
Wrap the native call plus
ThrowOnError()in each constructor in atry/catchthat calls
GC.SuppressFinalize(this)before rethrowing, matching the existingLightningEnvironmentpattern. Neither constructor allocates a native resourcethat needs explicit cleanup on this failure path (a failed
mdb_cursor_open/mdb_txn_begindoes not hand back a handle that needs closing/aborting), so nohandle-cleanup logic is needed — only preventing the finalizer from ever running
against the half-built object.
See
fix.patch.Tests
See
tests.patch. It adds:src/SecondProcess/Program.cs(the existing helper processused by
MultiProcessTests) that reproduce each scenario above end-to-end,including the
GC.Collect(); GC.WaitForPendingFinalizers();call, and printOKif the process survives.src/LightningDB.Tests/FinalizerSafetyTests.cs, with two tests(
cursor_constructor_failure_must_not_crash_finalizer_thread,transaction_constructor_failure_must_not_crash_finalizer_thread) that launchthat helper process for each mode and assert exit code
0andOKinstdout.
Both tests were run against this repo (net8.0, Linux x64):
new tests fail, each with the child process exiting with code
134,reproducing the reported crash exactly.
unaffected by the change (147/161 passing locally, matching the pre-existing
baseline — the 14 unrelated failures are
EntryPointNotFoundExceptions fromsubstituting an older native
liblmdb.sobuild in this sandbox that predatessome newer entry points such as
mdb_txn_prepare/mdb_env_rollback, not aregression from this change).
Affected versions
0.21.0, 0.22.0, and 0.23.0 all contain this pattern in both constructors
(verified by decompiling the shipped
LightningDB.dllfor each version); 0.23.0was additionally confirmed against upstream source at commit
dc732e3("Bumping to v0.23.0").