fix: close eight regressions the audit pass found, five of them mine

Two adversarial reviews over 61119be, 1d9fb11 and eb0e405. The merge itself came
back clean — client_upload_id end to end, TempFileGuard's arm/retarget/disarm, the
supervised sweep wiring and v_feed's column parity were all verified sound. What
follows is what my own three commits broke.

BLOCKER — a post-release rebuild was permanently impossible, and it 404'd the keepsake

1d9fb11 deferred prune_superseded_archives to run only on success, so a failed
rebuild could no longer destroy the last good archive. It did not follow that
through: ensure_export_space runs BEFORE the prune, so at rebuild time the previous
generation is still on disk and counted against free. That halves the gallery a
rebuild can survive (~4.6 GB) relative to what the upload gate accepts (~7.8 GB) —
and it self-locks, because invalidate_and_arm bumps the epoch on COMMIT, which 404s
both download routes immediately, while the only code that could free the space now
runs only after a success that can never happen. A guest deleting their own photo is
enough to trigger it. Recovery needed `docker exec rm`.

Now two-phase: try to build while preserving the old generation; if that genuinely
does not fit, reclaim it and try once more. Strictly better than both the original
ordering and my change — the old archive is sacrificed only when it is the only way
to get a new one.

BLOCKER — the deferred prune could delete the last archive when a worker LOST the race

run_*_export_inner returned Ok(()) on the superseded/discard path, so `res.is_ok()`
fired the prune with the worker's own RETIRED epoch as keep_seq. At that moment the
winning generation is still `pending` with no file, so protected_files is empty and
the last good archive was deleted with no replacement. Exactly the invariant
deferring the prune was meant to establish. Returns Err(Superseded) now, which
abandon_if_superseded already swallows for the caller.

BLOCKER — the low-disk banner could never fire before the wall

eb0e405's gate refuses at `free < keepsake + DISK_RESERVE`, while disk_is_low warned
at `free < keepsake`. The two differ by the whole reserve, so the wall always came
first: every guest blocked from uploading while the host dashboard showed ~27 GB free
and no banner, with nobody on site. disk_is_low now shares the gate's expression plus
a 25% margin, and a test asserts the banner fires at the gate threshold across the
whole gallery-size range.

BLOCKER — I raised the unauthenticated bcrypt ceiling 24x on a 2 vCPU box

1d9fb11 moved admin_login's tight bucket after verify_password (correct — that is what
stops a guest locking the operator out) but replaced the incidental 5/min bound on
bcrypt with 120/min and nothing global. bcrypt is on spawn_blocking, but tokio's
blocking pool is 512 threads, so "off the runtime" is not "bounded": enough concurrent
verifies preempt both async workers and uploads, feed and SSE stall. Three
unauthenticated endpoints reach bcrypt and every guest shares one NAT IP, so per-IP
limits bound nothing globally. Adds a process-wide semaphore of `cores - 1` around both
verify and hash, and drops the ceiling to 30.

Also correcting my own claim: "a correct password is never throttled" was wrong. The
failure bucket cannot block it, but the CPU ceiling still can. The code comment said so;
the commit message did not.

BLOCKER — migration 025 could crash-loop the app on boot

Its UPDATE derives `Name (8hex)` with no guard against idx_user_event_name_ci. A guest
who had already joined as exactly that string makes the migration fail, which
propagates out of create_pool, exits main, and `restart: unless-stopped` turns it into
a permanent loop — a worse version of the lockout the migration exists to clean up.
Now skips colliding rows (create_admin_user already falls back to Admin-<8hex>, so the
cleanup is convenience, not load-bearing). Also `role = 'guest'` rather than
`<> 'admin'`, which was renaming legitimately promoted hosts named "Host".

DEGRADATION — the watchdog's suspension credit was unbounded

Background tabs are throttled to ~1 tick/min WITHOUT the network stack pausing, and the
tick gap cannot tell that from a freeze. Crediting every late tick grew the observed
silence by only one interval per real minute, so a dead socket took ~18 minutes to
detect while holding the queue's processing latch. Credit is now capped at one stall
window and REFILLS on real progress: an upload that is moving survives any number of
screen locks, while one that is silent and suspended is detected within ~3 minutes.

DEGRADATION — the 4xx log line was an unauthenticated log-injection vector

