You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Multi-process: two processes creating a new database at the same time crash one of them on open #216
Split out of #134, reported there by @CaffeinatedCoder on 5.1.1 (macOS 27 arm64, .NET 10.0.12). The writer-lock part of #134 is fixed; this part isn't addressed yet.
What happens
With PageFileConfig { AllowMultiProcessAccess = true }, two processes start at the same time and open the same database path. When the file doesn't exist yet, one of them always crashes while opening (18 of 18 rounds). The other process commits all its documents, and no committed document is lost.
The crash takes one of three forms:
Fatal error.
System.AccessViolationException: Attempted to read or write protected memory.
at System.SpanHelpers.Memmove(Byte ByRef, Byte ByRef, UIntPtr)
at BLite.Core.Storage.PageFile.ReadPageCore(UInt32, System.Span`1<Byte>)
at BLite.Core.Storage.PageFile.ReadPage(UInt32, System.Span`1<Byte>)
at BLite.Core.Storage.StorageEngine.ReadPage(UInt32, System.Nullable`1<UInt64>, System.Span`1<Byte>)
at BLite.Core.Storage.StorageEngine.CollectSchemaPageBytes(UInt32)
at BLite.Core.Storage.StorageEngine.GetSchemas(UInt32)
at BLite.Core.Collections.DocumentCollection`2..EnsureSchema()
at BLite.Core.DocumentDbContext.CreateCollection(...)
System.UnauthorizedAccessException: Attempted to perform an unauthorized operation.
at System.IO.MemoryMappedFiles.MemoryMappedFile.CreateViewAccessor(Int64 offset, Int64 size, MemoryMappedFileAccess access)
at BLite.Core.Storage.PageFile.WritePageCore(UInt32 pageId, ReadOnlySpan`1 source)
at BLite.Core.Storage.StorageEngine.WritePageImmediate(UInt32 pageId, ReadOnlySpan`1 data)
at BLite.Core.Storage.StorageEngine.InitializeDictionary()
at BLite.Core.Storage.StorageEngine..ctor(String databasePath, PageFileConfig config)
System.IndexOutOfRangeException: Index was outside the bounds of the array.
The AccessViolationException can't be caught and terminates the process.
Likely cause
Creating the file, writing the header and initialising the dictionary and schema pages happen before, and outside, the cross-process lock. The second process maps a file the first one is still sizing and writing. Nothing in the report points to macOS specifically, so this probably applies to every platform.
Expected
Two processes opening a new database at the same time both succeed: one creates and initialises the file, the other waits and then opens it. For example, take an exclusive lock on a sidecar file around "create if missing and initialise", and re-check whether the file is initialised after acquiring it.
@CaffeinatedCoder, your ~200-line repro harness would be useful here as a test.
Here is a new reproduction harness; the one from #134 is gone. It's a ~230-line console app that runs N copies of itself against one database path per round.
How it works
Each round, the coordinator starts N child processes on a fresh path and releases them together through a "go" file.
Each child opens the database (a typed DocumentDbContext, or BLiteEngine with --dynamic) and writes B explicit transactions of K documents.
A crashed child is classified by its exit code and stderr. An AccessViolationException kills the process, so it shows up as [fatal].
Afterwards, a separate verify process reopens the database and checks that every batch a child reported as committed is present.
dotnet run -c Release -p:BLiteSrc=<BLite>/src -- --rounds 30 --procs 2 # or -p:BLiteVersion=5.1.1
--batches 0 open only
--writers 1 one writer; the other processes only open and read
--precreate control: the database exists before the processes start
Repro216.csproj + Program.cs
<ProjectSdk="Microsoft.NET.Sdk">
<!-- Default: builds against the BLite sources in $(BLiteSrc) (override with -p:BLiteSrc=...). With -p:BLiteVersion=5.1.1 it builds against that NuGet package instead. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BLiteSrcCondition="'$(BLiteSrc)' == ''">$(MSBuildThisFileDirectory)../wt216/src</BLiteSrc>
</PropertyGroup>
<ItemGroupCondition="'$(BLiteVersion)' == ''">
<ProjectReferenceInclude="$(BLiteSrc)/BLite.Core/BLite.Core.csproj" />
<ProjectReferenceInclude="$(BLiteSrc)/BLite.SourceGenerators/BLite.SourceGenerators.csproj"OutputItemType="Analyzer"ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroupCondition="'$(BLiteVersion)' != ''">
<PackageReferenceInclude="BLite"Version="$(BLiteVersion)" />
</ItemGroup>
</Project>
// Repro for EntglDb/BLite#216: N processes open the same, not yet existing database with// AllowMultiProcessAccess = true at the same moment; one of them crashes while opening.//// dotnet run -c Release -- [--rounds 30] [--procs 2] [--batches 20] [--size 50] [--writers N]// [--dynamic] [--precreate] [--keep] [--timeout 120]//// --writers only the first N children write (default: all); the others open the database// and read it, the "N readers / 1 writer" model of the multi-process WAL// --dynamic use BLiteEngine + DynamicCollection instead of a typed DocumentDbContext// --precreate control: the coordinator creates the database before the children start// --keep keep the database directory of rounds that failed// --timeout seconds after which a child still running is killed and reported as a hang//// Each round: spawn N children of this executable on a fresh temp path, wait until all of// them are ready, release them at once through a "go" file, then reopen the database in one// more child ("verify", so a hang can be killed) and check that every batch a child reported// as committed is present. --batches 0 only opens the database (and creates the collection).usingSystem.Diagnostics;usingSystem.Runtime.InteropServices;usingSystem.Text.RegularExpressions;usingBLite.Bson;usingBLite.Core;usingBLite.Core.Collections;usingBLite.Core.Storage;namespaceRepro216;publicclassItem{publicObjectIdId{get;set;}publicstringTag{get;set;}="";}publicpartialclassReproDbContext:DocumentDbContext{publicDocumentCollection<ObjectId,Item>Items{get;set;}=null!;publicReproDbContext(stringpath,PageFileConfigconfig):base(path,config)=>InitializeCollections();}publicstaticclassProgram{privatestaticreadonlyPageFileConfigConfig=PageFileConfig.Defaultwith{AllowMultiProcessAccess=true};privatestaticTimeSpan_timeout=TimeSpan.FromMinutes(2);publicstaticTask<int>Main(string[]args)=>argsswitch{["child", ..]=>Child(args),["verify",vardb,varapi]=>Verify(db,api=="dynamic"),
_ =>Coordinator(args),};// ── Child: open, write `batches` explicit transactions of `size` documents, report each commit ──privatestaticasyncTask<int>Child(string[]a){stringdb=a[1],go=a[2],who="p"+a[3];intbatches=int.Parse(a[4]),size=int.Parse(a[5]);if(batches<0)returnawaitVerify(db,a[6]=="dynamic",waitFor:go);// a readerConsole.WriteLine("READY");while(!File.Exists(go))Thread.SpinWait(50);try{awaitWriteBatches(db,a[6]=="dynamic",who,batches,size, b =>Console.WriteLine($"COMMITTED {who}-{b}"));return0;}catch(Exceptionex){Console.Error.WriteLine("EXC "+ex);return2;}}privatestaticasyncTaskWriteBatches(stringdb,booldynamic,stringwho,intbatches,intsize,Action<int>onCommit){if(dynamic){usingvarengine=newBLiteEngine(db,Config);varitems=engine.GetOrCreateCollection("items");for(intb=0;b<batches;b++){usingvartxn=engine.BeginTransaction();for(inti=0;i<size;i++)awaititems.InsertAsync(items.CreateDocument(["tag"], d =>d.AddString("tag",$"{who}-{b}-{i}")),txn);awaittxn.CommitAsync();onCommit(b);}}else{usingvarctx=newReproDbContext(db,Config);for(intb=0;b<batches;b++){usingvartxn=ctx.BeginTransaction();for(inti=0;i<size;i++)awaitctx.Items.InsertAsync(newItem{Tag=$"{who}-{b}-{i}"},txn);awaittxn.CommitAsync();onCommit(b);}}}// ── Verify: reopen the database and print the distinct tags of its documents ──// (collected first: a scan of a corrupt page chain can loop forever, and is killed then)privatestaticasyncTask<int>Verify(stringdb,booldynamic,string?waitFor=null){if(waitFor!=null){Console.WriteLine("READY");while(!File.Exists(waitFor))Thread.SpinWait(50);}try{vartags=newHashSet<string>();if(dynamic){usingvarengine=newBLiteEngine(db,Config);awaitforeach(vardinengine.GetOrCreateCollection("items").FindAllAsync())tags.Add(d.TryGetString("tag",outvart)?t!:"?");}else{usingvarctx=newReproDbContext(db,Config);awaitforeach(variteminctx.Items.FindAllAsync())tags.Add(item.Tag);}Console.WriteLine(string.Join("\n",tags));return0;}catch(Exceptionex){Console.Error.WriteLine("EXC "+ex);return2;}}// ── Coordinator ──────────────────────────────────────────────────────────────────────────privatestaticasyncTask<int>Coordinator(string[]args){intOpt(stringname,intfallback)=>Array.IndexOf(args,name)isvari and >=0?int.Parse(args[i+1]):fallback;introunds=Opt("--rounds",30),procs=Opt("--procs",2),batches=Opt("--batches",20),size=Opt("--size",50);intwriters=Opt("--writers",procs);booldynamic=args.Contains("--dynamic"),precreate=args.Contains("--precreate"),keep=args.Contains("--keep");_timeout=TimeSpan.FromSeconds(Opt("--timeout",120));Console.WriteLine($"BLite {typeof(StorageEngine).Assembly.GetName().Version} | {RuntimeInformation.OSDescription} "+$"{RuntimeInformation.OSArchitecture} | .NET {Environment.Version}");Console.WriteLine($"rounds={rounds} procs={procs} writers={writers} batches={batches}x{size} api={(dynamic?"dynamic":"typed")} "+$"db={(precreate?"pre-created":"new")}\n");varforms=newSortedDictionary<string,int>();intcrashRounds=0,lossRounds=0;for(intround=1;round<=rounds;round++){vardir=Directory.CreateTempSubdirectory("blite216_").FullName;stringdb=Path.Combine(dir,"repro.db"),go=Path.Combine(dir,"go");if(precreate)awaitWriteBatches(db,dynamic,"seed",1,1, _ =>{});stringapi=dynamic?"dynamic":"typed";varchildren=Enumerable.Range(0,procs).Select(i =>Spawn("child",db,go,$"{i}",$"{(i<writers?batches:-1)}",$"{size}",api)).ToArray();varstderr=children.Select(p =>p.StandardError.ReadToEndAsync()).ToArray();foreach(varpinchildren)awaitp.StandardOutput.ReadLineAsync();// READYFile.WriteAllText(go,"");varstdout=children.Select(p =>p.StandardOutput.ReadToEndAsync()).ToArray();varoutcomes=newstring[procs];varcommitted=newHashSet<string>();for(inti=0;i<procs;i++){boolhung=awaitExited(children[i])==false;stringerr=awaitstderr[i];outcomes[i]=Classify(children[i].ExitCode,err,hung);if(outcomes[i]!="ok"&&forms.TryAdd(outcomes[i],0))Console.WriteLine($" first '{outcomes[i]}' (p{i}, exit {children[i].ExitCode}):\n "+string.Join("\n ",err.Split('\n').Take(14)).TrimEnd());if(outcomes[i]!="ok")forms[outcomes[i]]++;foreach(MatchminRegex.Matches(awaitstdout[i],@"COMMITTED (\S+)"))for(intk=0;k<size;k++)committed.Add($"{m.Groups[1].Value}-{k}");}varverifier=Spawn("verify",db,api);var(tags,verifyErr)=(verifier.StandardOutput.ReadToEndAsync(),verifier.StandardError.ReadToEndAsync());boolverifyHung=awaitExited(verifier)==false;varpresent=(awaittags).Split('\n',StringSplitOptions.TrimEntries).ToHashSet();intlost=committed.Count(t =>!present.Contains(t));stringverify=verifier.ExitCode==0&&!verifyHung?$"committed {committed.Count}, lost {lost}":$"VERIFY FAILED: {Classify(verifier.ExitCode,awaitverifyErr,verifyHung)}";if(lost>0||verify.StartsWith("VERIFY"))lossRounds++;boolcrashed=outcomes.Any(o =>o!="ok");if(crashed)crashRounds++;Console.WriteLine($"round {round,3}: "+string.Join(" ",outcomes.Select((o,i)=>$"p{i}={o}"))+$" | {verify}");if(keep&&(crashed||verify.Contains("FAILED")||!verify.EndsWith("lost 0")))Console.WriteLine($" kept {dir}");elsetry{Directory.Delete(dir,recursive:true);}catch{/* best-effort */}}Console.WriteLine($"\nrounds with a crashed child: {crashRounds}/{rounds} rounds with lost/unreadable commits: {lossRounds}/{rounds}");Console.WriteLine($"| {"crash form",-70} | count |\n|{newstring('-',72)}|-------|");foreach(var(form,count)informs)Console.WriteLine($"| {form,-70} | {count,5} |");returncrashRounds+lossRounds==0?0:1;}privatestaticProcessSpawn(paramsstring[]args){varpsi=newProcessStartInfo(Environment.ProcessPath!){RedirectStandardOutput=true,RedirectStandardError=true};if(Path.GetFileNameWithoutExtension(psi.FileName)=="dotnet")psi.ArgumentList.Add(typeof(Program).Assembly.Location);foreach(vararginargs)psi.ArgumentList.Add(arg);returnProcess.Start(psi)!;}// Waits up to --timeout; a process still running then is killed and reported as a hang.privatestaticasyncTask<bool>Exited(Processp){try{awaitp.WaitForExitAsync(newCancellationTokenSource(_timeout).Token);returntrue;}catch(OperationCanceledException){p.Kill(entireProcessTree:true);awaitp.WaitForExitAsync();returnfalse;}}// "<ExceptionType> in <innermost BLite.Core frame>", e.g. "AccessViolationException in PageFile.ReadPageCore".// An AccessViolationException cannot be caught: the runtime prints "Fatal error." and aborts the process.privatestaticstringClassify(intexitCode,stringstderr,boolhung){if(hung)return"hang (killed)";if(exitCode==0)return"ok";vartype=Regex.Match(stderr,@"\b(\w+Exception)\b")is{Success:true}t?t.Groups[1].Value:$"exit code {exitCode}";varframe=Regex.Match(stderr,@"at BLite\.Core\.(?:[\w`]+\.)*?([\w`]+\.\w+)\(");return(stderr.Contains("Fatal error.")?"[fatal] ":"")+type+(frame.Success?" in "+frame.Groups[1].Value:"");}}
Results on current main (f8f437b), macOS 27 arm64 (M4 Max), .NET 10.0.12. Each cell shows rounds with a crashed process / rounds with lost commits:
UnauthorizedAccessException in PageFile.WritePageCore;
InvalidOperationException "Page size mismatch … 32 byte pages" in PageFile.Open;
with a reader process: [fatal] AccessViolationException in PageFile.ReadPageCore and InvalidDataException in PageFile.CheckedPageOffset.
NuGet 5.1.1: 30/30 rounds. A Linux container (Ubuntu 24.04 arm64) shows the same, including the AccessViolation and IndexOutOfRangeException in DictionaryPage.GetAll, so this is not macOS-specific.
Root cause. Your hypothesis holds, with two additions (items 3 and 4):
StorageEngine.cs:160-162 creates and opens the page file, and :246-247 initialise the dictionary and KV roots, all before and outside any cross-process lock. The SHM is only opened at :213.
PageFile.Open sizes a new file (PageFile.cs:415) before writing its header (:490), so a second opener reads a zero header (:450).
Each process maps the file at its open-time length (:473), but the bounds checks use the live file length (:605, :733, :875). After another process grows the file, a write past the map fails in CreateViewAccessor (:696). A read past it is a raw memcpy (:627), which ends in an AccessViolation.
Collection creation (EnsureSchema, AppendSchema, the catalog) and new dictionary keys write the page file directly. They use a per-process _nextPageId (:963) and a dictionary cache loaded once (StorageEngine.Dictionary.cs:170), so concurrent creators reuse page ids and dictionary ids.
Two processes writing at the same time. With the fix, nothing crashes on open any more, but the #134 setup (two writers) now loses committed documents in most rounds. Main does the same when the database already exists (control row), and in the 3 of 30 rounds where neither process crashed. The cause is per-process state: the free-space index, B-tree roots and data-page allocation. MULTI_PROCESS_WAL.md lists simultaneous writers as out of scope ("N readers / 1 writer"), but nothing enforces that. I'd suggest a follow-up that makes a second writer process fail fast, and I can open an issue for it.
#221 has the regression tests (2 and 4 concurrent creators, a half-initialised file, remap, dictionary ids) and the fix. The fix adds a cross-process open lock (a lock slot next to the WAL writer lock, <shm>.open.lock on macOS) around create/open/init and collection creation, and remaps before touching pages past the map.
Activity
CaffeinatedCoder commentedon Oct 13, 2026
Here is a new reproduction harness; the one from #134 is gone. It's a ~230-line console app that runs N copies of itself against one database path per round.
How it works
DocumentDbContext, orBLiteEnginewith--dynamic) and writes B explicit transactions of K documents.AccessViolationExceptionkills the process, so it shows up as[fatal].Repro216.csproj + Program.cs
Results on current main (
f8f437b), macOS 27 arm64 (M4 Max), .NET 10.0.12. Each cell shows rounds with a crashed process / rounds with lost commits:Crash forms on main:
UnauthorizedAccessException in PageFile.WritePageCore;InvalidOperationException"Page size mismatch … 32 byte pages" inPageFile.Open;[fatal] AccessViolationException in PageFile.ReadPageCoreandInvalidDataException in PageFile.CheckedPageOffset.NuGet 5.1.1: 30/30 rounds. A Linux container (Ubuntu 24.04 arm64) shows the same, including the AccessViolation and
IndexOutOfRangeException in DictionaryPage.GetAll, so this is not macOS-specific.Root cause. Your hypothesis holds, with two additions (items 3 and 4):
StorageEngine.cs:160-162creates and opens the page file, and:246-247initialise the dictionary and KV roots, all before and outside any cross-process lock. The SHM is only opened at:213.PageFile.Opensizes a new file (PageFile.cs:415) before writing its header (:490), so a second opener reads a zero header (:450).:473), but the bounds checks use the live file length (:605,:733,:875). After another process grows the file, a write past the map fails inCreateViewAccessor(:696). A read past it is a raw memcpy (:627), which ends in an AccessViolation.EnsureSchema,AppendSchema, the catalog) and new dictionary keys write the page file directly. They use a per-process_nextPageId(:963) and a dictionary cache loaded once (StorageEngine.Dictionary.cs:170), so concurrent creators reuse page ids and dictionary ids.Two processes writing at the same time. With the fix, nothing crashes on open any more, but the #134 setup (two writers) now loses committed documents in most rounds. Main does the same when the database already exists (control row), and in the 3 of 30 rounds where neither process crashed. The cause is per-process state: the free-space index, B-tree roots and data-page allocation.
MULTI_PROCESS_WAL.mdlists simultaneous writers as out of scope ("N readers / 1 writer"), but nothing enforces that. I'd suggest a follow-up that makes a second writer process fail fast, and I can open an issue for it.#221 has the regression tests (2 and 4 concurrent creators, a half-initialised file, remap, dictionary ids) and the fix. The fix adds a cross-process open lock (a lock slot next to the WAL writer lock,
<shm>.open.lockon macOS) around create/open/init and collection creation, and remaps before touching pages past the map.