fix(streaming): make ServerSession::CloseNow idempotent (one client drop no longer stalls others) - #9740
Conversation
|
Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would update our CHANGELOG.md based on your changes. |
Minimal reproduction (self-contained)Two ordinary clients on the world-snapshot stream; one is hard-killed (process terminated) so its socket RSTs with a read and a snapshot write in flight — the condition that re-enters Run (Python 3.7 + Verified results (same machine/install, only the server exe swapped): reproduce_min.py#!/usr/bin/env python
"""
Minimal, faithful reproduction of the CARLA streaming ServerSession
double-close bug -- and proof that the one-line CloseNow() guard cures it.
Why this shape
--------------
The corruption is NOT triggered by a single session: `MultiStreamState::
DisconnectSession` makes a lone double-disconnect harmless (the 2nd call hits
`if (_sessions.size() == 0) return;`). It bites when there are >= 2 sessions on
a stream and ONE of them reaches DisconnectSession twice:
_sessions = [V, A] # V = victim, A = attacker (size 2)
DisconnectSession(A) # 1st: erase A -> [V], size==1 -> _session = V
DisconnectSession(A) # 2nd: size==1 branch -> _session=null, _sessions.clear()
# => V is wrongly evicted, its broadcast goes silent
The world-snapshot stream always has every client as a session, so this is the
everyday case. We therefore use the MINIMAL realistic setup -- one persistent
"victim" client plus one short-lived "attacker" client per round -- rather than
a flood of clients (which also overruns CARLA in unrelated ways and is not what
this fix targets).
The attacker is hard-killed so its socket RSTs with a read and a snapshot write
in flight, which is what re-enters CloseNow() on stock builds. We then check
whether the victim is still receiving world ticks.
python reproduce_min.py --host 127.0.0.1 --port 3000 --rounds 20
REPRODUCED -> the victim stopped receiving ticks (stock 0.9.15).
CURED -> the victim kept ticking for every round (with the fix).
"""
import argparse, os, subprocess, sys, time
import carla
THIS = os.path.abspath(__file__)
def run_victim(host, port, hb_path):
"""Stay subscribed to the world-snapshot stream; write a heartbeat each tick."""
client = carla.Client(host, port); client.set_timeout(10.0)
world = client.get_world()
while True:
try:
snap = world.wait_for_tick(5.0)
with open(hb_path, "w") as f:
f.write(f"{snap.frame} {time.time():.3f}")
except RuntimeError:
# broadcast went silent -> stop updating the heartbeat
return
def run_attacker(host, port):
"""Become a 2nd session on the world stream, then block until hard-killed."""
client = carla.Client(host, port); client.set_timeout(10.0)
world = client.get_world()
world.wait_for_tick(5.0) # ensure the stream session exists
# keep a callback subscribed so snapshot writes are in flight at kill time
world.on_tick(lambda s: None)
while True:
time.sleep(1.0)
def hb_age(hb_path):
try:
frame, ts = open(hb_path).read().split()
return int(frame), time.time() - float(ts)
except Exception:
return None, 1e9
def victim_alive(hb_path, settle=6.0):
"""True if the heartbeat advanced within `settle` seconds."""
f0, _ = hb_age(hb_path)
deadline = time.time() + settle
while time.time() < deadline:
f1, age = hb_age(hb_path)
if f1 is not None and f0 is not None and f1 > f0 and age < 2.0:
return True
time.sleep(0.3)
return False
def controller(args):
hb = os.path.join(os.path.dirname(THIS), "victim_hb.txt")
try: os.remove(hb)
except OSError: pass
print(f"[min] target {args.host}:{args.port} rounds={args.rounds}")
victim = subprocess.Popen([sys.executable, THIS, "--victim", "--host", args.host,
"--port", str(args.port), "--hb", hb],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(5.0)
if not victim_alive(hb, 10.0):
print("[min] victim never became healthy; abort", file=sys.stderr)
victim.kill(); return 2
print("[min] victim healthy and receiving ticks.")
try:
for r in range(1, args.rounds + 1):
atk = subprocess.Popen([sys.executable, THIS, "--attacker", "--host", args.host,
"--port", str(args.port)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(args.stream_seconds) # let it become a 2nd session w/ writes in flight
atk.kill() # RST: read + snapshot write error together
try: atk.wait(timeout=5)
except Exception: pass
alive = victim_alive(hb, args.settle)
f, age = hb_age(hb)
print(f"[min] round {r}: victim_alive={alive} (frame={f}, hb_age={age:.1f}s)")
if not alive:
print(f"\n*** REPRODUCED *** the persistent victim stopped receiving world "
f"ticks after {r} attacker connect/hard-kill cycle(s). The attacker's "
f"double-close evicted the victim's session from the stream.")
return 1
print(f"\nCURED: victim kept receiving ticks through all {args.rounds} "
f"attacker connect/hard-kill cycles.")
return 0
finally:
victim.kill()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--victim", action="store_true")
ap.add_argument("--attacker", action="store_true")
ap.add_argument("--hb", default="victim_hb.txt")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=3000)
ap.add_argument("--rounds", type=int, default=20)
ap.add_argument("--stream-seconds", type=float, default=1.0)
ap.add_argument("--settle", type=float, default=6.0)
args = ap.parse_args()
if args.victim:
try: run_victim(args.host, args.port, args.hb)
except Exception: pass
return 0
if args.attacker:
try: run_attacker(args.host, args.port)
except Exception: pass
return 0
return controller(args)
if __name__ == "__main__":
sys.exit(main()) |
|
Please follow our issues template |
CloseNow() could be entered more than once for the same TCP server
session. The deadline timer firing, an async read/write completing with
an error, and an explicit Close() can all race, and each entry invoked
_on_closed(). That ran DisconnectSession twice on the stream state:
- in debug builds it tripped the DEBUG_ASSERT in
MultiStreamState::DisconnectSession (session == _session.load());
- in release builds it corrupted the active-session bookkeeping,
which surfaced later as dropped stream broadcasts and
get_world()/sensor stalls after a number of connect/disconnect
cycles.
Guard CloseNow() with an atomic flag so the close path (timer cancel,
socket shutdown, _on_closed) runs exactly once per session.
Reproduced with two clients on the world-snapshot stream: hard-killing
one while a snapshot write is in flight evicts the other on stock
0.9.15 (its wait_for_tick stalls on the first cycle); with this change
the surviving client keeps ticking 30/30 cycles.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
a1bc17e to
f744f20
Compare
|
Thanks for the review. I've updated this PR accordingly:
|
LuisPovedaCano
left a comment
There was a problem hiding this comment.
Looks good to me.
Thanks for the contribution.
This may need a port also to UE5 branch to have it fixed there.
I will open an issue to track it.
Setup
ue4-dev)Describe the bug
tcp::ServerSession::CloseNow()is not idempotent. The inactivity-deadline timer, an async read/write erroring, and an explicitClose()can each enter it for the same session, so_on_closed()→Dispatcher::DeregisterSession→MultiStreamState::DisconnectSessionruns twice.A lone double-disconnect is harmless, but with two sessions on a stream, one session disconnecting twice evicts the other one:
The world-snapshot stream (every client subscribes to it) is the everyday victim: when two clients are connected and one drops abruptly while a snapshot write is in flight, the survivor's broadcast goes silent and
world.wait_for_tick()hangs.Expected behavior
One client closing or dropping — even abruptly — should not stop other clients on the same stream from receiving data.
Steps to reproduce
Two clients on the world-snapshot stream; hard-kill one so its socket RSTs with a read and a snapshot write in flight. Reproduction script
reproduce_min.py(one persistent client + one client connected and killed each round), with a fast tick so a write is in flight:The fix (2 files, ~14 lines, no API/ABI change)
Guard
CloseNow()with anstd::atomic_bool _is_closedso the close path runs exactly once:Other information
LibCarlaC++; a Linuxmake checkfrom CI would be welcome.Co-Authored-Byon the commit); the analysis was reviewed and the before/after results were measured on real 0.9.15 Win64 binaries.This change is