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', () => {
|
||||
|
||||
@@ -49,6 +49,20 @@ const MAX_QUEUE_ITEMS = 100;
|
||||
*/
|
||||
const MAX_AUTO_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Quiet time after which an item's automatic-retry budget refills.
|
||||
*
|
||||
* The cap above is a rate limiter, and a rate limiter needs a window or it is a lifetime quota.
|
||||
* 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` container restart — permanently parked every
|
||||
* in-flight photo behind a per-row button three taps deep that no guest will find.
|
||||
*
|
||||
* 10 minutes is chosen against the thing being protected: the concern is a hot loop re-sending a
|
||||
* 200 MB video over a shared uplink, and one re-send per item per 10 minutes is not that. It is
|
||||
* also comfortably longer than every outage the queue can ride out on its own.
|
||||
*/
|
||||
const RETRY_BUDGET_WINDOW_MS = 10 * 60_000;
|
||||
|
||||
/** Exponential backoff between automatic attempts: 5s, 10s, 20s, 40s, … capped below. */
|
||||
const RETRY_BASE_DELAY_MS = 5_000;
|
||||
const MAX_RETRY_DELAY_MS = 5 * 60_000;
|
||||
@@ -186,6 +200,21 @@ interface QueueEntry {
|
||||
attempts?: number;
|
||||
/** Earliest ms timestamp at which an automatic resume may re-send this item. */
|
||||
nextAttemptAt?: number;
|
||||
/** When the most recent attempt failed. Lets the budget refill after a quiet spell. */
|
||||
lastFailureAt?: number;
|
||||
/**
|
||||
* The guest stopped this transfer themselves (the ✕ on an in-flight row).
|
||||
*
|
||||
* `requeueRetriable` requeues any blob-bearing `error` item that is under budget and past its
|
||||
* backoff — and a cancel deliberately charges NO attempt and sets NO backoff, so without this
|
||||
* flag it matched on both counts and the upload restarted 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
|
||||
* said "Abgebrochen. Tippe auf „Erneut“." the whole time, on a shared venue uplink.
|
||||
*
|
||||
* Cleared by `retryItem` — an explicit tap is the guest changing their mind.
|
||||
*/
|
||||
cancelled?: boolean;
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
@@ -273,10 +302,29 @@ async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Prom
|
||||
let soonest: number | null = null;
|
||||
for (const entry of all) {
|
||||
if (entry.userId !== myUserId || entry.status !== 'error' || !entry.blob) continue;
|
||||
// A cancel is the guest's decision, not a transient failure — never undo it automatically,
|
||||
// not even on `resetAttempts` (the host reopening the event says nothing about whether
|
||||
// this guest still wants this photo sent). Only `retryItem` clears it.
|
||||
if (entry.cancelled) continue;
|
||||
if (options.resetAttempts) {
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
}
|
||||
// The budget is a RATE, not a lifetime allowance.
|
||||
//
|
||||
// Five attempts with a 5/10/20/40s ladder is ~75 seconds of failure end to end. A venue
|
||||
// AP brownout, a re-armed captive portal or a backend restart lasting two minutes — with
|
||||
// `navigator.onLine` still true the whole time, so none of it takes the offline path —
|
||||
// therefore exhausted every automatic attempt and parked the item until the guest went
|
||||
// FAB → sheet → "Warteschlange" → per-row "Erneut". Nobody does that; the photo simply
|
||||
// never arrives. Refilling after a quiet spell keeps the bound that matters (no hot
|
||||
// retry loop against a server that is genuinely down) while letting the evening recover
|
||||
// from a blip on its own.
|
||||
const lastFailureAt = entry.lastFailureAt ?? 0;
|
||||
if (now - lastFailureAt > RETRY_BUDGET_WINDOW_MS) {
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
}
|
||||
if ((entry.attempts ?? 0) >= MAX_AUTO_ATTEMPTS) continue;
|
||||
if (entry.nextAttemptAt && entry.nextAttemptAt > now) {
|
||||
// Still cooling down — remember the earliest deadline so the sweep below can
|
||||
@@ -333,6 +381,7 @@ function scheduleRetrySweep(delayMs: number): void {
|
||||
function chargeAttempt(entry: QueueEntry): boolean {
|
||||
const attempts = (entry.attempts ?? 0) + 1;
|
||||
entry.attempts = attempts;
|
||||
entry.lastFailureAt = Date.now();
|
||||
if (attempts >= MAX_AUTO_ATTEMPTS) {
|
||||
entry.nextAttemptAt = undefined;
|
||||
return true;
|
||||
@@ -761,6 +810,9 @@ export async function retryItem(id: string): Promise<void> {
|
||||
// so a guest who watched their photo fail five times can still get it sent right now.
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
// And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped
|
||||
// themselves is them changing their mind.
|
||||
entry.cancelled = false;
|
||||
await storePut(entry);
|
||||
|
||||
queueItems.update((items) =>
|
||||
@@ -1179,7 +1231,10 @@ async function uploadItem(id: string): Promise<void> {
|
||||
if (removedUploads.delete(id)) throw e;
|
||||
// The guest's own ✕. Park it retryable with the blob intact and spend no retry
|
||||
// budget — they asked for the transfer to stop, not for the photo to be dropped.
|
||||
// `cancelled` is what keeps the automatic path from immediately undoing that; see the
|
||||
// field's docstring. Without it the ✕ was purely cosmetic.
|
||||
entry.status = 'error';
|
||||
entry.cancelled = true;
|
||||
entry.error = 'Abgebrochen. Tippe auf „Erneut“.';
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', entry.error);
|
||||
|
||||
Reference in New Issue
Block a user