Skip to content

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

Merged
vavallee merged 3 commits into
mainfrom
fix/2709-transmission-no-metadata
Oct 2, 2026
Merged

vavallee merged 3 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. Bindery removes the empty entry from the client, fails the queue row with a reason that says why, and re-searches, skipping that one release so the search can pick another. It is deliberately not blocklisted, and a client where most of the unfinished torrents look that way is left alone entirely.

GetStalledIDs becomes GetStalledTorrents and returns a StallReport with the two kinds in separate fields (ClientReported, NoMetadata) plus the Incomplete denominator. The rule, the report and the policy (Reason, Blocklists, DeletesData) live in internal/downloader/nometadata.go.

Why the reporter's torrent fell through both nets

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.

Updated for current main (merge of origin/main, no rebase)

Main moved a lot under this branch. Conflicts were resolved keeping both sides, then the logic was re-checked:

Client coverage

Client How a metadata-less magnet looks Old rule Now
Transmission totalSize 0, percentDone 0, metadataPercentComplete < 1, errorString empty; reporter saw status 0 No Yes, ebook and audiobook categories
qBittorrent state metaDL or forcedMetaDL, size 0, progress 0 No, only stalledDL Yes, both categories
Deluge state Downloading, total_size 0, progress 0 No, only Error Yes
rTorrent .meta placeholder, d.size_bytes 0, not complete No, needs d.message Yes, both labels
SABnzbd, NZBGet n/a, no magnets n/a n/a

rTorrent sometimes does not list the .meta placeholder at all; then failDownloadThatNeverArrived fires instead, which is the right outcome by another route.

Peers are fetched and logged but never decided on: a magnet with peers that none of them will serve metadata for is just as dead.

Timeout

There is no separate timeout. The existing stall timeout applies: stall.timeout_minutes (default 120), and check-stalled runs every 5 minutes. checkStalledDownloads drops every download whose grabbed_at is newer than now - timeout before polling, so nothing is judged before it has had the whole window to resolve. You cannot tell a dead magnet from a slow one; the argument is that two hours without a single peer serving metadata is not a slow magnet, and anyone on a thin swarm can raise the setting.

Review findings carried from round 2

  1. No-metadata stalls do not blocklist, and a client-wide fault is caught as one. "No metadata" is a property of the network at least as much as of the release; a lost port forward or blocked DHT produces it for every magnet at once, and the blocklist has no expiry. LooksLikeClientOutage(): more than half of the client's unfinished torrents with no metadata, with a floor of three, means one Warn line per client per run and the batch is skipped. Client reported stalls are untouched by the guard.
  2. The unsafe half is a separately named field with the two guards it needs in its doc comment.
  3. The zero StallKind is unusable: bug-shaped reason, no blocklist, no data delete, and handleStalledDownload rejects it.
  4. Transmission session ids: resolved by fix(transmission): identify downloads by info hash, not the session torrent id #2711, see above.
  5. Hand re-add of an old magnet keeps its old grabbed_at and would be failed on the next tick. Narrow, and now costs one failed row with nothing blocklisted; not guarded.

What the user sees

  • Queue row: Failed, stalled: the download client never resolved this magnet's metadata, so it has no files and no size.
  • Log: stall detected with kind=no_metadata and blocklisted=false, an Info line saying the blocklist was skipped, and on a client-wide fault one Warn per client per run.
  • History: downloadStalled, then downloadRequeued when the re-search fires.

Fail before evidence

This round (merge plus the three fixes)

Captured by neutralising each change in place (stall detection polling []string{client.Category} only, DeletesData() returning true, the stalled release spec disabled), then restoring.

--- FAIL: TestGetStalledTorrents_Transmission_PollsAudiobookCategory (0.04s)
    nometadata_test.go:61: audiobook magnet with no metadata: want a NoMetadata entry, the audiobook category must be polled
    nometadata_test.go:64: errored audiobook torrent: want a ClientReported entry
    nometadata_test.go:70: incomplete count: want 4 (the shared torrent once, the foreign one not at all), got 2
--- FAIL: TestCheckStalledDownloads_TransmissionNoMetadata (0.48s)
    scheduler_nometadata_test.go:249: a no-metadata torrent must be removed without delete-local-data, got [true]
--- FAIL: TestStallReSearch_PicksAnotherRelease (0.33s)
    scheduler_nometadata_test.go:447: the stall re-search must grab the runner up; the client got 0 requests

