Skip to content

Fix bridge retirement coroutine lifetimes#106

Closed
cursor[bot] wants to merge 1 commit into
mainfrom
cursor/critical-bug-investigation-c0d0
Closed

Fix bridge retirement coroutine lifetimes#106
cursor[bot] wants to merge 1 commit into
mainfrom
cursor/critical-bug-investigation-c0d0

Conversation

@cursor

@cursor cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Bug and impact

Bridge retirement helper coroutines captured stack-owned retirement sockets by reference. Because SpawnOnNewStrand posts work asynchronously, bridge teardown or fast shutdown could destroy those sockets before the spawned helper accepted or sent on them, causing use-after-free crashes. The same path also recorded outgoing active messages only after sending over the bridge, allowing a fast peer to retire on the separate retirement socket before the local reference was installed; that could crash on a null slot or leak the active slot and stall reliable channels.

Root cause

The bridge retirement listener/transmitter were owned by the parent bridge coroutine stack while child coroutines outlived that stack. Active retirement bookkeeping was installed after the data-plane send, racing the control-plane retirement notification.

Fix

  • Move retirement listener/transmitter sockets to shared heap ownership captured by spawned helpers.
  • Keep parent bridge coroutines able to close those shared sockets on teardown so helper waits unblock.
  • Track outgoing active messages before bridge sends and release the reference if the send fails.
  • Ignore null/spurious retirement notifications instead of dereferencing empty entries, and reset entries after retirement.

Validation

  • npx --yes @bazel/bazelisk test //client:bridge_test
  • npx --yes @bazel/bazelisk test --//:coro_backend=asio //client:bridge_test
Open in Web View Automation 

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