fix: close what nine adversarial reviews found, most of it mine
Some checks failed
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m5s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m55s
Checks / E2E — typecheck + lint (push) Failing after 49s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 10m42s
E2E / Cross-UA smoke matrix (push) Failing after 7m57s
Audit / cargo audit (backend) (push) Failing after 11m12s
Audit / npm audit (frontend) (push) Successful in 44s
Some checks failed
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m5s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m55s
Checks / E2E — typecheck + lint (push) Failing after 49s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 10m42s
E2E / Cross-UA smoke matrix (push) Failing after 7m57s
Audit / cargo audit (backend) (push) Failing after 11m12s
Audit / npm audit (frontend) (push) Successful in 44s
Nine focused reviews (export state machine, upload path, auth/abuse, client queue, guest UI, database, deploy/ops, regression hunt, test honesty). Every finding below was re-verified against the code before being acted on; several plausible-sounding ones were checked and rejected. ## Data loss and denial of service **One request could OOM-kill the app container.** `client_upload_id` was read with `Field::text()` — axum builds its multipart reader with no SizeLimit, so the only bound was the route's 576 MiB body limit, then decoded into a second full String. `caption` and `hashtags` go through `read_text_field_bounded` for exactly this reason; this field arrived later and missed it. Any guest, one request, and every SSE stream drops and every in-flight temp file is stranded. **Nothing bounded concurrent upload bodies.** The headroom gate can only refuse to COMMIT — the body is already streamed to a temp file by the time it runs, and neither axum, the tower stack nor Caddy limits how many stream at once. ~100 guests tapping "upload all" after the ceremony puts 10-20 GB of .tmp on a 40 GB volume, invisible to the gate, eating the reserve that keeps Postgres able to write WAL. New `UploadAdmission` budgets bytes (not requests, so one video and two hundred photos coexist) via a permit that releases on drop, so every exit path returns it. **The export decode bypassed the memory permit the compression path takes.** Same class of work — decode + resize every image in the gallery — in a bare spawn_blocking. A release fired while the last photos were still compressing put both in the same 1 GiB cgroup; the OOM kill marks the export failed and `recover_exports` re-spawns it into the same conditions on the next boot. The permit is now process-wide in `imaging`, because the constraint it expresses is the container's memory, not one worker's. **`MediaTotalCache` cached its own failure as 0.** For the whole TTL the gate then saw an empty event and collapsed to the flat reserve — the behaviour the two-halves design replaced — with no log line. And the trigger correlates with the danger: with max_connections 10 the query fails exactly during a burst. Now falls back to the last good reading and says so. **V8's heap ceiling sat above the frontend container's entire budget** (measured: 259 MB inside a 256M limit), so GC could never intervene and the only backpressure was SIGKILL under an arrival burst. ## Guest-visible **The feed stopped being newest-first after the first reconcile.** It fetches whole 100-item server pages while `uploads` grows in 20s, so everything in the gap was absent from `present`, classified as new, and prepended — ~80 photos from earlier in the evening above the newest ones. It also stalled infinite scroll, since the cursor still pointed at item 20 and the observer only re-fires on a change. The union is now sorted on the server's own (created_at, id) key, which additionally places an SSE arrival correctly. **A stale `loadMoreError` outlived every refresh and filter change**, leaving a false error above a button that returns immediately on `!nextCursor`. **A failed derivative toasted "Ein Upload konnte nicht verarbeitet werden."** for a photo sitting right there on screen — the handler still assumed 1d9fb11's pre-fix behaviour (row deleted, quota refunded, card evicted), none of which is true any more. It was the last surviving route for the "your photo is gone" signal that fix set out to remove. ## Enforcement that existed only in comments `recover_name_rate_per_15min` is clamped at the point of use: the ordering `3 x ceiling <= PIN_LOCK_THRESHOLD` is the whole control against one source locking any guest whose name is on the feed, it was asserted in a comment, and `patch_config` accepted 1..100_000. The test pinned the default constant rather than the enforced bound; it now pins the bound. ## Tests that could not fail - The gate test asserted only its own premise (`500MB x 100 > 35GB`) and never touched the gate. It now checks both controls against the same state and requires them to disagree in the right direction. - `the_banner_always_fires_before_the_upload_gate_closes` reduced to `G < G + G/4` — true for any margin, including zero, so it could not detect the banner moving to exactly the gate. It now pins the gap. - `disk_is_low`'s `free < LOW_DISK_FLOOR_BYTES` clause was unreachable (warn_at is always >= 12.5 GB against a 10 GB floor). Two tests were named after it and neither could fail if it were deleted. Clause and constant removed. - The suspension test I added last commit hard-coded the credit cap instead of importing it, so changing STALL_TIMEOUT_MS would leave it passing against a system that no longer exists. Now imports MAX_SUSPEND_CREDIT_MS. ## Stale comments corrected The prune doc still argued at length for the pre-build ordering that1d9fb11reversed — a reader trusting it would reopen the blocker0506369fixed. DISK_RESERVE_BYTES claimed to equal the banner threshold that0506369deliberately offset by 25%. And host.rs kept its own duplicate 10 GB literal instead of importing the constant. 154/154 backend, 59/59 vitest, clippy clean, svelte-check 0 errors, eslint clean, both builds, compose + caddy validate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,9 +44,6 @@ pub struct EventStatus {
|
||||
pub disk_low: bool,
|
||||
}
|
||||
|
||||
/// Absolute floor below which free space is worth surfacing regardless of gallery size — the
|
||||
/// threshold the README has carried on the roadmap since v1.
|
||||
const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
||||
|
||||
/// Is free space low enough that the host needs to know?
|
||||
///
|
||||
@@ -72,7 +69,12 @@ const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
||||
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
||||
let gate_closes_at = keepsake_required.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
|
||||
let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4);
|
||||
free < LOW_DISK_FLOOR_BYTES || free < warn_at
|
||||
// No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here,
|
||||
// and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at
|
||||
// least 1.25x it (12.5 GB) — always above the 10 GB floor. Two tests were named after that
|
||||
// clause and neither could fail if it were deleted. Keeping dead code that tests claim to
|
||||
// cover is worse than not having it.
|
||||
free < warn_at
|
||||
}
|
||||
|
||||
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
||||
@@ -824,7 +826,7 @@ pub async fn release_gallery(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
|
||||
use super::disk_is_low;
|
||||
use crate::handlers::upload::DISK_RESERVE_BYTES;
|
||||
use crate::services::export::required_free_bytes;
|
||||
|
||||
@@ -836,18 +838,18 @@ mod tests {
|
||||
assert!(!disk_is_low(60 * GB, 25 * GB));
|
||||
}
|
||||
|
||||
/// Renamed from `the_absolute_floor_fires_...`: there is no separate floor clause any more
|
||||
/// (see `disk_is_low`). What still has to hold is the behaviour the floor was there FOR — a
|
||||
/// nearly-empty disk is low even when the gallery is small enough that the keepsake term
|
||||
/// alone would clear it, because all three volumes share one filesystem and Postgres needs
|
||||
/// room to write.
|
||||
#[test]
|
||||
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
|
||||
fn a_nearly_empty_disk_is_low_even_when_the_gallery_is_tiny() {
|
||||
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
|
||||
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
|
||||
// the keepsake test comfortably; the floor is what catches this.
|
||||
assert!(disk_is_low(5 * GB, GB));
|
||||
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
|
||||
// NOT `!disk_is_low(LOW_DISK_FLOOR_BYTES, 0)` any more. The floor is a lower bound, not
|
||||
// the boundary: the gate-aware trigger now dominates it (at an empty gallery it warns
|
||||
// below 1.25 x the reserve, i.e. 12.5 GB). Pinning equality here asserted the very
|
||||
// behaviour that let the wall arrive before the warning.
|
||||
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
|
||||
assert!(disk_is_low(9 * GB, 0));
|
||||
assert!(!disk_is_low(20 * GB, 0), "a roomy empty disk is not low");
|
||||
}
|
||||
|
||||
@@ -858,12 +860,33 @@ mod tests {
|
||||
/// to upload while the dashboard shows a comfortable disk — with nobody on site to ask.
|
||||
#[test]
|
||||
fn the_banner_always_fires_before_the_upload_gate_closes() {
|
||||
// Asserting `disk_is_low(gate_closes_at, required)` is what this used to do, and it was a
|
||||
// tautology: `disk_is_low` recomputes the same `gate_closes_at` internally and compares
|
||||
// against `gate + gate/4`, so the assertion reduced to `G < G + G/4` — true for every G,
|
||||
// for any margin, even a margin of zero. It could not detect the banner being moved to
|
||||
// exactly the gate, which is the regression it is named for.
|
||||
//
|
||||
// So pin the GAP instead: find the free-space level at which the banner starts, and
|
||||
// require it to be strictly above the level at which the gate closes, by a usable amount.
|
||||
for media_gb in [0u64, 1, 4, 8, 16, 32] {
|
||||
let required = required_free_bytes(media_gb * GB, 2);
|
||||
let gate_closes_at = required + DISK_RESERVE_BYTES as u64;
|
||||
|
||||
// Just above the gate: guests can still upload, and the host must already be warned.
|
||||
assert!(
|
||||
disk_is_low(gate_closes_at, required),
|
||||
"at media={media_gb}GB the gate is about to close but no banner is shown"
|
||||
disk_is_low(gate_closes_at + 1, required),
|
||||
"at media={media_gb}GB the banner is not yet showing while the gate still allows uploads"
|
||||
);
|
||||
|
||||
// The warning must lead by a margin the host can act inside, not by one byte.
|
||||
let mut warn_starts_at = gate_closes_at;
|
||||
while disk_is_low(warn_starts_at, required) {
|
||||
warn_starts_at += GB / 10;
|
||||
}
|
||||
assert!(
|
||||
warn_starts_at >= gate_closes_at + gate_closes_at / 5,
|
||||
"at media={media_gb}GB the banner leads the gate by only {} bytes",
|
||||
warn_starts_at - gate_closes_at
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -889,7 +912,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
|
||||
fn an_empty_gallery_still_reserves_room_for_postgres() {
|
||||
// With no gallery the keepsake term is 0, so the warn threshold collapses to
|
||||
// 1.25 x DISK_RESERVE_BYTES (12.5 GB), which dominates the 10 GB absolute floor.
|
||||
assert!(!disk_is_low(13 * GB, 0));
|
||||
|
||||
Reference in New Issue
Block a user