fix(scripts): refuse to reuse a backend that is serving the previous database (#257)
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Successful in 26m47s

start-local.ps1 said "something is already listening on 3000; leaving it alone" and carried on. That is safe only while the database has not changed underneath that process. When it has, the old backend is serving a database it no longer owns and its in-memory state describes rows that no longer exist.

The rate limiter is the sharpest example. It keys on customer id and keeps buckets in memory for an hour, so a recreated database restarting ids at 1 hands a brand-new customer a previous run's spent allowance. That is #257: the resend-verification allowance test failing roughly one full run in three, never in isolation, with three refusal toasts where one was expected.

Reproduced deterministically rather than reasoned about. Fresh database and fresh backend: three of three pass. Recreate the database only, leaving the same backend running: the same test fails with exactly the reported "resolved to 3 elements". Control — recreate the ids again but restart the backend as well: passes. So the variable is the process outliving the database, not the id restart on its own.

That also explains why #257 could not find the mechanism. It had ruled out contention, a mis-keyed limiter, a shared fixture and identity reuse in the test helpers, all correctly. The recycling happens outside the suite entirely, in a process the suite never sees.

Now it refuses, names the reason, and says to run -Stop. A run that stops loudly is recoverable; one that quietly tests the wrong thing is not — and a stale listener on 3000 has already produced two wrong measurements in this project, a rate-limiter reading and an e2e run reported as 23 passed when the backend was talking to a deleted database.

DatabaseIsNew is set when -Fresh removes the container, when the container is created, and always under -E2eDb, whose tmpfs storage means it comes up empty whether created or restarted.

Verified by AST-parsing the script and confirming every reference to the flag is script-scoped — a function-local read would see $null and the guard would never fire. The script is deliberately never executed from an agent shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 11:13:02 -05:00
co-authored by Claude Opus 5
parent 93f05d06db
commit 175d21d11c
+48
View File
@@ -65,6 +65,11 @@ $ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$StateDir = Join-Path $RepoRoot '.local'
$PidFile = Join-Path $StateDir 'pids.json'
# Whether this run is starting from an empty database. Set below, and read by
# Start-Backend, which must refuse to reuse a backend that is still serving the
# previous one. See #257.
$script:DatabaseIsNew = $false
# Which database this run uses. Everything downstream reads these four, so the
# choice is made once here rather than branched at each use.
if ($E2eDb) {
@@ -184,14 +189,22 @@ function Start-Database {
Write-Step 'Removing the existing database (-Fresh)'
docker rm -f $Container *>$null
Write-Good 'removed'
$script:DatabaseIsNew = $true
}
# The e2e container stores its data on tmpfs, so it comes up empty whether it
# is created or merely restarted. There is no case where it carries anything
# over, which is the point of it (#186) — and it means a backend from a
# previous run is always stale against it.
if ($E2eDb) { $script:DatabaseIsNew = $true }
$existing = (docker ps -a --filter "name=^/$Container$" --format '{{.Names}}')
if ($existing -eq $Container) {
Write-Step "Reusing the database container"
docker start $Container *>$null
}
else {
$script:DatabaseIsNew = $true
Write-Step "Creating the database container on port $DbPort"
# tmpfs for the e2e database only: its contents are worthless the
# moment the run ends, and storing nothing is what makes it start empty
@@ -269,6 +282,41 @@ function Start-Backend {
finally { Pop-Location }
if (Test-Listening -Port $ApiPort) {
# Leaving a stranger's backend alone is only safe when the database has
# not just changed underneath it. When it has, that process is serving a
# database it no longer owns, and its in-memory state describes rows that
# no longer exist.
#
# That is not theoretical. The rate limiter keys on customer id and keeps
# its buckets in memory for an hour; a recreated database restarts ids at
# 1, so a new customer inherits a previous run's spent allowance. It
# reproduced exactly the intermittent resend-verification failure in #257
# — three refusal toasts where one was expected — and the control (same
# id restart, restarted backend) passed. Whatever else is stale, a
# process serving the wrong database gives wrong answers confidently,
# which is the worst kind.
#
# So this refuses rather than continuing, and says what to do. A run that
# stops loudly is recoverable; one that quietly tests the wrong thing is
# what cost two wrong measurements in this project already.
$tracked = Get-TrackedProcesses
$ours = $tracked.ContainsKey('backend') -and
(Get-Process -Id $tracked['backend'] -ErrorAction SilentlyContinue)
if ($script:DatabaseIsNew -and -not $ours) {
throw @"
Something is already listening on $ApiPort, and this run just created a new database.
That process is serving the previous database. Its in-memory state rate-limit
buckets keyed on customer id, most obviously describes rows that no longer
exist, and recycled ids will inherit them (#257).
Stop it and run this again:
.\scripts\start-local.ps1 -Stop
"@
}
Write-Note "something is already listening on $ApiPort; leaving it alone"
return
}