Files
logos-protocol/cpp/implementations
Dario Gabriel LipicarandClaude Opus 5 cb015f5a2a test(protocol): pin publishing as a waiter's LAST access to the object
PlainLogosObject's waiters are joinable, interruptible and reaped, and all
three rest on one ordering rule that nothing in the suite could see:

    ~FinishOnExit() {
        self->reapFinishedWaiters();      // others, never itself
        self->publishFinishedWaiter(id);  // strictly last
    }

reapFinishedWaiters() erases published entries from m_waiters under m_waiterMu
and joins those threads OUTSIDE it. stopAndJoinWaiters() swaps m_waiters under
the same lock and brute-force joins whatever it got. So a waiter that a
concurrent reaper is mid-join on is NOT in teardown's map, and teardown can
return — with release() going straight on to `delete this` — while that waiter
is still unwinding. stopAndJoinWaiters() already says this in as many words:
the guarantee is not "everything is joined when this returns" but "no waiter
touches this object after this returns". Publishing being last is the entire
reason the second sentence is true, so one member access below it is a
use-after-free, and moving the publish above the reap is a join cycle.

THE DEFECT SHIPS GREEN. Rebuild plain_logos_object.cpp with a single object
read after the publish and the whole of PlainObjectTeardownTest and
PlainWaiterReapingTest passes, 10 runs out of 10, cleanly under Guard Malloc.
That is not a hole in those suites. No SUPPORTED caller can provoke it: under
calls-in-flight-plus-release, every waiter is still joined transitively,
because a waiter leaves m_waiters only via teardown (which joins it) or a
reaper, and a reaper is either another waiter — itself in m_waiters until after
its join returns — or the async-spawn path, whose join completes before the
call returns. The one uncovered reaper is the spawn path racing a concurrent
release(), and calling a method on an object another thread is releasing is
caller-side UB that faults on correct code too. A test built on that race would
be red on green code, so it is not a usable detector.

SO STOP RACING AND OBSERVE. tests/protocol/test_plain_waiter_publish_is_last.cpp
drives a real PlainLogosObject through a scripted RpcConnectionBase — no socket,
no host, no event-loop timing, and the test decides exactly when the call's
future is satisfied — and watches the accesses in two halves.

  * THE STATE. The object is placement-newed into an mmap'd two-page arena, put
    down so a page boundary lands at m_waiterMu: the members teardown
    coordinates on go on the second page, everything else on the first. The
    first page is mprotect(PROT_NONE)'d for exactly as long as a waiter runs,
    and a SIGSEGV/SIGBUS handler RECORDS each access — address, thread, and how
    many ids were published at that instant — then unprotects so the access
    proceeds. Nothing crashes; the access is evidence. A correct waiter touches
    that page zero times: objectName and method are copied into the closure
    precisely so it needs nothing from the object. Four rounds, one per exit
    path out of the lambda (answered, rejected, timed out, cancelled), since all
    four end in the same guard.

  * THE REGISTRY, which that page cannot cover because publishing has to reach
    it. Caught with bait, using the reaper's own shape: reapFinishedWaiters()
    joins outside m_waiterMu, so a waiter that has picked up somebody else's
    finished thread sits in that join holding nothing — a window the test holds
    open as long as it likes, because the thread being joined is one the test
    planted and keeps parked. Plant bait 1; let the call finish; the exit guard
    reaps, takes it, parks. Plant bait 2 at leisure. Release bait 1; the waiter
    finishes its reap and publishes. Bait 2 must still be registered. Bait 1
    doubles as a check that the reap really does join with the lock free.

Neither half is probabilistic. A third test proves the detector can fire at all,
so the two "this counter stayed at zero" assertions are not vacuous.

MEASURED, rebuilding the file under test with each defect (caught/runs):

  defect below publishFinishedWaiter()      new    teardown+reaping
  ------------------------------------      ---    ----------------
  read m_objectName                       40/40                0/10
  read m_conn                             10/10                0/10
  read m_completions                      10/10                0/10
  read m_completionSubscribed             10/10                0/10
  lock m_mu                               10/10                0/10
  call reapFinishedWaiters() again        20/20                 2/2
  read m_stopping                          0/10                0/10
  (publish moved ABOVE the reap)            0/5               12/15
  no defect — 8f0c60f                      0/40                0/10

The one gap is m_stopping, the single member sharing the registry's page, which
cannot be guarded without guarding the publish. The inverted order is left to
the reaping suite's hammer, which has it covered. Runtime 0.9-1.0s for all
three tests; clean 40/40 on 8f0c60f, and clean 3/3 under Guard Malloc
(MALLOC_PROTECT_BEFORE=1, banner confirmed) — the test never touches freed
memory itself, which is the other half of not being built on UB. No Guard
Malloc needed to detect anything: mprotect and the bait are the detectors.

Also: nix build .#tests 100% (285/285), the full binary 285/285, and
CallErrorAfterAcquireTest 40/40.

CORRECTIONS to measurements claimed earlier on this branch. All three were
overstated in the same direction — a single sample read as a constant:

  * "ReapingRacesPublishingWithoutDeadlocking aborts the process, 5 runs out of
    5" (plain_logos_object.cpp, and 378d889's message) is 12 runs in 15, ~80%.
    It is a race detector, so one green run of it proves nothing — which is
    exactly the argument for the deterministic suite added here. Corrected in
    the comment.
  * C-ABI retention was reported as "+0.09 MiB / 10 B per call" for 10k
    lp_invoke_async on one client (8f0c60f's message). ~6 B/call. Same
    conclusion — flat — different arithmetic.
  * The burst retention figures 1428 (2000 calls, idle) and 610 of 800 came
    back as 1421 and 599 on re-measure of the same build. Race-dependent, same
    magnitude, which is why the tests assert a bound and not a value. Noted in
    test_plain_waiter_reaping.cpp so the next reader does not treat them as
    reproducible constants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 01:19:34 -03:00
..