From 328087042fc930d7d69eb603006ab5ce6771ca86 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Wed, 7 Oct 2026 19:04:33 -0400 Subject: [PATCH] fix: release homer sockets when a pcap request is abandoned (#565) The pcap route awaited homer with no timeout and no tie to the client connection, then used stream.pipe(res). If the client gave up (nginx 499) before homer answered, the homer response body was piped into a closed response, pipe() detached without destroying it, and the unread body kept its socket and buffers referenced by the global undici pool forever. With homer slow, this leaked roughly one socket per abandoned request: ~1,300 open fds and +200 MB RSS per api-server worker within four hours. - abort the homer auth and pcap fetches when the response closes or after 30s - use stream.pipeline() so the homer stream is destroyed if the client leaves Co-authored-by: Claude Opus 5.5 --- lib/routes/api/recent-calls.js | 24 +++++++++++++++++++++--- lib/utils/homer-utils.js | 6 ++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/routes/api/recent-calls.js b/lib/routes/api/recent-calls.js index c9673aa..0b9367d 100644 --- a/lib/routes/api/recent-calls.js +++ b/lib/routes/api/recent-calls.js @@ -1,4 +1,5 @@ const router = require('express').Router(); +const {pipeline} = require('stream'); const sysError = require('../error'); const {DbErrorBadRequest} = require('../../utils/errors'); const {getHomerApiKey, getHomerSipTrace, getHomerPcap} = require('../../utils/homer-utils'); @@ -16,6 +17,8 @@ const { const { parseServiceProviderSid, parseAccountSid } = require('./utils'); +const HOMER_PCAP_TIMEOUT_MS = 30000; + router.get('/', async(req, res) => { const {logger, queryCdrs, queryCdrsSP} = req.app.locals; try { @@ -80,10 +83,22 @@ router.get('/:call_id', async(req, res) => { router.get('/:call_id/:method/pcap', async(req, res) => { const {logger} = req.app.locals; + + /** + * Cancel the requests to homer if the client goes away or homer is too slow. + * Otherwise an unread homer response keeps its socket open forever. + */ + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), HOMER_PCAP_TIMEOUT_MS); + res.on('close', () => { + clearTimeout(timer); + ac.abort(); + }); + try { - const token = await getHomerApiKey(logger); + const token = await getHomerApiKey(logger, ac.signal); if (!token) return res.sendStatus(400, {msg: 'getHomerApiKey: Failed to get Homer API token; check server config'}); - const stream = await getHomerPcap(logger, token, [req.params.call_id], req.params.method); + const stream = await getHomerPcap(logger, token, [req.params.call_id], req.params.method, ac.signal); if (!stream) { logger.info(`getHomerApiKey: unable to get sip traces from Homer for ${req.params.call_id}`); return res.sendStatus(404); @@ -92,7 +107,10 @@ router.get('/:call_id/:method/pcap', async(req, res) => { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename=callid-${req.params.call_id}.pcap` }); - stream.pipe(res); + /* unlike pipe(), pipeline() destroys the homer stream if the client disconnects */ + pipeline(stream, res, (err) => { + if (err) logger.info({err}, `pcap: stream for ${req.params.call_id} ended early`); + }); } catch (err) { logger.error({err}, 'getHomerApiKey error retrieving sip traces from homer'); res.sendStatus(err.statusCode || 500); diff --git a/lib/utils/homer-utils.js b/lib/utils/homer-utils.js index 27fcc82..afb8f8a 100644 --- a/lib/utils/homer-utils.js +++ b/lib/utils/homer-utils.js @@ -7,13 +7,14 @@ const { Readable } = require('stream'); const SEVEN_DAYS_IN_MS = (1000 * 3600 * 24 * 7); const HOMER_BASE_URL = process.env.HOMER_BASE_URL || 'http://127.0.0.1'; -const getHomerApiKey = async(logger) => { +const getHomerApiKey = async(logger, signal) => { if (!process.env.HOMER_BASE_URL || !process.env.HOMER_USERNAME || !process.env.HOMER_PASSWORD) { logger.debug('getHomerApiKey: Homer integration not installed'); } try { const response = await fetch(`${HOMER_BASE_URL}/api/v3/auth`, { + signal, method: 'POST', headers: { 'Content-Type': 'application/json' @@ -82,13 +83,14 @@ const getHomerSipTrace = async(logger, apiKey, callId) => { } }; -const getHomerPcap = async(logger, apiKey, callIds, method) => { +const getHomerPcap = async(logger, apiKey, callIds, method, signal) => { if (!process.env.HOMER_BASE_URL || !process.env.HOMER_USERNAME || !process.env.HOMER_PASSWORD) { logger.debug('getHomerPcap: Homer integration not installed'); } try { const now = Date.now(); const response = await fetch(`${HOMER_BASE_URL}/api/v3/export/call/messages/pcap`, { + signal, method: 'POST', headers: { 'Content-Type': 'application/json',