fix(beacon): stop the processing spinner hanging forever (#28)

The torrent beacon's processing overlay was gated on WebTorrent's metadata-ready
callback (client.add/seed), which only fires once metadata arrives from peers. A
magnet with no live seeders never resolved it, so the spinner span forever, and a
torrent that emitted its own 'error' didn't always reach the client-level handler.

- beginProcessing() arms a 30s watchdog that drops the overlay and warns ("still
  searching for peers — it'll keep trying in the background") instead of hanging;
  the torrent stays in the table and keeps announcing.
- handleTorrentReady, the client error handler, and destroy() clear the watchdog.
- addMagnet/seedFiles wrap add/seed in try/catch and attach a per-torrent 'error'
  listener so bad/duplicate magnets fail visibly.
- Tests: tests/torrent/beacon-watchdog.test.mjs (JSDOM).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 21:57:11 -04:00
1 parent 1c256808ff
commit fdcf034464
4 files changed
+222 -15

No files matched your search

+78 -8
View File
@@ -12,6 +12,12 @@ const TRACKERS = [
const TORRENT_OPTIONS = { announce: TRACKERS };
const TRACKER_OPTIONS = { announce: TRACKERS };
// A magnet with no live seeders never fires WebTorrent's metadata-ready
// callback, so the processing overlay would otherwise spin forever. After this
// long we drop the overlay and tell the user it's still trying in the
// background (the torrent stays in the table and keeps announcing).
const PROCESSING_TIMEOUT_MS = 30000;
function formatBytes(num, speed = false) {
if (typeof num !== "number" || Number.isNaN(num)) {
return "";
@@ -208,11 +214,37 @@ export function createBeaconApp({
const cleanupTasks = [];
const intervalHandles = [];
let processingTimeoutId = null;
function addCleanup(task) {
cleanupTasks.push(task);
}
function clearProcessingWatchdog() {
if (processingTimeoutId !== null) {
view.clearTimeout(processingTimeoutId);
processingTimeoutId = null;
}
}
// Show the processing overlay and arm a watchdog so it can never hang
// forever: if the operation hasn't completed in time, drop the overlay and
// warn instead of leaving a dead spinner.
function beginProcessing() {
setProcessing(true);
clearProcessingWatchdog();
processingTimeoutId = view.setTimeout(() => {
processingTimeoutId = null;
if (!state.processing) {
return;
}
setProcessing(false);
toast?.warn(
"Still searching for peers — this is taking a while. It'll keep trying in the background.",
);
}, PROCESSING_TIMEOUT_MS);
}
function setProcessing(flag) {
state.processing = Boolean(flag);
@@ -359,6 +391,7 @@ export function createBeaconApp({
}
function handleTorrentReady(torrent, { isSeed = false } = {}) {
clearProcessingWatchdog();
setProcessing(false);
prepareTorrentFiles(torrent);
@@ -380,10 +413,20 @@ export function createBeaconApp({
return;
}
setProcessing(true);
client.add(trimmed, TORRENT_OPTIONS, (torrent) => {
handleTorrentReady(torrent, { isSeed: false });
});
beginProcessing();
let torrent;
try {
torrent = client.add(trimmed, TORRENT_OPTIONS, (added) => {
handleTorrentReady(added, { isSeed: false });
});
} catch (error) {
clearProcessingWatchdog();
setProcessing(false);
userLogger.error("[beacon] Failed to add magnet", error);
toast?.error(error?.message || "Couldn't add that magnet.");
return;
}
watchTorrentForFailure(torrent);
}
function seedFiles(files) {
@@ -391,10 +434,35 @@ export function createBeaconApp({
return;
}
setProcessing(true);
client.seed(files, TORRENT_OPTIONS, (torrent) => {
handleTorrentReady(torrent, { isSeed: true });
toast.success(`Seeding ${torrent.files.length} file(s)`);
beginProcessing();
let torrent;
try {
torrent = client.seed(files, TORRENT_OPTIONS, (seeded) => {
handleTorrentReady(seeded, { isSeed: true });
toast.success(`Seeding ${seeded.files.length} file(s)`);
});
} catch (error) {
clearProcessingWatchdog();
setProcessing(false);
userLogger.error("[beacon] Failed to seed files", error);
toast?.error(error?.message || "Couldn't seed those files.");
return;
}
watchTorrentForFailure(torrent);
}
// A torrent can emit its own 'error' (bad/duplicate magnet) without the
// client-level handler firing; surface it and drop the overlay so the add
// fails visibly instead of spinning.
function watchTorrentForFailure(torrent) {
if (!torrent || typeof torrent.once !== "function") {
return;
}
torrent.once("error", (error) => {
clearProcessingWatchdog();
setProcessing(false);
userLogger.error("[beacon] Torrent error", error);
toast?.error(error?.message || "That torrent could not be loaded.");
});
}
@@ -572,6 +640,7 @@ export function createBeaconApp({
const errorHandler = (error) => {
userLogger.error("[beacon] Torrent client error", error);
toast?.error(error?.message || String(error));
clearProcessingWatchdog();
setProcessing(false);
};
client.on("error", errorHandler);
@@ -610,6 +679,7 @@ export function createBeaconApp({
}
function destroy() {
clearProcessingWatchdog();
intervalHandles.forEach((id) => view.clearInterval(id));
intervalHandles.length = 0;