Skip to content

Database file remains locked after DocumentDbContext constructor failure #129

Description

@MacKey-255

Package version

5.0.3

Affected package

BLite.Core (storage engine)

.NET version

10.0.201

Description

Summary

When DocumentDbContext fails during construction (while reading pages and initializing collections), BLite does not release the underlying page file and memory-mapped resources. The database file (and related WAL sidecar files) remain locked by the process even though no usable DocumentDbContext instance is returned to the caller.

This report is not about the root cause of the exception that triggers the failure. The trigger can be any error during page read or collection bootstrap inside the constructor. In our case, the visible exception is an AES-GCM decryption failure when the configured ICryptoProvider password does not match the encrypted store. The bug we are reporting is the missing cleanup on constructor failure, which prevents deleting or replacing the database file afterward.


Configuration

var config = new PageFileConfig
{
    PageSize = PageFileConfig.Default.PageSize,
    GrowthBlockSize = PageFileConfig.Default.GrowthBlockSize,
    Access = MemoryMappedFileAccess.ReadWrite,
    AllowMultiProcessAccess = true,
    LockTimeout = LockTimeout.From(
        (int)TimeSpan.FromSeconds(15).TotalMilliseconds,
        (int)TimeSpan.FromSeconds(30).TotalMilliseconds
    ),
    CryptoProvider = new AesGcmCryptoProvider(new CryptoOptions(password))
};

// Throws during construction; no instance is returned
var context = new DocumentDbContext(databasePath, config);

Impact

Area Effect
Startup recovery Host applications that delete and recreate the store after a failed open cannot do so reliably in-process.
Multi-process access With AllowMultiProcessAccess = true, a failed open in one process can leave files locked and affect sibling processes sharing the same path.
Consumer workarounds Callers are forced to use GC.Collect(), GC.WaitForPendingFinalizers(), rename-to-quarantine fallbacks, or process restart — none of which should be required for correct library behavior.

Minimal reproduction

  1. Open or create an encrypted database with AesGcmCryptoProvider and password A.
  2. Insert at least one document and call CheckpointAsync so collection metadata pages are written.
  3. Dispose the context normally (await DisposeAsync()).
  4. Attempt to open the same database path with password B (incorrect password).
  5. Observe that new DocumentDbContext(path, config) throws before the constructor completes.
  6. Without terminating the process, try to delete the database file (and .wal / -shm sidecars if present).

Expected behavior

DocumentDbContext construction should follow a fail-safe pattern:

  1. If any step after PageFile / StorageEngine initialization throws, all already-acquired native and managed resources (PageFile, memory-mapped views, WAL handles, StorageEngine, collection managers, etc.) must be disposed before the exception propagates to the caller.
  2. After the constructor throws, the OS should not retain an exclusive lock on the database file from that failed attempt.
  3. Callers should be able to delete or replace the database file in the same process without relying on the finalizer thread or GC.Collect().

This is analogous to the standard IDisposable / IAsyncDisposable contract: a half-initialized object must not leak handles when construction fails.

Actual behavior

Actual: File.Delete fails with IOException — the file is still in use by the current process. The lock persists until the process exits (or until a non-deterministic GC/finalizer cycle eventually releases handles).

Expected: The file can be deleted immediately after the constructor throws, because no open database instance exists.

Additional context

Observed exception (trigger only — not the reported bug)

The following stack trace shows where construction fails in our scenario. Any similar failure during InitializeCollections() / ReadPage would exhibit the same resource leak.

System.Security.Cryptography.AuthenticationTagMismatchException:
  The computed authentication tag did not match the input authentication tag.
   at System.Security.Cryptography.AeadCommon.Decrypt(...)
   at System.Security.Cryptography.AesGcm.Decrypt(...)
   at BLite.Core.Encryption.AesGcmCryptoProvider.Decrypt(
        UInt32 pageId, ReadOnlySpan`1 ciphertext, Span`1 plaintext)
   at BLite.Core.Storage.PageFile.ReadPageCore(UInt32 pageId, Span`1 destination)
   at BLite.Core.Storage.PageFile.ReadPage(UInt32 pageId, Span`1 destination)
   at BLite.Core.Storage.StorageEngine.ReadPage(
        UInt32 pageId, Nullable`1 transactionId, Span`1 destination)
   at BLite.Core.Storage.StorageEngine.GetCollectionMetadata(String name)
   at BLite.Core.Indexing.CollectionIndexManager`2..ctor(
        StorageEngine storage, IDocumentMapper`2 mapper, String collectionName)
   at BLite.Core.Collections.DocumentCollection`2..ctor(
        StorageEngine storage, ITransactionHolder transactionHolder,
        IDocumentMapper`2 mapper, String collectionName, FreeSpaceIndex freeSpaceIndex)
   at BLite.Core.DocumentDbContext.CreateCollection[TId,T](IDocumentMapper`2 mapper)
   at BLite.Core.DocumentDbContext.InitializeCollections()
   at BLite.Core.DocumentDbContext..ctor(
        String databasePath, PageFileConfig config, BLiteKvOptions kvOptions)
   at BLite.Core.DocumentDbContext..ctor(String databasePath, PageFileConfig config)

Suggested fix direction

  • Wrap DocumentDbContext constructor body (page file open → storage engine → InitializeCollections()) in try/finally or use a private initialization helper that disposes partial state on any exception.
  • Ensure PageFile.Dispose() / DisposeAsync() releases the memory-mapped file even when opened but not fully initialized.
  • Add a regression test: open encrypted store with wrong password → assert constructor throws → assert database file is not locked (e.g. File.Delete succeeds in the same process).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions