fix(gallery): a missing thumbnail tier must not take the backend down (#1128)

The first load of a gallery whose ?w= tiers do not exist yet could exit the Node
process — not 500 one tile, kill the backend. Two defects stacked.

The reader: LocalFsStorage.get() returns a lazy fs.createReadStream, so an ENOENT
arrives after the await returned and outside the route's try/catch. An unhandled
'error' event is a process-level throw. pipeStreamToResponse attaches the handler
the routes were missing — 404 for a vanished source, connection destroyed if
bytes are already on the wire, file headers cleared so the JSON error is not
served as image/jpeg or cached as a broken tile for an hour. Applied to all nine
streaming responses in gallery.js.

The writer: ensureThumbnailAtWidth passed regenerate:true, whose first act is to
DELETE the target — on a path only reached when the tier is absent. A grid fires
one request per tile, so one request unlinked the file another had just published
and handed to a reader. Without the flag the write is an atomic rename.

Generation is now also deduped per tier key: 8 concurrent requests ran 5 Sharp
passes before, 1 after.

Reported with a full diagnosis by @BraynArts.
This commit is contained in:
Paul Nothaft
2026-08-22 21:37:15 +02:00
committed by GitHub
parent e243a88410
commit f735d26422
5 changed files with 393 additions and 30 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* Pipe a file/storage stream to an Express response without betting the
* process on the source still being there (#1128).
*
* `fs.createReadStream` — what LocalFsStorage.get() returns — is LAZY. It
* resolves immediately and only opens the file on a later tick, so an ENOENT
* arrives AFTER the `await` returned and outside the route's try/catch. An
* EventEmitter that emits 'error' with no listener throws, and an uncaught
* throw from an I/O callback is not something Express can catch: Node exits.
*
* That is how one missing thumbnail tier took down every gallery on the
* install — the process died on the first grid load and only came back
* because Docker restarted it.
*
* The window is real and cannot be closed by a stat() beforehand: between the
* stat and the open, another request regenerating the same derivative can
* unlink it. So the handler is the fix, not the preflight.
*/
const logger = require('./logger');
/**
* @param {import('stream').Readable} stream source, already opened or lazy
* @param {import('express').Response} res
* @param {object} [options]
* @param {string} [options.context] what was being served, for the log line
* @param {number} [options.missingStatus=404] status when the source is gone
*/
function pipeStreamToResponse(stream, res, options = {}) {
const { context = 'file', missingStatus = 404 } = options;
stream.on('error', (err) => {
const gone = err && (err.code === 'ENOENT' || err.code === 'EISDIR');
// Once bytes are on the wire the status line is spent — there is no way to
// turn this into a 404. Destroy the response so the client sees a broken
// connection rather than a silently truncated image it would cache.
if (res.headersSent) {
logger.warn(`Stream failed mid-response for ${context}: ${err.message}`);
res.destroy(err);
return;
}
// Every header staged for the FILE now describes a body that will never
// be sent. They are cleared rather than left to Express, which does not
// overwrite a Content-Type that is already set — so without this the JSON
// error goes out as `image/jpeg`, or as an `application/zip` attachment
// that saves to disk as a corrupt download.
//
// Cache-Control matters most. The image routes stage `max-age=1800` (the
// hero route 3600), so a 404 from the regeneration race — the transient
// case this whole helper exists for — would be cached as a broken tile for
// up to an hour after the tier finished generating.
res.removeHeader('Content-Length');
res.removeHeader('ETag');
res.removeHeader('Content-Type');
res.removeHeader('Content-Disposition');
res.setHeader('Cache-Control', 'no-store');
if (gone) {
// Expected under the regeneration race — the tier existed at stat time
// and was replaced before the open. One broken tile, not an outage.
logger.warn(`Source vanished while serving ${context}: ${err.message}`);
res.status(missingStatus).json({ error: 'File not found' });
return;
}
logger.error(`Failed to stream ${context}`, { error: err.message, code: err.code });
res.status(500).json({ error: 'Failed to serve file' });
});
// A client that navigates away mid-download leaves the source handle open
// otherwise; on a gallery grid that is one leaked fd per abandoned tile.
res.on('close', () => {
if (!res.writableEnded) stream.destroy();
});
stream.pipe(res);
}
module.exports = { pipeStreamToResponse };