fix(upload): revalidate the per-file cap before publishing a chunk

`allowance` is computed before the body arrives, so a chunk that completed
while this one was still streaming was not counted in it. Two overlapping
0.75MB chunks under a 1MB cap were therefore both accepted, leaving 1.5MB on
disk; enough concurrent streams could go well past the cap before anyone asked
to complete the upload.

The buffered version got this right for free, because it only ever checked
after reading the whole body. The streaming version keeps its pre-read check —
that is what makes a too-large request cheap — and asks again with the current
aggregate before renaming the staging file into place.

Relates to issue 1403
This commit is contained in:
Paul Nothaft
2026-09-11 09:17:56 +02:00
parent b87ca5fba1
commit 0435840a0f
2 changed files with 30 additions and 0 deletions
@@ -192,6 +192,24 @@ describe('chunked upload streams the body under a cap (#1403)', () => {
.rejects.toMatchObject({ code: 'ENOENT' });
});
it('counts chunks that landed while another was still streaming', async () => {
const { uploadId } = await init({ totalChunks: 3 });
// Start a slow 0.75MB chunk. Its allowance is computed now, when nothing
// else is banked.
const slow = new Readable({ read() {} });
const slowDone = chunkedUpload.uploadChunk(uploadId, 0, slow);
// A second 0.75MB chunk completes in the meantime.
await chunkedUpload.uploadChunk(uploadId, 1, Buffer.alloc(0.75 * MB));
// Finishing the first must not publish: 1.5MB against a 1MB cap.
slow.push(Buffer.alloc(0.75 * MB));
slow.push(null);
await expect(slowDone).rejects.toMatchObject({ code: 'FILE_TOO_LARGE', statusCode: 413 });
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
});
it('does not destroy the request stream when it trips the cap', async () => {
const { uploadId } = await init();
const source = countingSource(8 * MB);