Skip to content

EC: detect dead amuled cleanly across all clients (#757) - #758

Merged
mrjimenez merged 4 commits into
amule-project:masterfrom
got3nks:fix/ec-tcp-keepalive
May 28, 2026
Merged

EC: detect dead amuled cleanly across all clients (#757)#758
mrjimenez merged 4 commits into
amule-project:masterfrom
got3nks:fix/ec-tcp-keepalive

Conversation

@got3nks

@got3nks got3nks commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #757. When amuled's EC connection died (process killed, network blip, FIN/RST lost) every client and the daemon itself failed to react usefully:

  • amulegui — kernel observed FIN, asio reactor fired HandleRead with EOF, but dispatch landed in the empty CLibSocket::OnLost(){} default instead of the CECSocket::OnLost override CRemoteConnect actually overrides to flip the UI. The wedge symptom in the reporter's trace.
  • amuleweb — sync EC mode means no async_read pending after auth, so EOF was never seen at the asio layer. HTTP requests continued to return template-shell HTML 200s with stale data — no error visible to the user, no exit.
  • amulecmd — same root cause as amuleweb. Every command after disconnect failed but the loop kept prompting.
  • amuled server side — when a remote-GUI / web / cmd client died ungracefully, the accepted CECServerSocket sat on the half-open connection for the default ~2h TCP retransmit timeout, holding m_ec_notifier references and per-client state. Heavy sharesets (Stoatwblr's seedbox class) accumulate these.

Three layered fixes

TCP keepalive on both ends (10103389a)

SO_KEEPALIVE + per-socket TCP_KEEPIDLE (30s) / TCP_KEEPINTVL (10s) / TCP_KEEPCNT (3) on every EC socket on both ends. The win is asymmetric and worth stating honestly: the primary benefit is amuled-side cleanup. amulegui sends INC_UPDATE requests every few seconds so its connection is rarely idle long enough for the 30s timer to fire — its detection comes from TCP retransmit timeout when amuled stops responding. amuleweb / amulecmd CAN sit idle long enough for keepalive to fire kernel-side, but app-level detection still needs a read/write attempt. Where keepalive really pays off is the amuled side: when a remote client dies ungracefully, the accepted CECServerSocket gets freed in ~60s instead of ~2h, so server-side resources don't accumulate.

OnLost dispatch fix for the async EC client (48a98504a)

CECMuleSocket multi-inherits from CECSocket and CLibSocket. Both declared virtual void OnLost(). With identical signatures they live in separate vtables in the combined object — there was no shared override, and the asio reactor's socket->OnLost() dispatch (through the CLibSocket vtable) landed in the empty base instead of the EC-layer override. Fixed by renaming the CLibSocket-side hook to OnLost(int) (mirroring the existing OnConnect(int) pattern) so CECMuleSocket can override it unambiguously and forward to the EC-layer virtual via a static_cast<CECSocket*>. This is the load-bearing fix for amulegui — without it, no application-level reaction fires even when the kernel sees the FIN.

Sync clients fire OnLost from ReadSync / WriteSync (766bd6ffa)

Sync mode has no pending async_read so the EOF that fires HandleRead → PostLostEvent for async clients never happens. ReadSync / WriteSync now call a new DispatchSyncLost helper on non-zero error_code — direct synchronous dispatch through the same wrapper->OnLost(0) path the async reactor uses, just from the calling thread since it's already in sync-mode context. CRemoteConnect::OnLost's NULL-m_notifier branch then prints "External Connection lost — exiting" to stderr and calls _exit(1) so the supervisor (systemd / docker / shell loop) handles restart and reconnect rather than the process serving stale data in limp mode.

Direct dispatch rather than PostLostEvent + wxQueueEvent because amulecmd's main thread is in fgets reading stdin, not the wx event loop — queued events would never be processed. amuleweb has a wxApp::OnRun main loop but it's not running while an HTTP request handler is on the stack. Direct dispatch from the sync-mode call site avoids both questions.

_exit instead of exit() because we're calling from contexts with asio worker threads about to be torn down; racing static destructors against them risks the kind of UAF #748 was about. OS reaps fds at process exit anyway.

Live verification on Linux VM

  • amulegui + amuled killed with SIGKILL (FIN propagates): "Connection failure" dialog fires within ~1s.
  • amulegui + iptables drop on amuled's outbound EC port (true half-open, no FIN): "Connection failure" dialog within ~33s. Detection came from TCP retransmit timeout (amulegui's periodic requests stopped getting ACKs), not keepalive — but the dispatch chain handled it correctly either way.
  • amuleweb: first HTTP request after amuled killed triggers ReadSync EOF → OnLost_exit(1). Subsequent requests get connection refused.
  • amulecmd: first command after amuled killed prints External Connection lost — exiting. and exits with code 1.
  • amuled server side: ss -to confirms SO_KEEPALIVE + TCP_KEEPIDLE=30 on every accepted EC socket.
  • Builds clean on macOS and Linux.

Test plan

  • amulegui graceful disconnect — UI dialog fires
  • amulegui network-partition (half-open) — UI dialog fires via TCP retransmit
  • amuleweb first failing request — clean _exit
  • amulecmd first failing command — clean _exit
  • amuled accept-side keepalive timer visible in ss -to
  • Builds clean on macOS (Mac local) and Linux (Ubuntu ARM VM)

got3nks added 3 commits May 28, 2026 15:27
…amule-project#757)

Without keepalive, if amuled's FIN or RST never reaches amulegui /
amulecmd / amuleweb (peer crashed, network blip, process killed,
packet dropped), the kernel sits on the half-open connection for
the default ~2h TCP retransmit timeout. CECSocket::OnLost never
fires, the GUI shows a connected status that's actually dead, and
the daemon side keeps the CECServerSocket + its m_ec_notifier
reference live for the same duration.

Symmetric SO_KEEPALIVE + TCP_KEEPIDLE/INTVL/CNT (idle=30s,
interval=10s, count=3 → ~60s half-open detection) on every EC
socket, applied:

* Client side from CECMuleSocket::InternalConnect on a successful
  CLibSocket::Connect (catches sync clients like amulecmd).
* Async client side from CRemoteConnect::OnConnect, which fires
  when the async_connect handler runs on amulegui / amuleweb (where
  InternalConnect returns before the underlying connect completes,
  so we have to wait for the actual establishment).
* Server side from CExternalConnListener::OnAccept right after
  AcceptWith succeeds, on the freshly-created CECServerSocket.

The plumbing:

* LibSocketAsio.cpp: SetTcpKeepalive() static helper modelled on
  SetCloexecOnSocket — POSIX uses setsockopt(SOL_SOCKET,
  SO_KEEPALIVE) + the three TCP_KEEP* knobs (TCP_KEEPALIVE as the
  idle spelling on macOS/*BSD, no INTVL/CNT there), Windows uses
  WSAIoctl(SIO_KEEPALIVE_VALS) which exposes idle + interval but
  not count.
* CAsioSocketImpl::EnableTcpKeepalive() applies the helper to
  m_socket->native_handle() when the socket is open.
* CLibSocket::EnableTcpKeepalive() delegates to the impl.
* CECMuleSocket::ApplyEcKeepalive() bakes in the EC-tuned timings
  so the three call sites stay one-liners.

Refs amule-project#757.
…lips the UI

CECMuleSocket multi-inherits from CECSocket (EC protocol layer) and
CLibSocket (transport layer). Both declare `virtual void OnLost()`
with identical signature, so they live in separate vtables in the
combined object and there's no shared override. The Asio reactor's
HandleRead (LibSocketAsio.cpp) calls socket->OnLost() through the
CLibSocket vtable on EOF / peer-FIN — but that vtable slot is the
empty CLibSocket::OnLost(){} default, because nothing was overriding
the LibSocket-side virtual. CRemoteConnect::OnLost and
CECServerSocket::OnLost (the ones that actually flip the GUI to
disconnected and release server-side state) override the *CECSocket*
side and never get called from the Asio path.

End result on the wire: amuled closes the EC socket, amulegui /
amuleweb's kernel receives FIN and parks the socket in CLOSE_WAIT,
the Asio reactor reports EOF, dispatch lands in the empty stub, and
the client process happily sits "connected" forever. Verified live
on a Linux VM in this branch's previous keepalive-only commit — the
TCP-layer teardown via keepalive eventually fires (~60s) but
application-level state never reacts.

Fix mirrors the existing OnConnect(int) pattern in CECMuleSocket:
disambiguate by signature.

* CLibSocket::OnLost() → OnLost(int) (param ignored, exists only to
  give the LibSocket-side virtual a different signature from the
  EC-side one).
* LibSocketAsio.cpp dispatch updated to call OnLost(0).
* CWebSocket::OnLost overrides on the new signature (amuleweb's
  HTTP-side socket inherits CLibSocket directly so this is the right
  hook there).
* New CECMuleSocket::OnLost(int) forwards to CECSocket::OnLost()
  through a static_cast<CECSocket*>(this) call so virtual dispatch
  finds the most-derived override (CRemoteConnect on amulegui /
  amuleweb's EC side, CECServerSocket on amuled).

Refs amule-project#757.
…mulecmd exit cleanly

In sync mode (use_events=false on CECMuleSocket → amulecmd, amuleweb)
there's no pending async_read after the auth handshake, so the EOF
that fires HandleRead → PostLostEvent for async clients (amulegui)
never gets seen. SendRecvPacket detects the failure on its next
ReadSync (or WriteSync, eventually after keepalive teardown) and
returns NULL, but nothing notifies the EC layer — amuleweb continues
serving HTTP template shells forever with no live amuled data, no
error visible to the user, no exit. amulecmd sits at its prompt
with a dead socket and every command silently failing.

Fix: ReadSync / WriteSync, on a non-zero error_code, dispatch
OnLost(0) directly through the LibSocket wrapper (DispatchSyncLost
helper) — same dispatch path the asio reactor would take for async
clients, just synchronous since the sync-mode caller is already on
the main thread. From there:

* CECMuleSocket::OnLost(int) forwards through static_cast<CECSocket*>
  to the EC-layer virtual.
* CRemoteConnect::OnLost: if m_notifier is set (amulegui), posts the
  existing wxEVT_EC_CONNECTION event and the GUI handler flips the
  UI. If NULL (amuleweb, amulecmd), prints "External Connection
  lost — exiting" and calls _exit(1) so the supervisor (systemd /
  shell / docker) decides whether to restart and reconnect.

Direct synchronous dispatch (rather than PostLostEvent + wxQueueEvent)
because amulecmd's main thread is in fgets reading stdin, not in
wxApp's event loop, so queued events would never be processed.
amuleweb's wxApp::OnRun is the main loop but it's not running while
the HTTP request handler is on the stack — direct dispatch from
the same sync-mode call site avoids both reactor and event-loop
coordination questions.

_exit instead of exit() because we're calling from the main thread
holding stacks the asio threads will tear down on shutdown; racing
static destructors against them risks the kind of UAF amule-project#748 was
about. The OS reaps fds / sockets on process exit anyway.

Live-verified on Linux VM:

* amulegui: TCP-level FIN observed, UI flips to "Connection failure"
  within ~1s.
* amuleweb: First HTTP request after amuled killed returns with
  stale template, then amuleweb exits before next request can be
  served (curl gets connection refused).
* amulecmd: First "status" command after amuled killed prints
  "External Connection lost — exiting" to stderr and exits with
  code 1. User sees their shell prompt return.

Refs amule-project#757.
… error

ReadHeader / ReadPacket call CloseSocket() on six different
protocol-error paths (oversize header, unauthorized resize, bad
packet flags, zlib init/free, ReadFromSocket failure). On amulegui
that route looks like:

  asio HandleRead -> CECSocket::OnInput -> ReadPacket
    -> CloseSocket() -> CAsioSocketImpl::Close()

CAsioSocketImpl::Close sets m_closed = true *before* the asio close,
so when HandleRead later fires with operation_aborted the
PostLostEvent gate at LibSocketAsio.cpp:695 is closed and OnLost
never bubbles up. The wrapper-OnLost dispatch fix landed in this
branch only covered the kernel-FIN leg; locally-initiated aborts
sit in the same wedge state amule-project#757 was about.

Stoatwblr's seedbox repro caught exactly this: the link-add
re-entrancy in amule-project#757/amule-project#760 corrupts the rx state machine, ReadPacket
logs "ReadPacket: error in packet read", we CloseSocket ourselves,
amuled sees the FIN and logs "External connection closed", and
amulegui sits silent on stale data because OnLost was suppressed.

Add CECSocket::CloseAndDispatchLost() (inline in ECSocket.h) that
follows InternalClose with a virtual OnLost call, and route all six
ReadHeader / ReadPacket protocol-error sites through it. The new
helper bypasses the m_closed gate by dispatching OnLost on the EC
layer directly -- the same shape DispatchSyncLost already uses for
sync clients. ProcessAuthPacket keeps using plain CloseSocket since
it already fires wxEVT_EC_CONNECTION itself.

The double-fire concern (asio's later operation_aborted hitting
PostLostEvent again) doesn't materialise: by the time the asio
HandleRead fires after our InternalClose, m_closed is true and
PostLostEvent skips -- the same gate that was suppressing the
useful first dispatch is exactly what suppresses the redundant
second one.

amuled side gets the same fix for free: CECServerSocket::OnLost
now runs on protocol-error closes too, so the m_ec_notifier
reference and per-client state are cleaned up immediately instead
of leaking until the connection times out.
@got3nks

got3nks commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up after Stoatwblr's latest verification on #757 — this branch alone reproduced the desync as expected (we haven't touched the link-add corruption path yet, that's #760), but the wedge detection still didn't fire on his seedbox. His log showed ReadPacket: error in packet read, which is the local-protocol-error CloseSocket path in CECSocket::ReadPacket.

Root cause for the detection miss: CAsioSocketImpl::Close at LibSocketAsio.cpp:396-408 sets m_closed = true before the asio close, so when HandleRead later fires with operation_aborted the PostLostEvent gate at LibSocketAsio.cpp:695 suppresses OnLost. The wrapper-OnLost dispatch fix already in this branch only catches the kernel-FIN leg; locally-initiated aborts sit in the same wedge state.

Added a fourth commit: new CECSocket::CloseAndDispatchLost() helper that does InternalClose + OnLost, routed through the six ReadHeader / ReadPacket protocol-error sites. Same shape as the DispatchSyncLost pattern this PR already uses for sync clients — bypasses the m_closed gate by dispatching OnLost on the EC layer directly. CRemoteConnect::ProcessAuthPacket keeps plain CloseSocket since it already fires wxEVT_EC_CONNECTION itself.

@mrjimenez
mrjimenez merged commit a6351ce into amule-project:master May 28, 2026
7 checks passed
@got3nks
got3nks deleted the fix/ec-tcp-keepalive branch June 3, 2026 14:16
mrjimenez pushed a commit to mrjimenez/amule that referenced this pull request Aug 4, 2026
)

* feat(ec): poll every search's progress in one request

amuleGUI and amuleapi both polled EC_OP_SEARCH_PROGRESS once per open
search, so N tabs cost N round trips every cycle. The set they polled was
never pruned on completion either -- a search only leaves m_activeSearches
when its tab closes -- so finished searches kept being asked about a
lifecycle that could no longer change.

Add EC_TAG_CAN_SEARCH_PROGRESS_UNION. A client that advertises it and
sends EC_OP_SEARCH_PROGRESS with no EC_TAG_SEARCH_ID gets one child per
search instead of a single search's progress, so one request covers every
tab. Advertised only alongside multi-search: for a single-search client an
id-less request keeps its legacy "current search" meaning, which is what
amulecmd's `search progress` with no argument relies on.

The daemon emits per-id and union tags from one AppendSearchProgress
helper, and amuleGUI decodes both through one ApplySearchProgress, so the
two shapes cannot drift. Old daemons never echo the capability and both
clients keep their per-id path.

Two details the union has to get right:

The reply carries no EC_TAG_SEARCH_EXPIRED. It reports the daemon's whole
set, so a tab whose id is absent is one the daemon no longer holds. That
lands on the same cycle rather than whenever that id next gets polled, and
it catches an LRU eviction the per-id form only notices when it happens to
ask about that search.

Once negotiated the reply shape is fixed by the capability, not by the
request: naming ids narrows which searches come back, it does not opt back
into the single-search reply. Keying the union off "no ids named" instead
answers a client that named several about only the first, and it reads
every other search's absence as an expiry -- which is what the amuleapi
search smoke caught.

The request names the ids the client tracks, and the daemon reports and
Touches exactly those, resolving each through CSearchList::IsKnownSearchId
as the per-id form does rather than filtering a walk of the daemon's own
maps. amuleGUI's results poll takes the union branch,
which never Touched, so the per-id progress poll was its only LRU refresh
-- dropping it would let an overflowing ring evict a search the user still
has open. Touching everything the daemon holds instead would have made an
abandoned search as protected as a live tab. Carrying the ids costs
nothing: they ride in the one request either way.

Matters most for amuleapi, whose progress calls are synchronous
SendRecvSerialized round trips under a process-wide mutex, so N searches
serialised N of them inside a single tick.

Both clients skip the request entirely when they track nothing, so an
idle daemon costs no progress roundtrip at all -- the per-id loop simply
had nothing to iterate, and an unconditional union request would have
turned that into one every cycle.

* fix(amuleapi): do not treat an unparseable reply as an empty union

The union poll set have_union on a non-null reply alone. Absence from a
union reply is how a search is learnt to have expired, so any reply that
was not a union -- an EC_OP_FAILED, of which the daemon has around thirty
sources -- came back as an empty map and retired every tracked search in
one pass: active=false, terminal snapshot, a final search_progress SSE
frame to every subscriber.

The per-id form this replaced cannot do that. It expires only on an
explicit EC_TAG_SEARCH_EXPIRED, and an unexpected reply just leaves the
values at their defaults, so the union turned a fail-safe path into a
fail-dangerous one.

Gate on the opcode. A reply that is not EC_OP_SEARCH_PROGRESS now falls
through to per-id polling: slower, but correct. The discriminator is the
opcode and never the child count -- an empty union with the right opcode
is the daemon legitimately reporting that it holds none of the searches
asked about, and must still retire them.

The parse moves into Refresher.cpp as ParseSearchProgressUnion so it is
reachable from RefresherTest, which links the pure parsers but not the
wxApp-dependent tick body. Four tests cover the cases that matter: every
entry parsed, an omitted id absent from the map, an empty reply accepted,
and a failure reply rejected without touching the map. The last one fails
if the opcode gate is removed.

amuleGUI was never exposed to this -- HandlePacket gates on the opcode at
entry.

Also hoist the daemon's union check above the response allocation, so the
union branch no longer allocates a packet only to delete it.
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.

2 participants