validate_display_name allowed newlines, several 4xx messages interpolate the name, and
%message wrote it unescaped. Two unauthenticated /join requests could forge arbitrary
lines in the only forensic record an unattended event has. Fixed at both ends: control
characters rejected at the door, and `detail = ?message` escapes on the way out (which
also stops colliding with tracing's reserved `message` field). 401/404 drop to DEBUG —
they carry no operator signal and were the cheapest lines for a scanner to use to roll
the 30 MB log window in minutes.

DEGRADATION — the quota floor was inverted exactly where it mattered

`computed.max(MIN.min(budget))`: `budget` is the whole disk's share, so below 500 MiB
the "floor" became the entire remaining budget and EVERY uploader was authorised all of
it — 400 MB free, 3 uploaders, 300 MB each. A test pinned that as correct under the name
`the_floor_never_exceeds_what_the_disk_can_back`. Both fixed.

Also replaces the headline gate test, which asserted its own precondition inside an `if`
on that precondition and could not fail. It now pins what actually binds the gate to the
preflight — that required_free_bytes charges for both halves — plus the ceiling band.

Verified: 151/151 backend tests against a live Postgres, clippy clean, 58/58 vitest,
svelte-check 0 errors, eslint clean, both builds, caddy validate, and the migration
collision reproduced against Postgres 16 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-08 22:33:43 +02:00
parent 23e2f485dd
commit 05063694d2
8 changed files with 350 additions and 59 deletions

View File

@@ -503,7 +503,49 @@ async fn run_zip_export(
// `pending` with no worker and no error — the spinner-forever state `mark_failed`'s status
// guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the
// host gets the reason.
ensure_export_space(pool, event_id, export_path).await?;
// Two-phase, and the order is the whole point.
//
// Deferring the prune (so a failed rebuild can never leave the event with no archive at
// all) has a cost the first version of this did not follow through on: at rebuild time the
// previous generation is still on disk and still counted against free space, so the
// preflight demands room for BOTH. That halves the gallery size a rebuild can survive
// relative to the size the upload gate allows — and every path that bumps the epoch (a
// guest deleting their own photo, a caption edit, a ban, "Neu erzeugen") retires the
// current keepsake IMMEDIATELY on commit. So above that threshold the download 404s, the
// rebuild is refused, and the only thing that could free the space is the prune that now
// only runs on success. Permanently stuck, unreachable from any handler.
//
// So: try to build while preserving the old generation. If that genuinely does not fit,
// the old generation is the one thing we can reclaim — sacrifice it and try once more. A
// keepsake that exists beats one we preserved but can never replace.
if ensure_export_space(pool, event_id, export_path).await.is_err() {
tracing::warn!(
"not enough room to rebuild alongside the previous keepsake; reclaiming it first"
);
prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await;
// Two-phase, and the order is the whole point.
//
// Deferring the prune (so a failed rebuild can never leave the event with no archive at
// all) has a cost the first version of this did not follow through on: at rebuild time the
// previous generation is still on disk and still counted against free space, so the
// preflight demands room for BOTH. That halves the gallery size a rebuild can survive
// relative to the size the upload gate allows — and every path that bumps the epoch (a
// guest deleting their own photo, a caption edit, a ban, "Neu erzeugen") retires the
// current keepsake IMMEDIATELY on commit. So above that threshold the download 404s, the
// rebuild is refused, and the only thing that could free the space is the prune that now
// only runs on success. Permanently stuck, unreachable from any handler.
//
// So: try to build while preserving the old generation. If that genuinely does not fit,
// the old generation is the one thing we can reclaim — sacrifice it and try once more. A
// keepsake that exists beats one we preserved but can never replace.
if ensure_export_space(pool, event_id, export_path).await.is_err() {
tracing::warn!(
"not enough room to rebuild alongside the previous keepsake; reclaiming it first"
);
prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await;
ensure_export_space(pool, event_id, export_path).await?;
}
}
// On error, mark THIS generation failed — a no-op if we've since been superseded (the
// caller in `spawn_export_jobs` does it, epoch-guarded). Temp artifacts are cleaned up
@@ -657,7 +699,13 @@ async fn run_zip_export_inner(
tracing::info!(
"ZIP export for event {event_id} superseded (epoch {epoch} retired); discarded"
);
return Ok(());
// Err, NOT Ok. `abandon_if_superseded` swallows this sentinel for the caller, so the
// outcome is unchanged — but `run_*_export`'s deferred prune keys off `res.is_ok()`,
// and a worker that LOST the epoch race reporting success made it reclaim generations
// older than its own RETIRED epoch. At that moment the winning generation is still
// `pending` with no file, so nothing protected the last good archive and it was deleted
// with no replacement on disk — the precise outcome deferring the prune exists to stop.
return Err(Superseded.into());
}
prune_stale_export_files(pool, &exports_dir, "Gallery", event_id, epoch).await;
@@ -1090,7 +1138,13 @@ async fn run_html_export_inner(
tracing::info!(
"HTML export for event {event_id} superseded (epoch {epoch} retired); discarded"
);
return Ok(());
// Err, NOT Ok. `abandon_if_superseded` swallows this sentinel for the caller, so the
// outcome is unchanged — but `run_*_export`'s deferred prune keys off `res.is_ok()`,
// and a worker that LOST the epoch race reporting success made it reclaim generations
// older than its own RETIRED epoch. At that moment the winning generation is still
// `pending` with no file, so nothing protected the last good archive and it was deleted
// with no replacement on disk — the precise outcome deferring the prune exists to stop.
return Err(Superseded.into());
}
prune_stale_export_files(pool, &exports_dir, "Memories", event_id, epoch).await;