Model downloads: a rate limit is said out loud, a shared env build survives a cancel, and sizes stop disagreeing - #752
Merged
Conversation
A 429 from the Hub was indistinguishable from a broken link. It fell into
the generic `HTTP <code>` branch of the segment retry loop, spent the
whole `SEGMENT_ATTEMPTS` budget in about seven seconds of backoff, and
handed the repo to hf's own `snapshot_download` — a SLOWER download, with
nothing anywhere saying why it had become slow, and no hint that signing
in to the Hub would raise the limit.
Three halves to the fix:
- a throttle (429, or 503 WITH a `Retry-After`) is recognised as its
own case and gets its own generous allowance (`THROTTLE_ATTEMPTS`),
so waiting it out no longer aborts into the fallback;
- `Retry-After` is honoured in both permitted forms, clamped to
`THROTTLE_WAIT_MAX_S` and slept in slices so a ✕ still lands;
- the row says it. A process-global notice (one worker process serves
one download) carries the fact from the segment threads to
`fetch_with_progress`'s tick, which is the only channel to the row —
with the sign-in half only when `_hf_token()` finds no token, and
without naming Hugging Face at all on the mirror path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`envinstall.start` is single-flight per key: the first caller spawns `uv sync`, every later caller joins and polls. `_ensure_venv` and `_terminate` both cancelled on that KEY — which names the shared install, not this job's share of it — so a ✕ on a download that was merely WAITING tore the build down for every other download of that runner, each of which then failed with a cancellation it never asked for. With no pinned interpreter on the machine yet, `start` swaps in the machine-global `PYTHON_BOOTSTRAP_KEY`, so the blast radius was every runner. Ownership is now a fact rather than a guess: `envinstall.start` reports `claimed` (True only on the path that reached `_spawn`, and deliberately NOT part of `progress()`'s polled shape), `Worker.install_owned` records it beside the key, and both cancel sites are conditional on it. A joiner stops waiting, reports its own row cancelled, and leaves the install running for whoever owns it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both progress surfaces are already honest about an unknown total — no bar is drawn without one — but the DETAIL text could not tell "this download is building an environment for itself" from "this download is parked waiting for another download's build". Both read "Preparing <runner> — <stage>…", so a download blocked on someone else looked identical to one doing the work, and to one that had died. The joiner's row now names the wait, using the ownership fact from the previous commit. `_JOINED_INSTALL_DETAIL` sits beside `_QUEUED_DETAIL`, which is the same shape of answer the transcription queue already gives. No percentage: nothing here knows how far another worker's install has got, and `ModelProgress` refuses to invent one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two numbers described one download and the cards showed both at once: the catalog's hand-written `size_gb` (approximate, and it says so) beside the job row's `total`, which the worker sums from the live listing for exactly the files it is fetching. A card could read `~64 GB` next to `68 GB / 68 GB`, and the only available reading is a download overrunning its own size. The row is right; the constant is stale. The rule now lives in one module, `shared/modelSize`, rather than at each call site: once a RUNNING job for this model reports a positive byte total, that total is the size shown — otherwise the catalog figure, otherwise the em-dash the page has always used for an unmeasured model. `modelSizeHint` also says whether the number is approximate, so "~" and "About" are dropped over a measured figure rather than hedging it. `RecommendedCard` and Playground (state line, sidebar cell, Download button) call it. `liveSizeOverride` carries the same rule to the Hub search results, whose fallback figure is the Hub's own estimate rather than the catalog's (a third number, `hubSize.ts`) — that card draws `ModelProgress` in the slot right below its size, so it had the same "≈16 GB beside 17 GB / 17 GB" problem. Playground grows a `jobByModel` map built the way the rest of the page does — by job TITLE, never by re-deriving job ids in TypeScript. `catalog.py`'s numbers are untouched, and the Size sort's key still reads the Hub's figure: a sort that re-ordered the grid mid-download would be a different change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||
Review finding: the single-flight fix covered joiners and left the symmetric case broken. `envinstall.cancel` does not merely kill the installer pid — it writes `error: "the install was cancelled"` into the SHARED record every joiner is polling. So an OWNER's ✕ reached the joiners just as surely as a joiner's ✕ used to reach the owner: their next poll read the error and raised past the `_VENV_ROUNDS` loop, and a download nobody had touched died saying "the install was cancelled". Ownership is the wrong condition in both directions, so it stops being the condition. `_install_waiters` counts the bring-ups waiting on each `envinstall` key; `_hold_install` takes a share (under `_lock`, with the key that is also the token) and `_release_install` gives it back exactly once per worker however many threads call it. The install is cancelled by the LAST waiter to leave, whatever made it leave — a ✕, an eviction, `unload_all` at shutdown — which keeps the property the cancel exists for (nothing multi-GB outlives the app; an install nobody wants is not left running) without ever taking down work somebody else is waiting on. `Worker.install_owned` survives, but now words the ROW and nothing else. `_release_install(cancel=False)` covers leaving an install that is already over, built or failed: there is no installer to stop, and a genuine resolver error must reach every row verbatim rather than be overwritten or retried. That is the distinction the code has to keep, and it now comes from what happened rather than from parsing the error text. Second review finding, and the reason the first one shipped: the `shared_install` fixture's `cancel` only appended to a list, where the real one also marks the shared record done-with-an-error. That fake certified behaviour production does not have — it let a joiner sail past a cancellation that would really have killed it. It now does what the real one does, including refusing an already-`done` record, and with it the four new/changed tests fail against the previous commit for exactly the right reasons (the joiner reaches `error`, not `cancelled`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ait in time Three review findings on the throttle path, plus what the Hub's own rate-limit documentation says it sends. **We parsed the wrong header.** The Hub does not answer a rate limit with `Retry-After`. It meters request COUNTS over five-minute fixed windows in three buckets and answers a 429 with the IETF `RateLimit: "resolvers";r=0;t=N`, `t` being the exact seconds left in the window — which `huggingface_hub` has parsed since 1.2.0, meaning the FALLBACK was better informed about the wait than our own fast path. `_ratelimit_reset_s` reads it, tolerantly (quoted names, any parameter order, several buckets in one header, and the bucket whose `r=0` is the one that named the wait we care about), and anything it cannot make sense of falls through rather than raising or inventing a zero. Precedence is `RateLimit` `t=` → `Retry-After` → a computed backoff. **The bounds did not match their own rationale.** 60 attempts at a 60s ceiling is an hour of one segment sitting still, which is exactly what the ceiling says it exists to prevent. `THROTTLE_TOTAL_MAX_S` (600s, two of the Hub's windows) is now the real guarantee; the attempt count stays to bound the REQUESTS for the case the clock cannot see — a host naming a wait of a millisecond over and over. **A stated wait of zero is not a wait.** `t=0`/`Retry-After: 0` means "the window resets about now" and taken literally it turned the allowance into an immediate re-request loop against a host that had just refused us. Zero falls through to the exponential backoff, which is the floor. **The allowance now comes back on progress**, like `tries` always has. Without it the 61st 429 of a healthy multi-hour download — an hour and gigabytes after the 60th — became an ordinary fault and spent the retry budget falling back to hf. All of it lives in one `_Throttle`, because the chunk loop is not the only thing that gets throttled — and on the Hub it is not even the likely one. The metered bucket is URLs with a `/resolve/` segment; our ranged GETs go to the presigned CDN location, which has none. So the METADATA call is where a 429 realistically lands, and it used to raise straight out of `_segmented_fetch` into the fallback with none of this waiting and none of the disclosure. `_resolved_meta` is now the single funnel for every Hub metadata call — the pre-flight resolve and the mid-download re-resolve — and `_http_status`/`_http_headers` read a status and headers off both client shapes (urllib's `HTTPError`, huggingface_hub's requests-shaped one) without this stdlib-only module having to import either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review findings on the size cell, all of them about the number meaning what it says. **A row's total is one PHASE, not the download.** A single download can be several sequential fetches with a scoped total each — `torch_image.py`'s GGUF recipe pulls an allow-listed snapshot and then a quantized transformer out of a second repo — while `catalog.py` documents `size_gb` as every byte the download fetches across every repo it touches. So "prefer the live total" would have walked such a card from the whole figure down to phase one's and then to phase two's, understating the cost and changing twice, under a tooltip claiming it was "the size this download is actually fetching". The rule is now that the shown figure NEVER UNDERSTATES: a live total LARGER than the advertised one is a stale constant and the row is right; a smaller one is a phase, or a conservative constant, and either way quoting it would promise a cheaper download than the user is getting. (No curated entry hits the recipe path today — the one `_GGUF_RECIPES` row is not in any shortlist — so this is the rule being right rather than a visible bug being fixed.) **The two figures were in different units.** `size_gb` is decimal (4.62e9 → 4.6); `formatSize` is base-1024, which is what every other size on the page uses, the progress row included. Pressing Download therefore moved FLUX.2-Klein-4B-4bit's announced size from "4.6 GB" to "4.3 GB" — a 7% drop in the exact number this change exists to stop contradicting itself. The catalog figure is now converted to bytes and formatted like everything else, so the cell agrees with the row before and after. The visible cost, stated: a card reads 4.3 GB where `catalog.py` writes 4.6. The alternative was a cell in decimal beside a bar in binary, which is the same defect wearing different digits. **The Hub search cards keep the Hub's own estimate.** The override added there during the rebase broke a documented invariant: the size SORT ranks by `hubSizeBytes`, defined to match exactly what the cell shows, because "the number beside a name is the only evidence a reader has that a size sort worked". A running card showing a different figure sits visibly out of order in a size-sorted grid, so it is dropped — one measurement per column beats a more-accurate one on whichever row happens to be busy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… file
chunk = max(CHUNK_BYTES, ceil(size / MAX_CHUNKS_PER_FILE))
`Comfy-Org/MiniMax-H3` is 30 files and 471GB with single files up to
66.3GB, which planned 1,976 segments in ONE file and 14,057 across the
repo. Every segment's cursor lives in the sidecar, and the sidecar is
rewritten whole every `FLUSH_EVERY_S` — so that download serialises ~2,000
dicts a second, per file in flight, for the several hours it runs. Capped,
the same file is 500 × 133MB.
That is the whole justification: sidecar bookkeeping. It is NOT a
rate-limit measure and the comment says so out loud — the Hub meters URLs
carrying a `/resolve/` segment and our ranged GETs go to the presigned CDN
location, so chunk count consumes no quota at all. The metered cost is
about one metadata resolve per file (280 for the largest MiniMax repo,
against 3,000 per five minutes anonymously), which no chunking decision
changes.
Kept deliberately:
- `_chunks` stays a pure function of `size` — the cap is PER FILE, never
per repo, because a repo-wide budget would make one file's boundaries
depend on the file SET, and a resume after any change to that set (a
scoped download, an `allow_patterns` fetch, a repo that gained a file)
would re-plan a file whose own bytes never moved and discard its
recorded progress. The tighter bound is not worth that;
- the `SEGMENT_MIN_BYTES` short-circuit and the single-segment
no-range-support path, untouched;
- the tail fix. 500 units against `MAX_CONNECTIONS = 8` is still sixty
times more work than connections, which is the property that mattered
— unlike `_RETIRED_MAX_SEGMENTS_PER_FILE`'s 4, which was BELOW the
connection count and put a big file back on static shares.
`SIDECAR_VERSION` goes to 3. Boundaries change for every file above
500 × CHUNK_BYTES (~16.8GB), and a version-2 sidecar for such a file has
the right etag, the right size and an internally consistent layout at
every 32MB — the exact input that turns a resume into a silently wrong
blob, so only the version number can reject it.
Accepted cost, stated in the comment: at the 66.3GB extreme a failed chunk
re-fetches up to 133MB instead of 32MB. Every model in today's catalog,
and every file of the 280-file `MiniMaxAI/MiniMax-H3`, is planned exactly
as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… joined `_release_install` popped `_install_waiters` under `_lock`, then called `envinstall.cancel(key)` after releasing it. A fresh `_hold_install` for the same key — an ordinary reload landing right after a cancel — could complete its whole `envinstall.start()` + join round trip in that gap, and the departing worker's cancel would still fire, killing the pid and writing "the install was cancelled" into the record the reload had just joined. Fixed with a re-check immediately before the call: `key in _install_waiters` is true again the instant a new claim registers, so a cancel that arrives after one is a no-op. Deliberately a second, separate `with _lock:` rather than one continuous hold across the `envinstall.cancel` call (the "hold the lock" alternative) — this module never holds `_lock` over I/O (see `ready_worker`, `_claim_for_removal`), and `cancel` signals a pid and writes a file. That leaves a much smaller gap than the one being closed (a few bytecodes vs. an entire `envinstall.start` round trip), which is called out in the added comment rather than left implicit. Added a test that reproduces the exact window deterministically: it hooks `_install_waiters.pop` (the last thing `_release_install` does before dropping the lock, present in both the old and new code) to run a second thread's full `_hold_install` rejoin to completion right there, using the real lock rather than a sleep to force the ordering. Confirmed red against the unmodified source and green after, 5/5 runs each way with no flakes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
iamsdas
marked this pull request as ready for review
August 22, 2026 18:45
… sidebar origin/main's af8d109 (D428, "Playground sidebar: capability sections + four-fact model cards") rewrote the sidebar cards and stage header — pg-model-foot/pg-model-size markup, the pg-hero header, and a new four-fact definition list — replacing the structure our modelSize.ts routing had been laid over. Took upstream's JSX/markup/comments and re-applied our rule (modelSizeHint/modelSizeLabel, "never understate") at each size-naming site: the state line, the sidebar card, the Download button, and the four-fact list's new Download row (a site that didn't exist when our branch was written and was left printing a raw size_gb GB before this commit). The merge also had to restore the jobs-polling scaffolding (isBusy, the jobs state/effect, fetchJobs) that our size-hint lookups (jobForSelected, jobByModel) depend on — upstream's rewrite dropped it along with the ModelProgress bar it fed, which the size rule does not need back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A user reported downloading two models and seeing only one move — the other sat at 0. Downloads already run in parallel (a probe proved it), so this fixes the things that made a working parallel download look stuck, and makes the numbers on the page honest.
snapshot_download, which is slower — so a throttled user watched a download crawl, having been throttled and never told. Throttles now get their own allowance, separate from the fault budget, and the row reads "Hugging Face is limiting this download", with a sign-in prompt when no token is stored.RateLimit: "resolvers";r=0;t=87— the IETF scheme — notRetry-After. Parsing onlyRetry-Aftermeant guessing the wait on every real Hub throttle. Both are read now,RateLimitfirst, capped per-wait atTHROTTLE_WAIT_MAX_Sand in aggregate atTHROTTLE_TOTAL_MAX_S(600s) so a host that 429s forever still gives up rather than hanging a job indefinitely._hub_file_metaraises arequests-shapedHfHubHTTPError, noturllib.error.HTTPError, so a throttle on the metadata hop bypassed the new branch entirely — and that hop is the one the Hub actually meters (quota is counted per URL carrying a/resolve/segment; our ranged GETs go to the presigned CDN and cost nothing). Both call sites funnel through_resolved_meta, which reads either exception shape without importingrequestsinto this stdlib-only module.Worker.install_keynames a shared single-flight install, so cancelling on the key alone tore down the build every other download of that runner had joined — and before an interpreter is pinned that key is machine-global, so the blast radius was every runner. Installs are refcounted now: the last waiter out is the one that cancels, and the count is written under_lockso a cancel and a_terminatecan't interleave.catalog.pyestimate next to a row counting real listing bytes. One rule now decides, in one unit, at every site: never understate. A live total larger than the advertised figure means a stale constant and the row wins; a smaller one is either one phase of a multi-part fetch or a conservative constant, and quoting it would promise a cheaper download than the user will get.CHUNK_BYTESbecomes a floor:chunk = max(CHUNK_BYTES, ceil(size / 500)).Comfy-Org/MiniMax-H3has single files up to 66.3GB, which planned 1,976 segments in one file and 14,057 across the repo — and every segment's cursor lives in a sidecar rewritten whole every second, so that download serialised ~2,000 dicts a second per file in flight, for hours. This is a bookkeeping measure and the comment says so out loud: chunk count consumes no Hub quota. Files below ~16.8GB are chunked exactly as before;SIDECAR_VERSIONgoes to 3 because a v2 sidecar for a larger file is internally consistent and wrong, which only a version number can reject.Not changed: no download queue and no concurrency cap were added. Downloads are already concurrent; the perceived serialisation was the shared env build (fixed here), the per-process connection split, and throttling (also fixed here).
Visuals
What a segment does with an HTTP error — the throttle branch is the delta.
flowchart TD A[Segment gets an HTTP error] --> B{429, or 503<br/>with a wait header?} B -->|no| C[Count a fault<br/>backoff, retry] C --> D{Fault budget spent?} D -->|yes| E[Fall back to<br/>snapshot_download] B -->|yes| F[Say it on the job row] F --> G[Sleep in slices,<br/>separate allowance] G --> ABefore this PR the
yesbranch did not exist: a 429 took thenopath, exhausted the fault budget, and reached the fallback silently.Usage
Nothing new is user-invocable — these are behaviour changes on paths already in use.
Hugging Face is limiting this download — waiting 42s, or…— sign in to Hugging Face in Preferences → AI for a higher limitwhen no token is stored. The sign-in it points at is the existing device-code flow (D402); a 429 from aFUSED_MODEL_MIRRORhost says onlyThis download is being rate-limited, since neither the Hub nor a Hub sign-in is involved.the install was cancelled.Waiting for the <runner> environment — another download is building it…. No percentage, because nothing here knows how far another worker'suv synchas got.~on the Local tab's recommended card, which is the one surface that has always carried the hedge..fusedpartfor a file over ~16.8GB will be re-planned rather than resumed (sidecar v2 → v3).Test Plan
tests/test_ai_hub_fetch.py— throttle recognition (_is_throttled's 503-needs-a-wait-header distinction),RateLimitparsing (t=, malformed, absent → fall back toRetry-After), all fourRetry-Afterforms (delta-seconds, HTTP-date, naive-as-GMT, past-as-zero), per-wait and aggregate caps, cancel-during-a-throttle, mirror-vs-Hub wording, sign-in text only without a token, a 429 on the resolve hop, and the regression:SEGMENT_ATTEMPTS * 4consecutive 429s now complete the download (on stashed code it failed with exactlygave up at byte 0 — HTTP 429). Plus chunk-plan cases driving_chunkswith sizes as parameters — nothing allocated — covering the floor, the ceiling, the 16.8GB meeting point, and theSEGMENT_MIN_BYTESshort-circuit.tests/test_env_install.py—start()reportsclaimedon the spawn path only.tests/test_ai_runtime.py— newshared_installfixture (real_ensure_venv, fakedenvinstallwhosecancelmarks the record done-with-error like the real one, claim-once/join-after). Either side's cancel leaves the other's install alive; the joiner's row carries the waiting detail; a re-hold landing before the cancel call stops it. All were red before the fix (assert ['shared-key'] == [], and'Preparing Fake — sync…').frontend/src/apps/ai_models/shared/modelSize.test.ts— cases overliveModelTotal/modelSizeLabel/modelSizeHint/catalogSizeBytes, mutation-checked.mainin both directions (this repo's macOS baseline is not zero — see the caveats below).bun run typecheckclean andbun test src/apps/ai_models/shared/modelSize.test.tsgreen after mergingorigin/main(D428 reshaped this exact file).Known residuals, stated rather than hidden
envinstall.cancel, a new waiter can re-hold the key; a second lock hold re-checks and bails, which shrinks the window to a few bytecodes but does not remove it. Closing it properly needs a generation token at theenvinstalllayer — deliberately out of scope. Worst case is one spuriousthe install was cancelledon a fast cancel-then-retry.catalog.pywrites 4.6. The catalog's decimal GB is converted to bytes and formatted base-1024 like every other size on the page, including the progress row it sits beside. The card and its own progress bar agreeing was chosen over agreeing with the constant.jobspolling along with the progress bar it fed; the size rule needs a live pull's total, so the poll is back — gated onisBusy(runtime), so it runs only while something is actually live and clears its state otherwise. The progress bar was not resurrected./api/env/installgains a"claimed"field on the record. Additive; no client reads it.test_a_refused_body_this_cannot_frame_ends_the_connectionis a pre-existingConnectionResetErrorflake (~17 of 25 runs, a different parametrisation each time) that reproduces onmain. Not from this branch, not fixed here.🤖 Generated with Claude Code