1 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 01ebf33c8a fix(plain): answer a call that registers as the connection fails, instead of leaving it to its deadline (#49)
* fix(plain): answer a call that registers as the connection fails, instead of leaving it to its deadline

sendCallAsync() reads m_stopped and THEN registers its handler under m_mu.
fail() writes m_stopped and THEN sweeps the pending map under the same mutex.
The two are ordered opposite ways round, so a fail() that completes in between
sweeps a map the caller has not written to yet:

    caller                              fail()
    ------------------------------      -------------------------------
    m_stopped.load()  -> false
                                        CAS m_stopped -> true
                                        lock(m_mu); swap(m_pendingCalls)
                                        unlock(m_mu)   ... the map was EMPTY
    lock(m_mu); m_pendingCalls[id] = h
    writeFrame()                        ... drops: stopped

The handler is now parked in the pending map of a connection nobody will sweep
again — fail() runs once and has been, no reply can arrive on a closed socket,
and the frame was never written. THE CALL IS ANSWERED BY NOTHING, and what
answers instead is the caller's own deadline: callMethodAsyncWithError reports
"timeout" after the full timeoutMs, callMethodWithError blocks its thread for
the same span and reports the same wrong code, and getMethods() waits out a
hard-coded five seconds that no caller can shorten. A connection already known
to be gone is reported as a peer that was merely slow — which is also the code
callers retry and re-acquire on.

This predates the io_context fold: master has the identical shape on the
promise-based path. #46's cancelPending() only made the orphaned entry
self-cleaning rather than permanent.

THE FIX: register first, then re-read m_stopped, and reclaim our own entry if
the connection died in between. It closes the hole by an ordering argument
rather than by a smaller window:

  * if fail()'s sweep ran BEFORE the registration then its CAS ran before that,
    so the re-read cannot see false, and the reclaim answers the call;
  * if the re-read DOES see false then, in the total order over m_stopped, it
    precedes fail()'s store; the registration is sequenced-before the re-read,
    so it precedes fail()'s lock, and the sweep is guaranteed to find the entry.

There is no third case, and exactly one of the reclaim and the sweep can extract
the handler because both extract-and-erase under m_mu — the same single-winner
rule dispatchIncoming and cancelPending already play by, with one more
contender. sendMethods() gets the same treatment for the same reason.

REJECTED, since the tempting fixes deadlock: holding m_mu across the check AND
the delivery self-deadlocks on the first inline delivery, because a handler here
is AsyncCall's, which calls deliver(), which calls cancelPending(), which takes
m_mu — and m_mu is not recursive (the symmetric version, fail() invoking swept
handlers under the lock, dies the same way). Moving fail()'s once-only CAS under
m_mu is correct and deadlock-free, but makes teardown's flag wait on a mutex
every in-flight send and every decoded reply also take, so m_stopped stops being
the instantly-visible "stop writing" signal that writeFrame(), doWrite() and
doRead() read lock-free — a race traded for a teardown-latency regression.

sendSubscribe() has the same shape and is deliberately left alone, with a note
saying why: nobody waits on a subscription, so there is no deadline to blow and
no caller to strand.

tests/protocol/test_plain_send_after_fail.cpp builds the interleaving instead of
waiting for it — it takes the connection's own mutex, which parks the caller
between its m_stopped check and its registration, then drops the mutex and calls
stop() from the hot thread. Validated against cf1b9b0 (the head of #46), where
all four tests are red: 39 of 40 calls never answered and their registrations
left behind; 39 of 40 reported as "timeout" at 833ms against an 800ms deadline;
a 5053ms getMethods(); 5 of 10,000 calls dropped in the unaided race. The suite
spends 154s there and 9s here, almost all of it callers sitting out deadlines
that had already been decided.

Exactly-once is re-proved rather than assumed, because the fix adds a third
contender for a registered handler. The new 10,000-call burst that stops the
CONNECTION mid-burst reports 0 doubles and 0 drops over 12 runs (120,000 calls),
and it is a validated double-detector: against the obvious spelling of this fix
(copy the handler, deliver it without the extract-and-erase) it reports 5-10
doubles per 10,000. The suite's existing 10,000-call release-race is BLIND to
that — 0 doubles against the same broken code — because it races teardown of the
HANDLE with the connection still up, so sendCallAsync's stopped branch is never
taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(plain): arrange the volume test's race instead of timing it

The first CI run on the parent commit was green on ubuntu-latest (306/306)
and failed ONE assertion on macos-latest:

    10000 calls racing stop() -> answered=10000 (by a reply=1512,
      by fail()'s sweep=8488, stopped-connection=0)
      DROPPED=0 DOUBLED=0 left registered on a dead connection=0
    Expected: (byClosed) > (0)

Every substantive claim held — nothing dropped, nothing doubled, nothing
leaked. What failed was the test's own "this run actually raced something"
gate: on a 3-core runner the four caller threads finished issuing all 1,000
registrations before the teardown landed, so no call took the stopped path
and the run could not speak to it. That is exactly what those gates exist to
make loud, and it did its job — but a gate that goes vacuous on a slow box is
not one, so the race is now ARRANGED rather than timed.

Each of the three resolvers is made live by construction:

  * a REPLY — phase one answers normally and is waited for, and asserted;
  * fail()'s SWEEP — phase two puts the provider in a hold-every-reply mode,
    so every call registered between there and the stop is still pending when
    the sweep runs;
  * the RECLAIM — the stop is triggered by a COUNT of registrations rather
    than a clock, so hundreds of calls are always still to be issued when it
    lands, however slow the box.

Both earlier cuts failed the opposite way round on the same runner: a fixed
delay put the stop ahead of the whole burst, and a first-reply trigger put it
behind all of it.

This also strengthens the detector, re-validated against real pre-fix code
rather than assumed to carry over. On cf1b9b0 the four tests now report 40/40
calls never answered (was 39/40), 40/40 reported as a timeout at 828ms, a
5049ms getMethods, and 14 dropped per 10,000 (was 5). Against the broken
"deliver a copy without the extract-and-erase" reclaim it still reports 4-8
doubles per 10,000 over four runs, while the suite's existing 10,000-call
release-race still reports 0 — which is the point of having this one.

The other fix here is a SIGPIPE the harness was provoking (one run in six died
with signal 13): asio sets SO_NOSIGPIPE in socket_ops::socket() and accept(),
which is how every socket in the shipped transports is made, but connect_pair
descriptors go through assign(), which does not — so the test sets the option
asio would have, rather than touching the process-wide signal disposition.
Nothing to do with the product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:38:41 -03:00