Skip to content

fix(contract): cap maxTimeout so one request cannot pin a browser - #9

Merged
unseensnick merged 1 commit into
mainfrom
loop/5-cap-max-timeout
Aug 19, 2026
Merged

fix(contract): cap maxTimeout so one request cannot pin a browser#9
unseensnick merged 1 commit into
mainfrom
loop/5-cap-max-timeout

Conversation

@unseensnick

@unseensnick unseensnick commented Aug 19, 2026

Copy link
Copy Markdown
Owner

What changed and why

maxTimeout was bounded below but not above, so a caller could ask for hours and get them. The session such a request marks in use is skipped by the reaper for its whole life, so the browser behind it could not be reclaimed either, and a few of those exhaust the host with no recovery short of a restart.

It is now clamped to MAX_TIMEOUT_MS (180000 by default) with a warning, rather than refused. Refusing would break any client already sending a larger value, which the /v1 compatibility rule does not allow.

Provenance

Filed by /audit-scan on the resources dimension, and recorded as an unfixed inherited issue in Handoff.md before that. The refutation attempt looked for an existing cap in config.py, dtos.py, and the request-boundary validators added in 1.4.0, and for any recorded intent to leave it unbounded in CLAUDE.md, the sync ledger, or the CHANGELOG. Nothing.

Two deviations from the issue, both deliberate

The issue said reject non-integers. This coerces instead. int("5000") means a numeric string works end to end today and reaches the budget arithmetic fine, so hard-rejecting one would break callers that currently work. Only a value that cannot be read as a number is refused, and it now says so instead of raising ValueError from inside the budget arithmetic several frames later. That also closes the "bad parameter types give obscure 500s" complaint in Handoff.md, for this parameter.

A boolean is refused outright. int(True) is 1, so maxTimeout: true quietly became a one millisecond budget.

Scope covered

All three sites the issue listed:

  • src/flaresolverr_service.py:117 — the lower-bound-only check, replaced by _validate_max_timeout(req)
  • src/dtos.py:38maxTimeout: int = None; covered by validating at the boundary rather than by typing the DTO, matching how session_ttl_minutes and engine are handled
  • src/flaresolverr_service.py:394 — where the value becomes the budget; now receives a bounded int rather than possibly a string

The validator sits beside _validate_session_ttl and follows its shape. It is called once in _controller_v1_handler, before command dispatch, rather than at the two per-command sites, because that is where the existing default was already applied.

Gate A, browser-free suite

161 tests, OK. 151 before, plus 10 covering the default, the zero and negative fallbacks, the value below and exactly at the ceiling, the clamp above it, a numeric string, an unreadable value, a boolean, and the zero-ceiling escape hatch.

Gate B, live tally against a same-window baseline

No-regression tally, interleaved trial for trial, change on 8291 and baseline on 8391:

Arm Engine Trials Solved Times
change chrome 3 3/3 13.6, 11.4, 11.5
baseline chrome 3 3/3 11.4, 11.4, 11.5

Then the mechanism itself, A/B'd in the same window against a host stealth cannot clear from this address, so the budget is always spent in full. The ceiling was set to 15000 on a third container so the bound is reachable in seconds instead of waiting out the 180000 default:

baseline (no ceiling)               asked 25000 -> error in 25.4s
change  (MAX_TIMEOUT_MS=15000)      asked 25000 -> error in 14.0s

The change stopped at its ceiling, the baseline ran the full ask, and the clamp warning appears in the change container's log and nowhere in the baseline's.

Gate C, the consuming chain

byparr-proxy in front of /v1 on a private network, which is the correct chain for a controller change (the passthrough is a separate implementation and none of this code runs in it).

One search: HTTP 200, 648830 bytes, 26 result rows, 11.9s, comfortably inside the ~100s an indexer waits before backing off.

The check that mattered: the chain sends maxTimeout: 120000, and the container logged zero clamp warnings. The 180000 default sits above what the real consumer asks for, so nothing in the deployed path is affected by this change.

What was not covered

  • No Prowlarr instance. The chain was driven through byparr-proxy directly with the request an indexer produces. Prowlarr adds scheduling and backoff, neither of which this change touches.
  • The 180000 default was never actually reached. Every live demonstration used a small ceiling on purpose, because waiting out three minutes per trial proves the same thing more slowly.
  • Concurrency, which is bounded a layer up rather than here. src/flaresolverr.py:175 runs waitress with its default of 4 threads, so at most about four session-less browsers exist at once, and session browsers are capped by SESSION_MAX at 20 per engine. What remains is that those four threads can each be pinned for the whole budget, so four requests can block the server for as long as maxTimeout allows. This change improves that: the worst case goes from unbounded to three minutes. See the correction comment on audit(contract): maxTimeout has no upper bound, so one request can pin a browser indefinitely #5.

Review notes

Self-reviewed rather than run through /pr-review, because this session carries a standing instruction not to spawn subagents unasked. Nothing found on re-read.

Closes #5

Nothing bounded maxTimeout above, so a caller could ask for hours and get
them. The session such a request marks in use is skipped by the reaper
for its whole life, so the browser behind it could not be reclaimed
either, and a few of those exhaust the host with no recovery short of a
restart.

- Clamps to MAX_TIMEOUT_MS (180000 by default) with a warning rather than
  refusing, so a caller already asking for more keeps working.
- The default sits above the measured worst case rather than on a round
  number: a request that legitimately succeeded in 133 seconds is on
  record, so a ceiling at or below 120000 would refuse work that
  currently completes.
- A value that cannot be read as a number now says so, instead of
  raising ValueError from inside the budget arithmetic several frames
  later. A numeric string still works, because it always has.
- A boolean is refused outright, since int(True) quietly became a one
  millisecond budget.

Closes #5
@unseensnick
unseensnick marked this pull request as ready for review August 19, 2026 07:42
@unseensnick
unseensnick merged commit 582995d into main Aug 19, 2026
1 check passed
@unseensnick
unseensnick deleted the loop/5-cap-max-timeout branch August 19, 2026 07:42
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.

audit(contract): maxTimeout has no upper bound, so one request can pin a browser indefinitely

1 participant