playback: stop flooding the Soulseek server from the album fill
Clicking rapidly through a record's tracks kept dropping the server session and arming its reconnect throttle — a fresh login died inside ~90 seconds of a click-storm. Cached plays themselves were innocent (play_stream's cached branch never touches the network, preloads cache-check first); the traffic was the album FILL, which every play restarted immediately and which fires a server search for every remaining track the source memory does not know. Three protections, layered: The fill now starts only after the queue has sat still for four seconds, and a new play cancels the pending start — stress-clicking sends nothing at all, while normal listening fills the album four seconds into a track, hours before it matters. Searches are globally paced: never two inside 1.5 seconds, whoever asks. A user-triggered resolve still feels instant — the first search never waits — only bursts are spread. And a failed AUTOMATIC connect arms a three-minute backoff. Every dev-reload of the page runs autoConnect, and an auto-dial during an outage only extends the server's cooldown; the user's own login button still dials whenever pressed.
This commit is contained in:
1 parent
8cd56a433e
commit
cef1363acd
1 file changed
+50
-4
+50
-4
@@ -3584,7 +3584,19 @@ function sanitizeSearchQuery(query) {
|
||||
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
||||
* a workable set at 1800ms, and the full window only runs when results
|
||||
* are genuinely sparse. */
|
||||
/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER
|
||||
* traffic, and the server drops sessions that flood it — measured
|
||||
* 2026-08-03: a fresh login was killed inside ~90 seconds while background
|
||||
* album fills re-searched on every rapid track click, and the reconnect
|
||||
* throttle then locked the account out. User-triggered resolves feel
|
||||
* instant (the first search never waits); only bursts are spread. */
|
||||
const SEARCH_MIN_GAP_MS = 1500;
|
||||
let lastSearchStartedAt = 0;
|
||||
|
||||
async function collectSources(query, maxMs, goodCount) {
|
||||
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||||
lastSearchStartedAt = performance.now();
|
||||
const token = await api.startSearch(query);
|
||||
const responses = [];
|
||||
let unlisten = null;
|
||||
@@ -4256,7 +4268,7 @@ export async function streamSong(artist, title, opts = {}) {
|
||||
tracks: null,
|
||||
pending: false,
|
||||
};
|
||||
maybeExtendAlbumQueue();
|
||||
scheduleAlbumFill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4330,7 +4342,7 @@ export async function streamSong(artist, title, opts = {}) {
|
||||
tracks: null,
|
||||
pending: false,
|
||||
};
|
||||
maybeExtendAlbumQueue();
|
||||
scheduleAlbumFill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5279,6 +5291,15 @@ export async function autoConnect() {
|
||||
} catch {
|
||||
// status unavailable — fall through to the normal connect path
|
||||
}
|
||||
// A failed automatic attempt arms a backoff: the server throttles rapid
|
||||
// reconnects into "timed out" silence, and every dev-reload of the page
|
||||
// lands here — an auto-dial during an outage only extends the cooldown
|
||||
// (paid for on 2026-08-03, twice). The USER can always dial through the
|
||||
// login modal; only the automatic path waits.
|
||||
if (performance.now() - lastAutoConnectFailAt < AUTO_CONNECT_BACKOFF_MS) {
|
||||
app.connection = "offline";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const creds = await api.getCredentials();
|
||||
if (!creds.has_password) return;
|
||||
@@ -5297,9 +5318,12 @@ export async function autoConnect() {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
lastAutoConnectFailAt = performance.now();
|
||||
app.connection = "offline";
|
||||
}
|
||||
}
|
||||
const AUTO_CONNECT_BACKOFF_MS = 3 * 60 * 1000;
|
||||
let lastAutoConnectFailAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
export async function downloadFile(username, file) {
|
||||
await api.downloadFile(username, file.path, file.size);
|
||||
@@ -5400,7 +5424,7 @@ function playQueueIndex(i) {
|
||||
.catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
maybeExtendAlbumQueue();
|
||||
scheduleAlbumFill();
|
||||
return;
|
||||
}
|
||||
player.streamTotal = 0;
|
||||
@@ -5515,7 +5539,7 @@ function advanceGaplessly() {
|
||||
loadStreamWaveform(item);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
maybeExtendAlbumQueue();
|
||||
scheduleAlbumFill();
|
||||
return;
|
||||
}
|
||||
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
|
||||
@@ -5537,6 +5561,26 @@ function advanceGaplessly() {
|
||||
// over the following minute instead of never.
|
||||
let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending }
|
||||
|
||||
/** Start the album fill only after the queue has sat still for a moment.
|
||||
*
|
||||
* The fill fires a Soulseek SEARCH for every remaining track the source
|
||||
* memory does not know, and each play used to start it immediately — so
|
||||
* stress-clicking through a record restarted it on every click and flooded
|
||||
* the server with searches (2026-08-03: a fresh login was dropped inside
|
||||
* ~90 seconds of exactly that, and the reconnect throttle followed). A new
|
||||
* play cancels the pending start, so rapid track-hopping sends NOTHING;
|
||||
* normal listening starts the fill four seconds into the track, hours
|
||||
* before it is needed. */
|
||||
const ALBUM_FILL_DELAY_MS = 4000;
|
||||
let albumFillTimer = null;
|
||||
function scheduleAlbumFill() {
|
||||
clearTimeout(albumFillTimer);
|
||||
albumFillTimer = setTimeout(() => {
|
||||
albumFillTimer = null;
|
||||
void maybeExtendAlbumQueue();
|
||||
}, ALBUM_FILL_DELAY_MS);
|
||||
}
|
||||
|
||||
async function maybeExtendAlbumQueue() {
|
||||
const ctx = autoAlbum;
|
||||
if (!ctx || ctx.pending) return;
|
||||
@@ -5829,6 +5873,8 @@ export function cycleRepeat() {
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
clearTimeout(albumFillTimer);
|
||||
albumFillTimer = null;
|
||||
player.status = "idle";
|
||||
player.track = null;
|
||||
player.position = 0;
|
||||
|
||||
Reference in new issue
Block a user