fix(gallery): bound and reclaim storage reads in the remaining zip builders
PR 1402 fixes the cached-zip builder. The same unguarded pattern — one storage read appended per photo, archiver draining one at a time, nothing destroying the rest — is still present in three other places, two of which a gallery guest reaches with no admin credentials: - routes/gallery/downloads.js, download-all and download-selected - services/downloadJobService.js, the custom-resolution job builder, which is worse in one respect: its per-photo catch skips a bad source without ever destroying the stream it had already opened, so every skipped photo leaked a socket for the life of the process An unread S3 response body holds its socket open indefinitely — the SDK arms its socket timeout on a 3s delay and clears it the moment response headers land, so a fast response never gets one — and archiver's abort() does not touch its source streams. That is the mechanism behind the incident described in PR 1402: 43 of 50 pooled sockets held with unread bytes, uploads and gallery reads starved behind them, a process restart the only way out. utils/archiveStreamGuard.js caps reads in flight at 2 and destroys whatever is still open on every exit: an error, a failed append, and — for the two guest routes — the client closing the tab mid-download, which previously left every appended-but-undrained read parked forever. Deliberately does not touch downloadZipService.js, so there is no conflict with 1402. Once that lands, its inline equivalent can move onto this helper. Local-filesystem installs are unaffected either way: they take archiver's archive.file(path) branch and open no sockets. Relates to issue 1399
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Bounded, reclaimable storage reads for archiver downloads (#1399 follow-up).
|
||||||
|
*
|
||||||
|
* archiver drains the sources it is handed one at a time, so appending a
|
||||||
|
* storage read per photo opens N and drains one. Every other read parks its
|
||||||
|
* socket holding unread bytes, and nothing reclaims them: archiver's abort()
|
||||||
|
* does not touch source streams, and the S3 SDK clears its socket timeout as
|
||||||
|
* soon as response headers land. That is the mechanism behind the incident in
|
||||||
|
* PR #1402 — 43 of 50 pooled sockets held, uploads starved, restart required.
|
||||||
|
*
|
||||||
|
* #1402 fixes the cached-zip builder. These are the guarantees the same guard
|
||||||
|
* has to give the three remaining call sites, two of which need no admin
|
||||||
|
* credentials to reach.
|
||||||
|
*/
|
||||||
|
const { Readable } = require('stream');
|
||||||
|
const { createArchiveStreamGuard } = require('../../src/utils/archiveStreamGuard');
|
||||||
|
|
||||||
|
const makeStream = () => new Readable({ read() {} });
|
||||||
|
|
||||||
|
describe('archiveStreamGuard (#1399 follow-up)', () => {
|
||||||
|
it('lets the configured number of reads run at once', async () => {
|
||||||
|
const guard = createArchiveStreamGuard({ maxInFlight: 2 });
|
||||||
|
expect(await guard.acquire()).toBe(true);
|
||||||
|
guard.track(makeStream());
|
||||||
|
expect(await guard.acquire()).toBe(true);
|
||||||
|
guard.track(makeStream());
|
||||||
|
expect(guard.openCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parks the next acquire until a read finishes', async () => {
|
||||||
|
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||||
|
await guard.acquire();
|
||||||
|
const first = guard.track(makeStream());
|
||||||
|
|
||||||
|
let resumed = false;
|
||||||
|
const pending = guard.acquire().then((ok) => { resumed = ok; });
|
||||||
|
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
expect(resumed).toBe(false); // still parked — this is the cap doing its job
|
||||||
|
|
||||||
|
first.push(null);
|
||||||
|
first.resume();
|
||||||
|
await pending;
|
||||||
|
expect(resumed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases a slot when a read errors, not just when it ends', async () => {
|
||||||
|
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||||
|
await guard.acquire();
|
||||||
|
const stream = guard.track(makeStream());
|
||||||
|
stream.on('error', () => {});
|
||||||
|
stream.destroy(new Error('socket died'));
|
||||||
|
// Without the error listener the slot would never come back and the next
|
||||||
|
// photo would park forever.
|
||||||
|
expect(await guard.acquire()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys every read still holding bytes', async () => {
|
||||||
|
const guard = createArchiveStreamGuard({ maxInFlight: 5 });
|
||||||
|
const streams = [makeStream(), makeStream(), makeStream()];
|
||||||
|
for (const s of streams) { await guard.acquire(); guard.track(s); }
|
||||||
|
expect(guard.openCount).toBe(3);
|
||||||
|
|
||||||
|
guard.destroyAll();
|
||||||
|
expect(streams.every((s) => s.destroyed)).toBe(true);
|
||||||
|
expect(guard.openCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wakes a parked acquire on destroyAll so the loop can exit', async () => {
|
||||||
|
const guard = createArchiveStreamGuard({ maxInFlight: 1 });
|
||||||
|
await guard.acquire();
|
||||||
|
guard.track(makeStream());
|
||||||
|
|
||||||
|
const pending = guard.acquire();
|
||||||
|
guard.destroyAll();
|
||||||
|
// false, so the caller breaks out instead of appending to a dead archive.
|
||||||
|
expect(await pending).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys a stream tracked after shutdown rather than leaking it', () => {
|
||||||
|
const guard = createArchiveStreamGuard();
|
||||||
|
guard.destroyAll();
|
||||||
|
const late = guard.track(makeStream());
|
||||||
|
expect(late.destroyed).toBe(true);
|
||||||
|
expect(guard.openCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates destroyAll twice — exit paths overlap', () => {
|
||||||
|
const guard = createArchiveStreamGuard();
|
||||||
|
guard.track(makeStream());
|
||||||
|
guard.destroyAll();
|
||||||
|
expect(() => guard.destroyAll()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
} = require('../../services/downloadFilenameService');
|
} = require('../../services/downloadFilenameService');
|
||||||
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
|
const { buildContentDisposition } = require('../../utils/filenameSanitizer');
|
||||||
const { getStorage } = require('../../services/storage');
|
const { getStorage } = require('../../services/storage');
|
||||||
|
const { createArchiveStreamGuard } = require('../../utils/archiveStreamGuard');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
function parseByteRange(header, size) {
|
function parseByteRange(header, size) {
|
||||||
if (!header || typeof header !== 'string' || !size) return null;
|
if (!header || typeof header !== 'string' || !size) return null;
|
||||||
@@ -401,6 +402,8 @@ async function bumpEventDownloadCounts(eventId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
// Hoisted so the catch can reclaim reads opened before the failure.
|
||||||
|
let guard = null;
|
||||||
try {
|
try {
|
||||||
// Check if downloads are allowed for this event
|
// Check if downloads are allowed for this event
|
||||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
@@ -497,6 +500,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reclaim storage reads on every exit (#1399 follow-up). A guest closing
|
||||||
|
// the tab mid-download used to leave every appended-but-undrained read
|
||||||
|
// parked on its socket for the life of the process.
|
||||||
|
guard = createArchiveStreamGuard();
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableFinished) {
|
||||||
|
guard.destroyAll();
|
||||||
|
archive.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
archive.pipe(res);
|
archive.pipe(res);
|
||||||
|
|
||||||
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
// Get watermark settings - apply if global setting OR event-level setting is enabled
|
||||||
@@ -562,8 +576,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
if (rendered) {
|
if (rendered) {
|
||||||
archive.append(rendered, { name: archiveName });
|
archive.append(rendered, { name: archiveName });
|
||||||
} else if (storageKey) {
|
} else if (storageKey) {
|
||||||
|
if (!await guard.acquire()) break;
|
||||||
const stream = await storage.get(storageKey);
|
const stream = await storage.get(storageKey);
|
||||||
archive.append(stream, { name: archiveName });
|
archive.append(guard.track(stream), { name: archiveName });
|
||||||
} else {
|
} else {
|
||||||
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
|
||||||
}
|
}
|
||||||
@@ -605,12 +620,15 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (guard) guard.destroyAll();
|
||||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download selected photos as ZIP
|
// Download selected photos as ZIP
|
||||||
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||||||
|
// Hoisted so the catch can reclaim reads opened before the failure.
|
||||||
|
let selectedGuard = null;
|
||||||
try {
|
try {
|
||||||
// Check if downloads are allowed for this event
|
// Check if downloads are allowed for this event
|
||||||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||||||
@@ -679,6 +697,17 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
// ignore double-send errors
|
// ignore double-send errors
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Same reclaim contract as download-all above (#1399 follow-up).
|
||||||
|
selectedGuard = createArchiveStreamGuard();
|
||||||
|
archive.on('error', () => selectedGuard.destroyAll());
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableFinished) {
|
||||||
|
selectedGuard.destroyAll();
|
||||||
|
archive.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
archive.pipe(res);
|
archive.pipe(res);
|
||||||
|
|
||||||
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
// Check watermark settings - apply if global setting OR event-level setting is enabled
|
||||||
@@ -723,8 +752,9 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
if (rendered) {
|
if (rendered) {
|
||||||
archive.append(rendered, { name });
|
archive.append(rendered, { name });
|
||||||
} else if (storageKey) {
|
} else if (storageKey) {
|
||||||
|
if (!await selectedGuard.acquire()) break;
|
||||||
const stream = await selectedStorage.get(storageKey);
|
const stream = await selectedStorage.get(storageKey);
|
||||||
archive.append(stream, { name });
|
archive.append(selectedGuard.track(stream), { name });
|
||||||
} else {
|
} else {
|
||||||
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
archive.file(resolvePhotoFilePath(req.event, photo), { name });
|
||||||
}
|
}
|
||||||
@@ -763,6 +793,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (selectedGuard) selectedGuard.destroyAll();
|
||||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const crypto = require('crypto');
|
|||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
|
const { createArchiveStreamGuard } = require('../utils/archiveStreamGuard');
|
||||||
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
||||||
const { renderPhotoForDownload, resolveWatermarkSettings } = require('./downloadRendition');
|
const { renderPhotoForDownload, resolveWatermarkSettings } = require('./downloadRendition');
|
||||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||||
@@ -297,8 +298,13 @@ class DownloadJobService {
|
|||||||
const output = fs.createWriteStream(tmpPath);
|
const output = fs.createWriteStream(tmpPath);
|
||||||
// level 0 — photos are already compressed, so deflate only burns CPU.
|
// level 0 — photos are already compressed, so deflate only burns CPU.
|
||||||
const archive = archiver('zip', { zlib: { level: 0 } });
|
const archive = archiver('zip', { zlib: { level: 0 } });
|
||||||
|
// Bound and reclaim the storage reads (#1399 follow-up). The per-photo
|
||||||
|
// catch below deliberately skips a bad source, but it never destroyed
|
||||||
|
// the stream it had already opened, so every skipped photo leaked a
|
||||||
|
// socket for the life of the process.
|
||||||
|
const guard = createArchiveStreamGuard();
|
||||||
output.on('close', resolve);
|
output.on('close', resolve);
|
||||||
archive.on('error', reject);
|
archive.on('error', (err) => { guard.destroyAll(); reject(err); });
|
||||||
archive.pipe(output);
|
archive.pipe(output);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -312,7 +318,8 @@ class DownloadJobService {
|
|||||||
} else {
|
} else {
|
||||||
const key = resolvePhotoStorageKey(event, photo);
|
const key = resolvePhotoStorageKey(event, photo);
|
||||||
if (key) {
|
if (key) {
|
||||||
archive.append(await storage.get(key), { name });
|
if (!await guard.acquire()) break;
|
||||||
|
archive.append(guard.track(await storage.get(key)), { name });
|
||||||
} else {
|
} else {
|
||||||
archive.file(resolvePhotoFilePath(event, photo), { name });
|
archive.file(resolvePhotoFilePath(event, photo), { name });
|
||||||
}
|
}
|
||||||
@@ -331,7 +338,7 @@ class DownloadJobService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
archive.finalize();
|
archive.finalize();
|
||||||
})().catch(reject);
|
})().catch((err) => { guard.destroyAll(); reject(err); });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (appended === 0) {
|
if (appended === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Bounded, reclaimable storage reads for archiver-based downloads.
|
||||||
|
*
|
||||||
|
* archiver consumes the sources it is handed one at a time. Appending a
|
||||||
|
* storage read per photo in a tight loop therefore opens N reads and drains
|
||||||
|
* one, and every other one parks an S3 socket holding megabytes of unread
|
||||||
|
* body. Nothing reclaims them on its own: archiver's abort() does not touch
|
||||||
|
* its source streams, and the SDK arms its socket timeout on a 3s delay and
|
||||||
|
* clears it the moment response headers land, so a fast response never gets
|
||||||
|
* one at all.
|
||||||
|
*
|
||||||
|
* That is the shape of the incident in PR #1402 — 43 of 50 pooled sockets
|
||||||
|
* ESTABLISHED with unread bytes, uploads and gallery reads starved behind
|
||||||
|
* them, a process restart the only way out. #1402 fixes the cached-zip
|
||||||
|
* builder. This is the same guard for the other three call sites, two of
|
||||||
|
* which a gallery guest can reach with no admin credentials at all.
|
||||||
|
*
|
||||||
|
* Local-filesystem installs are unaffected — they take archiver's
|
||||||
|
* `archive.file(path)` branch and open no sockets — which is most likely why
|
||||||
|
* this went unnoticed for so long.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Two in flight: one being drained, one ready to go. Enough to keep archiver
|
||||||
|
// fed, few enough that a build cannot monopolise the agent pool.
|
||||||
|
const DEFAULT_MAX_IN_FLIGHT = 2;
|
||||||
|
|
||||||
|
function createArchiveStreamGuard({ maxInFlight = DEFAULT_MAX_IN_FLIGHT } = {}) {
|
||||||
|
const openReads = new Set();
|
||||||
|
let waiter = null;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const wake = () => {
|
||||||
|
if (!waiter) return;
|
||||||
|
const resume = waiter;
|
||||||
|
waiter = null;
|
||||||
|
resume();
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = (stream) => {
|
||||||
|
openReads.delete(stream);
|
||||||
|
wake();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Park until a read slot frees up. Returns false once destroyAll ran. */
|
||||||
|
async acquire() {
|
||||||
|
while (!closed && openReads.size >= maxInFlight) {
|
||||||
|
await new Promise((resolve) => { waiter = resolve; });
|
||||||
|
}
|
||||||
|
return !closed;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Register a stream and hand it straight back, for inline use. */
|
||||||
|
track(stream) {
|
||||||
|
if (closed) {
|
||||||
|
stream.destroy();
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
openReads.add(stream);
|
||||||
|
stream.once('end', () => release(stream));
|
||||||
|
stream.once('close', () => release(stream));
|
||||||
|
stream.once('error', () => release(stream));
|
||||||
|
return stream;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy every read still holding bytes. Safe to call more than once —
|
||||||
|
* the exit paths overlap (client disconnect and an error can both fire).
|
||||||
|
*/
|
||||||
|
destroyAll() {
|
||||||
|
closed = true;
|
||||||
|
for (const stream of openReads) {
|
||||||
|
try {
|
||||||
|
stream.destroy();
|
||||||
|
} catch {
|
||||||
|
// Already gone; nothing to reclaim.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openReads.clear();
|
||||||
|
wake();
|
||||||
|
},
|
||||||
|
|
||||||
|
get openCount() {
|
||||||
|
return openReads.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createArchiveStreamGuard, DEFAULT_MAX_IN_FLIGHT };
|
||||||
Reference in New Issue
Block a user