Skip to content

Resume an interrupted install instead of re-downloading 448 MB - #22

Merged
devbadya merged 3 commits into
mainfrom
cursor/resumable-model-install-a3c4
Aug 24, 2026
Merged

Resume an interrupted install instead of re-downloading 448 MB#22
devbadya merged 3 commits into
mainfrom
cursor/resumable-model-install-a3c4

Conversation

@devbadya

@devbadya devbadya commented Aug 24, 2026

Copy link
Copy Markdown
Owner

What this is

Two things, in answer to "we don't have the right model, it's too big":

  1. The model is the right one, and 448 MB is the floor. Researched against the Hub and Transformers.js upstream, then mechanized so it stays checked rather than remembered.
  2. The 448 MB is now genuinely a one-time cost. An install that broke at 90% used to start over from zero; it now continues from what arrived.

The research

Export of Qwen3.5-0.8B q4f16 total Loads through
onnx-community/Qwen3.5-0.8B-Text-ONNX (in use) 448 MiB pipeline('text-generation')
onnx-community/Qwen3.5-0.8B-ONNX-OPT 616 MiB Qwen3_5ForConditionalGeneration + vision encoder
onnx-community/Qwen3.5-0.8B-ONNX 617 MiB Qwen3_5ForConditionalGeneration + vision encoder
  • The export in use is exactly what Transformers.js added text-only Qwen3.5 support for (#1602), and the pinned 4.2.0 supports qwen3_5_text.
  • q4f16 is the smallest of the five variants published in it: q4 526 MiB, int8 896 MiB, fp16 1.4 GiB, fp32 2.9 GiB. INT4 is as far as ONNX Runtime Web goes.
  • Every other Qwen3.5-0.8B ONNX repository on the Hub is a copy of one of the three first-party ones, or larger.
  • Qwen3.5 has no size below 0.8B (the small series is 0.8B/2B/4B/9B), and a 248,320-token vocabulary tied to the output layer is why 0.8B parameters at four bits land at 448 MB rather than nearer 400 MB.

node tools/verify-model.mjs now surveys every variant through the Hub API and fails if a smaller one appears, so this does not have to be re-derived by hand.

The resume

Transformers.js reads a whole response into memory before handing it to a cache, so a put-side backend never sees a failure — at 400 MB of 448 MB there is nothing on disk. opfsCache.match therefore performs the download: into .part, retried three times from wherever it stopped, published under the real name, and only then answered with the file. Upstream shipped this for Node's filesystem cache in #1715; the browser half is still open.

Four details make it work rather than merely sound good:

  • Writes go through createSyncAccessHandle(). A FileSystemWritableFileStream discards its swap file unless it closes cleanly, which is why the old .part file was always empty after a failure.
  • The ETag is compared here, not by the server. The Hub's CDN ignores If-Range — verified by hand: a stale validator still comes back 206 with the old range, which would splice two different files together. The ETag and total size are recorded beside the partial instead, and a mismatch restarts the file.
  • A body that stops short is unfinished, not shorter. Transformers.js sizes its buffer from Content-Length and zero-pads the remainder, so publishing a truncated transfer would mean silently corrupt weights.
  • The download completes before the response is returned. Streaming the body back looked neater and was wrong: Transformers.js also calls match as an existence-and-size check and drops the body, which stranded the OPFS write lock and made the next store of that file fail with NoModificationAllowedError. Progress is therefore reported by the cache and mapped onto the library's own file names by the worker.

Also: modelCached now means the weights are present rather than "at least one of the seven files", so a half-install no longer reports itself as installed; Remove model reclaims partials, which listCachedFiles used to hide from it; and the gate offers Resume install (155 MB left) with a Discard download alternative.

Gate screen showing a partly downloaded model with a Resume install button

Verification

  • pnpm check (386 tests) and pnpm build.
  • 24 tests over the cache: resume after a drop, three failures leaving a valid resume point, a stale ETag, a host ignoring Range, a short body, one download shared by concurrent callers.
  • In headless Chrome against a range-serving host: a fresh download, a transfer killed mid-body that recovers byte for byte with Range: bytes=3145728- on the retry, and one that never finishes leaving a 3 MiB partial plus sidecar.
  • In headless Chrome against the real Hub through AutoTokenizer: the file is published to OPFS at its exact size, progress is reported, and a second load makes zero network requests.
  • node tools/verify-model.mjs against the live Hub, including the new resume preconditions and the variant survey.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

cursoragent and others added 3 commits August 24, 2026 17:25
The OPFS cache now performs the download rather than only storing it.
Transformers.js buffers a whole response before handing it to a cache, so
a put-side backend never sees a failure and the .part file was always
empty; owning the fetch in match() means the bytes that arrived stay on
disk and the next attempt continues with a Range request.

Writes go through a sync access handle, because a writable stream
discards its swap file unless it closes cleanly. The ETag and total size
are recorded beside the partial and compared here rather than with
If-Range, which the Hub's CDN ignores. A body that ends before its
declared length now fails instead of being published zero-padded.

The gate screen reads .onnx_data as the test of installed, so a run that
fetched only the small files is reported as partly downloaded with a
Resume install button, and Remove model reclaims partials too.

verify-model.mjs additionally proves the host can be resumed against and
surveys every published variant of the model, failing if a smaller one
appears: q4f16 text-only at 448 MiB is the smallest export of
Qwen3.5-0.8B that Transformers.js can load.

Co-authored-by: Sebastian <devbadya@users.noreply.github.com>
A streaming response was the wrong shape. Transformers.js also calls
match() to ask whether a file exists and how large it is, discarding the
body when it does, which left the OPFS write lock held by a reader that
would never read: the next store of the same file failed with
NoModificationAllowedError and the file went uncached.

The download now completes, publishes and only then answers, so the lock
never outlives it. One download per URL serves every caller, which is
what the existence check and the load that follows it now share, and the
transfer is retried three times from wherever it stopped before the file
is handed back to Transformers.js to fetch itself.

Since the library is given a file that is already on disk, install
progress is reported by the cache. The worker maps those URLs onto the
same file names the library reports, so the bar counts each file once.

Verified in Chrome against a range-serving host: a fresh download, a
transfer killed mid-body that recovers byte for byte, and one that never
finishes leaving a valid resume point. Also against the real Hub through
AutoTokenizer, where the second load makes no network requests.

Co-authored-by: Sebastian <devbadya@users.noreply.github.com>
Co-authored-by: Sebastian <devbadya@users.noreply.github.com>
@devbadya
devbadya marked this pull request as ready for review August 24, 2026 17:56
@devbadya
devbadya merged commit de239e9 into main Aug 24, 2026
1 check passed
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.

2 participants