Skip to content

fix(streaming): make ServerSession::CloseNow idempotent - #9739

Closed
mskwt wants to merge 1 commit into
carla-simulator:ue4-devfrom
mskwt:fix/streaming-serversession-double-close
Closed

fix(streaming): make ServerSession::CloseNow idempotent#9739
mskwt wants to merge 1 commit into
carla-simulator:ue4-devfrom
mskwt:fix/streaming-serversession-double-close

Conversation

@mskwt

@mskwt mskwt commented May 20, 2026

Copy link
Copy Markdown

Summary

tcp::ServerSession::CloseNow() is not idempotent. It can be reached more than
once for the same session: the inactivity deadline timer firing, an async
read/write completing with an error, and an explicit Close() can all
race, and each entry calls _on_closed(). That runs
MultiStreamState::DisconnectSession() more than once for the same session:

  • Debug builds: trips DEBUG_ASSERT(session == _session.load()) in
    MultiStreamState::DisconnectSession (MultiStreamState.h:100).
  • Shipping builds: the assert is compiled out, so the second disconnect
    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 an std::atomic_bool _is_closed. The first caller wins
via 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:

  • Field regression suite: a 23-scenario client matrix that previously got
    the server stuck and required a restart every ~6–12 scenarios now runs
    23/23 with zero degraded/restart events under sustained session churn.
  • Stress reproduction (script below, 6 concurrent high-rate camera clients
    hard-killed each round to RST their streaming sockets mid-write):
    • stock 0.9.15: world-snapshot broadcast stalls after ~18 abrupt
      disconnects
      ;
    • with this fix: ~54 abrupt disconnects before a stall — roughly
      more resilient.
    • A single client churned the same way does not stall either build, which
      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 Reviewable

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>
@update-docs

update-docs Bot commented May 20, 2026

Copy link
Copy Markdown

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.

@mskwt

mskwt commented May 20, 2026

Copy link
Copy Markdown
Author

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 (world.wait_for_tick() timing out). I'll push the script plus before/after logs (stock vs. patched) once I have a clean, deterministic repro. Will mark this Ready for review at that point.

@mskwt

mskwt commented May 20, 2026

Copy link
Copy Markdown
Author

Self-contained reproduction

This 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 CloseNow(). A clean client then probes world.wait_for_tick(); once the broadcast is corrupted the probe times out.

How to run (Python 3.7 + carla==0.9.15):

# start a server on port 3000 (offscreen is fine), then:
python reproduce_hardkill.py --host 127.0.0.1 --port 3000 --rounds 60 --workers 6 --stream-seconds 1.0 --check-every 3

Results (Shipping build, Win64, same machine/install, only the exe swapped):

build config outcome
stock 0.9.15 6 workers, hard-kill STALL after ~18 abrupt disconnects (3 rounds)
with fix 6 workers, hard-kill STALL after ~54 abrupt disconnects (9 rounds) — ~3x more resilient
stock 0.9.15 1 worker, hard-kill no stall in 40 rounds
stock & fix 1 client, graceful disconnect no stall in 200 iterations

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())

@mskwt

mskwt commented May 20, 2026

Copy link
Copy Markdown
Author

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 MultiStreamState::DisconnectSession. A lone session double-closing is harmless (the 2nd call hits if (_sessions.size() == 0) return;). It bites when a stream has exactly two sessions and one of them reaches DisconnectSession twice:

_sessions = [V, A]                  # size 2
DisconnectSession(A)  # 1st: erase A -> [V], size==1 -> _session = V
DisconnectSession(A)  # 2nd: size==1 branch -> DEBUG_ASSERT(A==_session) [A!=V],
                      #      then _session=null; _sessions.clear()  -> V is evicted

The double DisconnectSession(A) comes from ServerSession::CloseNow() being re-entered (deadline timer vs. a read/write erroring vs. Close()), which is exactly what the one-line _is_closed guard prevents.

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 wait_for_tick() hangs (the "server is stuck" symptom).

Verified, minimal, realistic reproduction (1 persistent client + 1 client that connects and is hard-killed, against a stock 0.9.15 server):

build result
stock 0.9.15 REPRODUCED on the 1st connect/kill cycle — the survivor stops receiving world ticks
with the _is_closed fix CURED — survivor kept ticking through 30/30 cycles

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.

@mskwt

mskwt commented May 20, 2026

Copy link
Copy Markdown
Author

Closing in favour of a cleaner PR with the minimal reproduction (see the analysis comment above).

@mskwt mskwt closed this May 20, 2026
@mskwt

mskwt commented May 20, 2026

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant