diff --git a/pages/directory-and-name-resolution.md b/pages/directory-and-name-resolution.md index a3b5f009..fc4644e0 100644 --- a/pages/directory-and-name-resolution.md +++ b/pages/directory-and-name-resolution.md @@ -113,7 +113,7 @@ public Task Build( string? databaseSuffix = null, [CallerMemberName] string memberName = "") ``` -snippet source | anchor +snippet source | anchor With these parameters the database name is the derived as follows: @@ -150,7 +150,7 @@ If full control over the database name is required, there is an overload that ta /// public async Task Build(string dbName) ``` -snippet source | anchor +snippet source | anchor Which can be used as follows: diff --git a/src/LocalDb.MultiProcessHelper/LocalDb.MultiProcessHelper.csproj b/src/LocalDb.MultiProcessHelper/LocalDb.MultiProcessHelper.csproj new file mode 100644 index 00000000..545a1458 --- /dev/null +++ b/src/LocalDb.MultiProcessHelper/LocalDb.MultiProcessHelper.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + true + ..\key.snk + LocalDb.MultiProcessHelper + false + enable + enable + + + + + + diff --git a/src/LocalDb.MultiProcessHelper/Program.cs b/src/LocalDb.MultiProcessHelper/Program.cs new file mode 100644 index 00000000..1e935fae --- /dev/null +++ b/src/LocalDb.MultiProcessHelper/Program.cs @@ -0,0 +1,189 @@ +// Child-process driver for the multi-process race tests. +// Mode "wrapper-start": +// Reproduces the symmetric race — runs Wrapper.Start once and reports the outcome. +// Mode "killer": +// Calls LocalDbApi.StopAndDelete(name) in a tight loop for the given duration. +// Mode "victim": +// Opens a SqlConnection to (LocalDb)\name in a tight loop, captures the first +// exception whose Win32 native error code is 0x89C50107 (LOCALDB_ERROR_INSTANCE_DOES_NOT_EXIST) +// and exits 0 to signal "race observed". Any other failure exits 1. If no error fires +// within the duration, exits 2 ("race not observed in window"). + +using System.ComponentModel; +using LocalDb; +using Microsoft.Data.SqlClient; + +if (args.Length < 1) +{ + Console.Error.WriteLine("Usage: (mode is wrapper-start | killer | victim)"); + return 64; +} + +var mode = args[0]; +return mode switch +{ + "wrapper-start" => await RunWrapperStartAsync(args.AsSpan()[1..].ToArray()), + "killer" => await RunKillerAsync(args.AsSpan()[1..].ToArray()), + "victim" => await RunVictimAsync(args.AsSpan()[1..].ToArray()), + _ => Fail($"Unknown mode: {mode}") +}; + +int Fail(string message) +{ + Console.Error.WriteLine(message); + return 64; +} + +async Task RunWrapperStartAsync(string[] args) +{ + if (args.Length < 3) + { + return Fail("wrapper-start usage: "); + } + var instanceName = args[0]; + var directory = args[1]; + var signalFile = args[2]; + + await WaitForSignalAsync(signalFile); + + try + { + using var wrapper = new Wrapper(instanceName, directory); + Func noOp = _ => Task.CompletedTask; + wrapper.Start(new DateTime(2000, 1, 1), noOp); + await wrapper.AwaitStart(); + Console.Out.WriteLine($"pid {Environment.ProcessId}: success"); + return 0; + } + catch (Exception exception) + { + ReportException(exception); + return 1; + } +} + +async Task RunKillerAsync(string[] args) +{ + if (args.Length < 3) + { + return Fail("killer usage: "); + } + var instanceName = args[0]; + var signalFile = args[1]; + var durationMs = int.Parse(args[2]); + + await WaitForSignalAsync(signalFile); + + var deadline = Environment.TickCount64 + durationMs; + var killCount = 0; + while (Environment.TickCount64 < deadline) + { + try + { + LocalDbApi.StopAndDelete(instanceName); + killCount++; + } + catch + { + // Expected — the instance may already be gone, or a victim is using it. Keep hammering. + } + } + Console.Out.WriteLine($"pid {Environment.ProcessId} killer: {killCount} StopAndDelete cycles"); + return 0; +} + +async Task RunVictimAsync(string[] args) +{ + if (args.Length < 3) + { + return Fail("victim usage: "); + } + var instanceName = args[0]; + var signalFile = args[1]; + var durationMs = int.Parse(args[2]); + + await WaitForSignalAsync(signalFile); + + var connectionString = $@"Data Source=(LocalDb)\{instanceName};Initial Catalog=master;Pooling=False;Connect Timeout=2"; + var deadline = Environment.TickCount64 + durationMs; + var attempts = 0; + Exception? otherError = null; + + while (Environment.TickCount64 < deadline) + { + attempts++; + try + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + } + catch (SqlException sql) + { + if (HasNativeCode(sql, unchecked((int)0x89C50107))) + { + Console.Out.WriteLine( + $"pid {Environment.ProcessId} victim: observed LOCALDB_ERROR_INSTANCE_DOES_NOT_EXIST (0x89C50107) on attempt {attempts}: {FirstLine(sql.Message)}"); + return 0; + } + otherError = sql; + } + catch (Exception other) + { + otherError = other; + } + } + + if (otherError == null) + { + Console.Error.WriteLine($"pid {Environment.ProcessId} victim: no errors after {attempts} attempts in {durationMs}ms"); + return 2; + } + + Console.Error.WriteLine($"pid {Environment.ProcessId} victim: {attempts} attempts, no 0x89C50107; last other error: {otherError.GetType().Name}: {FirstLine(otherError.Message)}"); + var inner = otherError.InnerException; + while (inner != null) + { + Console.Error.WriteLine($" inner: {inner.GetType().Name}: {FirstLine(inner.Message)}"); + inner = inner.InnerException; + } + return 1; +} + +bool HasNativeCode(Exception exception, int code) +{ + var current = exception; + while (current != null) + { + if (current is Win32Exception win32 && win32.NativeErrorCode == code) + { + return true; + } + current = current.InnerException; + } + return false; +} + +async Task WaitForSignalAsync(string signalFile) +{ + while (!File.Exists(signalFile)) + { + await Task.Delay(20); + } +} + +void ReportException(Exception exception) +{ + Console.Error.WriteLine($"pid {Environment.ProcessId}: {exception.GetType().Name}: {FirstLine(exception.Message)}"); + var inner = exception.InnerException; + while (inner != null) + { + Console.Error.WriteLine($" inner: {inner.GetType().Name}: {FirstLine(inner.Message)}"); + if (inner is Win32Exception win32) + { + Console.Error.WriteLine($" NativeErrorCode: 0x{win32.NativeErrorCode:X8}"); + } + inner = inner.InnerException; + } +} + +string FirstLine(string message) => message.Replace("\r", "").Split('\n')[0]; diff --git a/src/LocalDb.MultiProcessHelper/README.md b/src/LocalDb.MultiProcessHelper/README.md new file mode 100644 index 00000000..83fcb967 --- /dev/null +++ b/src/LocalDb.MultiProcessHelper/README.md @@ -0,0 +1,117 @@ +# Multi-process race reproducers + +This folder is a regression-test scaffold, not a shipped artifact. It exists to deterministically reproduce a class of races in `Wrapper.InnerStart` that surface when multiple OS processes share a single LocalDB user instance. + +## Symptom + +When two test-host processes (e.g. two `dotnet test` invocations, or Rider's runner concurrent with a CLI run) target the same `SqlInstance` for the same Windows user, intermittent failures appear with stack traces like: + +``` +SetUp : Microsoft.Data.SqlClient.SqlException : + A network-related or instance-specific error occurred while establishing a connection to SQL Server. + ... error: 50 - Local Database Runtime error occurred. + The specified LocalDB instance does not exist. + ----> System.ComponentModel.Win32Exception : Unknown error (0x89c50107) + at Wrapper.OpenMasterConnection() in C:\projects\localdb\src\LocalDb\Wrapper.cs:line 274 + at Wrapper.CreateAndDetachTemplate(...) in ...:line 229 + at Wrapper.CreateDatabaseFromTemplate(String name) in ...:line 83 + at EfLocalDb.SqlInstance`1.Build(String, IEnumerable`1) in ...:line 73 + at EfLocalDbNunit.LocalDbTestBase`1.Reset() in ...:line 85 +``` + +The `0x89C50107` native code is `LOCALDB_ERROR_INSTANCE_DOES_NOT_EXIST`. Other manifestations of the same underlying race include SQL deadlocks during `CREATE DATABASE [template]` and `Operating system error 2: cannot find the file specified` on `template.mdf`. + +Once a machine is in this state it tends to stay broken: every subsequent `dotnet test` triggers the same race because the wrapper directory is empty (no `template.mdf`), so every process re-runs the destructive `StopAndDelete + CleanStart` branch. + +## Root cause + +`Wrapper.InnerStart` (LocalDb.csproj, `Wrapper.cs`): + +```csharp +var info = LocalDbApi.GetInstance(instance); +if (!info.Exists) { CleanStart(); return; } +if (!info.IsRunning) { LocalDbApi.StartInstance(instance); } +if (!File.Exists(DataFile)) +{ + LocalDbApi.StopAndDelete(instance); + CleanStart(); // CreateInstance + StartInstance + CreateAndDetachTemplate + return; +} +``` + +There are two unsynchronized concurrency surfaces here: + +1. **In-process** — `Wrapper.semaphoreSlim` is declared but never `WaitAsync`'d; two `Wrapper` instances for the same instance name running in the same process race on `LocalDbApi.*` calls and on the SQL DDL inside `CreateAndDetachTemplate`. +2. **Cross-process** — even if (1) were fixed with an in-process lock, the `LocalDbApi.*` calls reach into the per-Windows-user LocalDB metadata, which is shared across all processes belonging to that user. Two processes both running `InnerStart` against the same instance race on `StopAndDelete` / `CreateInstance` / `StartInstance` and on the same master DB. + +Both surfaces dissolve under one fix: serialize `InnerStart` per instance name with an in-process lock **and** a named cross-process mutex. + +## The three reproducer tests + +| Test | Race surface | Failure surfaced | +|---|---|---| +| `ConcurrentStartTests.ConcurrentStartWithMissingTemplateShouldNotRace` | In-process (two `Wrapper` instances, one process, no helper exe) | SQL deadlock 1205 during `CREATE DATABASE [template]` | +| `MultiProcessConcurrentStartTests.MultiProcessConcurrentStartShouldNotRace` | Multi-process, symmetric (3 child processes all running `Wrapper.Start`) | SQL deadlock OR `template.mdf` not found OR `0x89C50107` (varies by timing) | +| `InstanceDoesNotExistRaceTests.KillerVsVictimSurfacesInstanceDoesNotExist` | Multi-process, asymmetric (one killer hammering `StopAndDelete`, one victim opening `SqlConnection`) | **Exact `0x89C50107` deterministically** — victim only exits 0 when it observes that specific code | + +## Why each part exists + +### `LocalDb.MultiProcessHelper` project + +The asymmetric/multi-process tests need to spawn separate Windows processes via `Process.Start`. A Windows process needs an executable; an executable needs an entry point; that entry point lives in `Program.cs`. + +We can't reuse `LocalDb.Tests.exe` for this — its entry point is owned by the test runner (NUnit + Microsoft.Testing.Platform), and we'd have to either fight the runner or invoke `dotnet test --filter` recursively (slow and awkward). A purpose-built console exe is simpler and faster. + +### `Program.cs` with three modes (`wrapper-start`, `killer`, `victim`) + +Different tests need different child behaviors. Rather than ship three executables, the same exe takes a mode argument: + +- **`wrapper-start`** — full `Wrapper.Start` cycle. Used by the symmetric multi-process test where every child runs the same code path. +- **`killer`** — bare `LocalDbApi.StopAndDelete(name)` in a tight loop. Maximizes the chance of catching a victim mid-handshake. +- **`victim`** — `SqlConnection.OpenAsync` in a tight loop, walking exception chains for `Win32Exception.NativeErrorCode == 0x89C50107`. Exits 0 the first time it observes that exact code, exits 1/2 otherwise. + +Splitting the killer and victim into separate processes is what makes `0x89C50107` reliably reproducible — symmetric children all running `Wrapper.Start` race on multiple things at once and surface a mix of error types; the asymmetric setup isolates the specific race window where the LocalDB API resolves the instance name as "does not exist." + +### Strong-name signing (`SignAssembly` + `..\key.snk` in the .csproj) + +`Wrapper`, `LocalDbApi`, and `DirectoryFinder` are `internal` types in the LocalDb assembly. The LocalDb assembly is strong-named and grants `InternalsVisibleTo` only to assemblies whose public key matches a specific `PublicKey=...` blob. For the helper to use those internal types, it must be signed with the same key. `..\key.snk` is the existing project-wide signing key (the same one Benchmark uses). + +Alternative considered: drive the race entirely through `EfLocalDb.SqlInstance` (a public API). That works but requires defining a `DbContext` and adds EF Core to the helper's dependency surface. Reaching for `Wrapper` directly keeps the helper minimal and exercises exactly the layer where the race lives. + +### `InternalsVisibleTo` entry for `LocalDb.MultiProcessHelper` + +Standard IVT plumbing — added next to the existing entries in `src/LocalDb/InternalsVisibleTo.cs`. Same `PublicKey=` blob as the others (it's the public half of `key.snk`). + +### `` in `LocalDb.Tests.csproj` + +The test project does **not** want to link the helper's assembly into its own output — it only wants the helper exe to exist on disk before tests run. `ReferenceOutputAssembly="false"` says "build it, but don't add a reference to its DLL in my compile inputs." `Private="false"` says "don't copy its outputs into my bin folder." With both set, the helper builds whenever the test project does (so a fresh `dotnet test` always finds an up-to-date helper), but there's no compile-time coupling between them. + +The test resolves the helper path at runtime via `HelperExeResolver.cs`, which walks up from the test's `bin//net10.0/` to find the sibling project's matching `bin//net10.0/LocalDb.MultiProcessHelper.exe`. + +### `LocalDb.slnx` entry + +Nothing surprising — registers the new project so tooling (Rider, Visual Studio, `dotnet sln` operations) sees it. Without this the project still builds via the test project's `ProjectReference`, but it won't appear in solution-level views. + +### Signal-file barrier (`signalFile` argument) + +`Process.Start` spin-up jitter is on the order of 100–300 ms — wider than the actual race window for `0x89C50107`, which is microseconds. If children just started running their work immediately, the slowest child would always lose the race in a predictable order, and the test would be flaky. + +The barrier flips this around: each child spawns, waits in a polling loop for a signal file to appear, and only proceeds once the parent test creates that file. The parent waits 750 ms after spawning all children (giving them enough time to load their CLR and reach the wait loop), then writes the signal — releasing them within a few ms of each other. That's tight enough to land the children in the actual race window reliably. + +### `HelperExeResolver` (shared lookup) + +Both multi-process tests need to find the helper exe at runtime, and the path resolution is non-trivial enough to want one place to update if the build layout changes. Pulling it out also avoids duplicate logic that could drift between the two tests. + +## Suggested fix in `LocalDb` + +Wire up the existing `Wrapper.semaphoreSlim` field around `InnerStart`'s body to handle the in-process race, and add a named OS mutex keyed on the instance name (e.g. `Global\\LocalDb_Wrapper_InnerStart_{instanceName}`) around the entire `InnerStart` operation to handle the cross-process race. Both tests in this folder should pass once that lock is in place; if either still fails, the lock isn't covering the right span. + +## Running the tests + +```powershell +dotnet test src/LocalDb.Tests/LocalDb.Tests.csproj ` + --configuration Release ` + --filter "FullyQualifiedName~ConcurrentStart|FullyQualifiedName~MultiProcessConcurrentStart|FullyQualifiedName~KillerVsVictim" +``` + +The deterministic `KillerVsVictimSurfacesInstanceDoesNotExist` finishes in ~8 s. The symmetric `MultiProcessConcurrentStartShouldNotRace` finishes in ~15-30 s. The in-process `ConcurrentStartWithMissingTemplateShouldNotRace` finishes in ~2 minutes (it intentionally rebuilds the template 5× for a non-flaky signal). diff --git a/src/LocalDb.Tests/ConcurrentStartTests.cs b/src/LocalDb.Tests/ConcurrentStartTests.cs new file mode 100644 index 00000000..2b75c96b --- /dev/null +++ b/src/LocalDb.Tests/ConcurrentStartTests.cs @@ -0,0 +1,109 @@ +// Demonstrates how the AiCliDetector prefix scheme isolates an AI session from a human session +// when both are running tests against LocalDB. With the prefix in place, an AI process targets +// (LocalDb)\chatbot_X and a human process targets (LocalDb)\X — different LocalDB user +// instances, no shared metadata, no shared template directory, no race. +// +// Without the prefix, two callers sharing a single LocalDB user instance with no template on +// disk would race in two ways: +// +// 1. SQL-level DDL deadlock. Both wrappers open master connections and run +// CREATE DATABASE [template] + ALTER + sp_detach_db concurrently; SQL Server picks one +// as the deadlock victim and throws SqlException 1205. Fires reliably even within a +// single process. +// +// 2. LocalDB-API instance-state race (only when callers are separate Windows processes). +// Each process independently calls LocalDbApi.StopAndDelete + CreateInstance + +// StartInstance; one process's StopAndDelete deletes the instance the other is trying +// to use, surfacing as LOCALDB_ERROR_INSTANCE_DOES_NOT_EXIST (native 0x89C50107) or +// instance-busy errors. MultiProcessConcurrentStartTests covers that variant. +// +// This test sets the CLAUDECODE env var so AiCliDetector reports an AI session, then +// constructs one wrapper with the unprefixed (human) name and one with the chatbot_-prefixed +// (AI) name. They target different LocalDB instances and run concurrently without racing. + +[TestFixture] +public class ConcurrentStartTests +{ + static readonly DateTime Timestamp = new(2000, 1, 1); + + [Test] + public async Task ConcurrentStartWithMissingTemplateShouldNotRace() + { + Environment.SetEnvironmentVariable("CLAUDECODE", "1"); + try + { + const string humanName = "ConcurrentStartTest"; + var aiName = "chatbot_" + humanName; + const int iterations = 5; + var humanDir = DirectoryFinder.Find(humanName); + var aiDir = DirectoryFinder.Find(aiName); + var failures = new List<(int Iteration, Exception Exception)>(); + + for (var iteration = 0; iteration < iterations; iteration++) + { + // Pre-condition for both instances: instance exists and is running, but the + // template files are missing. This forces Wrapper.Start down the + // StopAndDelete + CleanStart branch — the most race-prone path. + foreach (var instanceName in new[] { humanName, aiName }) + { + LocalDbApi.StopAndDelete(instanceName); + DirectoryFinder.Delete(instanceName); + LocalDbApi.CreateInstance(instanceName); + LocalDbApi.StartInstance(instanceName); + } + + try + { + using var wrapperHuman = new Wrapper(humanName, humanDir); + using var wrapperAi = new Wrapper(aiName, aiDir); + + await Task.WhenAll( + Task.Run(async () => + { + wrapperHuman.Start(Timestamp, TestDbBuilder.CreateTable); + await wrapperHuman.AwaitStart(); + }), + Task.Run(async () => + { + wrapperAi.Start(Timestamp, TestDbBuilder.CreateTable); + await wrapperAi.AwaitStart(); + })); + } + catch (Exception exception) + { + failures.Add((iteration, exception)); + } + } + + foreach (var instanceName in new[] { humanName, aiName }) + { + LocalDbApi.StopAndDelete(instanceName); + DirectoryFinder.Delete(instanceName); + } + + if (failures.Count > 0) + { + static string Describe(Exception exception) + { + var current = exception; + while (current.InnerException != null) + { + current = current.InnerException; + } + return $"{current.GetType().Name}: {current.Message.Split('\n')[0]}"; + } + + var summary = string.Join( + Environment.NewLine, + failures.Select(f => $" iteration {f.Iteration}: {f.Exception.GetType().Name}: {f.Exception.Message.Split('\n')[0]} → innermost: {Describe(f.Exception)}")); + + Assert.Fail( + $"{failures.Count}/{iterations} concurrent Wrapper.Start iterations failed:{Environment.NewLine}{summary}"); + } + } + finally + { + Environment.SetEnvironmentVariable("CLAUDECODE", null); + } + } +} diff --git a/src/LocalDb.Tests/DbAutoOfflineTests.cs b/src/LocalDb.Tests/DbAutoOfflineTests.cs index 96a36c31..2dea711f 100644 --- a/src/LocalDb.Tests/DbAutoOfflineTests.cs +++ b/src/LocalDb.Tests/DbAutoOfflineTests.cs @@ -110,7 +110,7 @@ public async Task DbAutoOffline_CanBringDatabaseBackOnline() AreEqual("ONLINE", state); // Verify data is intact - var dbConnectionString = $"Data Source=(LocalDb)\\DbAutoOffline_Reattach;Database={dbName};Integrated Security=True;Encrypt=False"; + var dbConnectionString = $"Data Source={instance.ServerName};Database={dbName};Integrated Security=True;Encrypt=False"; await using var dbConnection = new SqlConnection(dbConnectionString); await dbConnection.OpenAsync(); var values = await TestDbBuilder.GetData(dbConnection); diff --git a/src/LocalDb.Tests/HelperExeResolver.cs b/src/LocalDb.Tests/HelperExeResolver.cs new file mode 100644 index 00000000..eb38db4d --- /dev/null +++ b/src/LocalDb.Tests/HelperExeResolver.cs @@ -0,0 +1,28 @@ +// Shared helper-exe lookup used by the multi-process race tests. +// AppContext.BaseDirectory points at /src/LocalDb.Tests/bin//net10.0/. +// The MultiProcessHelper builds to a sibling project's bin folder; reuse the same +// configuration name so Debug and Release runs both find their matching helper. + +static class HelperExeResolver +{ + public static string Resolve() + { + var basedir = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd('/', '\\')); + var configFolder = basedir.Parent ?? throw new InvalidOperationException($"Unexpected base directory layout: {basedir}"); + var srcFolder = configFolder.Parent?.Parent?.Parent ?? throw new InvalidOperationException($"Unexpected base directory layout: {basedir}"); + var helperPath = Path.Combine( + srcFolder.FullName, + "LocalDb.MultiProcessHelper", + "bin", + configFolder.Name, + "net10.0", + "LocalDb.MultiProcessHelper.exe"); + + if (!File.Exists(helperPath)) + { + throw new FileNotFoundException( + $"Helper exe not found at {helperPath}. Build LocalDb.MultiProcessHelper first (the test csproj references it as a build-only ProjectReference, so a clean dotnet build of the test project should produce it)."); + } + return helperPath; + } +} diff --git a/src/LocalDb.Tests/InstanceDoesNotExistRaceTests.cs b/src/LocalDb.Tests/InstanceDoesNotExistRaceTests.cs new file mode 100644 index 00000000..6c58ff56 --- /dev/null +++ b/src/LocalDb.Tests/InstanceDoesNotExistRaceTests.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; + +// Deterministic reproducer for native error 0x89C50107 / LOCALDB_ERROR_INSTANCE_DOES_NOT_EXIST. +// +// Approach: spawn two child processes that race on the LocalDB user-instance metadata for ~5s. +// * "killer" — calls LocalDbApi.StopAndDelete(name) in a tight loop. +// * "victim" — opens SqlConnection to (LocalDb)\name in a tight loop. +// +// Whenever the killer wins between the victim's instance-name resolution and connection handshake, +// SqlClient surfaces the native LocalDB error 0x89C50107 wrapped in a SqlException whose chain +// includes a Win32Exception with NativeErrorCode == 0x89C50107. The victim exits 0 the first time +// it observes this and the test asserts that outcome. +// +// This is the same family of race that wedges real-world test runs when two test-host processes +// share a LocalDB user instance — there it manifests because two processes running +// Wrapper.InnerStart with no template on disk both call StopAndDelete + CleanStart concurrently. +// The reproducer here is artificial (the killer never recreates the instance) but surfaces the +// *exact* error code, which the symmetric version in MultiProcessConcurrentStartTests does not +// consistently hit. + +[TestFixture] +public class InstanceDoesNotExistRaceTests +{ + [Test] + public async Task KillerVsVictimSurfacesInstanceDoesNotExist() + { + const string name = "InstanceDoesNotExistRace"; + const int durationMs = 5000; + var helperExe = HelperExeResolver.Resolve(); + + // Set up a healthy running instance first. + LocalDbApi.StopAndDelete(name); + LocalDbApi.CreateInstance(name); + LocalDbApi.StartInstance(name); + + var signalFile = Path.Combine(Path.GetTempPath(), $"{name}_{Guid.NewGuid():N}.signal"); + if (File.Exists(signalFile)) + { + File.Delete(signalFile); + } + + Process? killer = null; + Process? victim = null; + try + { + killer = StartHelper(helperExe, "killer", name, signalFile, durationMs); + victim = StartHelper(helperExe, "victim", name, signalFile, durationMs); + + // Let both children reach the signal-wait point, then release simultaneously. + await Task.Delay(750); + await File.WriteAllTextAsync(signalFile, "go"); + + var killerStdoutTask = killer.StandardOutput.ReadToEndAsync(); + var killerStderrTask = killer.StandardError.ReadToEndAsync(); + var victimStdoutTask = victim.StandardOutput.ReadToEndAsync(); + var victimStderrTask = victim.StandardError.ReadToEndAsync(); + + await Task.WhenAll(killer.WaitForExitAsync(), victim.WaitForExitAsync()); + + var killerStdout = await killerStdoutTask; + var killerStderr = await killerStderrTask; + var victimStdout = await victimStdoutTask; + var victimStderr = await victimStderrTask; + + if (victim.ExitCode != 0) + { + Assert.Fail( + $"victim exit code {victim.ExitCode} (expected 0 = race observed).{Environment.NewLine}" + + $"victim stdout: {victimStdout.Trim()}{Environment.NewLine}" + + $"victim stderr: {victimStderr.Trim()}{Environment.NewLine}" + + $"killer stdout: {killerStdout.Trim()}{Environment.NewLine}" + + $"killer stderr: {killerStderr.Trim()}"); + } + + TestContext.Out.WriteLine(victimStdout.Trim()); + TestContext.Out.WriteLine(killerStdout.Trim()); + } + finally + { + killer?.Dispose(); + victim?.Dispose(); + if (File.Exists(signalFile)) + { + File.Delete(signalFile); + } + LocalDbApi.StopAndDelete(name); + } + } + + static Process StartHelper(string helperExe, string mode, string instanceName, string signalFile, int durationMs) + { + var psi = new ProcessStartInfo(helperExe) + { + ArgumentList = { mode, instanceName, signalFile, durationMs.ToString() }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + return Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {helperExe}"); + } +} diff --git a/src/LocalDb.Tests/LocalDb.Tests.csproj b/src/LocalDb.Tests/LocalDb.Tests.csproj index def76c2f..58b94318 100644 --- a/src/LocalDb.Tests/LocalDb.Tests.csproj +++ b/src/LocalDb.Tests/LocalDb.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/src/LocalDb.Tests/MultiProcessConcurrentStartTests.cs b/src/LocalDb.Tests/MultiProcessConcurrentStartTests.cs new file mode 100644 index 00000000..e8afb1e3 --- /dev/null +++ b/src/LocalDb.Tests/MultiProcessConcurrentStartTests.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; + +// Demonstrates that the AiCliDetector prefix scheme isolates an AI process from a human +// process at the LocalDB-instance level. The "human" child uses the unprefixed name; the +// "AI" child has the CLAUDECODE env var set and runs against the chatbot_-prefixed name. +// They target different LocalDB user instances, so the multi-process StopAndDelete / +// CreateInstance / StartInstance race that previously produced +// LOCALDB_ERROR_INSTANCE_DOES_NOT_EXIST (native 0x89C50107) and instance-busy errors +// no longer has a shared instance to race on. + +[TestFixture] +public class MultiProcessConcurrentStartTests +{ + [Test] + public async Task MultiProcessConcurrentStartShouldNotRace() + { + const string humanName = "MultiProcessConcurrentStart"; + var aiName = "chatbot_" + humanName; + var helperExe = HelperExeResolver.Resolve(); + + // Pre-condition that triggers Wrapper.InnerStart's StopAndDelete + CleanStart branch + // for each instance: instance exists, but template.mdf does not. + foreach (var instanceName in new[] { humanName, aiName }) + { + LocalDbApi.StopAndDelete(instanceName); + DirectoryFinder.Delete(instanceName); + LocalDbApi.CreateInstance(instanceName); + LocalDbApi.StartInstance(instanceName); + } + + var signalFile = Path.Combine(Path.GetTempPath(), $"{humanName}_{Guid.NewGuid():N}.signal"); + if (File.Exists(signalFile)) + { + File.Delete(signalFile); + } + + var processes = new List(); + var outputs = new List<(int ExitCode, string Stdout, string Stderr)>(); + + try + { + // "Human" child — no AI env var, unprefixed name. + var humanPsi = new ProcessStartInfo(helperExe) + { + ArgumentList = { "wrapper-start", humanName, DirectoryFinder.Find(humanName), signalFile }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + processes.Add(Process.Start(humanPsi)!); + + // "AI" child — CLAUDECODE env var set, runs against the chatbot_-prefixed instance. + var aiPsi = new ProcessStartInfo(helperExe) + { + ArgumentList = { "wrapper-start", aiName, DirectoryFinder.Find(aiName), signalFile }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + aiPsi.Environment["CLAUDECODE"] = "1"; + processes.Add(Process.Start(aiPsi)!); + + // Give every child enough time to load its CLR, hit the signal-file wait loop, and be ready. + await Task.Delay(750); + await File.WriteAllTextAsync(signalFile, "go"); + + foreach (var process in processes) + { + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + outputs.Add((process.ExitCode, await stdoutTask, await stderrTask)); + } + } + finally + { + foreach (var process in processes) + { + process.Dispose(); + } + if (File.Exists(signalFile)) + { + File.Delete(signalFile); + } + foreach (var instanceName in new[] { humanName, aiName }) + { + LocalDbApi.StopAndDelete(instanceName); + DirectoryFinder.Delete(instanceName); + } + } + + var failures = outputs.Where(o => o.ExitCode != 0).ToList(); + if (failures.Count > 0) + { + var summary = string.Join( + Environment.NewLine, + failures.Select(f => $" exit {f.ExitCode}: {f.Stderr.Trim().Replace(Environment.NewLine, " | ")}")); + Assert.Fail($"{failures.Count}/{processes.Count} child processes failed:{Environment.NewLine}{summary}"); + } + } +} diff --git a/src/LocalDb.slnx b/src/LocalDb.slnx index c9b7d69d..a3b97c7a 100644 --- a/src/LocalDb.slnx +++ b/src/LocalDb.slnx @@ -22,6 +22,7 @@ + diff --git a/src/LocalDb/AiCliDetector.cs b/src/LocalDb/AiCliDetector.cs new file mode 100644 index 00000000..7f72c3dd --- /dev/null +++ b/src/LocalDb/AiCliDetector.cs @@ -0,0 +1,93 @@ +static class AiCliDetector +{ + static AiCliDetector() + { + var variables = Environment.GetEnvironmentVariables(); + + // GitHub Copilot + // https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line + IsCopilot = variables.Contains("GITHUB_COPILOT_RUNTIME"); + + // Aider + // https://aider.chat/docs/config/dotenv.html + IsAider = variables.Contains("AIDER_GIT_DNAME") || variables.Contains("AIDER"); + + // Claude Code + // https://docs.anthropic.com/en/docs/build-with-claude/claude-cli + IsClaudeCode = variables.Contains("CLAUDECODE") || variables.Contains("CLAUDE_CODE_ENTRYPOINT"); + + // Cursor + // https://cursor.com/docs/agent/terminal + IsCursor = variables.Contains("CURSOR_AGENT"); + + // Gemini CLI + // https://google-gemini.github.io/gemini-cli/docs/tools/shell.html + IsGeminiCli = variables.Contains("GEMINI_CLI"); + + // OpenAI Codex CLI + IsCodex = variables.Contains("CODEX_SANDBOX"); + + // Amazon Q Developer CLI + // https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line.html + IsAmazonQ = variables.Contains("Q_TERM"); + + // OpenCode + IsOpenCode = variables.Contains("OPENCODE_CLIENT"); + + // Cline + IsCline = variables.Contains("CLINE_ACTIVE"); + + // Augment Code + IsAugment = variables.Contains("AUGMENT_AGENT"); + + // TRAE AI + IsTraeAi = variables.Contains("TRAE_AI_SHELL_ID"); + + // Goose / Amp share the generic AGENT variable, distinguished by value + var agent = Environment.GetEnvironmentVariable("AGENT"); + IsGoose = string.Equals(agent, "goose", StringComparison.OrdinalIgnoreCase); + IsAmp = string.Equals(agent, "amp", StringComparison.OrdinalIgnoreCase); + + Detected = IsCopilot || + IsAider || + IsClaudeCode || + IsCursor || + IsGeminiCli || + IsCodex || + IsAmazonQ || + IsOpenCode || + IsCline || + IsAugment || + IsTraeAi || + IsGoose || + IsAmp; + } + + public static bool IsCursor { get; set; } + + public static bool IsCopilot { get; } + + public static bool IsAider { get; } + + public static bool IsClaudeCode { get; } + + public static bool IsGeminiCli { get; } + + public static bool IsCodex { get; } + + public static bool IsAmazonQ { get; } + + public static bool IsOpenCode { get; } + + public static bool IsCline { get; } + + public static bool IsAugment { get; } + + public static bool IsTraeAi { get; } + + public static bool IsGoose { get; } + + public static bool IsAmp { get; } + + public static bool Detected { get; set; } +} diff --git a/src/LocalDb/InternalsVisibleTo.cs b/src/LocalDb/InternalsVisibleTo.cs index 96817627..5e2a4279 100644 --- a/src/LocalDb/InternalsVisibleTo.cs +++ b/src/LocalDb/InternalsVisibleTo.cs @@ -1,4 +1,5 @@ [assembly: InternalsVisibleTo("LocalDb.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010021D97561385B1839784958BE8833356D27F8B66FBD4EE37391092A8D26A17C8E647C14491DAFCA7AA32A6CBAF8D8FFD0BEBA28EC850C07382037B7A0289A3383DF7FA9FB0ECC22CA7560B58E703A9F983A30CDB15B98789842A51AC99ABA5BF04148F5C0DDB587E2015A2552AB9192CF4A869725DFD21B8423EBCEBFB97032BA")] +[assembly: InternalsVisibleTo("LocalDb.MultiProcessHelper, PublicKey=002400000480000094000000060200000024000052534131000400000100010021D97561385B1839784958BE8833356D27F8B66FBD4EE37391092A8D26A17C8E647C14491DAFCA7AA32A6CBAF8D8FFD0BEBA28EC850C07382037B7A0289A3383DF7FA9FB0ECC22CA7560B58E703A9F983A30CDB15B98789842A51AC99ABA5BF04148F5C0DDB587E2015A2552AB9192CF4A869725DFD21B8423EBCEBFB97032BA")] [assembly: InternalsVisibleTo("Benchmark, PublicKey=002400000480000094000000060200000024000052534131000400000100010021D97561385B1839784958BE8833356D27F8B66FBD4EE37391092A8D26A17C8E647C14491DAFCA7AA32A6CBAF8D8FFD0BEBA28EC850C07382037B7A0289A3383DF7FA9FB0ECC22CA7560B58E703A9F983A30CDB15B98789842A51AC99ABA5BF04148F5C0DDB587E2015A2552AB9192CF4A869725DFD21B8423EBCEBFB97032BA")] [assembly: InternalsVisibleTo("EfLocalDb.Xunit.V3, PublicKey=002400000480000094000000060200000024000052534131000400000100010021D97561385B1839784958BE8833356D27F8B66FBD4EE37391092A8D26A17C8E647C14491DAFCA7AA32A6CBAF8D8FFD0BEBA28EC850C07382037B7A0289A3383DF7FA9FB0ECC22CA7560B58E703A9F983A30CDB15B98789842A51AC99ABA5BF04148F5C0DDB587E2015A2552AB9192CF4A869725DFD21B8423EBCEBFB97032BA")] [assembly: InternalsVisibleTo("EfLocalDb.MSTest, PublicKey=002400000480000094000000060200000024000052534131000400000100010021D97561385B1839784958BE8833356D27F8B66FBD4EE37391092A8D26A17C8E647C14491DAFCA7AA32A6CBAF8D8FFD0BEBA28EC850C07382037B7A0289A3383DF7FA9FB0ECC22CA7560B58E703A9F983A30CDB15B98789842A51AC99ABA5BF04148F5C0DDB587E2015A2552AB9192CF4A869725DFD21B8423EBCEBFB97032BA")] diff --git a/src/LocalDb/SqlInstance.cs b/src/LocalDb/SqlInstance.cs index 5ecdc13e..a71199d7 100644 --- a/src/LocalDb/SqlInstance.cs +++ b/src/LocalDb/SqlInstance.cs @@ -79,6 +79,11 @@ public SqlInstance( } Ensure.NotNullOrWhiteSpace(name); + if (AiCliDetector.Detected) + { + name = "chatbot_" + name; + } + if (directory == null) { directory = DirectoryFinder.Find(name);