_TESTS/PSLocalBase.NativeStress.Tests.ps1

# Native resource and shared-memory locking probes, independent of the runner.
# Run in a fresh process. No on-disk databases or external services are used.
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PSLocalBase.psd1') -Force
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
public static class PSLocalBaseAuditNative {
    [DllImport("winsqlite3.dll", EntryPoint="sqlite3_memory_used", CallingConvention=CallingConvention.Cdecl)]
    public static extern long MemoryUsed();
    [DllImport("winsqlite3.dll", EntryPoint="sqlite3_next_stmt", CallingConvention=CallingConvention.Cdecl)]
    public static extern IntPtr NextStatement(IntPtr db, IntPtr stmt);
    [DllImport("winsqlite3.dll", EntryPoint="sqlite3_open_v2", CallingConvention=CallingConvention.Cdecl)]
    private static extern int OpenNative(byte[] path, out IntPtr db, int flags, IntPtr vfs);
    public static IntPtr OpenSharedMemory(string name) {
        IntPtr db;
        byte[] uri = System.Text.Encoding.UTF8.GetBytes("file:" + name + "?mode=memory&cache=shared\0");
        int rc = OpenNative(uri, out db, 2 | 4 | 0x40 | 0x10000, IntPtr.Zero);
        if (rc != 0) throw new InvalidOperationException("OpenSharedMemory rc=" + rc);
        return db;
    }
}
'@


# Warm up native SQLite allocations before measuring.
$warm = New-LocalBase
$warm.close()
$baseline = [PSLocalBaseAuditNative]::MemoryUsed()

$db = New-LocalBase
try {
    [void]$db.collection('items').createIndex('n', @{ unique = $true })
    for ($i = 0; $i -lt 500; $i++) {
        $db.collection('items').doc('one').set(@{ n = 1; value = "payload`0tail" })
        [void]$db.collection('items').doc('one').get()
        $matches = @($db.collection('items').where('value', '-like', "payload`0*").get())
        if ($matches.Count -ne 1) { throw "Full-string pattern match failed at iteration $i" }
        try { [void]$db.query('SELECT ?;', @()) } catch [System.InvalidOperationException] { }
        try { [void]$db.query('SELECT invalid_column FROM items;') } catch [System.InvalidOperationException] { }
        try { [void]$db.collection('items').add(@{ n = 1 }, 'duplicate') } catch [System.InvalidOperationException] { }
        if ([PSLocalBaseAuditNative]::NextStatement($db.DbHandle, [IntPtr]::Zero) -ne [IntPtr]::Zero) {
            throw "Statement leak at iteration $i"
        }
    }
    $child = $db.collection('items')
    $db.close()
    $rejected = $false
    try { [void]$child.get() } catch [System.InvalidOperationException] { $rejected = $true }
    if (-not $rejected) { throw 'Orphan child accepted access.' }
} finally { $db.close() }
Write-Host '[PASS] N01 - 500 mixed success/error cycles leave no prepared statements; orphan rejected.'

for ($i = 0; $i -lt 100; $i++) {
    $instance = New-LocalBase
    try { $instance.collection('items').doc('one').set(@{ value = ('x' * 1024) }) }
    finally { $instance.Dispose() }
}
$afterDisposed = [PSLocalBaseAuditNative]::MemoryUsed()
if ($afterDisposed -ne $baseline) { throw "Native bytes differ after disposal: baseline=$baseline after=$afterDisposed" }
Write-Host "[PASS] N02 - 100 disposed databases return SQLite allocations to baseline ($baseline bytes)."

