fix(streaming): make ServerSession::CloseNow idempotent - #9739
Conversation
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.
Validated against a 23-scenario client regression matrix that
previously forced a CARLA restart every 6-12 cells: 23/23 pass with
zero degraded/restart events under sustained session churn.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
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. |
|
Heads-up: keeping this as a draft while I put together a self-contained reproduction so reviewers can trigger the race directly (instead of relying on internal regression evidence). I'm iterating on a small Python script that hammers a stock 0.9.15 server with rapid streaming connect/disconnect cycles and detects the resulting world-snapshot broadcast stall ( |
Self-contained reproductionThis reproduces the streaming stall directly against a stock 0.9.15 server (no internal tooling needed). It runs several short-lived clients that each subscribe to a high-rate RGB camera and then get hard-killed (process terminated) so their streaming sockets are RST with reads+writes in flight — the condition that re-enters How to run (Python 3.7 + Results (Shipping build, Win64, same machine/install, only the exe swapped):
So the fix is a clear, measurable improvement (and eliminates the failure in our real 23/23 regression matrix), but under an extreme simultaneous disconnect burst a deeper multi-session teardown race remains — included here on purpose so the remaining problem is reproducible for a follow-up. reproduce_hardkill.py#!/usr/bin/env python
"""
Stronger reproduction for the CARLA streaming ServerSession double-close race.
Mechanism
---------
CloseNow() is re-entered when a streaming socket closes with several async ops
in flight (one read + many sensor writes), because each op completes with an
error and calls CloseNow() again -> _on_closed twice -> DisconnectSession twice
-> MultiStreamState corruption -> world-snapshot broadcast becomes a no-op.
A graceful client close (FIN) lets those ops finish/cancel in order and does
NOT trigger the race. To force it we run each streaming client in a SEPARATE
process and **hard-kill** it (TerminateProcess) while a high-rate camera stream
is in flight, which RSTs the socket and errors all pending ops at once. Several
workers are killed simultaneously per round to widen the window.
Controller detects the stall via a clean client's world.wait_for_tick().
python reproduce_hardkill.py --host 127.0.0.1 --port 3000 \
--rounds 60 --workers 6 --stream-seconds 1.0 --check-every 3
"""
import argparse, os, subprocess, sys, time
import carla
THIS = os.path.abspath(__file__)
def worker(host, port):
"""Connect, spawn a free high-rate RGB camera, stream until killed."""
client = carla.Client(host, port)
client.set_timeout(10.0)
world = client.get_world()
bp = world.get_blueprint_library().find("sensor.camera.rgb")
bp.set_attribute("image_size_x", "1280")
bp.set_attribute("image_size_y", "720")
bp.set_attribute("sensor_tick", "0.0") # max rate -> writes in flight
tf = carla.Transform(carla.Location(x=0, y=0, z=50))
cam = world.spawn_actor(bp, tf)
cam.listen(lambda image: None)
while True:
time.sleep(1.0) # never exits cleanly; will be killed
def health_check(host, port, timeout):
client = carla.Client(host, port)
client.set_timeout(timeout)
t0 = time.time()
try:
ok = client.get_world().wait_for_tick(timeout) is not None
return ok, time.time() - t0
except RuntimeError:
return False, time.time() - t0
finally:
del client
def cleanup(host, port):
try:
client = carla.Client(host, port); client.set_timeout(10.0)
w = client.get_world()
n = 0
for a in w.get_actors():
if a.type_id.startswith(("sensor.", "vehicle.")):
try: a.destroy(); n += 1
except RuntimeError: pass
return n
except RuntimeError:
return -1
def controller(args):
print(f"[repro] target {args.host}:{args.port} rounds={args.rounds} "
f"workers={args.workers} stream={args.stream_seconds}s")
ok, dt = health_check(args.host, args.port, args.health_timeout)
print(f"[repro] baseline health ok={ok} ({dt:.2f}s)")
if not ok:
print("[repro] not healthy at start; abort", file=sys.stderr); return 2
for r in range(1, args.rounds + 1):
procs = []
for _ in range(args.workers):
p = subprocess.Popen([sys.executable, THIS, "--worker",
"--host", args.host, "--port", str(args.port)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
procs.append(p)
time.sleep(args.stream_seconds) # let streams ramp up
# Simultaneous hard kill -> RST with reads+writes in flight (the trigger)
for p in procs:
try: p.kill()
except Exception: pass
for p in procs:
try: p.wait(timeout=5)
except Exception: pass
if r % args.check_every == 0:
ncl = cleanup(args.host, args.port)
ok, dt = health_check(args.host, args.port, args.health_timeout)
print(f"[repro] round {r}: cleaned={ncl} health ok={ok} ({dt:.2f}s)")
if not ok:
print(f"\n*** REPRODUCED *** world-snapshot broadcast stalled after "
f"{r} hard-kill rounds ({r*args.workers} abrupt disconnects). "
f"wait_for_tick timed out in {dt:.2f}s.")
return 1
print(f"\nNO STALL: healthy through {args.rounds} rounds "
f"({args.rounds*args.workers} hard-kill disconnects).")
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--worker", action="store_true")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=3000)
ap.add_argument("--rounds", type=int, default=60)
ap.add_argument("--workers", type=int, default=6)
ap.add_argument("--stream-seconds", type=float, default=1.0)
ap.add_argument("--check-every", type=int, default=3)
ap.add_argument("--health-timeout", type=float, default=8.0)
args = ap.parse_args()
if args.worker:
try: worker(args.host, args.port)
except Exception: pass
return 0
return controller(args)
if __name__ == "__main__":
sys.exit(main()) |
|
Closing this in favour of a cleaner PR — but leaving the full reasoning here so nothing is lost. Why I'm re-doing it: my first stress reproduction (6 clients streaming 720p, all hard-killed at once) was over-aggressive. It pushed the server past its design limits in unrelated ways (orphaned sensor actors, etc.) and made it look like the fix only "helped a bit", which under-sold it and muddied the signal. That was an investigation gap on my side. The corrected, precise analysis. The corruption is specifically in The double The everyday trigger: two clients subscribed to the same stream (the world-snapshot stream, which every client uses) and one disconnects abruptly while a snapshot write is in flight — the survivor is permanently evicted and its Verified, minimal, realistic reproduction (1 persistent client + 1 client that connects and is hard-killed, against a stock 0.9.15 server):
So the one-line change fixes the realistic, common case completely. I'll open a fresh PR with this minimal reproduction script and clean before/after logs. |
|
Closing in favour of a cleaner PR with the minimal reproduction (see the analysis comment above). |
|
Replaced by #9740, which carries the same one-line fix plus a minimal, verified reproduction (two ordinary clients on the world-snapshot stream, one abruptly killed): stock 0.9.15 reproduces the stall on the 1st connect/kill cycle, and the fix keeps the other client ticking for 30/30 cycles. Continuing there. |
Summary
tcp::ServerSession::CloseNow()is not idempotent. It can be reached more thanonce for the same session: the inactivity deadline timer firing, an async
read/write completing with an error, and an explicit
Close()can allrace, and each entry calls
_on_closed(). That runsMultiStreamState::DisconnectSession()more than once for the same session:DEBUG_ASSERT(session == _session.load())inMultiStreamState::DisconnectSession(MultiStreamState.h:100).silently corrupts the active-session bookkeeping — the world-snapshot
broadcast becomes a no-op and clients can no longer receive ticks
(
world.wait_for_tick()/get_snapshot()time out, i.e. the server appears"stuck").
Fix
Guard
CloseNow()with anstd::atomic_bool _is_closed. The first caller winsvia
exchange(true); later callers return immediately, so the close path(timer cancel, socket shutdown,
_on_closed) runs exactly once per session.14 lines, 2 files, no public API/ABI change.
Impact (measured)
This is a strict, minimal improvement that substantially reduces the
problem in normal use:
the server stuck and required a restart every ~6–12 scenarios now runs
23/23 with zero degraded/restart events under sustained session churn.
hard-killed each round to RST their streaming sockets mid-write):
disconnects;
more resilient.
matches the analysis: the re-entrancy is driven by several async ops on a
session erroring at once.
Scope / remaining issue (split out intentionally)
Under an extreme burst of simultaneous abrupt disconnects, a deeper
multi-session disconnect race remains and the server can still eventually stall
even with this fix (~54 vs ~18 disconnects in the stress test above). That looks
like a separate, larger concurrency problem around concurrent session teardown,
and is out of scope here — this PR is the minimal, correct, low-risk fix for
the single-session double-close, and is very likely a prerequisite for any
deeper fix. Happy to file a follow-up issue with the reproduction for the
remaining case.
Reproduction
A self-contained Python repro (stock-fails / fix-improves) and the stock-vs-fix
logs are attached in the PR comments so reviewers can run it directly against a
stock 0.9.15 server.
This change is