Conversation
MultipartUpload.save assigned each chunk back to `data`, rebinding the source buffer to the first chunk. Part 1 was correct; from part 2 on the offset ran past the end of the truncated buffer and every remaining part uploaded zero bytes, which the CDN rejects outright. Bind the slice to `chunk`, as AsyncMultipartUpload.save already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
What was wrong
MultipartUpload.save— the sync, in-memory-bytes path inprojects/fal_client/src/fal_client/client.py— assigned each chunk back into the name holding the source buffer:After the first iteration
datais no longer the payload, it is the payload's first chunk. Part 1 is correct. From part 2 on,startis already past the end of the now-truncated buffer, so the slice is empty and every remaining part is uploaded as zero bytes.Running the shipped loop offline against a 105 MB payload (11 parts) produces
[(1, 10485760), (2, 0), (3, 0), ..., (11, 0)]— 10 MB of 105 MB. Against the real CDN the upload does not silently truncate, it fails hard:FalClientHTTPError: Content length appears to be zero - refusing to proceed.Why it was never noticed
The path is only reachable from sync
SyncClient.upload()/upload_image()when the payload is in-memory bytes larger thanMULTIPART_THRESHOLD(100 MB) and the repository isfal_v3. That is a rare combination — uploads that large normally come from a file path, and every neighbouring code path is correct:MultipartUpload.save_filere-opens the file and seeks per thread, so there is no shared name to clobber.AsyncMultipartUpload.savebinds the slice to a separatechunk.fal.toolkitmultipart providers slicefile.datainto a new local, which does not rebind the source.So the defect is confined to this one loop, and has been there since the feature was introduced in
9a29db9a(#413).The fix
Bind the slice to
chunk, exactly asAsyncMultipartUpload.savealready does. One-line behavioural change; no signature, threading, or ordering change.Part ordering is deliberately left alone —
self._partsis appended in completion order and the server sorts by part number, which was confirmed against the real CDN with parts submitted out of order. Adding sorting here would be an unrelated change.Regression test
test_multipart_save_uploads_every_part_in_fullinprojects/fal_client/tests/unit/test_client.pypatchescreate/upload_part/complete, so it never touches the network, and uses a 1 KB payload withchunk_size=100so it is instant. It asserts that every part number is present, that each part carries exactly the bytes it should, and that concatenating the parts in part-number order reproduces the payload byte for byte.I also swept the repo for the same self-rebinding shape (
X = X[...]) with a backreference regex. The only other matches are one-shot rebinds outside any loop (dropping a leading element, truncating a list, walking a cursor, formatting a timezone offset); none re-slices a source buffer per iteration.How to test
Set up and run the suite from the repo root:
Full
fal_clientunit suite on this branch:The new test fails on the unfixed code. Reverting just the one-line change and running it alone:
Index 1 is part 2 — the first part the bug empties — which is exactly the reported failure mode.
Lint, matching the
v0.3.4pin in.pre-commit-config.yaml:Live CDN verification (post-fix)
The multipart code path was exercised against the real CDN, with
chunk_sizelowered to 5 MB so a 12 MB payload produces 3 parts without transferring 100 MB. Same script, same payload, before and after the change.Before the fix,
MultipartUpload.save(the pathupload()/upload_image()take above the 100 MB threshold) failed outright, because the CDN rejects the empty second part:After the fix, the same call succeeds and the stored object is byte-identical to the source:
Note the failure mode before the fix was a hard error, not silent truncation — the CDN refuses a zero-length part rather than completing a truncated object.
Not verified here
threading,unittest.mock, and stdlib slicing, so it should be compatible, but that is reasoned rather than executed.fal_clientunit suite was run, not the wider repo suites.🤖 Generated with Claude Code