Defer per-part hash verification to a dedicated worker thread - #498
Merged
Conversation
Introduces a single dedicated worker thread (mirroring the CPartFileWriteThread pattern) that runs HashSinglePart() off the main thread. Wires up the partFileHashThread member on CamuleApp, constructs it in OnInit, and tears it down at the start of OnExit so any in-flight HashSinglePart finishes and the m_pendingHashes counter on each CPartFile drops to zero before downloadqueue is destroyed (and ~CPartFile waits on that counter). The worker dispatches a CPartFileHashResultEvent back to the main thread, but no caller enqueues work yet — the hash thread is constructed and torn down inert. The next commit wires Phase 3 in PartFile::FlushBuffer to use it.
Replaces the synchronous Phase 3 loop in FlushBuffer (which read the just-written part back through the kernel cache and ran MD4 on the main thread, freezing the GUI for 30-60 s on a fresh download where every part is dirty) with an async dispatch: * WriteToBuffer stamps m_nLastBlockReceivedTick so FlushBuffer's new quiescent guard (1 s of no receives) can defer hashing during active receive bursts; the original PR-amule-project#454 objection that hashing competed with the write thread no longer applies once writes have drained. * FlushBuffer enqueues each dirty part to CPartFileHashThread and returns; the worker calls HashSinglePart off the main thread and posts a CPartFileHashResultEvent that CamuleApp::OnPartFileHashResult dispatches to CPartFile::OnAsyncHashComplete, which runs the original success / failure / AICH-recovery logic. * m_pendingHashes counts in-flight HashJobs targeting the file so CompleteFile and ~CPartFile know when it is safe to close m_hpartfile. * Phase 3 is skipped at gaplist completion: CCompletionTask re-reads the file for the ED2K root + AICH tree and subsumes per-part MD4 — running both is duplicate work and (for a fresh file) the source of the freeze. * The trailing CompleteFile check accepts both PS_EMPTY and PS_READY (LoadPartFile flips status to PS_READY on resume when any part is already complete) and waits on m_pendingHashes <= 0 so we do not close m_hpartfile while the worker is still reading it. * A final PB_WRITTEN harvest sweep runs before the trailing check — CPartFileWriteThread decrements m_iWrites and then sets PB_WRITTEN, so an item Phase 2 left as PB_PENDING can transition to PB_WRITTEN after Phase 2 returns; without this sweep the gap-closing write would leave m_BufferedData_list non-empty and CompleteFile would not fire. * OnAsyncHashComplete only calls SavePartFile when the part's outcome changed persistent state — the success path for an already-complete part is in-memory only, and saving on every event would re-create the freeze in a different place. ~CPartFile waits for m_pendingHashes to drain, calls FlushBuffer (which now early-returns under the m_inDestructor gate before Phase 3 fires), then synchronously sync-hashes any parts still flagged dirty in m_aChangedPart. The shutdown sweep covers the cancel-mid-download case where the quiescent guard never opened: without it, .met would be saved with gaplist marking those parts complete and m_corrupted_list empty, and on next launch they would be implicitly verified.
Adds CPartFile::HasPendingHashWork() and uses it in two places: * CPartFile::Process flushes the buffer not only on size/time limits but also whenever there is dirty work waiting (m_aChangedPart entries, or — when the gaplist is closed — un-harvested PB_WRITTEN items in the buffer list). Without this, a quiescent download with leftover dirty parts would wait up to 60 s for the next BUFFER_TIME_LIMIT tick before the async hash queue drained, and at file completion the trailing CompleteFile check would not be re-evaluated until the worker had finished the last write. * CDownloadQueue::Process drives FlushBuffer for paused / insufficient files (their Process() is gated above, so Phase 3 would otherwise never run on dirty pre-pause data).
CFileAutoClose::ReadAt and WriteAt are both Seek+Read / Seek+Write on the underlying fd (the mmap path is gated by ENABLE_MMAP=OFF, the default in our builds), so they share the OS file position. When CPartFileHashThread runs HashSinglePart concurrently with CPartFileWriteThread running FlushAt against the same CPartFile, their Seeks clobber each other and the read or the write lands at the wrong offset. This is reproducible by pausing a download (which drains the m_aChangedPart backlog through Phase 3) and resuming mid-hash: write thread restarts, races the still-running hash, disk corruption. Add std::mutex CPartFile::m_hpartfileMutex held around: * CPartFileWriteThread::Entry's pBuffer->area.FlushAt(...) * CPartFileHashThread::Entry's HashSinglePart(...) * ~CPartFile's sync-hash drain HashSinglePart(...) * FlushBuffer's PB_READY synchronous fallback FlushAt(...) This is a whole-file lock — one CPartFile, one mutex. Per-chunk locking would not help because the race is on the shared fd position, not on the bytes. Cost is small: writes are ~1 ms each, hashes ~50 ms each on Windows VM; the write thread can stall briefly behind a hash backlog drain but the main thread is unaffected. Throughput is benchmarked separately in PR amule-project#498 — the LAN-seeder test on the existing async-hash design holds.
mrjimenez
pushed a commit
that referenced
this pull request
Apr 29, 2026
Follow-up to PR #498 (b106e3a PartFile: serialise m_hpartfile access against the hash thread). When a download fills the disk, PartFile.cpp:3083 logs "Not enough free disk-space! Pausing file: …", calls PauseFile(true) (status -> PS_INSUFFICIENT), and returns. PR #498's CDownloadQueue::Process paused-drain branch then drives FlushBuffer for the file every Process tick (~100 ms) because HasPendingHashWork() still returns true for the dirty m_aChangedPart entries that were buffered before the disk filled. Each call re-enters the same disk-space check, re-logs the warning, and re-pauses — producing tens of log lines per second until the file is removed. Manually clicking Stop on the GUI doesn't help: the status flag stays PS_INSUFFICIENT, only m_stopped flips, and the drain branch keys on status. Drop PS_INSUFFICIENT from the drain set. PS_PAUSED (user-clicked pause) keeps its drain — that's the case the branch was added for. Disk-full files have no productive hash work to do anyway: the buffered items can't be written, so Phase 1 would just queue them to the worker, which would catch CIOFailureException (PR #499) and bounce them back as PB_ERROR. Leftover m_aChangedPart entries on a disk-full file are still covered by the destructor sync-hash drain at shutdown.
This was referenced Apr 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Moves
CPartFile::FlushBufferPhase 3 (per-part MD4 verification) off the main thread onto a dedicatedCPartFileHashThreadworker, gates it behind a 1 s receive-quiescent window, and skips it entirely once the gaplist closes (becauseCCompletionTaskre-reads the file for the ED2K root + AICH tree and subsumes the per-part work).The visible effect on a fresh download is that the 30–60 s GUI freeze at file completion (every part dirty, all hashed synchronously on the main thread) is gone. On Windows the freeze had been re-shaped as ~1–4 s sample-rate spikes during active receive bursts; with this PR throughput is steady at the LAN-seeder limit with no spikes or stalls.
What's in this PR
The branch is four logical commits.
1.
Add CPartFileHashThread for async per-part hash verificationNew
src/PartFileHashThread.{h,cpp}modelled on the existingCPartFileWriteThread:wxThreadwith a mutex-protected job queue (HashJob { CPartFile*, partNumber, fromAICHRecoveryDataAvailable })wxCondition+ stickym_bWorkPendingwake flag to avoid lost signalsEntry()callspFile->HashSinglePart(partNumber)synchronously off the main thread, then posts aCPartFileHashResultEventand decrementspFile->m_pendingHashesEndThread()joins on shutdownWired up on
CamuleApp:partFileHashThreadmember constructed inOnInitafterpartFileWriteThreadOnExit, beforedownloadqueueis destroyed, so any in-flightHashSinglePartfinishes and them_pendingHashescounter on eachCPartFiledrops to zero before~CPartFilewaits on itm_pendingHashes(std::atomic<int32>) declared onCPartFileso the worker can decrement it without holding any lockThe worker is constructed and torn down inert — no caller enqueues work yet. Commit 2 wires Phase 3 to use it.
2.
PartFile: defer per-part hash verification to the async workerReplaces the synchronous Phase 3 loop in
FlushBuffer(which read the just-written part back through the kernel cache and ran MD4 on the main thread) with an async dispatch:WriteToBufferstampsm_nLastBlockReceivedTick = GetTickCount()soFlushBuffer's new quiescent guard (1 s of no receives) can defer hashing during active receive bursts. The original PR download: port eMule's CPartFileWriteThread to offload disk writes #454 objection that hashing competed with the write thread no longer applies once writes have drained.FlushBufferPhase 3 enqueues each dirty part toCPartFileHashThread::QueueHashCheckand returns; the worker callsHashSinglePartoff the main thread and posts aCPartFileHashResultEventthatCamuleApp::OnPartFileHashResultdispatches toCPartFile::OnAsyncHashComplete, which runs the original success / failure / AICH-recovery logic.CCompletionTaskre-reads the whole file for the ED2K root hash and AICH Merkle tree — exactly the same bytes the per-part MD4s would touch. Running both is duplicate work; for a fresh file (every part dirty) it's also the source of the 30–60 s freeze.CompleteFilecheck accepts bothPS_EMPTYandPS_READY(LoadPartFileflips status toPS_READYon resume when any part is already complete) and waits onm_pendingHashes <= 0so we don't closem_hpartfilewhile the worker is still reading it. The lastOnAsyncHashCompleteto drain the counter re-runs the check and firesCompleteFilethen.PB_WRITTENharvest sweep runs before the trailing check.CPartFileWriteThread::Entrydecrementsm_iWritesand then setsPB_WRITTEN, so an item Phase 2 left asPB_PENDINGcan transition toPB_WRITTENafter Phase 2 returns; without this sweep the gap-closing write at file completion would leavem_BufferedData_listnon-empty andCompleteFilewould not fire.OnAsyncHashCompleteonly callsSavePartFilewhen the part's outcome changed persistent state (corruption, ICH recovery, or removing fromm_corrupted_list). The success path for an already-complete part is in-memory only; saving on every event would re-create the freeze in a different place when many results land in quick succession.Shutdown safety
~CPartFilewaits form_pendingHashesto drain, callsFlushBuffer(which now early-returns under them_inDestructorgate before Phase 3 fires), then synchronously sync-hashes any parts still flagged dirty inm_aChangedPart. The shutdown sweep covers the cancel-mid-download case where the quiescent guard never opened: without it,.metwould be saved with gaplist marking those parts complete andm_corrupted_listempty, and on next launch they would be implicitly verified ("verified iffIsComplete && !IsCorruptedPart"). Verified-vs-corrupt counts are logged.3.
PartFile/DownloadQueue: drain pending hash work at Process tick rateAdds
CPartFile::HasPendingHashWork()and uses it in two places:CPartFile::Processflushes the buffer not only on size/time limits but also whenever there's dirty work waiting (m_aChangedPartentries, or — when the gaplist is closed — un-harvestedPB_WRITTENitems in the buffer list). Without this, a quiescent download with leftover dirty parts would wait up to 60 s for the nextBUFFER_TIME_LIMITtick before the async hash queue drained, and at file completion the trailingCompleteFilecheck would not be re-evaluated until the worker finished the last write.CDownloadQueue::ProcessdrivesFlushBufferfor paused / insufficient files (theirProcess()is gated above, so Phase 3 would otherwise never run on dirty pre-pause data).PS_ERRORis intentionally excluded.4.
PartFile: serialise m_hpartfile access against the hash threadLive testing of commits 1-3 surfaced a corruption-causing race when the user pauses a download (which drains
m_aChangedPartthrough Phase 3 to the hash thread) and then resumes mid-hash: the write thread restarts while the hash thread is still chewing through the queue, and the two race on the underlying file.Root cause: with
ENABLE_MMAP=OFF(the default in the project's CMake),CFileAutoClose::ReadAtandWriteAtboth implement positional I/O asSeek(off); Read/Write(...)on the same OS fd. The hash thread's read inHashSinglePartand the write thread's write inFlushAttherefore share the OS file's position; concurrent execution interleaves seeks and lands one operation at the wrong offset.Fix: add
std::mutex CPartFile::m_hpartfileMutex(whole-file lock — one mutex perCPartFile), held around:CPartFileWriteThread::Entry'spBuffer->area.FlushAt(...)CPartFileHashThread::Entry'sHashSinglePart(...)~CPartFile's sync-hash drainHashSinglePart(...)FlushBuffer'sPB_READYsynchronous fallbackFlushAt(...)(defensive — the fallback only runs when the write thread is unavailable)A per-chunk lock would not help: the race is on the shared fd's position, not on the bytes themselves. Two threads reading/writing non-overlapping byte ranges still both call
Seekon the same fd. The whole-file lock has small cost — writes are ~1 ms each, hashes ~50 ms each on Windows VM — and the write thread can stall briefly behind a hash backlog, but the main thread is unaffected.If the serialisation later shows a measurable throughput regression on the LAN-seeder benchmark, the cleanest follow-up is to give the hash thread its own
dup()'d fd; with two independent fds and their own positions, they can run truly concurrently. Not needed for correctness; only if measurements demand it.Benchmarks
LAN seeder → leecher, single source, 90 s ramp benchmark with 1 s polling via
amulecmdover external connections. Two metrics matter:dl_mbps— instantaneous download throughputsample_ms— wall-clock duration of theamulecmd show transfersround-trip; this is the EC reply lag and is the most direct proxy for "is the main thread responsive". A synchronous Phase 3 hash on the main thread blocks every EC reply until it completes, so a per-sample multi-secondsample_msspike maps 1-to-1 onto the GUI freeze the user sees.macOS (Apple Silicon, Release)
Mac is fast enough (NVMe + Apple Silicon cores) that even a synchronous Phase 3 doesn't bleed into the EC reply lag — no regression, no improvement. Throughput is at the LAN seeder's serving rate on both.
Windows ARM64 (UTM VM, Release)
Windows under UTM is the regime where the synchronous Phase 3 hash shows up clearly: the master baseline has a max EC reply lag of 3.77 s and 2.8% of samples lag past 1 s — every one of those is a multi-second main-thread freeze. With this PR the worst sample is 218 ms (a 17× improvement at max, 10× at p99) and no sample crosses 500 ms. Mean throughput is also up 6.1%.
Backward compatibility
.metformat changes; no on-disk layout changes..metworks unchanged — the newm_aChangedPart/m_pendingHashes/m_inDestructor/m_nLastBlockReceivedTickstate is in-memory only and initialised at construction / load.m_corrupted_list, AICH recovery,CCompletionTaskpost-download rehash,CorruptionBlackBox) are unchanged. Per-part MD4 verification still happens — it just runs on the worker thread now and is skipped when the gaplist closes (becauseCCompletionTaskis about to re-hash everything anyway).~CPartFile's sync-hash drain at shutdown andm_aChangedPartre-marking onFlushBufferPhase 2 harvest after resume.CPartFile::FlushBufferPhase 2 (whenpThread && pThread->IsRunning()is false) is preserved unchanged.Edge cases tested
.met— status loads asPS_READY, downloads resume, gaplist closes,CompleteFilefires (the trailing-checkPS_EMPTY || PS_READYacceptance covers this).~CPartFilesync-hashes leftover dirty parts;.metsaves with correct gaps andm_corrupted_list; resume re-downloads them.CDownloadQueue::Processpaused-file drain branch runs Phase 3 on leftover dirty parts; AICH recovery still fires.PartFileHashFinishedadds gaps for corrupt parts, file goes back toPS_READY, peers re-serve, the second post-download rehash succeeds. (Pre-existing behaviour; the "Failed to store new AICH Hashset" debug log is the existing wording for "skipped because corruption was found".)Files changed
src/PartFileHashThread.hHashJob,CPartFileHashResultEvent,EVT_PARTFILE_HASH_RESULT)src/PartFileHashThread.cppm_hpartfileMutexlock aroundHashSinglePartsrc/PartFileWriteThread.cppm_hpartfileMutexlock aroundpBuffer->area.FlushAt(...)cmake/source-vars.cmakePartFileHashThread.cppsrc/amule.h/.cpppartFileHashThreadmember; ctor / OnInit construction; OnExit teardown;OnPartFileHashResultdispatchersrc/amule-gui.cppEVT_PARTFILE_HASH_RESULTregistrationsrc/amuled.cppEVT_PARTFILE_HASH_RESULTregistrationsrc/PartFile.hfriend CPartFileHashThread;OnAsyncHashComplete/HasPendingHashWorkdeclarations;m_nLastBlockReceivedTick/m_inDestructor/m_pendingHashes/m_hpartfileMutexmemberssrc/PartFile.cpp~CPartFilesync-hash drain (withm_hpartfileMutexlock);WriteToBuffertick stamp;FlushBufferasync Phase 3 + gaplist-close skip + quiescent guard +PB_WRITTENharvest + trailingCompleteFilecheck + sync-fallbackm_hpartfileMutexlock;HasPendingHashWork;OnAsyncHashCompletesrc/DownloadQueue.cppProcess