Summary
modal volume get can exit successfully while producing corrupted local files. In a real bulk download, some files contained multi-megabyte sparse/zero-filled regions and another file was silently truncated.
The corruption is consistent with an HTTP response for an 8 MiB Volume block ending early with a successful status. The Python client accepts however many bytes arrive without validating them against VolumeGetFile2Response.len. Later blocks are written at fixed 8 MiB offsets, so a short intermediate block leaves an unwritten sparse region. A short final block produces a truncated file.
Environment
- Modal client:
1.5.3
- Platform: Linux
- Command:
modal volume get
- Volume path:
rectangle-free-data-v2/rect8x8-production/solve
- Dataset: 1,024 files, approximately 104 GiB total
- The local destination directory existed before the command, so this was not caused by treating a nonexistent directory as a filename.
Reproduction
I ran:
mkdir -p /tmp/modal-repro/existing
modal volume get \
rectangle-free-data-v2 \
rect8x8-production/solve \
/tmp/modal-repro/existing
The download ran for approximately 17 minutes and exited with status 0:
✓ Finished downloading files to local!
All 1,024 filenames existed afterward, but exact-size and sparse-file validation found:
- 14 same-size files containing sparse, zero-filled regions
- 1 silently truncated file
- 15 corrupted files total (approximately 1.46% of the download)
The sparse files were:
s0111.orbits
s0243.orbits
s0289.orbits
s0301.orbits
s0421.orbits
s0488.orbits
s0504.orbits
s0521.orbits
s0548.orbits
s0608.orbits
s0637.orbits
s0902.orbits
s0968.orbits
s0975.orbits
Some representative zero runs were:
| File |
Zero-run start |
Zero-run length |
Zero-run end |
s0111.orbits |
53,477,376 |
5,242,884 |
58,720,260 (56 MiB + 4) |
s0421.orbits |
3,080,192 |
5,308,420 |
8,388,612 (8 MiB + 4) |
s0488.orbits |
78,118,912 |
5,767,172 |
83,886,084 (80 MiB + 4) |
s0975.orbits |
11,206,656 |
5,570,564 |
16,777,220 (16 MiB + 4) |
The extra four bytes are consistent with this file format's 20-byte header followed by 16-byte records. At the filesystem level, the holes themselves ended exactly at the 8 MiB boundaries.
The truncated file was:
s0792.orbits
remote size: 112,680,404 bytes
bulk-download size: 111,869,952 bytes
shortfall: 810,452 bytes
The local truncated size is 13 full 8 MiB blocks plus 2,818,048 bytes, which is also one of the partial-block cutoff offsets observed in the sparse files.
Verification that the Volume data is intact
I downloaded each of the 15 affected files again, individually, into a separate directory:
modal volume get \
rectangle-free-data-v2 \
rect8x8-production/solve/s0111.orbits \
/tmp/modal-repro/verification
The same procedure was repeated for all affected paths. Every individual download:
- completed successfully;
- had the expected remote size;
- was fully allocated rather than sparse; and
- had a different SHA-256 hash from its corrupted bulk-download copy.
This rules out corrupted stored Volume files. Since the bulk command itself exited successfully, it also rules out an interrupted download as the cause of this reproduction.
Relevant client behavior
VolumeGetFile2Response provides the number of bytes expected:
message VolumeGetFile2Response {
repeated string get_urls = 1;
uint64 size = 2;
uint64 start = 3;
uint64 len = 4; // number of bytes returned
}
However, _read_file_into_fileobj() does not use response.len for integrity validation. Each URL is downloaded independently, and a block is considered successful after any successful-status response reaches EOF:
block_start_pos = start_pos + idx * BLOCK_SIZE
async with session.get(url) as get_response:
await _raise_on_block_response_error(get_response)
async for chunk in get_response.content.iter_any():
# write bytes at block_start_pos + ...
Afterward, the client only sums the bytes it actually received:
total_size = sum(await asyncio.gather(*coros))
fileobj.seek(start_pos + total_size)
return total_size
There is no comparison between total_size and response.len. Because blocks are placed at fixed 8 MiB offsets, receiving only a prefix of an intermediate block followed by a complete later block creates exactly the sparse pattern observed here.
The streaming Volume.read_file() path likewise yields all returned block bodies without a final comparison to response.len.
Relevant source locations:
py/modal/volume.py: Volume.read_file() and _read_file_into_fileobj()
py/modal/cli/_download.py: destination files are opened directly with "wb"
modal_proto/api.proto: VolumeGetFile2Response.len
Expected behavior
The client should never report a successful download unless the exact expected number of bytes was received. A short block response should be retried or reported as an integrity error.
The CLI should also avoid publishing a partially written file under its final name. On failure, either the existing destination should remain intact or no final destination file should exist.
Suggested fixes
- Validate downloaded bytes against
VolumeGetFile2Response.len in both download paths.
- Where possible, derive and validate the expected length of every block so a short response can be retried by the existing per-block retry mechanism.
- Treat an unexpected URL count or aggregate byte count as an integrity failure.
- Cancel and await sibling block tasks when any block fails.
- Download CLI files to temporary files in the destination directory, then atomically rename them only after validation succeeds.
- Preserve an existing file until its replacement has downloaded and validated successfully.
Suggested regression tests
- An intermediate block URL returns a short
200 body and a later block succeeds.
- The final block URL returns a short
200 body.
- A chunked
200 response ends early without an HTTP-status error.
- The response contains too few block URLs for
response.len.
Volume.read_file() rejects an aggregate length smaller than response.len.
read_file_into_fileobj() rejects an aggregate length smaller than response.len.
- The CLI does not leave a corrupt file under the requested final name after failure.
--force preserves the previous valid file until the replacement succeeds.
Additional note
This demonstrates the client-side integrity failure but does not identify why the block service, proxy, or CDN returned cleanly terminated short bodies. Capturing that initiating fault would require HTTP-level telemetry such as the requested range, response headers, expected block length, received length, and request ID. Regardless of the upstream cause, the client has enough protocol information to detect and reject the corrupt result.
Version
1.5.3
App ID
No response
Summary
modal volume getcan exit successfully while producing corrupted local files. In a real bulk download, some files contained multi-megabyte sparse/zero-filled regions and another file was silently truncated.The corruption is consistent with an HTTP response for an 8 MiB Volume block ending early with a successful status. The Python client accepts however many bytes arrive without validating them against
VolumeGetFile2Response.len. Later blocks are written at fixed 8 MiB offsets, so a short intermediate block leaves an unwritten sparse region. A short final block produces a truncated file.Environment
1.5.3modal volume getrectangle-free-data-v2/rect8x8-production/solveReproduction
I ran:
The download ran for approximately 17 minutes and exited with status 0:
All 1,024 filenames existed afterward, but exact-size and sparse-file validation found:
The sparse files were:
Some representative zero runs were:
s0111.orbitss0421.orbitss0488.orbitss0975.orbitsThe extra four bytes are consistent with this file format's 20-byte header followed by 16-byte records. At the filesystem level, the holes themselves ended exactly at the 8 MiB boundaries.
The truncated file was:
The local truncated size is 13 full 8 MiB blocks plus 2,818,048 bytes, which is also one of the partial-block cutoff offsets observed in the sparse files.
Verification that the Volume data is intact
I downloaded each of the 15 affected files again, individually, into a separate directory:
The same procedure was repeated for all affected paths. Every individual download:
This rules out corrupted stored Volume files. Since the bulk command itself exited successfully, it also rules out an interrupted download as the cause of this reproduction.
Relevant client behavior
VolumeGetFile2Responseprovides the number of bytes expected:However,
_read_file_into_fileobj()does not useresponse.lenfor integrity validation. Each URL is downloaded independently, and a block is considered successful after any successful-status response reaches EOF:Afterward, the client only sums the bytes it actually received:
There is no comparison between
total_sizeandresponse.len. Because blocks are placed at fixed 8 MiB offsets, receiving only a prefix of an intermediate block followed by a complete later block creates exactly the sparse pattern observed here.The streaming
Volume.read_file()path likewise yields all returned block bodies without a final comparison toresponse.len.Relevant source locations:
py/modal/volume.py:Volume.read_file()and_read_file_into_fileobj()py/modal/cli/_download.py: destination files are opened directly with"wb"modal_proto/api.proto:VolumeGetFile2Response.lenExpected behavior
The client should never report a successful download unless the exact expected number of bytes was received. A short block response should be retried or reported as an integrity error.
The CLI should also avoid publishing a partially written file under its final name. On failure, either the existing destination should remain intact or no final destination file should exist.
Suggested fixes
VolumeGetFile2Response.lenin both download paths.Suggested regression tests
200body and a later block succeeds.200body.200response ends early without an HTTP-status error.response.len.Volume.read_file()rejects an aggregate length smaller thanresponse.len.read_file_into_fileobj()rejects an aggregate length smaller thanresponse.len.--forcepreserves the previous valid file until the replacement succeeds.Additional note
This demonstrates the client-side integrity failure but does not identify why the block service, proxy, or CDN returned cleanly terminated short bodies. Capturing that initiating fault would require HTTP-level telemetry such as the requested range, response headers, expected block length, received length, and request ID. Regardless of the upstream cause, the client has enough protocol information to detect and reject the corrupt result.
Version
1.5.3
App ID
No response