fix(protocol): the drain barrier must not dangle on its own timeout

Two defects in the barrier added by 1e9c934, both on the path it takes when
it fails:

  std::promise<void> drained;                                   // stack local
  boost::asio::post(ioc, [&drained] { drained.set_value(); });  // by REFERENCE
  fut.wait_for(std::chrono::seconds(5));                        // result dropped

1. The wait is bounded, so on timeout this frame returns while the posted task
   is still queued -- and the task holds a pointer to a destroyed stack object.
   set_value() then writes to freed stack memory. The bound that stops a wedged
   I/O thread hanging teardown introduced the exact class of use-after-free the
   barrier exists to prevent. The promise is now a shared_ptr captured BY VALUE,
   so the task keeps it alive whether or not anyone is still waiting.

2. The wait_for result was discarded. A timeout means the barrier did NOT hold
   and we are about to free an IncomingCallHandler that a live connection may
   still call back into -- the original crash, minus any way to know it
   happened. It now warns, naming the consequence.

Neither is reachable while the I/O thread drains promptly, which is why the
suite is green either way; both matter precisely when it does not, which is
the only situation the barrier is for.

Tests: 270/270.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-05 14:07:35 -03:00
co-authored by Claude Opus 5
parent 1e9c93434b
commit 4f9d824129
@@ -231,10 +231,23 @@ PlainTransportHost::~PlainTransportHost()
if (tcp || ssl) {
auto& ioc = IoContextPool::shared().ioContext();
if (!ioc.get_executor().running_in_this_thread()) {
std::promise<void> drained;
auto fut = drained.get_future();
boost::asio::post(ioc, [&drained] { drained.set_value(); });
fut.wait_for(std::chrono::seconds(5)); // safety-bounded; work-guard keeps the thread alive
// The promise is shared, NOT captured by reference. The wait below is
// bounded, so on timeout this frame returns while the posted task is
// still queued — a by-reference capture would then set_value() on a
// destroyed stack object, which is the very failure mode this barrier
// exists to prevent.
auto drained = std::make_shared<std::promise<void>>();
auto fut = drained->get_future();
boost::asio::post(ioc, [drained] { drained->set_value(); });
// Bounded so a wedged I/O thread cannot hang teardown — but a timeout
// means the barrier did NOT hold and we are about to free an
// IncomingCallHandler a connection may still call back into. Say so:
// silently proceeding is how this class of crash stays unexplained.
if (fut.wait_for(std::chrono::seconds(5)) != std::future_status::ready) {
qWarning() << "PlainTransportHost: I/O drain timed out after 5s;"
<< "tearing down anyway — a connection callback may still"
<< "reference this host (see the barrier comment above)";
}
}
}
}