Repository navigation
Peers: pending local updates suppress heartbeats during full resync #3518
Description
Detailed Description of the Problem
A full peer resynchronization can repeatedly disconnect and restart if the receiving node has a pending local table update. The learner sends no heartbeats even though normal updates are deferred until learning finishes. In a two-node loopback reproduction, the connection closes about every five seconds and the receiving table never finishes populating.
This reproduces without any reload and with only one peers section, so it is separate from #3517. I found it while investigating resynchronization of million-entry tables, then reduced it to 100,000 entries and a paced loopback TCP relay. The relay forwards both directions continuously at roughly 200 KiB/s; it does not disconnect or drop bytes.
Observed controls:
- Unmodified 3.4.6: fails identically; the table plateaus at 28,994 entries with
tx_hbt=0. - 3.4.3: local writes during learning reproduce the loop with
tx_hbt=0. - 3.4.6 carrying only the Multiple peers sections lose stick tables on reload (shared dont_stop state) #3517 patch, which does not touch this path: local writes reproduce the loop and the test exits nonzero.
- The same 3.4.6 binary with an idle learner: all 100,000 entries synchronize in roughly 22 seconds, heartbeats are sent, and the connection stays established.
- With the candidate fix below: 100,001 entries synchronize with local writes enabled, and the deferred learner-originated write reaches the teacher afterward. Tested with plain TCP and one thread (about 22 seconds) and with mutual TLS and eight threads (about 31 seconds, including a startup connection collision).
Expected Behavior
Full resynchronization should finish while local requests update tables. Updates that cannot yet be sent should not suppress the heartbeat needed to keep the synchronization connection alive.
Steps to Reproduce the Behavior
Save the standalone Python script below as test-haproxy-peer-resync.py and run:
python3 test-haproxy-peer-resync.py /absolute/path/to/haproxy
# Control: same paced synchronization without concurrent local writes
python3 test-haproxy-peer-resync.py /absolute/path/to/haproxy --idle
# Mutual TLS and multiple threads (requires openssl)
python3 test-haproxy-peer-resync.py /absolute/path/to/haproxy --tls --threads 8The script uses temporary configs, dynamic loopback ports, and two HAProxy processes that it owns and terminates. It seeds 100,000 IPv6 entries on A before starting B, paces transport so synchronization takes longer than five seconds, and updates a distinct key on B once per second. It checks full table size, sampled values, and deferred replication back to A. Failed runs print peer states and traces and exit nonzero. Python 3.13 was used.
Standalone reproducer
#!/usr/bin/env python3
"""Test peer heartbeats during slow full resynchronization with local writes.
Uses two isolated HAProxy processes and paced loopback relays. No reload is
involved. A successful run must learn every seeded entry,
retain the receiving node's marker, and replicate its deferred write back.
Requires Python 3.13, loopback socket access, and openssl for optional mTLS.
"""
import argparse
import os
from pathlib import Path
import re
import signal
import socket
import subprocess
import tempfile
import threading
import time
MARKER = "2001:db8:ffff::1"
def command(path: Path, request: str) -> str:
"""Send request to a runtime socket; return its reply or raise on I/O errors."""
with socket.socket(socket.AF_UNIX) as client:
client.settimeout(10)
client.connect(str(path))
client.sendall((request + "\n").encode())
chunks: list[bytes] = []
while chunk := client.recv(65536):
chunks.append(chunk)
return b"".join(chunks).decode()
def listener() -> socket.socket:
"""Reserve and return an IPv4 loopback listening socket on a free port."""
server = socket.socket()
server.bind(("127.0.0.1", 0))
server.listen()
return server
class Relay:
"""Pace both directions of loopback TCP sessions without inspecting payloads.
The caller owns the listener. close() stops accepting, closes active sockets,
and joins worker threads. Failed connections are closed, not retried.
"""
def __init__(self, server: socket.socket, destination: int, delay: float):
"""Start forwarding server to destination, delaying each 1 KiB by seconds."""
self.server = server
self.destination = destination
self.delay = delay
self.stopped = threading.Event()
self.lock = threading.Lock()
self.sockets: set[socket.socket] = set()
self.workers: list[threading.Thread] = []
self.thread = threading.Thread(target=self._accept, daemon=True)
self.thread.start()
def _accept(self) -> None:
"""Accept loopback connections and start one worker for each direction."""
self.server.settimeout(0.2)
while not self.stopped.is_set():
try:
client, _ = self.server.accept()
except socket.timeout:
continue
except OSError:
break
try:
remote = socket.create_connection(("127.0.0.1", self.destination), 1)
remote.settimeout(None)
except OSError:
client.close()
continue
with self.lock:
self.sockets.update((client, remote))
for source, target in ((client, remote), (remote, client)):
worker = threading.Thread(
target=self._forward, args=(source, target), daemon=True,
)
self.workers.append(worker)
worker.start()
def _forward(self, source: socket.socket, target: socket.socket) -> None:
"""Copy paced chunks, closing both directions on EOF or a socket error."""
try:
while not self.stopped.is_set():
chunk = source.recv(1024)
if not chunk:
break
target.sendall(chunk)
self.stopped.wait(self.delay)
except OSError:
pass
finally:
for connection in (source, target):
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
connection.close()
with self.lock:
self.sockets.discard(connection)
def close(self) -> None:
"""Stop the relay and release its listener, sockets, and worker threads."""
self.stopped.set()
self.server.close()
self.thread.join(timeout=2)
with self.lock:
connections = list(self.sockets)
for connection in connections:
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
connection.close()
for worker in self.workers:
worker.join(timeout=2)
def key(index: int) -> str:
"""Return a unique documentation-only IPv6 key for a nonnegative index."""
return f"2001:db8::{index >> 16:x}:{index & 65535:x}"
def run(binary: Path, entries: int, threads: int, delay: float,
timeout: int, tls: bool, idle: bool) -> bool:
"""Exercise binary with paced peer traffic; return whether resync succeeds.
entries controls the initial table size; threads sets each node's workers;
delay is seconds per relay chunk; timeout bounds learning after node B
starts. tls enables mutual TLS; idle disables writes during learning for
a control experiment. Temporary files and owned processes are always cleaned.
Raises on startup, configuration, runtime socket, or subprocess errors.
"""
with tempfile.TemporaryDirectory(prefix="ha-resync-") as directory:
root = Path(directory)
reserved = [listener() for _ in range(4)]
ports = [server.getsockname()[1] for server in reserved]
processes: list[subprocess.Popen[str]] = []
relays: list[Relay] = []
sockets = [root / "a.sock", root / "b.sock"]
try:
incoming = outgoing = ""
if tls:
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(root / "key.pem"), "-out", str(root / "cert.pem"),
"-days", "1", "-subj", "/CN=lab",
], check=True, capture_output=True)
(root / "node.pem").write_bytes(
(root / "cert.pem").read_bytes() + (root / "key.pem").read_bytes(),
)
incoming = f" ssl crt {root}/node.pem verify required ca-file {root}/cert.pem"
outgoing = f" default-server ssl verify required ca-file {root}/cert.pem crt {root}/node.pem\n"
for index in range(2):
relays.append(Relay(reserved[index + 2], ports[index], delay))
for index, node in enumerate(("a", "b")):
config = root / f"{node}.cfg"
config.write_text(f"""global
localpeer {node}
nbthread {threads}
stats socket {sockets[index]} level admin
peers synced
bind 127.0.0.1:{ports[index]}{incoming}
{outgoing} server {node}
server {'b' if node == 'a' else 'a'} 127.0.0.1:{ports[3 - index]}
table guard type ipv6 size {entries + 100} expire 1h store gpt(1),http_req_rate(1m),http_err_rate(1m)
""")
reserved[index].close()
with (root / f"{node}.log").open("w") as log:
process = subprocess.Popen(
[str(binary), "-db", "-f", str(config)],
stdout=log, stderr=log, text=True, start_new_session=True,
)
processes.append(process)
deadline = time.monotonic() + 10
while not sockets[index].exists():
if process.poll() is not None or time.monotonic() > deadline:
raise RuntimeError((root / f"{node}.log").read_text())
time.sleep(0.05)
command(sockets[index], "trace peers event none event sess_end event sess_shut event sess_err event proto_error event sess_resync level state sink buf0 start now")
if index == 0:
for offset in range(0, entries, 100):
request = ";".join(
f"set table synced/guard key {key(i)} data.gpt[0] 93"
for i in range(offset, min(offset + 100, entries))
)
reply = command(sockets[0], request)
if reply.strip():
raise RuntimeError(f"Seeding failed: {reply[:200]}")
# Wait for the source to finish its initial sync timeout.
deadline = time.monotonic() + 15
while True:
flags = re.search(r"disabled=0 flags=(0x[0-9a-f]+)", command(sockets[0], "show peers"))
if flags and int(flags[1], 16) & 3 == 3:
break
if time.monotonic() > deadline:
raise RuntimeError("Source did not finish initial sync")
time.sleep(0.1)
started = time.monotonic()
last_write = 0
observed_learning = False
while time.monotonic() - started < timeout:
time.sleep(1)
if not idle:
last_write += 1
command(sockets[1], f"set table synced/guard key {MARKER} data.gpt[0] {last_write}")
peers = command(sockets[1], "show peers")
observed_learning |= "learn_state=PROCESSING" in peers
flags = re.search(r"disabled=0 flags=(0x[0-9a-f]+)", peers)
used = re.search(r"used:(\d+)", command(sockets[1], "show table"))
summary = [line.strip() for line in peers.splitlines() if "(remote," in line or "tx_hbt=" in line]
elapsed = time.monotonic() - started
print(f"elapsed={elapsed:.1f}s used={used[1] if used else '?'} " + " ".join(summary), flush=True)
if flags and int(flags[1], 16) & 3 == 3 and used and int(used[1]) == entries + (not idle):
if not observed_learning or elapsed < 8:
raise RuntimeError("Resync was too fast; increase entries or relay delay")
for sample in (0, entries // 2, entries - 1):
if "gpt0=93" not in command(sockets[1], f"show table synced/guard key {key(sample)}"):
raise RuntimeError("Learned sample value is incorrect")
deadline = time.monotonic() + 10
while not idle:
response = command(sockets[0], f"show table synced/guard key {MARKER}")
value = re.search(r"gpt0=(\d+)", response)
if value and int(value[1]) == last_write:
break
if time.monotonic() > deadline:
raise RuntimeError("Deferred local write did not reach source")
time.sleep(0.1)
print("PASS: full resync completed; sampled values verified"
+ ("" if idle else "; deferred write verified"), flush=True)
return True
print("FAIL: full resync did not finish", flush=True)
for index, node in enumerate(("a", "b")):
print(f"--- {node} ---\n" + command(sockets[index], "show peers"))
print(command(sockets[index], "show events buf0"))
return False
finally:
for process in processes:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
for relay in relays:
relay.close()
for server in reserved:
server.close()
def main() -> None:
"""Parse test options and exit nonzero on failed synchronization or errors."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary", type=Path)
parser.add_argument("--entries", type=int, default=100000)
parser.add_argument("--threads", type=int, default=1)
parser.add_argument("--delay", type=float, default=0.005)
parser.add_argument("--timeout", type=int, default=45)
parser.add_argument("--tls", action="store_true")
parser.add_argument("--idle", action="store_true")
args = parser.parse_args()
if args.entries < 1000 or args.threads < 1 or args.delay <= 0 or args.timeout < 10:
parser.error("entries >= 1000, threads >= 1, delay > 0, timeout >= 10 required")
raise SystemExit(0 if run(args.binary.resolve(), args.entries, args.threads,
args.delay, args.timeout, args.tls, args.idle) else 1)
if __name__ == "__main__":
main()Do you have any idea what may have caused this?
__process_running_peer_sync() treats st->last_pushed != st->table->localupdate as evidence that updates will be sent. It clears PEER_F_HEARTBEAT and postpones the heartbeat timer in that case.
But peer_send_msgs() only sends normal incremental updates when the peer is not learning (learnstate == PEER_LR_ST_NOTASSIGNED) and not in the teaching branch (PEER_F_TEACH_PROCESS clear). While learning, a local write creates a pending update that cannot be sent yet, but it still suppresses heartbeats.
Why that closes the connection after about five seconds, with line numbers from unmodified 3.4.6 src/peers.c:
- While
learnstate == PEER_LR_ST_PROCESSINGthe learner does not advancest->last_get(line 1829), so it sends no acks either. - Heartbeats are therefore the learner's only outgoing traffic, and the pending local update suppresses them.
- The peers frontend runs with independent streams (
PR_O2_INDEPSTR, line 3442).sc_ep_report_send_activity()ininclude/haproxy/stconn.honly reports read activity whenSC_FL_INDEP_STRis clear, so the teacher's continuous sending does not refresh its own read timeout. - The frontend's client and server timeouts are hardcoded to 5 s (lines 3438 and 3439). The teacher hears nothing for 5 s and closes the session.
This is also why tx_hbt and no_hbt both stay at zero in the traces: the explicit dead-peer path never runs, the stream simply times out on the teacher.
There is a related case on the teacher: after PEER_F_TEACH_FINISHED is set, it waits for the resync confirmation before resuming normal updates, and PEER_F_TEACH_PROCESS stays set during that wait. The pending local-update comparison can continue suppressing heartbeats there. A learner-only condition fixed the plain-TCP test but was not enough for the paced TLS test; checking both learning and teaching state passed both.
Source inspection shows the same unmatched conditions in upstream master at 88576119c7efbda508c5d65c1f418f5fdeb8fd97; I have not run this reproducer against a master build.
Do you have an idea how to solve the issue?
Only suppress heartbeats for normal incremental updates when those updates are eligible to be sent. During learning or teaching, keep scheduling heartbeats. This adds the two state conditions that the normal-update branch of peer_send_msgs() already uses:
--- a/src/peers.c
+++ b/src/peers.c
@@ -3707,7 +3707,9 @@
/* Awake session if there is data to push */
for (st = peer->tables; st ; st = st->next) {
- if (st->last_pushed != st->table->localupdate) {
+ if (peer->learnstate == PEER_LR_ST_NOTASSIGNED &&
+ !(peer->flags & PEER_F_TEACH_PROCESS) &&
+ st->last_pushed != st->table->localupdate) {
/* wake up the peer handler to push local updates */
update_to_push = 1;
/* There is no need to send a heartbeat messageThis is a candidate fix for maintainer review, not an assertion that every full-resync edge case is covered. With this patch, 101 applicable upstream peer/TLS/Lua/HTTP-rule/QUIC regression tests passed, 13 were skipped, and none failed. The #3517 reproducer also still retains both tables through five reloads with eight threads.
What is your configuration?
The reproducer generates this configuration for each node (ports and socket paths differ). Both peer directions go through the paced relay. There is no reload, no second peers section, and no application backend:
global
localpeer a
nbthread 1
stats socket /tmp/example/a.sock level admin
peers synced
bind 127.0.0.1:19001
server a
server b 127.0.0.1:19004
table guard type ipv6 size 100100 expire 1h store gpt(1),http_req_rate(1m),http_err_rate(1m)The --tls variant adds a temporary self-signed CA/certificate and verify required on both bind and default-server. Its keys are created only inside the temporary test directory and deleted afterward.
Output of haproxy -vv
Unmodified 3.4.6 built with make TARGET=linux-glibc USE_OPENSSL= USE_LUA= USE_PCRE2=:
HAProxy version 3.4.6-56332c5 2026/09/28 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2031.
Known bugs: http://www.haproxy.org/bugs/bugs-3.4.6.html
Running on: Linux 6.12.107+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.107-1 (2026-08-29) x86_64
Build options :
TARGET = linux-glibc
CC = cc
CFLAGS = -O2 -g -fwrapv -fvect-cost-model=very-cheap
OPTIONS = USE_OPENSSL= USE_LUA= USE_PCRE2=
DEBUG =
Feature list : -51DEGREES +ACCEPT4 +BACKTRACE -CLOSEFROM +CPU_AFFINITY +CRYPT_H -DEVICEATLAS +DL -ECH -ENGINE +EPOLL -EVPORTS +GETADDRINFO +HAVE_TCP_MD5SIG -KQUEUE +KTLS -LIBATOMIC +LIBCRYPT +LINUX_CAP +LINUX_SPLICE +LINUX_TPROXY -LUA -MATH -MEMORY_PROFILING +NETFILTER +NS -OBSOLETE_LINKER -OPENSSL -OPENSSL_AWSLC -OPENSSL_WOLFSSL -OT -PCRE -PCRE2 -PCRE2_JIT -PCRE_JIT +POLL +PRCTL -PROCCTL -PROMEX -PTHREAD_EMULATION -QUIC -QUIC_OPENSSL_COMPAT +RT +SHM_OPEN +SLZ -SSL -STATIC_PCRE -STATIC_PCRE2 +TFO +THREAD +THREAD_DUMP +TPROXY +TRACE -WURFL -ZLIB
Detected feature list : +HAVE_WORKING_TCP_MD5SIG
Default settings :
bufsize = 16384, maxrewrite = 1024, maxpollevents = 200
Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=8).
Built with network namespace support.
Built with libslz for stateless compression.
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND
Built without PCRE or PCRE2 support (using libc's regex instead)
Encrypted password support via crypt(3): yes
Built with gcc compiler version 14.2.0
Available polling systems :
epoll : pref=300, test result OK
poll : pref=200, test result OK
select : pref=150, test result OK
Total: 3 (3 usable), will use epoll.
Available multiplexer protocols :
(protocols marked as <default> cannot be specified using 'proto' keyword)
h2 : mode=HTTP side=FE|BE mux=H2 flags=HTX|HOL_RISK|NO_UPG
<default> : mode=HTTP side=FE|BE mux=H1 flags=HTX
h1 : mode=HTTP side=FE|BE mux=H1 flags=HTX|NO_UPG
fcgi : mode=HTTP side=BE mux=FCGI flags=HTX|HOL_RISK|NO_UPG
<default> : mode=SPOP side=BE mux=SPOP flags=HOL_RISK|NO_UPG
spop : mode=SPOP side=BE mux=SPOP flags=HOL_RISK|NO_UPG
<default> : mode=TCP side=FE|BE mux=PASS flags=
none : mode=TCP side=FE|BE mux=PASS flags=NO_UPG
Available services : none
Available filters :
[BWLIM] bwlim-in
[BWLIM] bwlim-out
[CACHE] cache
[COMP] comp-req
[COMP] comp-res
[COMP] compression
[FCGI] fcgi-app
[SPOE] spoe
[TRACE] trace
The 3.4.3 build on which the problem was first observed:
HAProxy version 3.4.3-80ea565fd 2026/07/29 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2031.
Known bugs: http://www.haproxy.org/bugs/bugs-3.4.3.html
Running on: Linux 6.12.107+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.107-1 (2026-08-29) x86_64
Build options :
TARGET = linux-glibc
CC = cc
CFLAGS = -O2 -g -fwrapv -fvect-cost-model=very-cheap
OPTIONS = USE_GETADDRINFO=1 USE_OPENSSL_AWSLC=1 USE_LUA=1 USE_QUIC=1 USE_PROMEX=1 USE_PCRE2=1 USE_PCRE2_JIT=1
DEBUG = -DDEBUG_STRICT -DDEBUG_STRICT_ACTION -DDEBUG_MEMORY_POOLS -DDEBUG_DONT_SHARE_POOLS -DDEBUG_POOL_INTEGRITY
Feature list : -51DEGREES +ACCEPT4 +ACME +BACKTRACE -CLOSEFROM +CPU_AFFINITY +CRYPT_H -DEVICEATLAS +DL -ECH -ENGINE +EPOLL -EVPORTS +GETADDRINFO +HAVE_TCP_MD5SIG -KQUEUE +KTLS -LIBATOMIC +LIBCRYPT +LINUX_CAP +LINUX_SPLICE +LINUX_TPROXY +LUA +MATH -MEMORY_PROFILING +NETFILTER +NS -OBSOLETE_LINKER +OPENSSL +OPENSSL_AWSLC -OPENSSL_WOLFSSL -OT -PCRE +PCRE2 +PCRE2_JIT -PCRE_JIT +POLL +PRCTL -PROCCTL +PROMEX -PTHREAD_EMULATION +QUIC -QUIC_OPENSSL_COMPAT +RT +SHM_OPEN +SLZ +SSL -STATIC_PCRE -STATIC_PCRE2 +TFO +THREAD +THREAD_DUMP +TPROXY +TRACE -WURFL -ZLIB
Detected feature list :
Default settings :
bufsize = 16384, maxrewrite = 1024, maxpollevents = 200
Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=8).
Built with SSL library version : OpenSSL 1.1.1 (compatible; AWS-LC 4.0.0)
Running on SSL library version : AWS-LC 4.0.0
SSL library supports TLS extensions : yes
SSL library supports SNI : yes
SSL library FIPS mode : no
SSL library default verify directory : /etc/ssl/certs
SSL library supports : TLSv1.0 TLSv1.1 TLSv1.2 TLSv1.3
QUIC: connection sock-per-conn mode support : unknown
QUIC: GSO emission support : unknown
Built with Lua version : Lua 5.4.7
Built with the Prometheus exporter as a service
Built with network namespace support.
Built with libslz for stateless compression.
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND
Built with PCRE2 version : 10.46 2025-08-27
PCRE2 library supports JIT : yes
Encrypted password support via crypt(3): yes
Built with gcc compiler version 14.2.0
Available polling systems :
epoll : pref=300, test result OK
poll : pref=200, test result OK
select : pref=150, test result OK
Total: 3 (3 usable), will use epoll.
Available multiplexer protocols :
(protocols marked as <default> cannot be specified using 'proto' keyword)
qmux : mode=HTTP side=FE|BE mux=QMUX flags=HTX|NO_UPG
quic : mode=HTTP side=FE|BE mux=QUIC flags=HTX|NO_UPG|FRAMED
h2 : mode=HTTP side=FE|BE mux=H2 flags=HTX|HOL_RISK|NO_UPG
<default> : mode=HTTP side=FE|BE mux=H1 flags=HTX
h1 : mode=HTTP side=FE|BE mux=H1 flags=HTX|NO_UPG
fcgi : mode=HTTP side=BE mux=FCGI flags=HTX|HOL_RISK|NO_UPG
<default> : mode=SPOP side=BE mux=SPOP flags=HOL_RISK|NO_UPG
spop : mode=SPOP side=BE mux=SPOP flags=HOL_RISK|NO_UPG
<default> : mode=TCP side=FE|BE mux=PASS flags=
none : mode=TCP side=FE|BE mux=PASS flags=NO_UPG
Available services : prometheus-exporter
Available filters :
[BWLIM] bwlim-in
[BWLIM] bwlim-out
[CACHE] cache
[COMP] comp-req
[COMP] comp-res
[COMP] compression
[FCGI] fcgi-app
[SPOE] spoe
[TRACE] trace
Last Outputs and Backtraces
Representative failing learner (3.4.6 with only the #3517 patch):
learn_state=PROCESSING last_status=ESTA
flags=0x20900005
used:18401 out of 100001 expected entries
tx_hbt=0 rx_hbt=0 no_hbt=0 new_conn=5 proto_err=0
The table plateaus while connections repeatedly restart. The local writes use a distinct key, so they do not overwrite entries being learned. No crash or backtrace is involved.
Additional Information
#3521 describes the same hardcoded five-second timeout and independent-stream behaviour on the local reload handover path, where the new worker has no heartbeat scheduling at all. The two fixes are independent and touch different code paths.
I searched existing peer/heartbeat/full-resync reports. The nearby #3234 was a different shutdown-handling defect in the peer applet, where a peer listener stopped responding, and #1216 concerns repeated reloads. Neither covers this no-reload reproduction.
Field results: a 3.4.6 build carrying this patch together with the fixes proposed in #3517, #3519, #3520 and #3521 was deployed to a four-node production cluster on 2026-10-02. Each node has two peers sections, the nodes talk over mTLS, and the replicated tables hold 1.3 to 2.4 million entries each. After deployment, reloading the nodes one at a time and then all four at once preserved every checked entry and test marker. The local handovers took 15 to 33 seconds and the new worker sent three to six progress heartbeats (#3521) during each.

Activity