Keying the no-metadata half by session id (this branch's pre-merge behaviour) against main's hash-keyed downloads:

--- FAIL: TestGetStalledTorrents_Transmission_NoMetadata (0.04s)
    nometadata_test.go:130: stopped magnet with no metadata: want a NoMetadata entry
    nometadata_test.go:133: downloading magnet with no metadata: want a NoMetadata entry
--- FAIL: TestCheckStalledDownloads_TransmissionNoMetadata (0.32s)
    scheduler_nometadata_test.go:236: download status: want "failed", got "downloading"
    scheduler_nometadata_test.go:239: error message should say the metadata never resolved, got ""
    scheduler_nometadata_test.go:243: expected the empty torrent to be removed from Transmission by its info hash, got []
    scheduler_nometadata_test.go:265: expected 1 downloadStalled history event, got 0

Earlier rounds

Round 1 (detection) and round 2 (no blocklist, outage guard, zero value) fail before evidence is unchanged from the previous revision of this description: every no-metadata predicate neutralised fails TestGetStalledTorrents_{Transmission_NoMetadata,QBittorrent_MetaDL,Deluge_NoMetadata,Rtorrent_NoMetadata} and the scheduler end to end test; restoring the round 1 policy fails TestStallReport_LooksLikeClientOutage, TestStallKind_ZeroValueIsUnusable, TestCheckStalledDownloads_NoMetadataClientOutage and TestHandleStalledDownload_RejectsStallNone.

NoMetadataYoungerThanTimeout, HealthyTransmissionDownloadUntouched and NoMetadataBelowOutageFloor pass before as well, by design: they are over-reach guards.

Tests

  • internal/downloader/nometadata_test.go: the reporter's Transmission shape at status 0 and 4, keyed by hash with an upper case wire hash to prove normalisation; healthy and metadata-but-no-progress torrents untouched; a real errorString lands in ClientReported only; torrent-get requests the fields; Transmission audiobook category polled with a shared torrent counted once and a foreign one ignored; qBittorrent metaDL/forcedMetaDL; Deluge; rTorrent; the outage threshold table; the zero StallKind.
  • internal/scheduler/scheduler_nometadata_test.go: end to end Transmission no-metadata stall (failed, removed by hash, no data delete, no blocklist, history event); client outage; below the floor; younger than the timeout; healthy torrent; StallNone rejected; the stall re-search grabbing the runner up instead of nothing.
  • TestCheckStalledDownloads_QBitStalledTorrent still asserts the blocklist entry for the client reported kind.

Verification

  • go build ./..., go vet ./..., gofmt -l clean, GOOS=windows go build ./... passes
  • go test ./internal/downloader/... ./internal/importer/... ./internal/db/... ./internal/scheduler/... -count=1 pass

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

Security

No new endpoint, no auth or settings surface, no user scoped query. The only destructive action is the existing removeStalledFromClient path, now strictly narrower: a no-metadata stall never asks the client to delete data.

Thanks ccarpinteri for the report and the diagnosis.

🤖 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 81.42857% with 26 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/downloader/nometadata.go 58.82% 14 Missing ⚠️
internal/downloader/adapter.go 85.71% 6 Missing and 2 partials ⚠️
internal/scheduler/regrab.go 81.81% 1 Missing and 1 partial ⚠️
internal/scheduler/scheduler.go 94.59% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

vavallee and others added 2 commits September 19, 2026 00:48
…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>
Main moved under this branch: Transmission downloads are keyed by info
hash (#2711), the importer polls the Transmission audiobook category
(#2712), remove on import exists (#2713) and per indexer seed limits
apply after add (#2917).

Conflict resolution keeps both sides. GetStalledTorrents now keys both
Transmission halves of the StallReport by lower-cased info hash, never
the session id, and the torrent-get field list carries main's addedDate
alongside metadataPercentComplete and peersConnected.

Re-checking the logic against current main turned up three things,
fixed here:

- Stall detection still polled only the Transmission ebook category, so
  an audiobook magnet was invisible to it. It now polls every category
  CategoriesToPoll returns, counting a torrent both filters return once
  so the outage guard's denominator is not doubled.
- A no-metadata torrent was removed with delete-local-data. It has no
  files, so it is now removed entry only (StallKind.DeletesData), the
  same as remove on import. Client reported stalls still delete data.
- The re-search after a no-metadata stall did nothing when the dead
  release still ranked first: it was approved again and the #2710
  cooldown refused the grab instead of trying the runner up. The
  re-search now carries the stalled GUID and a decision spec skips it
  for that one search only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@vavallee
vavallee marked this pull request as ready for review October 2, 2026 04:37

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean fix overall. A few observations:

qBittorrent/Deluge: Incomplete can be double-counted

adapter.go: the qBittorrent and Deluge loops in GetStalledTorrents increment report.Incomplete for every torrent returned per category, with no deduplication across categories. When Category="" (empty = all torrents) and CategoryAudiobook is also set, CategoriesToPoll returns both, and the second poll re-visits the same audiobook torrents — Incomplete counts them twice while the maps deduplicate by hash. The Transmission case correctly guards this with seen[t.ID].

The consequence is that LooksLikeClientOutage sees an inflated denominator and becomes harder to fire — a false-negative for outage suppression, not a false-positive, so individual downloads would be failed when they should have been held during a real client outage. The fix would be to mirror the Transmission seen pattern (keyed by hash) for qBittorrent and Deluge, or to call CategoriesToPoll once, collect all hashes, and iterate the merged set.

Not blocking — the case only arises when Category is intentionally left empty alongside a non-empty CategoryAudiobook, and missing the outage guard in that state fails downloads without blocklisting them, so they can be recovered.

Everything else looks solid

  • The mutual exclusivity of ClientReported / NoMetadata via switch is correct — a torrent with both errorString and totalSize=0 lands in ClientReported only.
  • The StallKind zero-value trap (bug-string reason, no blocklist, rejected by the handler) is a good defensive pattern.
  • The stalledReleaseSpec exclusion being context-scoped to one searchAndGrabFormat call and not stored is the right scope — ordinary sweeps can pick the release again after the cooldown clears.
  • float64(n) > float64(r.Incomplete) * noMetadataOutageShare using strict greater-than is consistent with the table test (exactly 50% is not an outage).
  • Test coverage is thorough: per-client unit tests, the outage threshold table, the zero kind, age gate, healthy torrent, and the re-search runner-up path.

— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.

@vavallee
vavallee merged commit 76f4569 into main Oct 2, 2026
39 of 42 checks passed
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