fix(upload): retire the connection after an early refusal, clean up a failed publish
Two more from review. Refusing a body before reading it is the point of the streaming cap, but the unread bytes are still in flight on a connection the response advertises as keep-alive. Node does not drain them, so the NEXT request on that socket hangs until it times out — reproducible with an 8MB body against a 1MB cap. The error response now sets Connection: close whenever the request was not read to the end. A failed rename — ENOSPC, a vanished directory — left the fully written staging file behind. Staging names are per-attempt, so a client that retries instead of aborting accumulates one per try until the upload expires. The partial is removed on that path too. Relates to issue 1403
This commit is contained in:
@@ -210,6 +210,21 @@ describe('chunked upload streams the body under a cap (#1403)', () => {
|
||||
expect(chunkedUpload.getUploadStatus(uploadId)).toBeNull();
|
||||
});
|
||||
|
||||
it('removes the staging file when publishing it fails', async () => {
|
||||
const { uploadId } = await init();
|
||||
const dir = path.join(process.env.STORAGE_PATH, 'chunks', uploadId);
|
||||
// Make the rename fail by putting a directory where the chunk goes.
|
||||
await fs.mkdir(path.join(dir, 'chunk_000000'), { recursive: true });
|
||||
|
||||
await expect(chunkedUpload.uploadChunk(uploadId, 0, countingSource(1024)))
|
||||
.rejects.toThrow();
|
||||
|
||||
// The fully written .part must not survive a failed publish — its name is
|
||||
// per-attempt, so retries would otherwise pile them up until expiry.
|
||||
const leftovers = (await fs.readdir(dir)).filter((f) => f.endsWith('.part'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not destroy the request stream when it trips the cap', async () => {
|
||||
const { uploadId } = await init();
|
||||
const source = countingSource(8 * MB);
|
||||
|
||||
@@ -1718,6 +1718,13 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
// large) carry their own status. Only a genuinely unexpected error should
|
||||
// reach the 500 below and the error log with it.
|
||||
if (error.statusCode) {
|
||||
// Refusing the body early is the point — but it leaves unread bytes in
|
||||
// flight on a connection this response still advertises as keep-alive.
|
||||
// Node does not drain them, so the NEXT request on that socket hangs
|
||||
// until it times out. Retire the connection instead.
|
||||
if (!req.readableEnded) {
|
||||
res.set('Connection', 'close');
|
||||
}
|
||||
return res.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
logger.error('Error uploading chunk:', error);
|
||||
|
||||
@@ -289,7 +289,14 @@ async function uploadChunk(uploadId, chunkIndex, source, { declaredBytes } = {})
|
||||
await abortUpload(uploadId);
|
||||
throw fileTooLargeError(uploadMeta.maxFileSizeBytes);
|
||||
}
|
||||
await fs.rename(partPath, chunkPath);
|
||||
await fs.rename(partPath, chunkPath).catch(async (renameErr) => {
|
||||
// A failed publish (ENOSPC, a vanished directory) left the fully
|
||||
// written staging file behind. Its name is per-attempt, so a client
|
||||
// that retries instead of aborting just accumulates more of them until
|
||||
// the upload expires.
|
||||
await fs.rm(partPath, { force: true }).catch(() => {});
|
||||
throw renameErr;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.overAllowance) {
|
||||
await abortUpload(uploadId);
|
||||
|
||||
Reference in New Issue
Block a user