fix: close four confirmed defects an adversarial review found
Findings from a multi-angle review, most of them in code I wrote in the last
few commits. Each was verified against the code before being acted on.
## Backend
**The export daily limit was bypassable ~60x/minute.** `SseTicketStore` is
untyped, and the export download quietly started reusing it. `POST
/stream/ticket` is free and rate-limited at 60/min per user; `POST
/export/ticket` charges one of three PER-DAY downloads. So a guest could mint
at the cheap endpoint and redeem at the expensive one, each redemption
streaming the whole multi-GB keepsake, `no-store`, off the same filesystem
Postgres writes WAL to. Tickets now carry a `TicketKind` and `consume` requires
it to match, asserted in both directions. The comment claiming "one mint is at
most one download" was simply false.
**`export_ticket` answered 200 `{"ticket": null}` when the store was full** —
after charging a daily slot. `issue` returns `Option`; `sse.rs` handles the
None with a 503 and this call site unwrapped it into the JSON body. The page
toasted success, the iframe navigated to `?ticket=null`, and one of three
downloads was gone. That is the phantom-success failure the pre-validation in
5b70531 exists to prevent, arriving through the other door.
**`finalize_job` collapsed a DB error into "we lost the epoch race."** At that
point the archive is built, fsynced and renamed, so the caller deleted the
finished multi-GB file and returned the Superseded sentinel — which
`abandon_if_superseded` swallows into Ok, so `mark_failed` never ran either.
The row stayed `running` at 99% at the LIVE epoch: "Wird erstellt (99 %)",
download disabled, forever. No sweep re-examines `running` rows and
`recover_exports` runs only at boot. `claim_job`'s own doc comment says errors
are distinguished there precisely because of this failure shape. A pool timeout
is not exotic: max_connections 10, acquire_timeout 5s, firing at the end of a
full-gallery export while 100 guests upload.
**`PATCH {"hashtags": []}` was a free keepsake-retire loop.** The no-op guard
only compared captions, and my comment defended the gap by claiming an
identical hashtag list "is not a free loop". It is exactly one. Each request
bumped the epoch, retiring the HTML keepsake; REGEN_DEBOUNCE throttles when a
rebuild may start, not the bump, so at 30/min no rebuild ever gets a quiet
window and /export/html 404s all event. Now compares against the stored tags.
Also: four config keys migration 025 inserts (and the handlers read) were
missing from `patch_config`'s allowlist, so `GET /admin/config` listed them
while `PATCH` answered "Unbekannter Konfigurationsschlüssel" — the rate limits
an operator reaches for while abuse is happening.
## Client upload queue
**The ✕ was cosmetic.** A cancel deliberately charges no attempt and sets no
backoff — so `requeueRetriable` matched it on both counts and restarted the
upload from byte zero within ~120s (an `online` event, or the SSE backstop's
`feed-delta` poll). It then restarted forever, because a path that never
charges an attempt can never exhaust the budget that would stop it. The row
read "Abgebrochen. Tippe auf „Erneut“." throughout. Cancels are now explicitly
terminal until the guest taps Erneut.
**The retry budget was a lifetime quota, not a rate.** Five attempts on a
5/10/20/40s ladder is ~75 seconds, so any outage longer than that — a venue AP
brownout, a captive portal re-arming, an `app` restart, all with
`navigator.onLine` still true — permanently parked every in-flight photo
behind a per-row button three taps deep. It now refills after 10 quiet minutes,
which still forbids a hot loop re-sending a 200 MB video over a shared uplink.
**A test asserted a property the code does not have.** The suspension test
omitted the MAX_SUSPEND_CREDIT_MS clamp the production tick applies, so it
could not fail. Replaced with a helper that replays the real tick loop, and the
true bound is now asserted: a 60s lock survives, a 3-minute lock aborts.
152/152 backend, 59/59 vitest, clippy clean, svelte-check 0 errors, eslint
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -203,14 +203,47 @@ describe('suspendedSinceLastTick', () => {
|
||||
expect(suspendedSinceLastTick(now + 60_000, now, 5_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('a suspension longer than the stall ceiling does not abort a healthy upload', () => {
|
||||
// The bug, end to end: 3 minutes suspended, interval resumes, no bytes since.
|
||||
const lastProgressAt = now - 180_000;
|
||||
const credited = Math.min(
|
||||
now,
|
||||
lastProgressAt + suspendedSinceLastTick(now - 185_000, now, 5_000)
|
||||
);
|
||||
expect(shouldAbortForStall(credited, now, false)).toBe(false);
|
||||
/**
|
||||
* Replays the production watchdog tick faithfully — including the MAX_SUSPEND_CREDIT_MS clamp.
|
||||
*
|
||||
* The previous version of the test below omitted that clamp, so it asserted a property the
|
||||
* shipped code does not have and could not fail. Anything checking the suspension behaviour
|
||||
* must go through here.
|
||||
*/
|
||||
function runTicks(lockMs: number, tickMs = 5_000, ticks = 3): boolean {
|
||||
const CAP = 90_000; // MAX_SUSPEND_CREDIT_MS
|
||||
let lastProgressAt = 0;
|
||||
let lastTickAt = 0;
|
||||
let creditSpent = 0;
|
||||
let clock = lockMs; // first tick lands when the page resumes
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const credit = Math.min(
|
||||
suspendedSinceLastTick(lastTickAt, clock, tickMs),
|
||||
Math.max(0, CAP - creditSpent)
|
||||
);
|
||||
creditSpent += credit;
|
||||
lastProgressAt = Math.min(clock, lastProgressAt + credit);
|
||||
lastTickAt = clock;
|
||||
if (shouldAbortForStall(lastProgressAt, clock, false)) return true;
|
||||
clock += tickMs;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
it('a pocket-length screen lock does not abort a healthy upload', () => {
|
||||
// 60s locked, then the interval resumes: fully credited, nothing aborted.
|
||||
expect(runTicks(60_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('a suspension beyond the credit cap DOES abort — the cap is the deliberate bound', () => {
|
||||
// 3 minutes locked. The cap forgives 90s, so the first tick after resume survives and the
|
||||
// next one aborts. This is intended: after a lock that long the socket is almost certainly
|
||||
// reaped (iOS does so without firing `error`), and re-sending beats hanging on `xhr.timeout`
|
||||
// for 5-60 minutes while the queue's latch is held.
|
||||
//
|
||||
// It is asserted rather than merely tolerated because the cost lands on the retry budget —
|
||||
// see RETRY_BUDGET_WINDOW_MS, which is what keeps this from parking the photo for good.
|
||||
expect(runTicks(180_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('but a socket still silent 91s AFTER resume is aborted, never left to xhr.timeout', () => {
|
||||
|
||||
Reference in New Issue
Block a user