Skip to content

Port eMule CUploadDiskIOThread + adaptive chunks and ASIO race/EPOLLET fixes - #451

Merged
mrjimenez merged 4 commits into
amule-project:masterfrom
got3nks:async-disk-io-upload-pr
Apr 22, 2026
Merged

Port eMule CUploadDiskIOThread + adaptive chunks and ASIO race/EPOLLET fixes#451
mrjimenez merged 4 commits into
amule-project:masterfrom
got3nks:async-disk-io-upload-pr

Conversation

@got3nks

@got3nks got3nks commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Offloads disk reads and ed2k packet construction from the main thread to a dedicated disk I/O thread (ported from eMule's CUploadDiskIOThread), fixes a latent RC4 stream-desync race in the ASIO socket layer, fixes an EPOLLET spurious-wakeup bug in HandleRead that killed read pipelines under high load, and introduces adaptive per-slot packet chunk sizing that raises upload throughput 3-4x on fast links without hurting slow peers.


What's in this PR

1. Disk I/O thread

New CUploadDiskIOThread (src/UploadDiskIOThread.{h,cpp}) owns all disk reads and ed2k packet construction for uploads. The main thread no longer blocks on file I/O or zlib compression during upload slot service.

Design choices vs eMule:

  • wxThread + wxCondition replaces CWinThread + WaitForMultipleObjects
  • Synchronous CFileArea::ReadAt() replaces ReadFile(OVERLAPPED) + IOCP (no need for a pending-IO list; reads go straight to the finished list)
  • Sticky wake flags prevent lost signals from wxCondition's pulse semantics (m_bNewBlocksPending, m_bSocketNeedsPending under m_mutex)
  • Upload list protected by wxMutex for cross-thread iteration safety

Converges several behaviours with eMule that aMule previously lacked:

  • AddReqBlock: validates upload list membership, file availability, block bounds, IsDownloading, and m_bIOError before queueing
  • AddReqBlock: bSignalIOThread param for batched block requests
  • ReadCompletionRoutine: HasQueues() / IsBusyQuickCheck() for the compression starvation check (replaces aMule's older HasSent())
  • m_bDisableCompression: persistent per-slot flag; once tripped the slot uses uncompressed packets for the session
  • m_bIOError: set by the disk thread when a read fails, consumed by the upload queue Process() loop to drop the slot cleanly
  • CreateStandardPackets / CreatePackedPackets moved from CUpDownClient to static methods on CUploadDiskIOThread (matches eMule's code organisation; lets the disk thread run without holding client state)
  • Disk I/O thread wait timeout aligned with eMule (500 ms)

Adopts two eMule 0.70b optimisations:

  • Compression level lowered from 9 to 1 — for typical blocks the size difference is small (~4-12%) but level 1 is 1.5-2.5x faster
  • Small-block threshold: when a block's remainder is below chunkSize + 2600 bytes, send it in one packet rather than splitting into tiny fragments (eMule uses a fixed 13000 against its 10240 chunk; this generalises to any chunk size)

2. ASIO RC4 stream desync fix (pre-existing bug)

In EMSocket.cpp and LibSocketAsio.cpp, m_sendBuffer and m_blocksWrite were shared between the throttler thread and the ASIO thread pool without synchronisation. eMule does not have this issue because it uses synchronous Winsock Send() on a single thread; the race is specific to aMule's Boost.ASIO async model and was latent until the disk I/O thread added enough write pressure to expose it.

DispatchWrite() read m_sendBuffer from the strand, but the throttler thread could have already replaced it — causing the same encrypted data to be sent twice and desynchronising the RC4 cipher against the peer. Additionally, CEMSocket::Send() advanced sent only after BlocksWrite() which could return a stale value and cause the same bytes to be re-sent.

Fix:

  • Make m_sendBuffer and m_blocksWrite std::atomic
  • Capture the buffer pointer at dispatch time and pass it explicitly through to HandleSend
  • Use compare_exchange_strong in HandleSend to clear m_sendBuffer only if it still points to the buffer we just sent
  • Advance sent before checking BlocksWrite() in EMSocket::Send()

3. EPOLLET spurious-wakeup fix

CAsioSocketImpl::HandleRead() had two latent issues under the 4-thread io_service that became reproducible at high upload rates:

a. available() / read_some() race. available() sized the buffer, then read_some(avail) consumed it. If more data arrived between those two calls, it stayed in the kernel buffer: boost.asio uses EPOLLET (edge-triggered) and the kernel does not re-fire the read-ready event unless the buffer empties and then refills. The socket would stall indefinitely with data pending. Fix: loop available() + read_some() until drained.

b. available()==0 treated as "peer closed". The same condition triggers on spurious wakeups — boost.asio can schedule a read handler for a queued edge event that was already consumed by a concurrent drain on another ASIO thread. The old code called SetError() / PostLostEvent(), permanently killing the socket's read pipeline even though the connection was alive. Fix: probe with ::recv(MSG_PEEK | MSG_DONTWAIT) to distinguish real EOF (returns 0) from a spurious wakeup (returns -1 with EAGAIN / EWOULDBLOCK). On spurious, re-arm the async_read_some and return without error.

Note: m_socket->non_blocking() in the constructor is a getter, not a setter — the socket stays in blocking mode. The raw native_handle + MSG_DONTWAIT is therefore necessary for a guaranteed non-blocking probe.

Reproducer: with 32 KiB upload chunks and ~50+ MB/s upload throughput on a VM, the bug triggers within 30-60 s; with eMule's default 10 KiB chunks it is much rarer but still latent.

4. Adaptive chunk size (10 KiB – EMBLOCKSIZE)

Replaces eMule's fixed 10 KiB packet size with a per-slot formula:

chunkSize = clamp(uploadDatarate / 8, 10240, EMBLOCKSIZE)

At session start (datarate=0) the floor keeps behaviour at eMule's default 10 KiB. As the slot ramps up, chunks grow with throughput — /8 targets ~125 ms of data per packet, enough to minimise per-packet overhead (ed2k framing, encryption, syscalls) without making per-packet latency pathological on slow peers. Capped at EMBLOCKSIZE (180 KiB) — one packet per block. There is no benefit in going higher since the receiver requests blocks of this size.

uploadDatarate is plumbed from ReadCompletionRoutine via CUpDownClient::GetUploadDatarate() into both CreateStandardPackets (uncompressed) and CreatePackedPackets (compressed).


Benchmarks

Tested using a standalone Python benchmark client that speaks the ed2k obfuscated protocol and requests upload slots like a real aMule peer:

Benchmark script: https://gist.github.com/got3nks/3e7661add07ba862a5ead0aa2e85ff28

On a 1 Gbps Docker host (single client, 60 s, MaxUpload=65534)

Stock aMule master (same host, same peer, same benchmark, no changes from this PR):

=== Benchmark Results ===
Duration    : 60.3s
Total       : 28.0 MB
Average     : 0.46 MB/s
Peak        : 0.58 MB/s
Chunks recv : 2863
Block reqs  : 160

With this PR (uint16 → uint32 widening from #436 not applied):

=== Benchmark Results ===
Duration    : 60.0s
Total       : 3721.5 MB
Average     : 62.02 MB/s
Peak        : 63.94 MB/s
Chunks recv : 25642
Block reqs  : 21172

~130x throughput on the same host, same peer, same benchmark. With #436 applied, peak throughput scales further (gigabit-class links are limited by the uint16 configuration cap before it is widened).


Backward compatibility

  • No wire protocol changes — works with all stock eMule / aMule clients.
  • Adaptive chunk size floor is eMule's original 10 KiB; behaviour on slow peers is unchanged.
  • ASIO / EPOLLET fixes are strictly defensive — they only change behaviour in cases where the old code killed or stalled a working socket.
  • Compression level 1 (vs 9) is a sender-side-only change; peers decompress identically.

Files changed

File Purpose
src/UploadDiskIOThread.h New disk I/O thread header
src/UploadDiskIOThread.cpp New disk I/O thread implementation
src/UploadClient.cpp AddReqBlock validation; moved packet helpers out
src/updownclient.h Added m_bIOError, m_bDisableCompression flags
src/UploadQueue.cpp IsDownloading; consume m_bIOError flag
src/UploadQueue.h IsDownloading accessor
src/UploadBandwidthThrottler.* Wake disk I/O thread via condition variable
src/EMSocket.cpp / .h RC4 race fix; HasQueues / IsBusyQuickCheck
src/LibSocketAsio.cpp RC4 race fix; EPOLLET drain + spurious-wakeup fix
src/amule.cpp / .h Start / stop the disk I/O thread
src/Makefile.am Build UploadDiskIOThread.cpp
cmake/source-vars.cmake Build UploadDiskIOThread.cpp (cmake)

Offload file reads and packet creation from the main thread to a
dedicated disk I/O thread, ported from eMule's CUploadDiskIOThread.
Additionally fixes a latent RC4 stream-desync race in the ASIO socket
layer, fixes an EPOLLET spurious-wakeup bug in HandleRead that killed
read pipelines under high load, and introduces adaptive per-slot
packet chunk sizing that raises upload throughput 3-4x on fast links
without hurting slow peers.

--- Disk I/O thread ---

New CUploadDiskIOThread (src/UploadDiskIOThread.{h,cpp}) owns all
disk reads and ed2k packet construction for uploads.  The main thread
no longer blocks on file I/O or zlib compression during upload slot
service.  Key design choices vs eMule:

  * wxThread + wxCondition replaces CWinThread + WaitForMultipleObjects
  * Synchronous CFileArea::ReadAt() replaces ReadFile(OVERLAPPED) + IOCP
    (no need for a pending-IO list; reads go straight to finished list)
  * Sticky wake flags prevent lost signals from wxCondition's pulse
    semantics (m_bNewBlocksPending, m_bSocketNeedsPending under
    m_mutex)
  * Upload list protected by wxMutex for cross-thread iteration safety

Converges several behaviours with eMule that aMule previously lacked:

  * AddReqBlock: validates upload list membership, file availability,
    block bounds, IsDownloading, and m_bIOError before queueing
  * AddReqBlock: bSignalIOThread param for batched block requests
  * ReadCompletionRoutine: HasQueues()/IsBusyQuickCheck() for the
    compression starvation check (replaces aMule's older HasSent())
  * m_bDisableCompression: persistent per-slot flag, once tripped the
    slot uses uncompressed packets for the session
  * m_bIOError: set by the disk thread when a read fails, consumed by
    the upload queue Process() loop to drop the slot cleanly
  * CreateStandardPackets / CreatePackedPackets moved from
    CUpDownClient to static methods on CUploadDiskIOThread (matches
    eMule's code organisation, lets the disk thread run without
    holding client state)
  * Disk I/O thread wait timeout aligned with eMule (500 ms)

Adopts two eMule 0.70b optimisations:

  * Compression level lowered from 9 to 1 — for typical blocks the
    size difference is small (~4-12%) but level 1 is 1.5-2.5x faster
  * Small-block threshold: when a block's remainder is below
    chunkSize + 2600 bytes, send it in one packet rather than
    splitting into tiny fragments (eMule uses a fixed 13000 against
    its 10240 chunk; this generalises to any chunk size)

--- ASIO RC4 stream desync (pre-existing bug) ---

In EMSocket.cpp and LibSocketAsio.cpp, m_sendBuffer and m_blocksWrite
were shared between the throttler thread and the ASIO thread pool
without synchronisation.  eMule does not have this issue because it
uses synchronous Winsock Send() on a single thread; the race is
specific to aMule's Boost.ASIO async model and was latent until the
disk I/O thread added enough write pressure to expose it.

DispatchWrite() read m_sendBuffer from the strand, but the throttler
thread could have already replaced it — causing the same encrypted
data to be sent twice and desynchronising the RC4 cipher against the
peer.  Additionally, CEMSocket::Send() advanced 'sent' only after
BlocksWrite() which could return a stale value and cause the same
bytes to be re-sent.

Fix: make m_sendBuffer and m_blocksWrite std::atomic, capture the
buffer pointer at dispatch time and pass it explicitly through to
HandleSend, use compare_exchange_strong in HandleSend to clear
m_sendBuffer only if it still points to the buffer we just sent, and
advance 'sent' before checking BlocksWrite() in EMSocket::Send().

--- EPOLLET spurious wakeup ---

CAsioSocketImpl::HandleRead() had two latent issues under the
4-thread io_service that became reproducible at high upload rates:

  1. available()/read_some() race.  available() sized the buffer,
     then read_some(avail) consumed it.  If more data arrived between
     those two calls, it stayed in the kernel buffer: boost.asio uses
     EPOLLET (edge-triggered) and the kernel does not re-fire the
     read-ready event unless the buffer empties and then refills.
     The socket would stall indefinitely with data pending.
     Fix: loop available()+read_some() until drained.

  2. available()==0 was unconditionally treated as "peer closed".
     But the same condition triggers on spurious wakeups — boost.asio
     can schedule a read handler for a queued edge event that was
     already consumed by a concurrent drain on another ASIO thread.
     The old code called SetError()/PostLostEvent(), permanently
     killing the socket's read pipeline even though the connection
     was alive.
     Fix: probe with ::recv(MSG_PEEK|MSG_DONTWAIT) to distinguish
     real EOF (returns 0) from a spurious wakeup (returns -1 with
     EAGAIN/EWOULDBLOCK).  On spurious, re-arm the async_read_some
     and return without error.

Reproducer: with 32 KiB upload chunks and ~50+ MB/s upload throughput
on the dev VM, the bug triggers within 30-60 s; with eMule's default
10 KiB chunks it is much rarer but still latent.

Note: m_socket->non_blocking() in the constructor is a getter, not a
setter — the socket stays in blocking mode.  The raw native_handle +
MSG_DONTWAIT is therefore necessary for a guaranteed non-blocking
probe.

--- Adaptive chunk size (10-128 KiB) ---

Replaces eMule's fixed 10 KiB packet size with a per-slot formula:

    chunkSize = clamp(uploadDatarate / 8, 10240, 131072)

At session start (datarate=0) the floor keeps behaviour at eMule's
default 10 KiB.  As the slot ramps up, chunks grow with throughput —
/8 targets ~125 ms of data per packet, enough to minimise per-packet
overhead (ed2k framing, encryption, syscalls) without making per-
packet latency pathological on slow peers.  Capped at 128 KiB to
stay well within typical TCP send-buffer sizes.

uploadDatarate is plumbed from ReadCompletionRoutine via
CUpDownClient::GetUploadDatarate() into both CreateStandardPackets
(uncompressed) and CreatePackedPackets (compressed).

Measured on the dev VM (6.3 GB upload against a Python test client):

    eMule 10 KiB fixed: ~15-18 MB/s ceiling
    Adaptive:            67.3 MB/s avg, 73.5 MB/s peak

Chunk count dropped from ~180k to ~40k for the same payload, meaning
the /8 formula saturated at the 128 KiB ceiling for most of the
transfer.
@mifritscher2

Copy link
Copy Markdown

I just conducted a short smoke test and can confirm that both down- and uploading are working well (Debian Trixie). Download rate was about 3 MB/s, Upload about 0,7 MB/s (both limited by the network), transfer of 2 files at the same time. One original and one patched client.

@got3nks

got3nks commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for testing @mifritscher2 ! Great to hear both directions work on Debian Trixie.

To see the throughput improvements from the adaptive chunking and disk I/O thread, you can use the benchmark script against your own amuled instance on localhost. It speaks the full ed2k obfuscated protocol and simulates a downloading client:

Benchmark script: https://gist.github.com/got3nks/3e7661add07ba862a5ead0aa2e85ff28

Quick test (requires a shared file and pycryptodome for best performance):

pip3 install pycryptodome
python3 amule-bench.py localhost <ed2k_hash> --userhash-file ~/.aMule/preferences.dat --duration 120 --filesize <size_in_bytes>

@mifritscher2

Copy link
Copy Markdown

Works fine as well.

If I limit the upload rate to 2000 KB/s, I get 1,96 MB/s.
If I limit it to 19375 KB/s (interestingly the GUI caps it to this limit), I get 16 MB/s. More than enough ;-)

The receiver requests blocks of EMBLOCKSIZE (180 KiB). Chunks larger
than a block have no effect since each block is sent as a single
packet when chunkSize >= togo.  The previous 128 KiB ceiling caused
each 180 KiB block to be split into two packets unnecessarily.

Ceiling changed from the magic constant 131072 to EMBLOCKSIZE so
the chunk size naturally tracks the protocol's block size.  At fast
upload rates (>= 1.4 MB/s) each block is now sent as one packet
instead of two, halving per-block packet overhead.
got3nks added 2 commits April 20, 2026 00:04
OnExit() deleted uploadqueue before calling uploadDiskIOThread->EndThread().
If the disk I/O thread was mid-iteration over uploadqueue->GetUploadingList()
when uploadqueue was freed, it would access dangling memory and crash
(wxFatalSignalHandler → abort).

Fix: stop uploadDiskIOThread first, before freeing uploadqueue.
The EPOLLET spurious-wakeup workaround added in the CUploadDiskIOThread
port uses <sys/socket.h>, <unistd.h>, and ::recv() with
MSG_PEEK | MSG_DONTWAIT. That all works on Linux but breaks the Windows
MinGW compile: <sys/socket.h> doesn't exist, and MSG_DONTWAIT isn't a
defined flag in WinSock2.

boost::asio on Windows uses IOCP rather than EPOLLET, so the underlying
reason for the peek-recv dance — a completion handler firing with zero
bytes after a concurrent drain on another ASIO thread consumed the
data — does not apply. An async_read_some completion with zero bytes
on IOCP cleanly means EOF.

- Guard the POSIX headers with #ifdef _WIN32, including <winsock2.h>
  on Windows instead.
- On Windows, treat "avail == 0 in a read completion" as EOF directly,
  skipping the peek recv. Linux/Mac keep the existing behaviour.
@mrjimenez
mrjimenez merged commit cd0c831 into amule-project:master Apr 22, 2026
3 checks passed
@got3nks
got3nks deleted the async-disk-io-upload-pr branch May 3, 2026 15:19
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
got3nks added a commit to got3nks/amule that referenced this pull request May 5, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (amule-project#451) dominates and the leecher-side stack
   (amule-project#454/amule-project#484/amule-project#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
mrjimenez pushed a commit that referenced this pull request May 6, 2026
Two tables at the top of the Performance section in 3.0.0:

1. Cross-platform end-to-end (2.3.3 vs 3.0.0) — sustained download
   throughput on macOS / Linux ARM / Windows ARM, peer-to-peer over
   LAN, 30 GB file:
     macOS:   0.35 -> 135 MB/s   (381x)
     Linux:   0.34 -> 117 MB/s   (345x)
     Windows: 0.36 ->  39 MB/s   (107x)

   Plus a per-platform seeder/leecher contribution split that shows
   the seeder-side fix (#451) dominates and the leecher-side stack
   (#454/#484/#491) adds another 4.5-5.8x on top.

2. vs eMule 0.70b on Windows — same UTM hardware, both directions:
     Upload   (Windows seeds -> Mac leecher):  22 vs 106 MB/s  (~4.8x)
     Download (Linux seeds -> Windows leech):  20 vs  39 MB/s  (~1.9x)

   Methodology footnote on the table since eMule has no remote-
   control protocol and is measured leecher-side via aMule's EC
   channel on the receiving box.

Highlights line 16 also updated to mention both directions of the
eMule comparison instead of just download.

The existing per-PR benches in #### Upload / #### Download stay
unchanged — those are PR-specific numbers documenting how each
single change contributed; the new tables document the end-to-end
2.3.3-to-3.0.0 user experience.

Numbers measured with the bench-matrix.sh dispatcher
(scripts/bench-matrix.sh) — 12-run aMule-vs-aMule matrix plus 2
manual aMule-vs-eMule runs. Raw logs in
bench-results/bench-matrix-20260505T141651Z/.
ngosang pushed a commit to ngosang/amule that referenced this pull request Jul 13, 2026
…mule-project#445) (amule-project#471)

The per-poll log batching added in amule-project#451 scrolls the "aMule Log" text ctrl
(EndLogBatch) while it is still frozen, then thaws. On Windows, ShowPosition()
on a frozen wxTE_RICH2 control doesn't lay out the visible region, so after
Thaw the view comes back blank -- only the last line pinned at the top -- until
a manual scroll forces a repaint. Since every stats poll carrying new daemon
log lines runs through this path, the log window blanked on each new line.

Thaw the control before scrolling so ShowPosition operates on a live control.
Keeps the batching win (Freeze still spans the appends); macOS/GTK, which
tolerate scroll-while-frozen, are unaffected either way.
mrjimenez pushed a commit to mrjimenez/amule that referenced this pull request Jul 15, 2026
…ject#445) + list rows blanking on scroll/keyboard (amule-project#478) (amule-project#477)

* fix(remote-gui): stop the log view blanking on Windows by dropping Freeze/Thaw (amule-project#445)

amule-project#471 tried to fix the "log view goes blank on every new line" Windows
regression by thawing before scrolling, but it made no difference: the
AppendText still ran while the control was frozen. Appending to a frozen
wxTE_RICH2 (RichEdit) on Windows leaves its line/scroll metrics stale, so on
Thaw the view renders blank with the newest line pinned to the top until a
manual scroll forces a recompute. The Freeze()/Thaw() that amule-project#451 wrapped the
per-poll appends in is the actual culprit, not the scroll order.

Drop the Freeze()/Thaw() and append on a live control (as the pre-amule-project#451 code
did). Keep the two real wins from amule-project#451: the daemon's 5000-lines-per-poll cap
and the conditional SetDefaultStyle, plus one coalesced ShowPosition per poll
instead of per line (m_logBatching still suppresses the per-line scroll). The
per-line SetDefaultStyle was the dominant first-sync cost, so responsiveness is
retained without the frozen-append rendering corruption.

macOS/GTK recompute metrics regardless and were unaffected either way.

* fix(remote-gui): repaint list rows on scroll & keyboard nav on Windows (amule-project#478)

amule-project#348 replaced the vendored wxListMainWindow::OnScroll's synchronous
HandleOnScroll(event) with event.Skip() -- HandleOnScroll became a private
member in wx 3.3.3, so the macOS/Linux CI (on wx 3.3.x) stopped compiling. On
wxMSW the resulting deferred-scroll path (wxScrollHelperBase::HandleOnScroll ->
ScrollWindow) blits the retained rows and only invalidates the newly exposed
strip, which is then left unpainted: rows go blank on mouse-wheel, scrollbar and
pagination scrolling until a redraw is forced. Verified in the wx 3.2.10 and
3.3.x sources that the scroll+repaint mechanism is identical, so this is a wxMSW
platform behavior, not a wx-version one.

Rather than gate on platform or wx version (both fragile -- the former needs the
now-private HandleOnScroll, the latter breaks once Windows ships wx 3.3.3),
reimplement the synchronous scroll using only the public wxScrollHelper API
(GetViewStart / GetScrollLines / GetScrollPageSize / Scroll), mirroring
HandleOnScroll()/CalcScrollInc(): translate the scroll event into a target
position in scroll units and scroll to it.

Factor the "scroll + repaint" sequence into a shared ScrollListTo(x, y) helper
(Update() to flush pending paints so the blit is clean, Scroll(), then
ResetVisibleLinesRange() so the exposed rows repaint) and route both OnScroll
(scrollbar/wheel) and MoveToItem (keyboard nav) through it. This also fixes the
keyboard HOME/END/PgUp/PgDn blanking, which was the same bug from a different
path: MoveToItem only reset the visible-line range after Scroll() under
__WXMAC__, so on Windows the range stayed stale and keyboard scrolls came up
blank. The reset now runs on every platform via the shared helper, and the old
__WXMAC__-only workaround is gone.

Not skipping the event means the base scroll helper won't also scroll, so no
double-scroll. Works on every supported wx (>= 3.2.0) and every platform with no
version or platform guard; compiles against wx 3.3.3.

Refs amule-project#478
got3nks added a commit to got3nks/amule that referenced this pull request Jul 22, 2026
The amuleGUI log panes used a wxTE_RICH2 (RichEdit) control, which holds the
whole document and reflows O(n) on scroll. A remote-GUI first sync can dump
tens of thousands of daemon-log lines at once, so scrolling crawled and the
view mispainted -- only the tail visible until a manual one-line scroll
(issues amule-project#445, amule-project#547).

Replace the three log/info panes (aMule Log, aMuleGUI Log, server info) with a
new CMuleLogCtrl backed by wxStyledTextCtrl. Scintilla renders only the
visible lines, so scrolling and full-history retention stay O(visible) at any
size, and unlike a virtual list it keeps character-level selection and find.
Critical lines are bold via per-line Scintilla styles; the view tail-scrolls
only when already at the bottom, so scrolling up to read history sticks.

This removes the whole RichEdit workaround stack -- the per-poll append
batching, the deferred hidden-page scroll, and the Freeze/Thaw lineage from
amule-project#451/amule-project#471/amule-project#477 -- none of which Scintilla needs.

wxStyledTextCtrl (the stc component) ships with the wxWidgets packages every
platform already builds against, so it adds no new dependency; it is requested
only for the GUI library that owns the log views.
got3nks added a commit to got3nks/amule that referenced this pull request Jul 22, 2026
The amuleGUI log panes used a wxTE_RICH2 (RichEdit) control, which holds the
whole document and reflows O(n) on scroll. A remote-GUI first sync can dump
tens of thousands of daemon-log lines at once, so scrolling crawled and the
view mispainted -- only the tail visible until a manual one-line scroll
(issues amule-project#445, amule-project#547).

Replace the three log/info panes (aMule Log, aMuleGUI Log, server info) with a
new CMuleLogCtrl backed by wxStyledTextCtrl. Scintilla renders only the
visible lines, so scrolling and full-history retention stay O(visible) at any
size, and unlike a virtual list it keeps character-level selection and find.
Critical lines are bold via per-line Scintilla styles; the view tail-scrolls
only when already at the bottom, so scrolling up to read history sticks.

This removes the whole RichEdit workaround stack -- the per-poll append
batching, the deferred hidden-page scroll, and the Freeze/Thaw lineage from
amule-project#451/amule-project#471/amule-project#477 -- none of which Scintilla needs.

wxStyledTextCtrl (the stc component) ships with the wxWidgets packages every
platform already builds against, so it adds no new dependency; it is requested
only for the GUI library that owns the log views.
got3nks added a commit to got3nks/amule that referenced this pull request Jul 22, 2026
The amuleGUI log panes used a wxTE_RICH2 (RichEdit) control, which holds the
whole document and reflows O(n) on scroll. A remote-GUI first sync can dump
tens of thousands of daemon-log lines at once, so scrolling crawled and the
view mispainted -- only the tail visible until a manual one-line scroll
(issues amule-project#445, amule-project#547).

Replace the three log/info panes (aMule Log, aMuleGUI Log, server info) with a
new CMuleLogCtrl backed by wxStyledTextCtrl. Scintilla renders only the
visible lines, so scrolling and full-history retention stay O(visible) at any
size, and unlike a virtual list it keeps character-level selection and find.
Critical lines are bold via per-line Scintilla styles; the view tail-scrolls
only when already at the bottom, so scrolling up to read history sticks.

This removes the whole RichEdit workaround stack -- the per-poll append
batching, the deferred hidden-page scroll, and the Freeze/Thaw lineage from
amule-project#451/amule-project#471/amule-project#477 -- none of which Scintilla needs.

wxStyledTextCtrl (the stc component) ships with the wxWidgets packages every
platform already builds against, so it adds no new dependency; it is requested
only for the GUI library that owns the log views.
got3nks added a commit to got3nks/amule that referenced this pull request Jul 22, 2026
The amuleGUI log panes used a wxTE_RICH2 (RichEdit) control, which holds the
whole document and reflows O(n) on scroll. A remote-GUI first sync can dump
tens of thousands of daemon-log lines at once, so scrolling crawled and the
view mispainted -- only the tail visible until a manual one-line scroll
(issues amule-project#445, amule-project#547).

Replace the three log/info panes (aMule Log, aMuleGUI Log, server info) with a
new CMuleLogCtrl backed by wxStyledTextCtrl. Scintilla renders only the
visible lines, so scrolling and full-history retention stay O(visible) at any
size, and unlike a virtual list it keeps character-level selection and find.
Lines word-wrap as the old pane did; critical lines are bold via per-line
Scintilla styles.

The view tail-scrolls after new content only when it was already at the
bottom, so scrolling up to read history sticks. When the backlog arrives while
the Networks page is hidden (the default tab on launch is Transfer) the log
control has no laid-out geometry and cannot be scrolled reliably, so the
tail-scroll is deferred and performed when the page is next shown.

This removes the whole RichEdit workaround stack -- the per-poll append
batching and the Freeze/Thaw lineage from amule-project#451/amule-project#471/amule-project#477 -- none of which
Scintilla needs.

wxStyledTextCtrl (the stc component) ships with the wxWidgets packages every
platform already builds against, so it adds no new dependency; it is requested
only for the GUI library that owns the log views.
got3nks added a commit to got3nks/amule that referenced this pull request Jul 22, 2026
The amuleGUI log panes used a wxTE_RICH2 (RichEdit) control, which holds the
whole document and reflows O(n) on scroll. A remote-GUI first sync can dump
tens of thousands of daemon-log lines at once, so scrolling crawled and the
view mispainted -- only the tail visible until a manual one-line scroll
(issues amule-project#445, amule-project#547).

Replace the three log/info panes (aMule Log, aMuleGUI Log, server info) with a
new CMuleLogCtrl backed by wxStyledTextCtrl. Scintilla renders only the
visible lines, so scrolling and full-history retention stay O(visible) at any
size, and unlike a virtual list it keeps character-level selection and find.
Lines word-wrap as the old pane did; critical lines are bold via per-line
Scintilla styles.

The view tail-scrolls after new content only when it was already at the
bottom, so scrolling up to read history sticks. When lines arrive while a pane
is hidden (its notebook page or sub-tab is not selected -- e.g. the first-sync
backlog before the Networks tab is opened) the control has no laid-out geometry
and cannot be scrolled reliably, so the tail-scroll is deferred and applied on
the first idle once the pane is on screen. This lives in CMuleLogCtrl, so all
three panes share it.

This removes the whole RichEdit workaround stack -- the per-poll append
batching and the Freeze/Thaw lineage from amule-project#451/amule-project#471/amule-project#477 -- none of which
Scintilla needs.

wxStyledTextCtrl (the stc component) ships with the wxWidgets packages every
platform already builds against, so it adds no new dependency; it is requested
only for the GUI library that owns the log views.
got3nks added a commit to got3nks/amule that referenced this pull request Jul 22, 2026
…le-project#548)

The amuleGUI log panes used a wxTE_RICH2 (RichEdit) control, which holds the
whole document and reflows O(n) on scroll, so a remote-GUI first-sync backlog
of tens of thousands of daemon-log lines made scrolling crawl and mispaint
(only the tail visible until a manual scroll).

Replace the three log/info panes (aMule Log, aMuleGUI Log, server info) with a
new CMuleLogCtrl backed by wxStyledTextCtrl (Scintilla), which renders only the
visible lines: full history is kept and scrolling stays O(visible) at any size,
with character-level selection/find preserved. Lines word-wrap; critical lines
are bold via per-line styles. Tail-scroll only fires when already at the bottom;
a scroll requested while the pane is hidden is deferred to the first idle once
it is on screen (in the base control, so all three panes share it). Removes the
RichEdit workaround stack (per-poll batching, the Freeze/Thaw lineage of
amule-project#451/amule-project#471/amule-project#477). The stc component ships with every platform's wxWidgets and is
requested only for the GUI library; the Windows portable bundles its DLL via the
existing GET_RUNTIME_DEPENDENCIES install step. Reported in amule-project#547.
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.

3 participants