Implement endgame mode: request the final blocks from multiple sources - #225
Conversation
|
Thanks for the follow-up — the diff is much tighter than #224 (4 files, no scope creep) and the symptom is real. But I think the proposed shape (a parallel endgame path with redundant requests) is the wrong tool for what aMule already has, and the implementation underneath has issues independent of that. My recommendation is to redirect this PR toward improving the existing mechanism rather than adding a new one. aMule already has the right shape — it's just narrowly triggeredThe "stuck at 99% on a slow source" case has a working remedy today, in
That's the correct strategy: rotate work away from a slow source onto a fast one when one becomes available, with explicit cancellation, zero bandwidth waste. Same end result your endgame mode is after, achieved without redundant requests. Failure modes that match the "stuck at 99%" symptom:
Each of those is a small, well-scoped tweak — relax DROP_FACTOR near completion, remove the pref gate when remaining < N parts, add a periodic stale-assignment scan, special-case "one chunk left + single source" to force a rotation when any new source for the file becomes known. Any one of those is a much safer change than a new redundant-request path. Why the current PR shape is also problematic on its own meritsEven if we ended up wanting endgame-style redundant requests, this implementation has three issues that would block it:
|
|
Correction / additional context to my earlier comment: I undersold how narrow that existing rotation path actually is in practice. That makes your point about the "stuck at 99%" symptom more valid than my earlier comment gave it credit for — and it also opens a much smaller, safer fix than either your endgame design or the per-knob tweaks I listed. Concrete suggestion for a follow-up PR — make
Concrete site to put the gate: bool nearCompletion =
m_reqfile && m_reqfile->GetPartCount() > 4 &&
m_reqfile->GetIncompletePartCount() <= 4;
if (thePrefs::GetDropSlowSources() || nearCompletion) {
slower_client = m_reqfile->GetSlowerDownloadingClient(m_lastaverage, this);
}( That gives you the symptom fix you're after, with no concurrency rework, no redundant requests, no bandwidth-claim wiring, and a clean small-file guard. ~3 lines of substantive change plus a helper. |
7e61b30 to
3a4d279
Compare
|
@Cflsft much better — the diff on the latest commit ( That said — this isn't mergeable on inspection alone. The reason: So before merging we need a test cycle from you that demonstrates:
Paste the relevant log excerpts as text in code fences (not screenshots), along with the aMule rev + platform you tested on. If everything checks out and we don't see latent issues surface, this is ready to land. |
730d6a0 to
3a4d279
Compare
|
@Cflsft this is a real improvement on
That said, the fix-3 branch is incomplete and slightly leaky: } else {
AddDebugLogLineN(logLocalClient,
"Local Client: OP_CANCELTRANSFER (freed blocks not available here) to " + GetFullIP());
slower_client = this;
slower_client->SetDownloadState(DS_NONEEDEDPARTS);
return;
}The log line says Suggested shape — actually send the cancel so the log matches reality: } else {
if (!GetSentCancelTransfer()) {
CPacket* packet = new CPacket(OP_CANCELTRANSFER, 0, OP_EDONKEYPROT);
theStats::AddUpOverheadFileRequest(packet->GetPacketSize());
ClearDownloadBlockRequests();
SendPacket(packet, true, true);
SetSentCancelTransfer(1);
}
AddDebugLogLineN(logLocalClient,
"Local Client: OP_CANCELTRANSFER (freed blocks not available here) to " + GetFullIP());
SetDownloadState(DS_NONEEDEDPARTS);
return;
}Two minor nits while you're in there:
Testing still required before merge. The previous review's test cycle still applies and matters more now that the diff activates the rotation path widely. Per my earlier comment, please run a Debug build with |
19dc62f to
9bb34be
Compare
|
Thanks for the review and the excellent suggestions! When testing the new nearCompletion trigger on a real download, the code activated the rotation path much more aggressively. This stress-test exposed two edge cases in the DropSlowSources logic, which we have now addressed in this PR:
The Issue: A Segfault occurred because calling TickDownloadAndMeasure() could trigger the rotation logic, which synchronously removes clients from m_downloadingSourcesList while the loop was still iterating over it. The Issue: The code could hit the wxFAIL_MSG("No free blocks to request after freeing some blocks"). This happened because forcing a slower client to free its blocks does not guarantee those blocks can be assigned to the faster client, specifically if the faster client does not have the Part where those freed blocks reside. Added the actual OP_CANCELTRANSFER packet dispatch in the graceful drop fallback. |
|
@Cflsft — the latest changes all landed correctly: the graceful-drop now actually dispatches Three things before this can land: 1. The test cycle needs to be shown, across several different runs. Your comment says it was verified, but no logs are attached — and since this activates a rotation path that's been effectively dead for years, "works perfectly" on a single download isn't enough. Please paste, as text in code fences, the 2. Drop the leftover comments. The graceful-drop branch still carries the commented-out original 3. This turns a years-dormant code path on for the entire user base — that's the heart of our caution, and it can't be overstated. Today |
9cf8006 to
5bdab54
Compare
|
Although I have already successfully downloaded quite a few files of various types and sizes with this patch, I completely agree with your caution regarding the blast radius of activating this globally. To properly address the concerns about heuristic and edge cases, I am going to test for the next few days to gather logs across a variety of downloads (different sizes, ≤4 parts, "all sources slow", etc.). Thanks for the meticulous review! |
|
Great, thank you for the contribution. 👍 |
|
I have been extensively testing the latest build on Linux running as a daemon ( Test Results & StabilityBased on the collected logs (with
I have gathered 6 test cases that cover different scenarios. For ease of review, I am providing both the complete logs and filtered extracts highlighting the most notable events:
Overall, the logic manages the block queues correctly without choking, stalling, or causing network starvation. Moving Forward: Opt-in Endgame OptionEven though the baseline behavior is very stable and files are completing naturally, I understand the concerns about making aggressive changes to the default endgame behavior ( To ensure safety and allow for wider community testing, I propose adding a hidden configuration option to
By default, this will remain off, so the standard aMule behavior remains completely untouched for regular users. Advanced users or testers who experience the "99% stall" issue can opt-in to enable the Endgame rotation logic. Addressing the "Aggressive Drop" Flaw: An Experimental ApproachWhile I haven't observed this flaw trigger negatively in the recent stress tests, there is a known theoretical issue with the traditional Endgame approach of dropping slow sources: When we send an To address this without destroying connections, we could develop a smarter, non-destructive rotation strategy. The proposed logic would work as follows:
Do you think it would be worth implementing and testing this new rotation method in the future? |
5bdab54 to
030209b
Compare
Endgame rotation: graceful block eviction to prevent part corruptionContextWhile testing the endgame client rotation on high-concurrency downloads, I identified The bugWhen the endgame logic evicts a slow client to make room for a faster one, the current // DownloadClient.cpp – current eviction sequence
slower_client->ClearDownloadBlockRequests(); // frees ALL pending blocks
slower_client->SendPacket(OP_CANCELTRANSFER);
slower_client->SetDownloadState(DS_NONEEDEDPARTS);
The evicted client's partial write is "sealed" into the gap list with no re-verification Two corruption scenariosIt is important to distinguish two different situations that can arise when multiple Scenario 1 — Complete blocks, two clients (no inherent bug)This is normal network-level corruption, not an endgame bug. It is handled correctly Scenario 2 — Split block, two clients (the actual bug)This is the bug introduced by the current hard eviction. A secondary effect: endgame rotation statistically increases the number of distinct Proposed fix: graceful evictionInstead of canceling all blocks at once, I propose canceling all pending blocks This eliminates the partial-write window entirely. The slow client's data is always Implementation sketch1.
|
| File | Change |
|---|---|
BaseClient.cpp |
Add bool bKeepFirst = false param to ClearDownloadBlockRequests(); skip first m_PendingBlocks_list entry when set |
BaseClient.h / updownclient.h |
Update declaration with default param |
DownloadClient.cpp |
Split lines 659-671 into two branches: graceful (no OP_CANCELTRANSFER, no SetDownloadState) vs. hard (existing behaviour) |
No other files need to change. All existing call sites pass no argument and get the
current (hard-clear) behaviour unchanged.
Trade-offs
| Hard eviction (current) | Graceful eviction (proposed) | |
|---|---|---|
| Corruption risk | High – partial write left on disk | None – block always complete |
| Re-download on hash fail | Up to 9.28 MB per part | Zero (no partial write) |
| Fast client wait | 0 ms | Time to finish 1 block at slow speed |
| Implementation complexity | Existing | +1 bool param + minor refactor |
The worst case wait is one 180 KB block at the slow client's speed. For a client at
5 KB/s that is ~36 seconds, during which the fast client is busy downloading the
other freed blocks — so the net throughput impact is minimal.
Related
CCorruptionBlackBox::TransferredData()/VerifiedData()— attribution is more
accurate when the slow client always writes complete blocks.CPartFile::AICHRecoveryDataAvailable()— AICH block-level recovery works correctly
regardless, but is invoked less often with this fix.
Design evolution: superseding the earlier RFC
An earlier version of this work proposed a time-bounded partial reallocation strategy
based on a minimum throughput threshold of 10.0 KB/s (derived from the 18-second
Maximum Tolerable Delay for a 180 KB block at that speed). The idea was to classify
slow clients into two tiers:
- < 10.0 KB/s → destructive reallocation (current hard eviction behaviour)
- ≥ 10.0 KB/s → protected reallocation: steal all blocks except one, let the
client finish its current block cleanly
After testing and further analysis, I concluded that this threshold-based approach
solves the right problem but draws the wrong line:
-
The corruption risk is speed-independent. A client writing a partial block at
9 KB/s creates the same split-block situation as one writing at 1 KB/s. The
corruption window exists whenever a client is mid-block at eviction time, regardless
of its throughput. Applying the protection only above 10 KB/s leaves the bug intact
for the majority of evictions. -
The threshold introduces a fragile edge case. A client oscillating around
10 KB/s would alternate between protected and destructive eviction non-deterministically,
making the corruption window intermittent and hard to reproduce or reason about. -
The graceful eviction generalises the "keep one block" insight universally.
The protected path of the RFC — preserve one block, let the client finish it — is
exactly correct. The fix here simply removes the threshold gate and applies that
logic unconditionally. The cost (waiting for one block to finish) is the same in
both cases; the benefit (no partial write on disk) is now guaranteed for every
eviction, not just those above the speed cutoff.
The graceful eviction (bKeepFirst = true) is therefore a strict improvement over the
RFC: simpler, more correct, and without speed-dependent edge cases.
Proposal: Safe Hard Eviction with GAP Recovery (Alternative to Graceful Eviction)First of all, I would like to sincerely apologize for the multiple changes in design direction over the course of this PR. I am a bit embarrassed by the back-and-forth, but as I ran more real-world tests and analyzed different edge cases, it became clear that the previous solutions either introduced new bottlenecks or didn't fully solve the core issue. I truly appreciate your patience. After a lot of reflection and tracing actual transfer behaviors in a test environment, I believe I have finally found the cleanest, most robust, and bottleneck-free approach: Safe Hard Eviction with GAP Recovery. I would like to ask if you think it is appropriate to update this PR to implement this new design instead of the original one. The New Design: Safe Hard Eviction with GAP RecoveryInstead of keeping the first block in flight and waiting for the slow source to finish (which bottlenecks the end of the download), we disconnect the slow source immediately. However, to prevent split-block corruption, we revert the entire range of the in-flight block back to a GAP in the temporary file. Why it is better:
Implementation DetailsThe changes are compact and self-contained: 1.
|
| Hard Eviction (original code) | Graceful Eviction (previous proposal) | Safe Hard Eviction (this proposal) | |
|---|---|---|---|
| Endgame Completion Speed | Fast (immediate drop) | Slow (must wait for slow client block) | Fast (immediate drop) |
| Split-Block Corruption Risk | High (partial writes left) | None (block completed by slow peer) | None (block re-downloaded from scratch) |
| Wasted Bandwidth | None | None | Very low (max 180 KB of partial block discarded) |
Given that a block is only 180 KB, discarding the partial progress of a slow client is extremely cheap in terms of bandwidth, while the benefits in speed and safety are absolute.
Would you see fit that I update the PR to implement this new design? Let me know your thoughts.
|
Hi @Cflsft don't worry about iterating on this PR multiple times, it will ultimately reach the good solution. And I think many users will appreciate a solution to the slow download speed when file reaches 99%. On your last question, IMHO wasting 180KB of transfer seems acceptable to me in 2026 Internet. However, I have a question concerning your previous comment:
If I'm understanding correctly, the difference between the 2 scenarios is that one 180KB corrupt block is downloaded from a single peer in the first scenario, and from 2 different peers in the second. However, I don't understand why the first scenario would be recovered by AICH downloading only the wrong block, but on the second scenario you talk about a potential need to download the 9.28MB part. I mean, if we ask a peer for the needed AICH hash set to identify the wrong block, verify the packet and find the wrong block, why would corruption attribution be an issue and requiere a larger redownload to fix it? If that is the case, that would be a latent bug worth fixing independent of your endgame PR. |
|
Hi @danim7, you are correct about the 9.28 MB fallback. Both scenarios recover only the 180 KB block if AICH is available, and both fall back to the full 9.28 MB part if it is not. My previous comment was wrong to attribute that difference specifically to Scenario 2. That said, during my testing I noticed an unjustified increase in corruption events specifically during the endgame phase with the original eviction code. The original code systematically creates split blocks at scale — during a single endgame phase, dozens of evictions can occur, each leaving partial writes from one peer to be completed by another. In normal downloads, split blocks are rare and accidental; in the endgame, they become the rule rather than the exception. When I switched to the GAP Recovery approach — reverting the in-flight block to a GAP and letting the fast peer download it entirely from scratch — I did not encounter a single corruption error during the endgame phase across all my latest downloads. That said, given that I was already wrong about how AICH recovery works in this context, I do not want to rush into a new design change. Before formally proposing this as a code change I want to do more testing with a wider variety of files, to rule out that the corruption increase I observed was specific to the files I used for testing. |
|
Hi @Cflsft , Think of the PR area as a workplace. You can polish the code until it shines, no need to hurry. The problem you are trying to solve is complicated and poorly understood in general. No hurries, take as much time as you need. Change the code as much as you need. Best regards. |
|
@danim7 is right that the recovery mechanism is symmetric across the two scenarios — both recover a single 180 KB block when AICH is usable, and both fall back to the full 9.28 MB part when it isn't. What decides which path runs is whether the file's AICH master hash is trusted, and that's a property of the file's source population, not of the block or the download phase: when a part fails its MD4 hash, On the corruption itself, it's worth pinning down the mechanism before redesigning around it, because the committed code doesn't obviously create one. The current path is the existing drop-slow-source-and-reassign: @Cflsft — could you share the before/after corruption counts from the runs that motivated this (the committed build vs. your local GAP-recovery build, ideally same files/sources)? If the increase holds up across a wider set, that isolates whether it's the reassignment frequency or something subtler in the partial-write/gap accounting, and it would justify the change on its own merits. For the design direction, GAP-recovery is a reasonable refinement regardless: reverting the in-flight block to a gap keeps the reassigned block single-sourced and avoids the slow-tail wait that the keep-first variant reintroduces (the 99 % stall this PR exists to remove), at a negligible ≤180 KB cost. If you take it, a small |
|
Hi again, To better understand this issue, I ran some tests with full debug logging enabled. I wanted to trace exactly what happens during the endgame phase when blocks are split, specifically testing the hypothesis that AICH_TRUSTED would safely recover those corrupted blocks. In one specific session, I was downloading 4 files simultaneously. I am attaching the annotated log (logfile_popular_files_annotated.txt) showing what happened to the 2 largest ones. The log confirms aMule easily reached AICH_TRUSTED (100% quorum) for both files (one had 56 trusted sources, the other 10). I am not entirely sure exactly why this happens at the code level—specifically, why the boundary corruption occurs during the splice, or why AICH fails to recover the 180 KB blocks despite having a full trusted hashset—but the behavior is clearly documented in the logs. What is clear is the outcome: AICH appears unable to recover these specific split-block corruptions, resulting in discarded parts and massive data loss even with full quorum. Alternatively, I could test the approach of cancelling the transfer but completely "stealing" the current gap (discarding the slow client's partial block and re-assigning the entire gap to the fast source). This would avoid the split-block splice entirely and theoretically prevent these corruptions. Let me know if you would like me to implement and test that approach instead. I've attached the log for your review. Let me know your thoughts. |
|
Thanks for the rigorous logging — I went through the full log and it's very informative. A few clarifications, then a concrete next step. On AICH: the log actually shows recovery was never invoked — there are no On the mechanism: the corruption comes from two things acting together — the endgame splitting a block across two sources and those transfers being compressed. When the faster source evicts the slower one mid-block ( Next step (#270): I opened #270, which makes Since #270 isn't merged yet, you can pull it straight from my fork and test it together with your endgame branch: # add my fork as a remote (once) and fetch the AICH-recovery branch
git remote add got3nks https://github.com/got3nks/amule.git
git fetch got3nks fix/aich-recover-on-completion-rehash
# throwaway test branch off your endgame work, with the fix merged in
git checkout -b endgame-aich-test feature/endgame-mode
git merge got3nks/fix/aich-recover-on-completion-rehash
# build, then re-run the popular-file scenario on endgame-aich-testThat keeps your Once that's confirmed, your "discard the slow source's partial and re-request the whole block from one source" idea is the right root-cause fix — a block should come wholly from a single source, never spliced. Let's land the AICH recovery piece first, then do that. |
Thank you for your analysis, got3nks! Independently of the endgame feature, could this mechanism be already causing corruption in the production version? What happens with a 180KB block when a peer suddenly disconnects leaving the block half completed, and we finally fill it from another peer? |
|
Hi @got3nks, Thank you for the analysis. To test this, I created a local experimental branch merging my endgame branch with your #270 PR (fix/aich-recover-on-completion-rehash). I compiled the debug build and ran a similar scenario to trigger the endgame source rotation. The results confirm your analysis. The fix works as intended. This confirms definitively that the endgame rotation corrupted precisely a single 180 KB chunk, and your PR successfully intercepted the final hash failure,, and restored the remaining 9.10 MB of the part. This resolves the data loss issue. |
… re-hash (#270) When the final full-file re-hash finds a part corrupt (e.g. a block rewritten after the part completed, as with endgame source-rotation block splicing, #225), PartFileHashFinished only re-gapped the whole 9.28 MB part and never requested AICH recovery. Collect the failing parts and call RequestAICHRecovery() for each once the file is back in PS_READY. It is self-guarded (no-op without a trusted/verified AICH hashset), so non-AICH files keep the existing whole-part re-download; AICH files now recover at 180 KB block granularity.
2a0f51a to
07f5242
Compare
… re-hash (amule-org#270) When the final full-file re-hash finds a part corrupt (e.g. a block rewritten after the part completed, as with endgame source-rotation block splicing, amule-org#225), PartFileHashFinished only re-gapped the whole 9.28 MB part and never requested AICH recovery. Collect the failing parts and call RequestAICHRecovery() for each once the file is back in PS_READY. It is self-guarded (no-op without a trusted/verified AICH hashset), so non-AICH files keep the existing whole-part re-download; AICH files now recover at 180 KB block granularity.
07f5242 to
cc6b8bc
Compare
This exposes the previously introduced s_Endgame preference to the text client (amulecmd). It allows headless setups (e.g. amuled) to toggle the endgame source rotation feature remotely via EC tags. New commands added: - set Endgame <1|0>: Enables or disables the feature. - get Endgame: Returns the current state of the preference.
cc6b8bc to
f6eea4d
Compare
|
The endgame fix and the clang-tidy pass both look right — One thing to pull out before merge though: the That's unrelated to the endgame work — it looks like a broad After that this is good to go from my side. |
f6eea4d to
4932564
Compare
|
Thank you so much to everyone for the guidance, reviews, and invaluable help throughout this PR! |
|
There is something wrong with the clang-tidy Tier-1 job, it is taking too long. |
I'm monitoring it. It was just a VERY SLOW dependencies download from Ubuntu servers. |
|
The analysis seems compromised, some files get an usage message with |
|
The All real source was analyzed normally — the run linted 219/249 files and the endgame changes went through it ( If we want the log clean, |
|
Merged, congratulations! 🎉 Thanks for seeing this all the way through, @Cflsft. This one had a genuinely tricky failure mode (the 99.9% self-banishment cascade) and you nailed both the mechanism and the fix, plus wired the toggle cleanly through every surface. Validated 5/5 clean endgame cycles on the final tip with no thrash. Great contribution. |
|
Congratulations, all of you! That must have been one of the most tricky bugs I have seen in aMule development. |
…ine endings (amule-org#333) * fix(ec): add EC_TAG_FILES_ENDGAME to the ECCodes generator sources PR amule-org#225 added EC_TAG_FILES_ENDGAME (0x1810) to the generated src/libs/ec/cpp/ECCodes.h but not to its generator source src/libs/ec/abstracts/ECCodes.abstract (nor the ECCodes.java binding). Clean builds that regenerate the header from the abstract — as the Flatpak CI does — drop the tag, so amulecmd (TextClient.cpp) and ECSpecialMuleTags.cpp fail to compile with "EC_TAG_FILES_ENDGAME was not declared in this scope". AppImage and the standard builds compile the committed header and stay green, which is why only Flatpak broke. Add the tag to both generator sources so the regenerated and committed headers agree in every build configuration. * fix(po): restore LF on gettext infra files flipped to CRLF by amule-org#225 amule-org#225 (c844cb9) was committed from a CRLF environment (core.autocrlf), flipping 12 gettext infrastructure/skeleton files to CRLF with no content change: CMakeLists.txt, LINGUAS, Makevars, POTFILES.in, Rules-quot, boldquot.sed, quot.sed, [email protected], [email protected], insert-header.sin, remove-potcdate.sin, l10n.xsl. Renormalize them back to LF and add a `po/** text=auto eol=lf` .gitattributes rule so a Windows / autocrlf checkout can't reintroduce it. The .po/.pot catalogs themselves stayed LF and are untouched here.
This PR solves the notorious issue where a download gets stuck at 99% because the last remaining block is exclusively assigned to a very slow or dead source.
It implements a BitTorrent-style "Endgame Mode":
GetNextEmptyBlockInPartallows multiple available sources to request the same missing block concurrently.HasRequestedBlockensures that a single source doesn't redundantly request the same block from itself.IsComplete()) safely discard any incoming redundant data in memory. The core then automatically cancels the remaining redundant transfers viaOP_CANCELTRANSFER.This change guarantees that downloads finish smoothly without stalling, with negligible and strictly controlled bandwidth overhead.