A restart used to disconnect every client. eMule then reconnects to whichever server is
first in its priority-sorted list (srchybrid/ServerConnect.cpp, CS_DISCONNECTED →
ConnectToAnyServer()), which is often not this one. Clients that do come back
re-publish their whole share list and lose the search they were running.
A handover avoids that. The running server starts its successor (normally a new binary at the same path) and passes it every listening socket, every TCP session and the storage state those sessions need. The successor carries on serving them, and the old process exits. Clients see a pause of a few tens of milliseconds and nothing else: no disconnect, no new login, no re-publish. Obfuscated sessions continue at the exact byte of their RC4 keystream.
Linux and macOS only. Windows cannot pass sockets between processes, so a restart there drops the connections as before.
| How | What happens |
|---|---|
scripts/update.sh |
Installs the new release. If an enode from that directory is running, sends it SIGUSR2 and waits for the successor, instead of starting a second server. |
kill -USR2 <pid> |
Hands over to whatever binary is now at the path the server was started from, or to the same binary if nothing changed. |
systemd ExecReload |
systemctl reload enode with the unit below. |
The pid is in pidFile (default data/enode.pid), which the server writes once it
serves. The successor rewrites it with its own pid. A clean stop removes it.
The successor runs detached from the terminal, so Ctrl+C no longer stops it: use kill $(cat data/enode.pid).
pidFile: "data/enode.pid"update.sh takes the file's location from pidFile in the config file: the one named
by -config among its arguments, else enode.config.yaml in its install directory.
Without that key it uses data/enode.pid, and ENODE_PIDFILE overrides all of these.
Relative paths resolve against the install directory. Before signalling, it checks that the pid belongs
to a process named enode, because SIGUSR2 kills a process that does not handle it,
including an enode older than this feature. Those older versions write no pid file, so
they are never signalled. ENODE_NO_HANDOFF=1 skips the handover, and
ENODE_HANDOFF_WAIT (default 900 s) bounds how long the script waits for one.
A handover that is refused or fails leaves its reason in <pidFile>.handoff
(data/enode.pid.handoff). update.sh removes that file before signalling and reports
it as soon as it appears, instead of waiting out ENODE_HANDOFF_WAIT. The old server is
then still running the previous version, and the script says so.
The successor gets the predecessor's command line without -daemon (it is started
detached anyway) and without -reindex-manticore (which would rebuild the index on every
handover). It reads the config file again, so config changes take effect. Ports that
changed are bound fresh, and inherited sockets the new config does not use are closed.
With the default Type=simple, systemd treats the old process's exit as the service
stopping and kills the successor along with it. Let the successor announce itself
instead:
[Service]
Type=notify
NotifyAccess=all
ExecStart=/opt/enode/enode -config /opt/enode/enode.config.yaml
ExecReload=/bin/kill -USR2 $MAINPIDThe server sends READY=1 once it serves. A successor sends MAINPID=<its pid> and
READY=1 once it has taken over, so systemd follows the service to the new process. If
NOTIFY_SOCKET is not set, none of this is sent.
predecessor (serving) successor
───────────────────── ─────────
SIGUSR2
spawn bootExecutable, passing:
control lane, descriptor lane, every
listening socket (TCP, UDP, admin, Meta API,
server search) ──────▶ boots with all gates closed:
keeps serving meanwhile adopts the inherited sockets as it binds,
accepts nothing, reads no UDP
◀────── READY (protocol version)
close the TCP gate (accept nothing)
park every session at its next read
── clients paused from here ──
export sessions, then storage ──────▶ import storage
send session states, then their sockets ─────▶ rebuild sessions (LowIDs, hash index)
◀────── IMPORTED
COMMIT ──────▶ start sessions, open the gates
── clients served again ──
close its copies of the sockets and exit
Before READY, the predecessor serves normally. That covers however long the successor
takes to boot: resolving dynIp, loading GeoIP, setting up Manticore. The pause clients
see runs from parking to commit. The end-to-end test measures about 65 ms on macOS and
240 ms in a Linux container.
A session parks at its next read. The read loop checks for a handover before each read,
and ParkSessions keeps setting a past read deadline on any session that has not parked
yet, which interrupts a read that is blocked. A session busy in a handler parks when the
handler returns, for example a login waiting on its dial-back probe, or an offer being
written to a slow database.
A session that has not parked after 15 s is closed and released as if it had disconnected. Its client reconnects, as every client did on a restart before.
Once a session is parked, its write lock is held, so nothing writes to it: not the status
ticker, and not a peer's OP_CALLBACKREQUEST. Its state can then be read out without
moving its RC4 keystream. On commit the predecessor keeps those locks until it exits. On
abort it releases them.
ed2k.SessionState (gob):
- Obfuscation: the state machine, the pending handshake bytes, and both RC4 keys with their positions.
- A half-received packet: a partial header, or a header with part of its payload.
- Identity: login state,
ClientInfo(ID, StoreID, hash, ports, IPv6), LowID flag, nick, software, mod, connect time, IPv6 and meta capabilities. - Session-local state: the pending search page for
OP_QUERY_MORE_RESULT, the soft-limit warning latch, and the hard-limit, login-gate and ban-list latches with their hit sets.
Bytes the predecessor had not read yet stay in the kernel's socket buffer, and the
successor reads them. Its first OP_SERVERSTATUS comes one status interval after the
handover.
| Engine | What crosses | Successor's Init |
|---|---|---|
memory |
Everything: files (including those with no source), every source in offer order, the clients with their StoreIDs, and the StoreID counter. Streamed from the predecessor under its read lock. | LoadSnapshot is skipped: the handover is newer and has the sources a snapshot lacks. |
mysql, mongodb |
Only the per-session offered counts that files.softLimit is checked against. The rows stay in the database. |
Does not reset the online flags (storage.Config.KeepOnline). The rows belong to live sessions. |
+ manticore |
Whatever the wrapped engine sends. The index holds nothing per session. | As the wrapped engine. |
The predecessor skips its shutdown snapshot after a handover, because the successor now writes the snapshots.
A successor configured with a different storage engine refuses the stream, and the predecessor resumes serving.
With the memory engine the whole index crosses, and this key decides how. The database
engines ignore it.
handoff:
memory: "stream" # stream | file | off| Value | How the index moves | Peak RAM | Pause |
|---|---|---|---|
stream (default) |
Over the control lane, straight into the successor. | Both processes hold the index: about twice the index. | Shortest |
file |
Written to storage.snapshot.file + .handoff (data/storage.gob.handoff), then dropped from the predecessor and its memory returned to the system, and only then read by the successor. The predecessor removes the file once the handover is committed. |
About one index: the two never hold it at once. | Longer: a write and a read of the file. Needs the disk space. |
off |
No handover. SIGUSR2 is refused and the reason written to the status file, so update.sh reports it and leaves the old server running. |
— | — |
file is for a host whose RAM holds one index but not two. It is not the snapshot
format: a snapshot leaves out the sources and the clients (see
storage-snapshot.md), and a handover needs both. The handover file
is written only for the handover, uncompressed, and next to the snapshot file because
that is where the operator has already made room for an index on disk. Snapshots do not
need to be enabled.
While the index is out in the file, the predecessor holds none of it. TCP sessions are parked, but UDP searches and source requests reaching the predecessor in that window are answered from an empty index, as they would be just after a restart. A periodic snapshot in that window writes nothing and leaves the file on disk as it was. If the handover fails, the predecessor reads the file back before it resumes the sessions.
The key is read from the config file when SIGUSR2 arrives, so one handover can use another mode without a restart. If the file no longer loads, the value from boot applies.
Measured on Linux (golang:1.26 container) with 2 million files and no sessions:
| Predecessor before | Lowest predecessor during | Both processes, peak | Successor after | Pause | |
|---|---|---|---|---|---|
stream |
1879 MiB | 1530 MiB | 2763 MiB | 1016 MiB | 3.3 s |
file |
1849 MiB | 50 MiB | 2287 MiB | 1016 MiB | 4.1 s |
In file mode the peak is the predecessor on its own, before it lets go, and the
successor then grows into the memory it freed. The predecessor is larger than the
successor holding the same index, because a Go heap carries headroom over what it holds.
macOS keeps counting freed memory in a process's RSS until the system needs it, so the
drop shows there only under memory pressure; the log line heap in use … → … MiB shows
it either way.
The pause grows with the index. With the memory engine, clients wait about 1.5 to 2 s per million files, against tens of milliseconds for a small index or a database engine.
All of this is soft state that rebuilds itself:
- PR_NAT registrations. Clients re-register on their next keepalive cycle. Until then, a LowID↔LowID rendezvous for them fails as it would for an unregistered client.
- The gossip peer table. The successor reads
server.metat boot, so it misses peers learned since the last persist. - Spam-detector observations of current sessions. Its verdict log persists.
- Rate limiters, the UDP bot detector, admin counters and uptime, meta-search caches. All start from zero.
- In-flight HTTP requests on the admin dashboard, Meta API or server search. Both processes serve those sockets until the old one exits, so a request still running in the old process when it exits is cut off.
MaxConnections. Sessions taken over are not counted against the listener'smaxConnectionsin the successor. They are counted formaxConnsPerIP.
UDP needs no handover. Both processes read the shared sockets until the predecessor exits. The successor reads none before commit.
The predecessor never stops serving because of a failed handover:
| Failure | Outcome |
|---|---|
handoff.memory: off with the memory engine |
Refused before anything starts. |
| Successor fails to start, crashes while booting, or is not ready within 5 min | Killed; predecessor goes on serving. Logged as handoff failed, still serving. |
| Successor speaks another handover protocol version | Refused before anything parks; restart the old way. |
| Storage export or import fails, or the transfer takes over 10 min | Successor killed; with handoff.memory: file the index is read back from the file; parked sessions resume; TCP gate reopens. |
| Predecessor vanishes before COMMIT | Successor exits without serving anything. It never serves a socket the predecessor may have gone back to. |
| A session does not park within 15 s | That session is dropped and its client reconnects; the rest move. |
Every row here also writes its reason to <pidFile>.handoff. SIGUSR2 can be sent again
after a failure.
internal/handoff. The successor receives these descriptors and finds them through
the ENODE_HANDOFF environment variable (JSON), which it removes once read:
- fd 3, control lane. A stream socket carrying
[kind u8][len u32 BE][payload]frames. The storage export travels asMsgDatachunks ending withMsgDataEnd. - fd 4, descriptor lane. Each message carries one byte of data plus up to 64 socket
descriptors (
SCM_RIGHTS). The receiver reads with a one-byte buffer, so a read never spans two messages and no descriptor can be matched to the wrong session. Sessions arrive on the control lane in the same order as their sockets on this lane. - fd 5+, listening sockets. Keyed by
network + " " + address, exactly as the binding code asks for them (handoff.Listen,handoff.ListenUDP), so an unchanged config adopts each one.
ProtocolVersion changes only when this sequence changes. Session and storage states are
gob, so fields can be added to or removed from them between versions without a bump.
Two things matter when passing sockets:
- Never call
File.Fd()on a socket that is still being served. It switches the socket to blocking mode, and that mode is shared by every duplicate, so the connection's deadlines stop working. The lane gets descriptors throughSyscallConninstead. os/execdoes callFd()onExtraFiles, soSpawnswitches every registered socket back to non-blocking mode after starting the successor.
internal/handoff: socket adoption, frames, the data stream and 67 sockets across the lanes, sockets staying non-blocking, and both sides of the protocol for success, a failed import, a protocol version mismatch and a missing commit.storage: memory engine round trip (order, offered lists, complete flags, files with no source, the StoreID counter), offered counts for the database engines, pass-through for Manticore, and refusal of another engine's stream. Through a file: the predecessor is emptied, the successor gets what a stream gives, an abort reads the index back and removes the file, a failed write keeps the index, and a snapshot taken meanwhile keeps the file on disk.config:handoff.memoryvalues and the handoff file's path.cmd/enode: which mode applies to which engine, and the fallback to the boot config.ed2k: an obfuscated LowID session and a plaintext HighID session move between two runtimes, each with half a packet in flight, then complete it, get their sources, and route a callback. Also: resume after an abort, and stragglers being dropped.cmd/enode(skipped with-short): builds the binary and hands a live client over twice, the second time after the binary was replaced the wayupdate.shreplaces it. The same once withhandoff.memory: file. Withoff, SIGUSR2 leaves the reason in the status file and the server keeps serving the same socket. Run it on Linux too, for example ingolang:1.26.cmd/enodewithENODE_INTEGRATION=1: the same two handovers on themysqlengine, against the MariaDB ofscripts/docker-mariadb.sh(port 13306, orENODE_TEST_MYSQL_PORT), and on themongodbengine, against the MongoDB ofscripts/docker-mongodb.sh(port 37017, orENODE_TEST_MONGODB_PORT). Each is skipped when nothing listens on its port.