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

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 that 1d9fb11
reversed — a reader trusting it would reopen the blocker 0506369 fixed.
DISK_RESERVE_BYTES claimed to equal the banner threshold that 0506369
deliberately 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:
MechaCat02
2026-08-09 14:51:58 +02:00
parent 214f9e3062
commit ef6d3a077a
14 changed files with 453 additions and 89 deletions

View File

@@ -4,7 +4,8 @@ import {
isReversibleLock,
entryToQueueItem,
shouldAbortForStall,
suspendedSinceLastTick
suspendedSinceLastTick,
MAX_SUSPEND_CREDIT_MS
} from './upload-queue';
/**
@@ -174,7 +175,7 @@ describe('suspendedSinceLastTick', () => {
// Background tabs are throttled to ~1 tick/min WITHOUT the network stack pausing, so a
// socket can be dead while ticks keep arriving. Unbounded crediting made this take ~18
// minutes, holding the queue's latch the whole time.
const CAP = 90_000; // MAX_SUSPEND_CREDIT_MS
const CAP = MAX_SUSPEND_CREDIT_MS;
let lastProgressAt = 0;
let lastTickAt = 0;
let creditSpent = 0;
@@ -211,7 +212,7 @@ describe('suspendedSinceLastTick', () => {
* must go through here.
*/
function runTicks(lockMs: number, tickMs = 5_000, ticks = 3): boolean {
const CAP = 90_000; // MAX_SUSPEND_CREDIT_MS
const CAP = MAX_SUSPEND_CREDIT_MS;
let lastProgressAt = 0;
let lastTickAt = 0;
let creditSpent = 0;

View File

@@ -130,7 +130,7 @@ const SUSPEND_TOLERANCE_MS = 2_000;
* silent AND suspended spends it, and that is exactly the case where being wrong is cheap: one
* re-send, bounded by MAX_AUTO_ATTEMPTS.
*/
const MAX_SUSPEND_CREDIT_MS = STALL_TIMEOUT_MS;
export const MAX_SUSPEND_CREDIT_MS = STALL_TIMEOUT_MS;
/**
* Wall-clock the watchdog interval FAILED to cover because the page was suspended.

View File

@@ -347,22 +347,21 @@
/* ignore */
}
}),
// A background transcode failed: the backend already cleaned up (refunded
// quota, removed the row) and an upload-deleted evicts the card. Only the
// uploader gets a toast — registered before upload-deleted so the card is
// still present to check ownership.
onSseEvent('upload-error', (data) => {
try {
const { upload_id } = JSON.parse(data) as { upload_id: string };
const mine = uploads.find((u) => u.id === upload_id && u.user_id === myUserId);
if (mine) {
toast('Ein Upload konnte nicht verarbeitet werden.', 'error');
void refreshQuota();
}
} catch {
/* ignore */
}
}),
// A background DERIVATIVE failed the photo itself is fine.
//
// This used to read "the backend already cleaned up (refunded quota, removed the
// row)". None of that is true any more: 1d9fb11 stopped soft-deleting on a failed
// transcode precisely so a guest never loses a photo to a thumbnailing bug, the quota
// is deliberately NOT refunded (the bytes are still on disk), and `upload-deleted` is
// no longer emitted. So the row is present, the card is visible, and the feed falls
// back to serving the original.
//
// Which made this handler the last remaining way the old "your photo is gone" signal
// reached the guest — a red error toast about a photo sitting right there on screen,
// plus a pointless quota refetch. There is nothing here for the uploader to do and
// nothing for them to worry about, so say nothing. The host dashboard and the boot
// log still surface it to the people who can act on it.
onSseEvent('upload-error', () => {}),
// A banned user's uploads were hidden — drop all their cards live.
onSseEvent('user-hidden', (data) => {
try {
@@ -568,7 +567,34 @@
const byId = new Map(fetched.map((u) => [u.id, u]));
uploads = uploads.map((u) => byId.get(u.id) ?? u);
const fresh = fetched.filter((u) => !present.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
if (!fresh.length) return;
// SORT the union — never blindly prepend.
//
// `fetched` is whole SERVER pages of 100, while `uploads` grows in pages of 20, so
// `uploads.length` is almost never a multiple of 100. Everything in the gap between what
// we hold and the next 100-boundary is absent from `present` and therefore looks "new"
// while being strictly OLDER than what we already show. Prepending it put ~80 photos from
// earlier in the evening above the newest ones — the gallery silently stopped being
// newest-first after the very first reconcile, which fires on any `upload-processed`
// event, i.e. constantly at a party.
//
// It also stalled infinite scroll: `nextCursor` still pointed at item 20, so the next
// `loadMore` re-fetched items already merged in, appended nothing, and the
// IntersectionObserver — which only re-fires on a change — never fired again.
//
// Sorting on the server's own ordering key fixes both, and additionally places an upload
// that arrived by SSE mid-fetch correctly rather than wherever the array splice left it.
const merged = [...fresh, ...uploads];
merged.sort((a, b) => {
// (created_at DESC, id DESC) — mirrors the keyset cursor in backend/src/handlers/feed.rs.
// Ties on the timestamp are real: a burst of uploads can share a millisecond, and the
// id is what the server breaks them with.
if (a.created_at !== b.created_at) return a.created_at < b.created_at ? 1 : -1;
if (a.id === b.id) return 0;
return a.id < b.id ? 1 : -1;
});
uploads = merged;
}
/**
@@ -601,6 +627,12 @@
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = res.uploads;
nextCursor = res.next_cursor;
// A fresh full load supersedes any earlier append failure. Without this the stale
// panel survived every refresh, filter change and pull-to-refresh — and once the new
// result set was short enough to have no next page, its "Erneut laden" button called a
// `loadMore` that returns immediately on `!nextCursor`. A false error message above a
// button that does nothing, for the rest of the evening.
loadMoreError = false;
loadError = false;
// A full refresh (pull-to-refresh, filter change) has resynced page 1, so the
// "new posts" pill is no longer relevant. Cleared only on SUCCESS: clearing it up