# Undisposed wrappers are released by the connection SafeHandle finalizer.
for ($i = 0; $i -lt 20; $i++) {
    $abandoned = New-LocalBase
    $abandoned.collection('items').doc('one').set(@{ value = ('x' * 65536) })
    $abandoned = $null
}
for ($attempt = 0; $attempt -lt 5 -and [PSLocalBaseAuditNative]::MemoryUsed() -ne $baseline; $attempt++) {
    [GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
}
$afterAbandoned = [PSLocalBaseAuditNative]::MemoryUsed()
if ($afterAbandoned -ne $baseline) { throw "Undisposed wrappers retained native memory after GC: baseline=$baseline after=$afterAbandoned" }
Write-Host "[PASS] N03 - 20 undisposed wrappers are closed by the finalizer; native memory returned to baseline."

function New-SharedConnection([string]$Name) {
    $connection = New-LocalBase
    $connection.close()
    $connection.DbHandle = [PSLocalBaseAuditNative]::OpenSharedMemory($Name)
    [void][WinSqlite3]::BusyTimeout($connection.DbHandle, 100)
    return $connection
}

# Deterministic interleaving of two native connections to the same in-memory DB.
# Shared memory uses journal_mode=memory, not WAL. Lock errors are expected.
$sharedName = 'audit_' + [Guid]::NewGuid().ToString('N')
$first = New-SharedConnection $sharedName
$second = New-SharedConnection $sharedName
try {
    Write-Host "[OBSERVATION] N04 - shared database journal_mode=$($first.query('PRAGMA journal_mode;').journal_mode)."
    $first.collection('items').doc('one').set(@{ a = 0; b = 0 })
    $first.beginTransaction()
    $first.collection('items').doc('one').update(@{ a = 1 })
    $locked = $false
    try { $second.collection('items').doc('one').update(@{ b = 1 }) }
    catch [System.InvalidOperationException] { $locked = $true; Write-Host "[OBSERVATION] N04 - competing write rejected: $($_.Exception.Message)" }
    if (-not $locked) { throw 'Competing uncommitted write unexpectedly succeeded.' }
    $first.commit()
    $second.collection('items').doc('one').update(@{ b = 1 })
    $actual = $first.collection('items').doc('one').get()
    if ($actual.a -ne 1 -or $actual.b -ne 1 -or $second.InTransaction) { throw 'Lost update or open transaction after retry.' }
    Write-Host '[PASS] N04 - competing writer lock is surfaced; explicit retry preserves both updates.'
    if ([PSLocalBaseAuditNative]::NextStatement($second.DbHandle, [IntPtr]::Zero) -ne [IntPtr]::Zero) { throw 'Lock failure leaked a statement.' }

    $first.collection('parallel').doc('one').set(@{ members = @() })
    $startGate = [System.Threading.ManualResetEventSlim]::new($false)
    $readyGate = [System.Threading.CountdownEvent]::new(2)
    $workers = @()
    $workerScript = @'
param($modulePath, $sharedName, $workerId, $startGate, $readyGate)
$ErrorActionPreference = 'Stop'
Import-Module $modulePath -Force
$workerDb = New-LocalBase
$workerDb.close()
$workerDb.DbHandle = [PSLocalBaseAuditNative]::OpenSharedMemory($sharedName)
$retries = 0
try {
    [void]$readyGate.Signal()
    if (-not $startGate.Wait(10000)) { throw 'Worker start timeout.' }
    for ($i = 0; $i -lt 25; $i++) {
        $done = $false
        for ($attempt = 0; $attempt -lt 2000 -and -not $done; $attempt++) {
            try {
                $workerDb.collection('parallel').doc('one').update(@{ members = @{ '$arrayUnion' = @("$workerId/$i") } })
                $done = $true
            } catch {
                if ($_.Exception.Message -notmatch 'locked|busy') { throw }
                $retries++
                [System.Threading.Thread]::Sleep(2 + ($attempt % 5))
            }
        }
        if (-not $done) { throw "Retry limit reached for $workerId/$i" }
    }
    [pscustomobject]@{ Worker = $workerId; Writes = 25; Retries = $retries }
} finally { $workerDb.close() }
'@

    try {
        for ($workerId = 0; $workerId -lt 2; $workerId++) {
            $pipeline = [System.Management.Automation.PowerShell]::Create()
            [void]$pipeline.AddScript($workerScript).AddArgument((Join-Path (Split-Path $PSScriptRoot -Parent) 'PSLocalBase.psd1')).AddArgument($sharedName).AddArgument($workerId).AddArgument($startGate).AddArgument($readyGate)
            $workers += @{ Pipeline = $pipeline; Async = $pipeline.BeginInvoke() }
        }
        if (-not $readyGate.Wait(10000)) { throw 'Workers failed to become ready.' }
        $startGate.Set()
        foreach ($worker in $workers) {
            if (-not $worker.Async.AsyncWaitHandle.WaitOne(20000)) { throw 'Parallel worker timeout.' }
            $result = $worker.Pipeline.EndInvoke($worker.Async)
            if ($worker.Pipeline.HadErrors) { throw ($worker.Pipeline.Streams.Error -join '; ') }
            foreach ($row in $result) { Write-Host "[OBSERVATION] N05 - worker=$($row.Worker) writes=$($row.Writes) retries=$($row.Retries)" }
        }
        $members = @($first.collection('parallel').doc('one').get().members)
        if ($members.Count -ne 50 -or @($members | Sort-Object -Unique).Count -ne 50) { throw "Lost concurrent array mutations: members=$($members.Count)" }
        Write-Host '[PASS] N05 - two concurrent runspaces retained all 50 atomic array mutations with explicit lock retries.'
    } finally {
        $startGate.Set()
        foreach ($worker in $workers) { $worker.Pipeline.Stop(); $worker.Pipeline.Dispose() }
        $startGate.Dispose(); $readyGate.Dispose()
    }
} finally { $first.close(); $second.close() }

if ([PSLocalBaseAuditNative]::MemoryUsed() -ne $baseline) { throw 'Probe cleanup did not return native memory to baseline.' }
Write-Host '[PASS] Native probes cleaned all retained connections; no native allocations remain above baseline.'