fix(radio): reject null-body and out-of-range mirror statuses instead of crashing

fetchPinnedRadioResponse built `new Response(stream, { status })` inside
the raw https response callback, which runs outside the surrounding
promise. A Radio Browser mirror answering 204, 205 or 304 (null-body
statuses), or any status outside 200-599, made the Response constructor
throw. Nothing caught it, so the whole dev/preview server process exited
and every layer went down, not only radio. The catalog's mirror failover
never saw the error.

Mirror the guard local-receivers.js already uses: null-body statuses get
a null body, statuses outside 200-599 are rejected, and anything thrown
in the callback rejects the promise so the existing error handling and
mirror failover run.
This commit is contained in:
Aditya committed 2026-10-13 17:04:20 +05:30
1 parent 8b8ccc9a81
commit fc48eda113
4 files changed
+104 -15

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
- A Radio Browser mirror answering 204, 205 or 304, or a status outside
200–599, no longer crashes the local dev/preview server. Null-body statuses
get an empty body; anything else that cannot become a response fails that
mirror so the next one is tried.
- Treat explicitly current, complete empty vessel coverage as a successful update, - Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots. clearing obsolete contacts while retaining stale or incomplete snapshots.
+6 -3
View File
@@ -1135,9 +1135,12 @@ CCTV catalog acquisition, source normalization and frame/media delivery now live
in separate modules. Each CCTV factory owns its catalog and health state; its in separate modules. Each CCTV factory owns its catalog and health state; its
`sourceRoot` option resolves relative source files against the application root. `sourceRoot` option resolves relative source files against the application root.
Radio Browser station normalization, restricted outbound transport and directory Radio Browser station normalization, restricted outbound transport and directory
caching are separate modules. Node-only package entries expose both provider caching are separate modules. The pinned transport gives a mirror's 204, 205 or
factories. Routes, payloads, fallback behavior and existing dev/preview hook 304 reply an empty body and fails any reply outside 200–599 without throwing
registration are preserved; browser rendering is unchanged. outside its promise, so an unusual mirror status cannot crash the server.
Node-only package entries expose both provider factories. Routes, payloads,
fallback behavior and existing dev/preview hook registration are preserved;
browser rendering is unchanged.
## Terrain, traffic, fire and bike-share provider modules ## Terrain, traffic, fire and bike-share provider modules
+29 -12
View File
@@ -1,6 +1,9 @@
import https from 'node:https'; import https from 'node:https';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { isNonGlobalIpv4 } from './stations.js'; import { isNonGlobalIpv4 } from './stations.js';
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
export function radioMirrorOrigin(value) { export function radioMirrorOrigin(value) {
const hostname = String(value ?? '') const hostname = String(value ?? '')
.toLowerCase() .toLowerCase()
@@ -124,19 +127,33 @@ export function fetchPinnedRadioResponse(url, options, addresses) {
}, },
}, },
(response) => { (response) => {
const headers = new Headers(); // Nothing thrown here may escape: this callback runs outside the
for (const [name, value] of Object.entries(response.headers)) { // promise, so an exception would be uncaught and crash the server.
if (Array.isArray(value)) try {
value.forEach((item) => headers.append(name, item)); const status = response.statusCode;
else if (value !== undefined) headers.set(name, String(value)); // A `Response` only represents statuses up to 599; Node never hands
// this callback a 1xx.
if (status > 599)
throw new Error(
`Radio Browser returned unsupported status ${status}`,
);
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers)) {
if (Array.isArray(value))
value.forEach((item) => headers.append(name, item));
else if (value !== undefined) headers.set(name, String(value));
}
const nullBody = NULL_BODY_STATUSES.has(status);
const upstream = new Response(
nullBody ? null : Readable.toWeb(response),
{ status, statusText: response.statusMessage || '', headers },
);
if (nullBody) response.resume();
resolve(upstream);
} catch (error) {
response.destroy();
reject(error);
} }
resolve(
new Response(Readable.toWeb(response), {
status: response.statusCode || 500,
statusText: response.statusMessage || '',
headers,
}),
);
}, },
); );
request.on('error', reject); request.on('error', reject);
+64
View File
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import https from 'node:https';
import { Readable } from 'node:stream';
import test from 'node:test'; import test from 'node:test';
import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js';
import { import {
createRadioProxyMiddleware, createRadioProxyMiddleware,
isPublicRadioAddress, isPublicRadioAddress,
@@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor
} }
assert.equal(fetchCount, 0); assert.equal(fetchCount, 0);
}); });
// Answer every pinned request with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of leaving the fetch pending.
function mockMirrorStatus(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'application/json' },
}),
),
);
return request;
});
return crashed;
}
function fetchMirror() {
return fetchPinnedRadioResponse(
new URL('https://de1.api.radio-browser.info/json/servers'),
{ headers: {} },
[{ address: '93.184.216.34', family: 4 }],
);
}
for (const status of [204, 205, 304]) {
test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
const response = await Promise.race([fetchMirror(), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
await assert.rejects(
Promise.race([fetchMirror(), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});