fix(imaging): restore the decode allocation guard I removed in round 1
This is a regression I introduced, not a pre-existing gap. Before 05948d8 the
compression worker used `ImageReader::decode()`, which does:
let mut decoder = Self::make_decoder(format, self.inner, limits.clone())?;
limits.reserve(decoder.total_bytes())?; // enforces max_alloc
decoder.set_limits(limits)?;
Reading the EXIF orientation tag needs `into_decoder()` instead, and that skips
the reserve entirely — the crate's own FIXME concedes `from_decoder` doesn't
compensate. Nothing else enforces `max_alloc`: the JPEG decoder's `set_limits`
only checks support and dimensions. So the 256 MiB budget has been inert since
that commit, and round 2 then propagated the weakened path into export.rs through
the shared helper, in a commit whose message claimed the helper "carries" the
decompression-bomb cap. It didn't, and the comment saying max_alloc "hard-caps
the decode allocation" was simply false.
What was left was only the per-axis cap, which permits 12000x12000 — 412 MiB
decoded, 824 MiB for the two concurrent decodes the worker runs by default,
against a 1 GiB container. Deploy-blocking right now because bumping
DERIVATIVES_REV makes the first boot after a deploy re-decode the entire gallery
two at a time: an OOM kill there restarts the container, which re-runs the
backfill. A boot loop, on the first deploy of these fixes.
Re-add the reserve exactly as `decode()` does it. Per the budget decision it stays
at 256 MiB (~89 MP for RGB8, above any mainstream phone's real output); two
concurrent decodes now peak at 512 MiB. Oversized images take the graceful path
from round 1 — original retained, quota refunded, upload-error toast — and fail
after the header parse but BEFORE any pixels are read, so they cost a header read
rather than an allocation. Measured peak during a concurrent oversized burst: 3.0
MiB.
Test parity is the other half, and the reason this was invisible: the e2e app
container had NO memory limit while production is capped at 1 GiB, so a decode
that would OOM-kill production simply succeeded in CI. Mirror the 1 GiB cap in
docker-compose.test.yml. That is the third divergence of this shape, after WebKit
missing from CI and /health existing only in Caddyfile.test.
Tests: a fixture that is 568 KiB on disk and 283 MiB decoded (11000x9000 = 99 MP,
deliberately UNDER the per-axis cap so the axis check cannot be what rejects it).
A unit test asserts the refusal — it fails against the old code, which decoded it
into an 11000x9000 buffer — with a companion asserting an ordinary photo still
decodes AND still gets its orientation applied, so the guard didn't become a
blanket refusal. An e2e test uploads it singly and as a concurrent pair, asserting
compression lands in 'failed' and the backend is still serving and still
processing afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,15 @@ services:
|
||||
# Separate volume, exactly as in production: a keepsake archive contains every
|
||||
# photo in the event, so it is kept off the media tree.
|
||||
- exports_data:/exports
|
||||
# Mirror production's cap (docker-compose.yml). The test stack having NO memory limit is
|
||||
# why an unbounded image decode was invisible here: a 99 MP upload that would OOM-kill the
|
||||
# 1 GiB production container simply succeeded in CI. A test environment more generous than
|
||||
# production cannot catch a resource bug — the same shape as WebKit being absent from CI
|
||||
# and /health existing only in Caddyfile.test.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
expose:
|
||||
- '3000'
|
||||
|
||||
|
||||
94
e2e/specs/02-upload/oversized-image.spec.ts
Normal file
94
e2e/specs/02-upload/oversized-image.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Regression guard — an image that would blow the decode budget must be refused, not
|
||||
* allocated, and the container must survive it.
|
||||
*
|
||||
* The compression worker sets `max_alloc = 256 MiB`, but that budget was inert: reading the
|
||||
* EXIF orientation tag requires `ImageReader::into_decoder()`, which skips the
|
||||
* `limits.reserve(decoder.total_bytes())` that `decode()` performs, and nothing else enforces
|
||||
* it (the JPEG decoder's `set_limits` only checks support and dimensions). So the only real
|
||||
* bound was the 12000px per-axis cap — leaving 12000x12000 decodable at 412 MiB, and two
|
||||
* concurrent decodes at 824 MiB against a 1 GiB container.
|
||||
*
|
||||
* That mattered acutely because bumping DERIVATIVES_REV makes the first boot after a deploy
|
||||
* re-decode the whole gallery two at a time: an OOM kill there restarts the container, which
|
||||
* re-runs the backfill — a boot loop.
|
||||
*
|
||||
* This suite could never have caught it, because until now the e2e app container had NO
|
||||
* memory limit at all while production is capped at 1 GiB. The cap is mirrored in
|
||||
* docker-compose.test.yml so this test means something.
|
||||
*
|
||||
* Fixture: 11000x9000 = 99 MP, 568 KiB on disk. Deliberately UNDER the per-axis cap, so the
|
||||
* axis check cannot be what rejects it — 283 MiB decoded against a 256 MiB budget.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HUGE = join(process.cwd(), 'fixtures', 'media', 'huge-99mp.jpg');
|
||||
const SAMPLE = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||
|
||||
test.describe('Upload — an oversized image is refused, not allocated', () => {
|
||||
test('a 99 MP upload fails compression gracefully and the backend stays up', async ({
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const g = await guest('BombThrower');
|
||||
|
||||
// The upload itself is accepted — 568 KiB is well within the body cap. The rejection
|
||||
// happens in the compression worker, where the decode budget lives.
|
||||
const res = await uploadRaw(g.jwt, readFileSync(HUGE), {
|
||||
filename: 'huge.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
caption: 'zu gross',
|
||||
});
|
||||
expect(res.status, 'a 568 KiB file is a legitimate upload').toBe(201);
|
||||
const { id } = (await res.json()) as { id: string };
|
||||
|
||||
// It must land in 'failed', not 'done' — and must get there, rather than the container
|
||||
// dying mid-decode and leaving it stuck in 'processing' forever.
|
||||
await expect
|
||||
.poll(() => db.compressionStatus(id), { timeout: 60_000, intervals: [500] })
|
||||
.toBe('failed');
|
||||
|
||||
// The whole point: the process is still alive. An OOM kill would have taken the backend
|
||||
// down here, and Docker would have restarted it.
|
||||
const health = await fetch(`${BASE}/health`);
|
||||
expect(health.status, 'the backend must have survived the oversized decode').toBe(200);
|
||||
|
||||
// And it is still doing useful work afterwards — not wedged or restarting.
|
||||
const ok = await uploadRaw(g.jwt, readFileSync(SAMPLE), {
|
||||
filename: 'after.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(ok.status).toBe(201);
|
||||
const after = (await ok.json()) as { id: string };
|
||||
await expect.poll(() => db.compressionStatus(after.id), { timeout: 30_000 }).toBe('done');
|
||||
});
|
||||
|
||||
test('two oversized uploads at once still leave the container alive', async ({ guest, db }) => {
|
||||
// The concurrent case is the one that actually OOM'd: `compression_concurrency` is 2, so
|
||||
// two decodes overlap. Under the old behaviour this pair peaked near the container cap.
|
||||
test.setTimeout(90_000);
|
||||
const g = await guest('BombThrower2');
|
||||
const bytes = readFileSync(HUGE);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
uploadRaw(g.jwt, bytes, { filename: 'huge-a.jpg', contentType: 'image/jpeg' }),
|
||||
uploadRaw(g.jwt, bytes, { filename: 'huge-b.jpg', contentType: 'image/jpeg' }),
|
||||
]);
|
||||
expect([a.status, b.status]).toEqual([201, 201]);
|
||||
const ids = [((await a.json()) as { id: string }).id, ((await b.json()) as { id: string }).id];
|
||||
|
||||
for (const id of ids) {
|
||||
await expect
|
||||
.poll(() => db.compressionStatus(id), { timeout: 60_000, intervals: [500] })
|
||||
.toBe('failed');
|
||||
}
|
||||
|
||||
const health = await fetch(`${BASE}/health`);
|
||||
expect(health.status, 'two concurrent oversized decodes must not kill the backend').toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user