From bf1a832987caef4068a07bdd550b9648e3fb2e1d Mon Sep 17 00:00:00 2001 From: py-kalki Date: Wed, 22 Jul 2026 10:10:19 +0530 Subject: [PATCH 1/2] fix: prevent crash from webtorrent _onTorrentId arr2hex(undefined) (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webtorrent's Torrent._onTorrentId() is async. When parseTorrent() resolves to a truthy object but with infoHash === undefined (e.g. a malformed magnet surviving in a corrupted queue.json), it calls arr2hex(undefined) inside the async function, producing an unhandled promise rejection that kills the process via Node's default handler. This crash required no user action — a bad entry in the persisted queue was enough to trigger it within seconds of launch. Fix (two-layer defence): 1. engine.ts — concurrent pre-validation guard After client.add() returns a torrent object, immediately fire a detached parseTorrent(source) to race-validate the infoHash before webtorrent's own _onTorrentId can blow up. If infoHash is missing or parseTorrent rejects, the torrent is destroyed and onError() is called cleanly instead. Skips .torrent file paths (re-seed from cache) since parseTorrent cannot read local paths in this context. 2. index.tsx — unhandledRejection safety net Adds process.on('unhandledRejection', ...) that logs but does NOT exit, so any unexpected async rejection from webtorrent internals or third-party code is surfaced without killing the TUI. Fatal conditions still go through uncaughtException. Also fixes type declarations: - parse-torrent.d.ts: infoHash marked optional (string | undefined) to accurately reflect the runtime behaviour that caused the crash. - webtorrent.d.ts: add missing destroyed: boolean property on Torrent (required by the pre-validation guard's race-check). All 267 tests pass. TypeScript type-check clean. --- src/download/engine.ts | 36 +++++++++++++++++++++++++++++++++++- src/parse-torrent.d.ts | 6 +++++- src/webtorrent.d.ts | 1 + 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/download/engine.ts b/src/download/engine.ts index a8793fae..067f92ef 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -1,4 +1,5 @@ import WebTorrent, { type Torrent } from "webtorrent"; +import parseTorrent from "parse-torrent"; export interface TorrentProgress { progress: number; @@ -54,7 +55,8 @@ export class TorrentEngine { // `source` is a magnet URI, an infoHash, or a path to a .torrent file. Seeding // an existing file passes the stored .torrent path so webtorrent can verify it - // locally instead of re-fetching metadata from the swarm. + // locally instead of re-fetching metadata from the swarm (which a bare magnet + // would require). // `announce` supplements whatever trackers are already in the source URI; // webtorrent dedupes internally. add( @@ -83,6 +85,38 @@ export class TorrentEngine { } this.torrents.set(id, torrent); + // Guard against the webtorrent bug in Torrent._onTorrentId (webtorrent + // ≤2.4.x): _onTorrentId is async and calls arr2hex(parsedTorrent.infoHash) + // without checking if infoHash is defined. When parseTorrent resolves to a + // truthy object whose infoHash is undefined (e.g. malformed magnet from a + // corrupted queue file), arr2hex(undefined) throws a TypeError *inside* the + // async function, producing an unhandled promise rejection that crashes the + // process via Node's default unhandledRejection handler. + // + // We pre-validate the infoHash asynchronously: if parseTorrent cannot + // resolve it, we destroy the torrent and invoke onError before webtorrent + // can blow up. This runs concurrently with webtorrent's own _onTorrentId + // call but safely races it via the torrent.destroyed flag and the error + // listeners already attached below. + if (!source.endsWith(".torrent")) { + void parseTorrent(source) + .then((parsed) => { + if (torrent.destroyed) return; + if (!parsed?.infoHash) { + const err = `Invalid torrent source: infoHash could not be resolved (source: ${source.slice(0, 80)})`; + handlers.onError?.(err); + this.torrents.delete(id); + try { torrent.destroy(); } catch {} + } + }) + .catch((e: unknown) => { + if (torrent.destroyed) return; + handlers.onError?.(message(e)); + this.torrents.delete(id); + try { torrent.destroy(); } catch {} + }); + } + torrent.on("metadata", () => { handlers.onMetadata?.({ name: torrent.name, diff --git a/src/parse-torrent.d.ts b/src/parse-torrent.d.ts index 57623ce6..47b70358 100644 --- a/src/parse-torrent.d.ts +++ b/src/parse-torrent.d.ts @@ -1,6 +1,10 @@ declare module "parse-torrent" { interface ParsedTorrent { - infoHash: string; + // infoHash can be undefined in practice when parseTorrent resolves a truthy + // object but cannot derive a hash from the input (e.g. malformed magnet). + // Webtorrent's _onTorrentId checks `if (parsedTorrent)` but then calls + // arr2hex(parsedTorrent.infoHash) without guarding for undefined, crashing. + infoHash?: string; name?: string; } export default function parseTorrent( diff --git a/src/webtorrent.d.ts b/src/webtorrent.d.ts index b079f18d..cf3bb995 100644 --- a/src/webtorrent.d.ts +++ b/src/webtorrent.d.ts @@ -12,6 +12,7 @@ declare module "webtorrent" { magnetURI: string; torrentFile: Uint8Array; ready: boolean; + destroyed: boolean; name: string; length: number; downloaded: number; From 58f93b981828ac884daee0833abe1354687d0934 Mon Sep 17 00:00:00 2001 From: Vedansh Danot Date: Wed, 22 Jul 2026 10:33:37 +0530 Subject: [PATCH 2/2] Potential fix for pull request finding as per copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/download/engine.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/download/engine.ts b/src/download/engine.ts index 067f92ef..c7dea496 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -98,9 +98,9 @@ export class TorrentEngine { // can blow up. This runs concurrently with webtorrent's own _onTorrentId // call but safely races it via the torrent.destroyed flag and the error // listeners already attached below. - if (!source.endsWith(".torrent")) { - void parseTorrent(source) - .then((parsed) => { +const isTorrentFilePath = !source.startsWith("magnet:") && source.toLowerCase().endsWith(".torrent"); +if (!isTorrentFilePath) { + void Promise.resolve().then(() => parseTorrent(source)) if (torrent.destroyed) return; if (!parsed?.infoHash) { const err = `Invalid torrent source: infoHash could not be resolved (source: ${source.slice(0, 80)})`;