Skip to content

fix(downloader): fail a magnet the client never resolves metadata for (#2709) - #2715

Draft
vavallee wants to merge 2 commits into
mainfrom
fix/2709-transmission-no-metadata
Draft

vavallee wants to merge 2 commits into
mainfrom
fix/2709-transmission-no-metadata

Conversation

@vavallee

@vavallee vavallee commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Closes #2709

What

A magnet the client accepts and never resolves the metadata for now counts as stalled once it is older than the stall timeout: it is removed from the client, the queue row is failed with a reason that says why, and a fresh search runs. It is deliberately not blocklisted, and a client where most of the unfinished torrents look that way is left alone entirely. Both of those are review findings, written up under "Round 2" below.

GetStalledIDs becomes GetStalledTorrents and returns a StallReport rather than a map of ids. The rule, the report and the policy live in internal/downloader/nometadata.go.

Why the reporter's torrent fell through both nets

Confirmed on main. GetStalledIDs qualified a Transmission torrent on Status == 0 && ErrorString != "", and Transmission does not consider "no peers, no metadata" an error, so the string is empty. failDownloadThatNeverArrived only fires when the torrent is absent from the client; this one is present, just empty. Its doc comment did claim to cover "a magnet whose metadata never resolves", which only holds when the client dropped the add.

Round 2: the review findings

1. BLOCKING, accepted in full. A no-metadata stall no longer blocklists, and a client-wide fault is caught as one

The finding is right and I had it wrong. I originally argued that a dead magnet is "a property of the release, not of Bindery's wiring", and blocklisted on it. That reasoning only holds for one torrent in isolation. "No metadata" is a property of the network at least as much as of the release: blocked DHT or UDP, a VPN drop, a lost port forward and a firewall change all produce exactly the same shape, for every in-flight magnet at once. internal/db/blocklist.go has no expiry and IsBlocked is global, so the entries are permanent and hand-cleared. Two hours into a network fault, the previous commit would have failed and permanently blocklisted every grabbed release, then grabbed replacements that could not resolve either.

Both halves of the requested fix are in:

(a) Fail without blocklisting. StallKind.Blocklists() returns true only for StallClientReported, which is the client asserting that a specific torrent has gone wrong. That path is byte for byte what it was before this PR, blocklist included. A StallNoMetadata stall removes the torrent, fails the row and re-searches, leaving the release grabbable so a later search recovers it once the network is back. handleStalledDownload logs the skipped blocklist at Info so it is not silent.

On the retry rate: without a blocklist the re-search can pick the same release, and each fresh grab gets a new grabbed_at, so a book can round-trip about every 2 hours rather than a dozen times a day. Nothing permanent accumulates now, and #2714's six hour cooldown on re-grabbing a dead row bounds it further. I kept the re-search rather than dropping it, because it is what the reporter asked for and because on a working network it genuinely does find a different release.

(b) Batch guard. StallReport.LooksLikeClientOutage(): more than half of the client's unfinished torrents reporting no metadata, with a floor of three, means blame the client. checkStalledDownloads then logs one Warn line per client per run naming the count and the denominator, and skips that batch. The client's own per torrent signals are untouched by the guard: those are still individually meaningful during an outage.

Justifying the two numbers:

  • Share of one half. One dead magnet in a healthy queue is a bad release. Most of the queue stuck the same way at the same moment is not a run of bad luck in release selection. Half is deliberately unaggressive: a user whose grabs genuinely are mostly dead magnets still wants them failed, and everything under the line is still handled one torrent at a time.
  • Floor of three. A share is meaningless at n=1, and "one wanted book, one dead magnet" is exactly the install this feature exists for, so a pure share would disable it there. Three is the smallest count where "all of them, at once" is likelier to be one cause than three coincidences. Below the floor the damage is bounded anyway, because (a) already removed the permanent part.
  • Denominator is unfinished torrents, not all torrents. A seeding library would otherwise dilute any share to nothing. One honest limitation: a part-downloaded torrent keeps its metadata through an outage and counts in the denominator without ever being stuck, so a user with many partials and a few magnets can sit under the threshold during a real outage. They then lose a few queue rows, with no blocklist, which is the bounded outcome (a) guarantees.

2. PLAUSIBLE misuse, accepted. The unsafe half is now a separate, named field

GetStalledTorrents returns StallReport{ClientReported, NoMetadata, Incomplete, UsesTorrentID} instead of one map[string]StallKind. I chose separating the kinds over passing grab times in, for two reasons: the age gate is not the only guard the NoMetadata half needs (the breadth check is the other, and it needs the denominator the report already carries), and the downloader has no business knowing when Bindery grabbed anything. A caller now has to reach for the field called NoMetadata, whose doc comment opens with the two guards it owes, rather than iterate one map and get both. ClientReported stays safe to act on the moment it appears.

3. LOW, accepted. The zero value is unusable

StallNone.Reason() returns BUG: the stall handler was called with no stall reason, please report this, StallNone.Blocklists() is false, and handleStalledDownload rejects StallNone with a slog.Error and returns before touching the download. Covered by TestStallKind_ZeroValueIsUnusable and TestHandleStalledDownload_RejectsStallNone.

4. LOW, confirmed, documented as a known limitation

I cannot key Transmission on the hash cheaply. SendDownload stores strconv.FormatInt(torrentID, 10) in Download.TorrentID for Transmission, and RemoveDownload parses it straight back with ParseInt; the live-status poller and the importer key on the same value, and every existing row in every install holds a numeric id. Switching to hashString (which the add response does return) means a migration plus a dual-key read path across four call sites, which is its own PR and its own risk.

So, stated plainly: Transmission remote ids are session-local integers and are reassigned after a daemon restart; qBittorrent, Deluge and rTorrent are all keyed on the info hash and are not affected. The review is right that this PR widens the window: previously a mismatched id had to be stopped with an error string to matter, now it only has to be a zero-size magnet, which is more common. What a mismatch costs is one wrong row failed and one wrong torrent removed from the client, both bounded by the client's configured download dir or label filter, and with no blocklist entry now. Worth fixing properly, separately.

5. LOW, mentioned rather than guarded

A download that was legitimately slow for weeks, removed and re-added by hand today, keeps its old grabbed_at and the same hash, so the very next tick sees "grabbed weeks ago, no metadata" and fails it. Real, and narrow: it needs a hand re-add of a magnet that is still resolving at the moment the job runs.

I chose not to guard it. The clean guard is the client's own added date (addedDate, added_on, time_added, d.timestamp.started), which is four more fields across four clients and two of them not currently fetched, to cover a case whose entire cost is now one failed queue row that will be searched for again, with nothing blocklisted. A guard on Transmission alone would be worse than none, because it would read as covered.

The four points from the original brief

Age, and what the minimum safe age is

stallTimeoutDefault in internal/scheduler/scheduler.go is 120 minutes, overridden by stall.timeout_minutes when it parses to a positive integer. check-stalled runs every 5 minutes.

The age gate already existed: checkStalledDownloads drops every download whose grabbed_at is newer than now - timeout before it groups by client and polls. Minimum safe age is therefore the stall timeout itself, 2 hours by default. The argument cannot be "we can tell a dead magnet from a slow one", because you cannot; it is "we waited long enough that it does not matter". Two hours of a client failing to find one peer willing to serve metadata is not a slow magnet.

Every torrent client has the blind spot, and all four are covered

Client How a metadata-less magnet looks Old rule caught it Now
Transmission totalSize 0, percentDone 0, metadataPercentComplete 0, errorString empty; reporter saw status 0 No, needs a non-empty errorString Yes
qBittorrent state metaDL (or forcedMetaDL), size 0, progress 0 No, the rule is stalledDL only Yes
Deluge state Downloading, total_size 0, because Deluge folds libtorrent's downloading_metadata into plain Downloading No, the rule is the Error state Yes
rTorrent the <hash>.meta placeholder, d.size_bytes 0, d.message empty No, the rule needs a d.message Yes
SABnzbd, NZBGet not applicable, usenet has no magnets n/a n/a

rTorrent caveat: it sometimes does not publish the .meta placeholder in the download list at all (Client.Add already warns about that window). When it is invisible this rule cannot see it and failDownloadThatNeverArrived fires instead, which is the right outcome by a different route.

Transmission gained two requested RPC fields, metadataPercentComplete and peersConnected (RPC 14, Transmission 2.80, 2013; an older daemon omits them and they decode as 0, which the totalSize test has already established). totalSize is load-bearing. Status is deliberately not part of the test, because the reporter's torrent was status 0 without anyone having stopped it.

I did not require zero connected peers, although the issue suggests it: a magnet with peers that none of them will serve metadata for is just as dead, and requiring 0 would let that case sit for ever. The field is fetched and logged, never decided on.

What handleStalledDownload does

Confirmed on main, in order: removeStalledFromClient (delete with data), SetError, a downloadStalled history event, a blocklist entry, then, if auto grab is on, a downloadRequeued event and a re-search. That is what the reporter asked for, except the blocklist, which now applies to the client-reported kind only, for the reasons in finding 1. Removal with data stays correct for both kinds: a torrent that never resolved metadata has no data, so the removal is just the empty entry going away.

What the user sees

  • Queue row: Failed, with stalled: the download client never resolved this magnet's metadata, so it has no files and no size. 98 characters, under ERROR_SUMMARY_LEN (200), so summarizeError shows it whole with no expander. Distinct from the stalled: no peers / no download progress the client-reported kind still writes.
  • Log, one torrent: stall detected with kind=no_metadata, the reason, and blocklisted=false, plus an Info line saying the blocklist was skipped and why.
  • Log, client-wide: one Warn per client per run, stall check: most of this client's unfinished torrents have no metadata, which is a download client or network fault rather than bad releases — leaving them alone, with the count and the denominator. A silent skip here would look exactly like a working stall detector, which is the No import from Deluge #1019 failure mode.
  • History: downloadStalled, then downloadRequeued when the re-search fires.
  • Blocklist page: nothing new for this kind, by design.

No new frontend strings, so no en.json change: these are server generated and stored on the row.

Fail before evidence

Round 2 findings

Captured by restoring the previous commit's policy in place (Blocklists() returns true for every kind, LooksLikeClientOutage() returns false, the StallNone rejection removed) and leaving everything else, then restoring.

go test ./internal/downloader/ -run 'LooksLikeClientOutage|ZeroValueIsUnusable'

--- FAIL: TestStallReport_LooksLikeClientOutage (0.00s)
    --- FAIL: TestStallReport_LooksLikeClientOutage/three_dead_magnets,_all_of_the_queue (0.00s)
        nometadata_test.go:299: LooksLikeClientOutage() = false, want true
    --- FAIL: TestStallReport_LooksLikeClientOutage/four_dead_magnets_of_seven,_above_the_share (0.00s)
        nometadata_test.go:299: LooksLikeClientOutage() = false, want true
--- FAIL: TestStallKind_ZeroValueIsUnusable (0.00s)
    nometadata_test.go:314: StallNone reason must not read like a real stall, got "stalled: no peers / no download progress"
    nometadata_test.go:317: StallNone must never blocklist
    nometadata_test.go:320: a no-metadata stall must not blocklist: it says nothing about the release
FAIL

go test ./internal/scheduler/ -run 'NoMetadata|RejectsStallNone' — the outage case is the reviewer's scenario reproduced, and it shows exactly the damage described:

--- FAIL: TestCheckStalledDownloads_TransmissionNoMetadata (0.13s)
    scheduler_nometadata_test.go:229: a no-metadata stall must not blocklist the release: the blocklist is permanent and the stall says nothing about the release
--- FAIL: TestCheckStalledDownloads_NoMetadataClientOutage (0.08s)
    scheduler_nometadata_test.go:259: g-nometa-1: a client-wide fault must not fail the downloads, got status "failed"
    scheduler_nometadata_test.go:266: g-nometa-1: a client-wide fault must never blocklist anything
    scheduler_nometadata_test.go:259: g-nometa-2: a client-wide fault must not fail the downloads, got status "failed"
    scheduler_nometadata_test.go:266: g-nometa-2: a client-wide fault must never blocklist anything
    scheduler_nometadata_test.go:259: g-nometa-3: a client-wide fault must not fail the downloads, got status "failed"
    scheduler_nometadata_test.go:266: g-nometa-3: a client-wide fault must never blocklist anything
    scheduler_nometadata_test.go:259: g-nometa-4: a client-wide fault must not fail the downloads, got status "failed"
    scheduler_nometadata_test.go:266: g-nometa-4: a client-wide fault must never blocklist anything
    scheduler_nometadata_test.go:270: nothing should have been removed from the client, got [4 3 2 1]
    scheduler_nometadata_test.go:277: expected no stall history events during a client outage, got 4
--- FAIL: TestHandleStalledDownload_RejectsStallNone (0.08s)
    scheduler_nometadata_test.go:383: the zero stall kind must not fail the download, got status "failed"
FAIL

Round 1, the detection rule

Captured against main by neutralising the four predicates to return false and reverting the two added Transmission RPC fields.

go test ./internal/downloader/ -run 'NoMetadata|MetaDL'

--- FAIL: TestGetStalledTorrents_Transmission_NoMetadata (0.04s)
    nometadata_test.go:72: stopped magnet with no metadata: want StallNoMetadata, got none
    nometadata_test.go:75: downloading magnet with no metadata: want StallNoMetadata, got none
--- FAIL: TestGetStalledTorrents_Transmission_NoMetadataFieldsRequested (0.02s)
    nometadata_test.go:110: torrent-get must request "metadataPercentComplete", request was: {"arguments":{"fields":["id","hashString","name","totalSize","downloadedEver","leftUntilDone","status","errorString","rateDownload","rateUpload","eta","percentDone","downloadDir","labels"]},"method":"torrent-get"}
--- FAIL: TestGetStalledTorrents_QBittorrent_MetaDL (0.01s)
    nometadata_test.go:144: metaDL: want StallNoMetadata, got none
    nometadata_test.go:147: forcedMetaDL: want StallNoMetadata, got none
--- FAIL: TestGetStalledTorrents_Deluge_NoMetadata (0.01s)
    nometadata_test.go:195: magnet with no metadata: want StallNoMetadata, got none
--- FAIL: TestGetStalledTorrents_Rtorrent_NoMetadata (0.04s)
    nometadata_test.go:211: magnet placeholder: want StallNoMetadata, got none
FAIL
--- FAIL: TestCheckStalledDownloads_TransmissionNoMetadata (0.13s)
    scheduler_nometadata_test.go:185: download status: want "failed", got "downloading"
    scheduler_nometadata_test.go:188: error message should say the metadata never resolved, got ""
    scheduler_nometadata_test.go:192: expected the empty torrent to be removed from Transmission, got []
    scheduler_nometadata_test.go:200: expected the release to be blocklisted so the next search picks a different one
    scheduler_nometadata_test.go:208: expected 1 downloadStalled history event, got 0
FAIL

(The assertions moved between rounds: round 1's version of that test asserted the blocklist entry, round 2's asserts its absence. The line numbers above are each round's file.)

Stated plainly: TestCheckStalledDownloads_NoMetadataYoungerThanTimeout, TestCheckStalledDownloads_HealthyTransmissionDownloadUntouched and TestCheckStalledDownloads_NoMetadataBelowOutageFloor pass before their respective changes as well, and they should. They are guards against over-reach, not proofs of new behaviour: they go red if a later edit drops the age gate, widens the rule onto healthy torrents, or lets the batch guard swallow ordinary stalls.

TestGetStalledTorrents_Transmission_StoppedWithError needed its fixture fixed rather than its assertions: its four fake torrents carried no totalSize at all, so under the new rule every one of them also matched "no metadata". They now carry a size, which is what a torrent with metadata looks like.

Tests

internal/downloader/nometadata_test.go:

  • the reporter's exact Transmission shape, at both status 0 and status 4, lands in NoMetadata
  • a Transmission torrent with metadata, and one with metadata but no progress yet, land in neither half
  • a Transmission torrent with a real errorString lands in ClientReported and not in NoMetadata
  • torrent-get actually asks for totalSize, percentDone and metadataPercentComplete
  • qBittorrent metaDL and forcedMetaDL, Deluge Downloading with total_size 0, rTorrent's zero d.size_bytes placeholder
  • the outage threshold as a table: nothing stuck, one of ten, one of one, two of two (under the floor), three of three, three of six (exactly half, not above), four of seven, three of ten, and a zero denominator
  • the zero StallKind: bug-shaped reason, no blocklist, and the two real kinds' blocklist policy

internal/scheduler/scheduler_nometadata_test.go:

  • end to end: a 34 day old Transmission magnet is failed, removed and given a history event, with an error message naming the metadata, and is not blocklisted
  • end to end: a simulated client outage, four stuck magnets of five unfinished torrents, all four old enough — nothing failed, nothing removed, nothing blocklisted, no history events
  • end to end: two dead magnets, under the floor, are still failed individually
  • end to end: the same magnet 30 minutes old is untouched
  • end to end: a 34 day old torrent whose metadata resolved is untouched
  • handleStalledDownload with the zero kind does nothing

The pre-existing TestCheckStalledDownloads_QBitStalledTorrent still asserts the blocklist entry and still passes, which is the regression guard for "client-reported stalls behave exactly as before".

Verification

  • go build ./..., go vet ./..., gofmt -l clean
  • go test ./internal/downloader/... ./internal/scheduler/... ./internal/importer/... ./internal/api/... pass
  • go test -race ./internal/downloader/ ./internal/scheduler/ pass
  • GOOS=windows go build ./... and GOOS=darwin go build ./... pass

No new Go module or npm package. No frontend change.

Security

No new endpoint, no auth or settings surface, no user scoped query, nothing reading X-Forwarded-*. The one new outbound behaviour is two extra fields on an existing Transmission torrent-get. The only destructive action, removing the torrent with data, is the pre-existing removeStalledFromClient path with its guards unchanged, and the batch guard now makes it strictly harder to reach during a fault than it was before this PR.

A note for the maintainer

ccarpinteri offered to put up a PR for this and diagnosed it down to the line in GetStalledIDs before filing. I did not wait, so please credit them as you see fit; the changelog fragment thanks them for the report and the diagnosis. Note that their suggested outcome (blocklist and re-search) is the part I ended up not taking, for the reason in finding 1.

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

…#2709)

A magnet nobody serves is accepted by every torrent client and then sits
there for ever. None of the native stall signals fire: Transmission leaves
errorString empty, qBittorrent parks it in metaDL rather than stalledDL,
Deluge calls it Downloading, rTorrent keeps it as a .meta placeholder with
no message. failDownloadThatNeverArrived does not catch it either, because
the torrent is present, just empty. The reporter's had been at "downloading"
for 34 days.

GetStalledIDs becomes GetStalledTorrents and returns why, not just which,
so the queue row and the log can say what happened. Alongside each client's
own signal it now reports a torrent the client is holding with no metadata:
no total size, no progress, not complete. That shape is shared by all four
torrent clients, so all four are covered.

Age is the whole safety argument. A healthy magnet looks identical while it
resolves, so the rule is only ever applied to downloads whose grabbed_at is
older than the stall timeout (stall.timeout_minutes, default 120), which
checkStalledDownloads already enforces before it polls.

These go through handleStalledDownload unchanged: removed from the client,
failed, blocklisted, re-searched. Blocklisting is right here, unlike in
failDownloadThatNeverArrived, because a magnet with no metadata is a
property of the release rather than of Bindery's wiring.

Reported and diagnosed by ccarpinteri.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.78378% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/downloader/nometadata.go 62.50% 12 Missing ⚠️
internal/downloader/adapter.go 89.13% 5 Missing ⚠️
internal/scheduler/scheduler.go 96.77% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

…he batch

Review finding: "no metadata" is a property of the network at least as much
as of the release, and the blocklist has no expiry and is hand-cleared. A
firewall change, a VPN drop, blocked DHT or UDP, a lost port forward: every
in-flight magnet reports it at once, and two hours later the previous commit
would fail them all, blocklist them all permanently, grab replacements that
cannot resolve either, and repeat about a dozen times a day per wanted book.
The existing per torrent stall signals never had that shape.

Two changes, both needed:

- StallKind.Blocklists. Only the client's own per torrent signal blocklists,
  exactly as before #2709. A no-metadata stall fails the download, removes it
  from the client and re-searches, but leaves the release grabbable, so a
  later search recovers it once the network is back.
- StallReport.LooksLikeClientOutage. More than half of a client's unfinished
  torrents with no metadata, with a floor of three, is the client's fault:
  log once per client per run at Warn and leave that batch alone.

GetStalledTorrents now returns a StallReport instead of one map of both
kinds. The two halves have different contracts, and one map with only a
comment between them was an invitation to a future caller. The unsafe half
is a separate field that says in its doc comment which guards it needs.

StallNone is rejected by the handler and its Reason is an obvious bug
string, so a caller that forgets to set the kind cannot fail a download with
a plausible message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transmission: a magnet that never gets metadata is never failed or cleaned up

1 participant