EC: detect dead amuled cleanly across all clients (#757) - #758
Conversation
…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.
|
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 Root cause for the detection miss: Added a fourth commit: new |
) * 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.
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:
HandleReadwith EOF, but dispatch landed in the emptyCLibSocket::OnLost(){}default instead of theCECSocket::OnLostoverrideCRemoteConnectactually overrides to flip the UI. The wedge symptom in the reporter's trace.async_readpending 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.m_ec_notifierreferences and per-client state. Heavy sharesets (Stoatwblr's seedbox class) accumulate these.Three layered fixes
TCP keepalive on both ends (
10103389a)SO_KEEPALIVE+ per-socketTCP_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)CECMuleSocketmulti-inherits fromCECSocketandCLibSocket. Both declaredvirtual void OnLost(). With identical signatures they live in separate vtables in the combined object — there was no shared override, and the asio reactor'ssocket->OnLost()dispatch (through the CLibSocket vtable) landed in the empty base instead of the EC-layer override. Fixed by renaming the CLibSocket-side hook toOnLost(int)(mirroring the existingOnConnect(int)pattern) soCECMuleSocketcan override it unambiguously and forward to the EC-layer virtual via astatic_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_readso the EOF that firesHandleRead → PostLostEventfor async clients never happens.ReadSync/WriteSyncnow call a newDispatchSyncLosthelper on non-zeroerror_code— direct synchronous dispatch through the samewrapper->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_notifierbranch 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 + wxQueueEventbecause amulecmd's main thread is infgetsreading 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._exitinstead ofexit()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
ReadSyncEOF →OnLost→_exit(1). Subsequent requests get connection refused.External Connection lost — exiting.and exits with code 1.ss -toconfirmsSO_KEEPALIVE+TCP_KEEPIDLE=30on every accepted EC socket.Test plan
_exit_exitss -to