openvibers/openvibe.live Commits

Live: files and proxied bodies go to responses through stream.pipeline (deleted live thumbnails stayed open) (#117)

OP OpenVibers committed 13 Oct 2026 · 8e5594d5dd · 6 files, +41 −7

On 2026-10-13 the Live process (up 12 h 40 min) held 36 file descriptors on **deleted** live thumbnails, some of them five times over (`/proc/<pid>/fd`, `live-thumbs/stream-2561-….jpg (deleted)`).

**Cause:** `fs.createReadStream(file).pipe(res)`. When a viewer leaves mid-transfer the response is destroyed, but `.pipe()` doesn't destroy its source. The read stream stays paused with its file open for the life of the process, and the thumbnail cleanup deletes the name, not the open file. Media had the same leak on 2026-09-15.

**Fix:** `stream.pipeline(source, res, () => {})`, which destroys the source when the response closes or fails:
- `server/media-proxy/thumbnails.js`: the live thumbnail and its `stream-<id>-live.jpg` alias;
- `server/arena/routes.js` and `server/chat/live-context-routes.js`: cached Arena voice files;
- `server/media-client.js`: a proxied Media body (`Readable.fromWeb`), which used to keep downloading into a closed response;
- `server/net/egress.js`: the forward proxy's upstream response.

`server/streaming/routes.js` already destroys its upstream on `req` close and is unchanged.

**Tests:**
- `test/stream-pipeline.test.js` checks that no file under `server/` sends `createReadStream(…)` or `fromWeb(…)` to a response with `.pipe(`, and that both thumbnail responses use `pipeline`.
- `media-privacy`, `egress`, `arena` and `arena-voice` pass.

Changes

ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex f19a7e01..467b1368 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -130,7 +130,7 @@ router.get('/voice/:user', optionalAuth, async (req, res) => {
if (who !== 'announcer' && !user) return res.status(404).json({ error: 'No such user' });
const out = await voice.speak({ user, text });
res.set({ 'Content-Type': out.mimeType, 'Cache-Control': 'public, max-age=604800, immutable', 'X-Arena-Voice': out.voice, 'X-Cache': out.cached ? 'HIT' : 'MISS' });
-- require('fs').createReadStream(out.path).pipe(res);
++ require('stream').pipeline(require('fs').createReadStream(out.path), res, () => {}); // closes the file when the listener leaves
} catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }
});
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 3c97b62c..e5e047a4 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -245,7 +245,7 @@ contextRouter.get('/tts-audio/:file', (req, res) => {
const hit = require('../arena/voice').cachedByName(req.params.file);
if (!hit) return fail(res, 404, 'No such clip');
res.set({ 'Content-Type': hit.mimeType });
-- require('fs').createReadStream(hit.path).pipe(res);
++ require('stream').pipeline(require('fs').createReadStream(hit.path), res, () => {}); // closes the file when the listener leaves
} catch (err) { fail(res, 500, err.message); }
});
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 2269cf22..6fb8b457 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -382,8 +382,9 @@ async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}
if (v) res.set(h, v);
}
if (!upstream.body) return res.end();
-- const { Readable } = require('stream');
-- Readable.fromWeb(upstream.body).pipe(res);
++ const { Readable, pipeline } = require('stream');
++ // pipeline: a viewer who leaves cancels Media's body too (.pipe() kept downloading it into a closed response).
++ pipeline(Readable.fromWeb(upstream.body), res, () => {});
}
/**
ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex 9990b548..8af8d658 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -10,6 +10,9 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
++// pipeline, never .pipe(res): a viewer who leaves mid-file destroys the response, and .pipe() left the file open for the
++// life of the process (36 deleted live thumbnails were still open after 12 h, 2026-10-13).
++const { pipeline } = require('stream');
const multer = require('multer');
const db = require('../db/database');
const { can } = require('../auth/permissions');
@@@ -134,7 +137,7 @@ router.get('/:filename', async (req, res) => {
const current = await liveThumbs.getStreamThumbnailState(Number(alias[1]));
if (current.exists) {
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': fs.statSync(current.filePath).size, 'Cache-Control': 'no-cache' });
-- return fs.createReadStream(current.filePath).pipe(res);
++ return pipeline(fs.createReadStream(current.filePath), res, () => {});
}
res.set('Cache-Control', 'no-cache');
// The fallback frame for a WebRTC stream is a Media thumbnail URL (server/index.js).
@@@ -149,7 +152,7 @@ router.get('/:filename', async (req, res) => {
'Content-Length': stat.size,
'Cache-Control': 'public, max-age=30',
});
-- return fs.createReadStream(localPath).pipe(res);
++ return pipeline(fs.createReadStream(localPath), res, () => {});
}
if (filename.startsWith('stream-')) {
// Missing live thumbnail → transparent pixel (old behavior; avoids broken cards).
ddiff --git a/server/net/egress.js b/server/net/egress.js
iindex 5aef4e1a..23e35a0c 100644
---- a/server/net/egress.js
++++ b/server/net/egress.js
@@@ -137,7 +137,7 @@ function proxy() {
delete headers['proxy-connection']; delete headers['proxy-authorization'];
const upstream = http.request(target, { method: req.method, headers, lookup: safeLookup }, (up) => {
res.writeHead(up.statusCode, up.headers);
-- up.pipe(res);
++ require('stream').pipeline(up, res, () => {}); // a client that leaves ends the upstream request too
});
upstream.on('error', (e) => { if (!res.headersSent) res.writeHead(e.code === 'EGRESS_DENIED' ? 403 : 502); res.end(); });
req.pipe(upstream);
ddiff --git a/test/stream-pipeline.test.js b/test/stream-pipeline.test.js
nnew file mode 100644
iindex 00000000..a5842726
---- /dev/null
++++ b/test/stream-pipeline.test.js
@@@ -0,0 +1,30 @@
++'use strict';
++// A file or an upstream body sent to a response goes through stream.pipeline, never .pipe(res): when the viewer leaves,
++// the response is destroyed and .pipe() does not destroy its source, so the file stayed open for the life of the process
++// (36 deleted live thumbnails still open after 12 h, 2026-10-13) and a proxied body kept downloading into nothing.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const SERVER = path.join(__dirname, '..', 'server');
++const files = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.js')) files.push(p);
++ }
++})(SERVER);
++
++const offenders = [];
++for (const f of files) {
++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
++ if (/^\s*(\/\/|\*)/.test(line)) return;
++ if (/createReadStream\([^)]*\)\s*\.pipe\(/.test(line) || /fromWeb\([^)]*\)\s*\.pipe\(/.test(line)) offenders.push(`${path.relative(SERVER, f)}:${i + 1}`);
++ });
++}
++assert.deepStrictEqual(offenders, [], `use stream.pipeline(source, res, cb): ${offenders.join(', ')}`);
++
++const thumbs = fs.readFileSync(path.join(SERVER, 'media-proxy', 'thumbnails.js'), 'utf8');
++assert.strictEqual((thumbs.match(/pipeline\(fs\.createReadStream\(/g) || []).length, 2, 'both live-thumbnail responses use pipeline');
++console.log(`stream-pipeline: ${files.length} server files checked`);