Skip to content

fix(api): bound request input that could exhaust memory or stall the loop - #533

Merged
thcp merged 1 commit into
0.16.1from
fix/512-unbounded-request-input
Aug 31, 2026
Merged

fix(api): bound request input that could exhaust memory or stall the loop#533
thcp merged 1 commit into
0.16.1from
fix/512-unbounded-request-input

Conversation

@thcp

@thcp thcp commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #512. Independent; branches off 0.16.1.

Four related holes, each reachable with a single unauthenticated request.

1. The trim range had no ceiling

end: float | None = Query(default=None, gt=0) -- no le=, and the only cross-check was start >= end. It reaches:

buf = np.zeros(int(round(duration * sample_rate)), dtype=np.float64)

?start=0&end=20000&count_in=1 asks for ~7 GB, plus another ~7 GB in the int16 conversion. A larger value raises MemoryError inside a blanket except, which silently shipped the export with no click rather than failing.

_validate_trim_range bounds it by the job's own duration_sec, with a six-hour backstop for a job whose duration was never recorded, and 1s of slack -- ffprobe's duration can sit a hair under the decoded length and the UI legitimately asks for the very end of a track. Wired into all three handlers that accept a range.

2. The render blocked the event loop

_click_lane was a plain def called inside async def. All of the above allocation plus a Python loop over every beat ran on the loop, so every SSE progress stream and the queue worker stalled behind it. Now asyncio.to_thread.

Buffer switched to float32: output is 16-bit PCM, so the extra mantissa was never audible, and a long export was allocating twice what it needed.

3. The click cache evicted its own render

_prune_mixdown_cache(_CLICK_CACHE_DIR) was called without keep=. A render larger than the 500 MB budget evicted itself the instant it was written, and ffmpeg was handed a missing -i. That is the #482 bug, unfixed on this path. The mixdown write had the same gap against a concurrent render's prune; both now pass keep=.

4. The body guard covered two paths and had a chunked bypass

Scoped by path suffix (/sections, /beats), leaving /api/search, /api/playlist, /api/playlist/preview, /api/settings and the JSON branch of /api/jobs uncapped. A 200 MB body to /api/search stalled every other request.

Now applied by method, exempting multipart uploads (they stream to disk under their own 400 MB limit). A chunked request with no Content-Length made declared None and skipped the check entirely -- it gets a 411 now rather than falling through to an unbounded request.body().

Verification

New tests/test_request_body_limits.py, 8 tests. Confirmed not vacuous -- reverting the guard scope and the trim bound fails 6 of 8.

ruff check       All checks passed
ruff format      94 files already formatted
pytest tests/    901 passed, 2 failed (pre-existing ogg pair)

Two review notes

_EDITOR_BODY_LIMIT was renamed to _JSON_BODY_LIMIT since it is no longer editor-specific. tests/test_jobs_api.py imported it directly and was updated.

411 is an unusual status to return. The alternative was streaming and counting the body ourselves, which is more code in a middleware that runs on every request. Given nothing in the app sends chunked JSON, refusing it outright seemed proportionate -- but if any client does, this would break it.

…loop

Four related holes, all reachable with one unauthenticated request.

The trim range had no ceiling. `end` is a float that reaches
np.zeros(int(round(duration * sample_rate))) in the click renderer, so
?start=0&end=20000&count_in=1 asked for roughly 7 GB, and another 7 GB in the
int16 conversion. A larger value raised MemoryError inside a blanket except,
which silently shipped the export with no click rather than failing.
_validate_trim_range bounds it by the job's own recorded duration, with a
six-hour backstop for a job whose duration was never recorded, and a second of
slack because ffprobe's duration can sit a hair under the decoded length.

The click render ran synchronously inside async handlers, so all of that
allocation and a Python loop over every beat blocked the event loop -- every
SSE progress stream and the queue worker stalled behind it. It goes through
asyncio.to_thread now. The buffer is float32 rather than float64: the output is
16-bit PCM, so the extra mantissa was never audible and a long export was
allocating twice what it needed.

The click cache pruned without keep=, so a render larger than the cache budget
evicted itself the instant it was written and ffmpeg was handed a missing -i.
That is the #482 bug, unfixed on this path. The mixdown write had the same gap
against a concurrent render's prune.

The body-size guard was scoped to paths ending /sections or /beats, leaving
/api/search, /api/playlist, /api/settings and the JSON branch of /api/jobs
uncapped -- Starlette buffers the whole body, then json.loads runs it on the
event loop. A 200 MB body to /api/search stalled every other request with no
valid job or prior state needed. It now applies by method, exempting multipart
uploads, which stream to disk under their own 400 MB limit. A chunked request
with no Content-Length used to skip the check entirely and fall through to an
unbounded request.body(); it gets a 411 now.

Six of the eight new tests fail against the old code.

Refs #512
@thcp
thcp marked this pull request as ready for review August 31, 2026 21:17
@thcp
thcp merged commit 3a78a8d into 0.16.1 Aug 31, 2026
8 checks passed
@thcp
thcp deleted the fix/512-unbounded-request-input branch August 31, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant