Skip to content

Fix Asio bridge retirement and cancellation-slot crashes#99

Closed
cursor[bot] wants to merge 2 commits into
mainfrom
cursor/critical-bug-investigation-caeb
Closed

Fix Asio bridge retirement and cancellation-slot crashes#99
cursor[bot] wants to merge 2 commits into
mainfrom
cursor/critical-bug-investigation-caeb

Conversation

@cursor

@cursor cursor Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Bug and impact

  • Bridge retirement coroutines spawned with SpawnOnNewStrand captured retirement sockets by reference to parent coroutine locals. Under the Asio backend, spawn is deferred through a strand, so bridge teardown or early return could leave the child coroutine dereferencing destroyed stack state, causing intermittent crashes or memory corruption for bridged reliable channels using retirement notifications.
  • Asio wait/socket helpers unconditionally installed cancellation handlers on yield_context cancellation slots. User coroutines spawned normally with boost::asio::detached have no connected cancellation slot, so async client paths could abort inside Boost.Asio (cancellation_signal::prepare_memory) during ordinary pub/sub use.

Root cause

  • The bridge code assumed coroutine spawn started synchronously and that referenced locals would remain valid.
  • The async facade assumed every yield_context came from AsyncRuntime::SpawnTracked, which provides a connected cancellation slot.

Fix

  • Keep bridge retirement listener/transmitter sockets alive with std::shared_ptr captured by value into deferred coroutines.
  • Guard cancellation-slot assignment in Asio wait/socket helpers with is_connected() and add regression coverage for detached user coroutines.

Validation

  • npx --yes @bazel/bazelisk test --//:coro_backend=asio //client:bridge_test //client:asio_client_test //common/async:async_test
  • npx --yes @bazel/bazelisk test //common/async:async_test //client:bridge_test --test_filter='BridgeTest.BasicRetirement:BridgeTest.MultipleRetirement:BridgeTest.MultipleRetirement2'
Open in Web View Automation 

cursoragent and others added 2 commits June 29, 2026 11:07
Co-authored-by: dallison <dallison@users.noreply.github.com>
Co-authored-by: dallison <dallison@users.noreply.github.com>
@dallison dallison closed this Jul 7, 2026
dallison added a commit that referenced this pull request Jul 16, 2026
…ss validation, shadow recovery) (#109)

* Fix streaming RPC cancel watcher lifetimes

Streaming RPC handlers in the rpc, asio_rpc, coro_rpc and co20_rpc server
backends spawned a detached cancel watcher that captured the per-request
AnyStreamWriter and RpcRequest stack objects by reference. After a stream
completed normally or errored, a later cancel/shutdown wakeup could
resume the watcher and access freed stack memory, risking server crashes
or request-id corruption. AnyStreamWriter also stored the RpcRequest by
reference, extending the dangling-reference hazard.

Make AnyStreamWriter own a copy of the RpcRequest and share an atomic
cancellation state, have watchers capture only stable request/session
IDs plus the shared state and a per-stream stop pipe, and wake each
watcher when the handler returns so normal completion leaves no suspended
watcher behind.

Combines cursor bot PR #90.

* Fix Asio wait timeouts and cancellation-slot crashes

Two problems in the optional Boost.Asio coroutine backend:

- async::WaitEither had no timeout support, so client wrappers such as
  Subscriber::Wait(fd, timeout, ctx) and reliable publisher waits
  discarded the caller-provided timeout and could hang forever when
  neither fd became ready. Added timeout support to WaitEither (with a
  non-suspending fast path) and passed the client deadlines through.

- Asio wait/socket helpers unconditionally installed cancellation
  handlers on the yield_context cancellation slot. Plain
  boost::asio::spawn(..., detached) coroutines have no connected
  cancellation slot, so ordinary async pub/sub paths could abort inside
  Boost.Asio. Guard cancellation-slot assignment/clear with
  is_connected().

Combines cursor bot PR #92 (supersedes the cancellation-slot portion of
PR #99).

* Fix bridge retirement lifetime races

Bridged reliable channels with retirement notifications could crash or
wedge when retirement messages raced with bridge teardown or arrived
immediately after a bridged send:

- The retirement receiver was spawned (via SpawnOnNewStrand, which posts
  work asynchronously) with a reference to a parent coroutine's
  stack-owned listener socket, and the retirement notifier captured a
  stack-owned transmitter. Shutdown or early bridge teardown could leave
  spawned coroutines using destroyed sockets.
- Outstanding bridged ActiveMessage tracking was updated only after the
  network send and shared without synchronization, so a fast peer could
  retire a slot before it was recorded, causing a null dereference (the
  old code did (*active_retirement_msgs)[slot_id]->DecRef() with no null
  check) or a permanently pinned reliable slot.

Move the retirement listener/transmitter sockets to shared ownership and
close them when parent bridge coroutines unwind so helpers wake safely.
Add a synchronized BridgeRetirementState that tracks bridged
ActiveMessages before sending, releases duplicate/late/failed retirements
safely, and cleans up outstanding slots on teardown.

Combines cursor bot PR #107 (supersedes duplicate PRs #100, #102, #106
and the bridge portions of #93 and #99).

* Validate bridged channel address payloads

Malformed discovery/bridge protobufs could set a channel address family
while providing fewer than four address bytes. The server decoded those
bytes with unchecked memcpy/reinterpret_cast in bridge subscribe and
retirement handling, letting a network peer trigger out-of-bounds
reads/undefined behavior and potential server crashes.

Add a shared ParseChannelAddress helper that validates address length
and family before decoding, and use it for both the subscribe receiver
and retirement socket parsing.

Combines cursor bot PR #97.

* Rebuild CCB subscriber registrations on shadow recovery

When a server recovered channel state from a shadow, RecoverFromShadow
re-created the subscriber users but never rebuilt the recovered channel's
CCB subscriber registrations, so a channel could come back with a stale
or zero subscriber count until the next remap. Call
RegisterExistingSubscribers() after re-adding the recovered subscribers,
matching the RemapChannel path.

Combines cursor bot PR #96.

* Update bazel lock file

* Confine bridge retirement state to one strand, drop its mutex

The BridgeRetirementState vector was shared between the bridge
transmitter and its retirement-reader coroutine, which ran on two
separate strands, so it needed a mutex to stay correct under a
multi-threaded io_context. Spawn the retirement reader on the
transmitter's own strand (new AsyncRuntime::SpawnOnCurrentStrand) so
the two are serialized by the strand and the mutex can be removed.
They still interleave cooperatively at every suspension point, so the
reader keeps draining retirements while the transmitter is parked on
backpressure.
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.

2 participants