Files
MobileGL/docs/Disaggregated/notes/p5d/reports/T.md
T
swung0x48 aba329cc53 [Docs] (Disaggregated): P6's plan and contract draft; the working notes move from a local directory into the repository
Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.

1. P6's plan and contract draft (neither started; not one line of code)

     docs/Disaggregated/P6-SPAWN-PLAN.md      the package plan
     docs/Disaggregated/P6-CONTRACT-DRAFT.md  the contract draft (moves to
                                              MG_Remote/CONTRACT-P6.md when c6 lands)

   The three findings that mattered, all written into the documents:

   a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
      the tree and already tested: SocketDoorbell including hangup detection and the two
      lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
      passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
      protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
      SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
      assembly plus a control plane plus a process, not "write a socket transport".

   b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
      audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
      the tree again. So the first package, a6, is a read-only audit that writes no code, and
      every contract row that depends on it is marked PENDING a6 rather than decided in
      advance.

   c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
      and protocol.fbs:186-196's own comment says Android transfers the window out of band -
      that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
      the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
      gate measures. A real window reaching the server is a named refusal. This gets its own
      section because "P6 is done" would otherwise read as "the game runs now".

   The contract adds rule G: a role may not name a process-local handle. It extends P5c's
   rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
   EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
   a token this contract defines or they do not cross, and a descriptor crosses only through
   SCM_RIGHTS, where the kernel renumbers it.

   What P5e left for P6 gets its own section. The latch semantics are already designed
   (ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
   client no longer waits while the peer process can DIE: the client holds records that were
   published and will never be applied, and Android's freezer suspends the child without
   closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
   That is P6's only new design; everything else is carriage. It shares a predicate with E1's
   outstanding re-specification (ID-122), and the two should be designed together.

   Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
   match integration-split's name for name, because a differing set means the lane is quietly
   running less; and the arm-proof gate requires every entry to record the child pid and
   transport=spawn in its OWN private log - ID-124 established that this kind of false green
   really happens, and a spawn lane that actually ran monolith would pass every other gate.
   S2's shape must be settled before it is written, and the document says why: E1 is the
   cautionary case.

2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)

   ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
   the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
   is under no version control. A record cited hundreds of times that lives only locally is not
   a record, so it moves in whole, with its directory structure intact.

   Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
   stay where they are: they are evidence DATA rather than documents - large, regenerated per
   run, and mostly carrying absolute paths and device serials that mean something only on the
   machine that produced them. The mapping and this split are written in
   docs/Disaggregated/notes/README.md.

   Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
   document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
   material salvaged from failed workflow runs, which may duplicate the like-named phase
   directories and is kept because a few of those were the only complete version at the time.

   CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
   deliberately outside it: those notes' file:line citations were written against their own
   code heads, and resolving them against today's HEAD would fail wholesale - which is not an
   error, it is their correct state as a historical record. To check one, use the revision it
   declares (--rev).

3. Inconsistencies fixed in passing

   - README.md's status line still said P5e was in progress while the stage table already said
     closed.
   - CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
     (a) and (b) were run in the previous commit; rewritten to closed plus the one item that
     actually remains (E1).
   - ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
     package order, the scope boundary and the two new gates.

Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
2026-09-18 23:31:39 -04:00

287 lines
20 KiB
Markdown

# P5d round 3, package T - transport: apply loop idle poll, clock-free spin, park counters
Worktree `MobileGL-p5d-T`, branch `p5d-T`, base `85362a85`. Not built, not committed. Patch:
`scratchpad/p5d/patches/T.patch` (13 files, +489/-25).
## What changed, per file
### `MG_Remote/Server/ServerLoop.{h,cpp}` - item 1, the idle poll's mutex
- New `std::atomic<Bool> m_controlPosted` replaces a `std::condition_variable` of the same name
that had **no waiter and no notifier anywhere in the tree** (`m_controlDone` carries the whole
handshake). `ControlIsPending()` is now one acquire load, no lock, and moved to the public
section (a test reads it - see red-once); `PumpControlRequest()` gained a fast negative on the
same load before it touches `m_controlMutex`.
- Protocol: the poster stores the shadow **last of the four fields, with release**, inside the
existing critical section in `RunOnApplyThread`; the pump clears it **under the lock, when it
takes the request**; the C2 exit block clears it under the same lock as the `m_running` clear.
The C2 critical section, the NOT_INITIALIZED answer at exit, publish-then-ring and the
re-entrant inline arm are unchanged in meaning; `ready` now reads only atomics.
- Why: simpleperf at `56a77348` put `mutex::lock` 12.4 + `mutex::unlock` 13.6 + the two
`pthread_mutex_*` halves at ~49% of `mgl-srv-apply`, with `ApplyThreadMain` the **direct** caller
of 58-60% and 99.8% of `pthread_mutex_lock`-via-`mutex::lock`. Those are these two call sites,
twice per idle iteration; most of `@plt` 6.1% goes with them.
- Item 2 (`DrainRing` on an empty ring): **audited, no code change**, finding written into the
function. `ApplyOne` -> `RingConsumer::Pop`, whose first act is one acquire load of `cmdHead`
against `m_localTail` (`Ring.cpp:222-232`); `applied == 0` skips the retire block. No mutex, no
clock, no allocation.
### `MG_Remote/Transport/Doorbell.{h,cpp}` - item 3, the clock-free spin
- `Detail::g_spinItersPerUs` + `Detail::CalibrateSpinItersPerUs()`, and an inline
`SpinItersPerUs()` that is a relaxed load and a predicted branch - deliberately **not** a
function-local static, whose guard-variable load on every `Wait` is the cost this removes.
`Wait()`'s spin phase is now `SpinItersPerUs() * spinUs` iterations of `ready()` + `CpuRelax()`
and reads **no clock at all**; the deadline is computed once, at the top of the park phase, and
`kWaitForever` reads no clock even there.
- Calibration: two passes of 8192 `load(acquire)` + `CpuRelax()` timed with `steady_clock`, faster
pass wins (a preempted pass reads absurdly slow, and under-spinning costs a park), clamped to
[4, 2000] iters/us with a 200 fallback for a clock too coarse to see the probe; a race between
two calibrators stores two legitimate budgets.
- **Overshoot bound** (documented in the file): the old loop overshot by at most one batch of 64
yields; this one by the calibration error - a DVFS drop or little-core migration stretches it by
the frequency ratio (~3-4x here, so 50 us can reach ~200 us), a preemption adds one slice as
before, and the probe omits the caller's `ready()`, so a real iteration is *dearer* than a
probed one and the spin ends early by that factor. Not a correctness bound: `ready()` is
re-tested every iteration and the deadline is still real-clock. Stated drift: the spin is no
longer charged against `timeoutMs`, so a wait is bounded by `timeoutMs + budget` - 50 us against
millisecond-scale timeouts, and erring long is the safe direction for a timeout.
- `Doorbell::ParkEntries()` + a private relaxed counter incremented at the one place a waiter
stops spinning and blocks - unconditional (one relaxed add on a path about to enter a condvar
or `poll()`), so a test can read it without `MOBILEGL_PIPE_STATS`.
- Why: `steady_clock::now()` 2.34 self / 5.91 incl + `__kernel_clock_gettime` 2.37 + `clock_gettime`
1.94 = ~6.6% of the GL thread at 900+ waits/frame, for a duration that is a hint.
`WaitForApplied`'s common path paid 2+ reads per wait; it now pays zero unless it parks.
### `MG_Remote/Transport/SessionRings.h`, `Transport/Ring.cpp` - item 4, client half
- `SessionProducer::Waits()` (relaxed atomic, incremented in `Park` *after* the two refusals that
never reach the bell) and `Parks()` (delegates to the self bell's `ParkEntries()` - only
`Doorbell::Wait` knows whether its budget ran out). Atomic because `ClientSession::Stop`'s drain
waits through this producer from the tearing-down thread; it is never copied (checked).
### `MG_Util/Metrics/PipeStats.{h,cpp}`, `Client/EmitTables.cpp`, `Client/ClientSession.cpp`
- Four gauges, **all inside existing `#if MOBILEGL_PIPE_PUSH` blocks**: `ServerWaits`,
`ServerParks`, `ClientWaits`, `ClientParks`. New `wait[srv= srvpark= cli= clipark=]` group on
the one `MGPipe stats:` line (it closes the `emit[` bracket the ring gauges were riding), same
four under long names in the JSON dump, run totals like the gauges above them. Both server
numbers, not one: `srv` is `ParkCount()`'s long-standing meaning (an intention, counted on the
way toward a park - ServerLoopTest's C10 note), `srvpark` is a real block.
- No new include edge into MG_Util: the existing `PublishGauge` pattern already crosses that way
(`EmitTables.cpp:1000`). The client pair is published from `EmitPresent`; the **server pair by
the apply thread from its own loop**, because CONTRACT-P5C rule E layer 2 forbids a GL-thread
read of `ServerLoop`'s memory - PipeStats is the meeting point precisely because it is below
both roles. Both publishes sit behind `PipeStats::Enabled()`.
- `ClientSession::LogWireLedger` also gained `cliwait=`/`clipark=`: that line is in **every** split
private log, and the round-3 profile runs did not set `MOBILEGL_PIPE_STATS`.
## Red-once
| change | goes red as |
|---|---|
| shadow **set** side dropped | `ServerLoopTest.AControlRequestRunsOnTheApplyThreadAndUnparksIt` (existing) - the predicate sees nothing, the poster blocks, the case says so within 5 s |
| shadow **clear** side dropped | `ServerLoopTest.TheControlShadowIsClearedSoTheLoopParksAgainAfterARequest` (**new**) - `ready` stays true, the loop spins for ever and never stores `consumerParked`, so the second `WaitUntilTrulyParked()` times out; plus a direct `ControlIsPending()` read |
| spin budget zero / calibration broken | `InProcessTransportTest.DoorbellSpinsWithoutParkingWhenReadyFlipsInsideTheBudget` (**new**) - `ParkEntries()` moves although the condition flipped on the 8th probe |
| spin never ends / park unreachable | `InProcessTransportTest.DoorbellStillParksWhenTheSpinBudgetRunsOut` (**new**) - `ParkEntries()` does not move |
| `wait[]` renamed or dropped | `PipeStatsTest.SummaryLineCarriesEveryClassAndGate` (extended) |
Kept passing by inspection: `DoorbellWakesAParkedWaiter`, `DoorbellReturnsImmediatelyWhenAlreadyReady`,
`DoorbellTimesOutWhenNothingHappens` (the uncharged spin only makes elapsed larger than its `>= 20 ms`),
`ShutdownUnparksAWaiterWithNoDeadline` (fence protocol and `Dead()` unchanged), `RingTest`, `SessionTest`.
## G1 / monolith impact
**None.** Every touched file is MG_Remote (built only under `MOBILEGL_BUILD_DISAGGREGATED`,
`CMakeLists.txt:547-578`), a test, or - for `PipeStats.{h,cpp}`, which *is* in the pull build -
edited exclusively inside pre-existing `#if MOBILEGL_PIPE_PUSH` regions (`PipeStats.h:186-236`,
`PipeStats.cpp:484-545`, `:600-622`), so `MOBILEGL_PIPE_PUSH=OFF` compiles no added token and the
pull build's text is unmoved. No include-closure probe is touched.
## Risks
1. **The spin's duration bound is weaker** (see above): it burns CPU before a park, cannot change
a result, and the [4, 2000] clamp plus probed-cheaper-than-real bounds both directions. The
probe reads the clock twice per pass, once per process (2-30 us) - no steady-state path.
2. `m_controlPosted` is a second copy of one mailbox bit. Disagreement is a performance fault in
both directions (a spin that never parks; a wakeup the doorbell's own re-test recovers), never
a lost request - the pump re-checks `m_controlPending` under the lock and clears a stale shadow.
3. `wait[` closes `emit[` earlier than before, but `PipeStatsWindow::CounterOrAbsent` matches
`" name="` / `"[name="`, so `fbe`/`sve`/`csom`/`mpr`/`rsp`/... are unaffected and the four new
names collide with nothing (`cli=` cannot match `clipark=`).
## What I did not do
- Did not build, run, commit or push. No `Co-Authored-By` anywhere.
- Did not hoist `ServerSession::Consumer()` / `Applier()` out of `DrainRing` - two out-of-line
calls per idle iteration, a small win, but caching them touches `ApplyThreadMain`'s opening
lines, which package D owns. Flagged for round 4 / D; nothing of `OnApplyThread`,
`m_applyThreadId` or those lines is touched here. Also left: the per-park `steady_clock::now()`
under `kWaitForever` (one read per real park), to keep the park phase structurally identical.
- Did not update `docs/Disaggregated/ARCHITECTURE.md:623`, which enumerates the summary line's
counters and now omits `wait[]` - left to the round's docs pass so four packages do not collide.
Same for stale `Doorbell.h:NNN` references nearby: already drifted at `85362a85`.
- The brief's "visible to `ControlIsPending` **without the pump**" is not directly constructible:
`m_callerMutex` allows one outstanding request and a live apply thread pumps every iteration, so
no window exists in which a posted-but-unpumped request is observable by a third thread. The new
test pins the clear side (which nothing else could see), the existing unpark test the set side.
---
# Fix round (after review)
Two reviews, no blockers, three distinct majors (two of them the same defect seen from two
sides) and five minors. Every one is addressed below; nothing was widened. Patch regenerated
from `MobileGL-p5d-T`: 16 files, +749/-40 (was 13 / +489/-25). Still not built, not committed.
## Majors
### M1 (review 1) - the clear side's red-once did not fire
**Upheld.** `PumpControlRequest` clears the shadow twice - at the take (`ServerLoop.cpp`, under
the lock, when the work is handed out) and defensively on the next pump when it finds the shadow
set with `m_controlPending` false. The old case asserted only the END STATE ("the shadow is clear
after the request was answered, and the loop parks again"), and the defensive clear supplies that
end state on the very next iteration, so deleting the take-clear left the case green. The
reviewer's trace is exact.
**Fix: observe the window, keep both clears.** `ServerLoopTest.TheControlShadowIsClearedSoTheLoop
ParksAgainAfterARequest` is replaced by
`ServerLoopTest.TheControlShadowIsClearedWhenThePumpTakesTheRequest`. The posted work now blocks
on a gate the test thread holds; the test waits until the work is really running and reads
`ControlIsPending()` in that window. There `m_controlPending` is still true and no pump is
running, so the ONLY thing that can make the accessor answer false is the clear at the take -
delete it and the read returns true on every run, deterministically. The end-state assertions
(answer returned, `WaitUntilTrulyParked()` again) are kept as the symptom an operator would see,
with their comment now saying which clear each one pins.
The defensive clear is kept: it is a genuine self-heal for a shadow that outlived its request,
and with the window observed directly it no longer masks anything.
### M2 (both reviews) - `Parks()` was not the subset of `Waits()` that everything claimed
**Upheld, in all three of its parts**, and the fix is one mechanism for all three.
1. *Counted per `Park()`, not per `Wait()`.* The increment sat inside the park-retry loop, so a
wait that parked, woke on a remembered `Notify`, re-tested false and parked again counted
twice - a routine path, exactly as review 2 describes.
2. *Counted for `Park(0)`.* When the remaining deadline rounds down to 0 ms the loop spins
through the counting site without ever blocking.
3. *Counted off a shared bell.* `SessionProducer::Parks()` returned `m_selfBell->ParkEntries()`,
and `ClientSession::Start` installs that same object as the encoder's stage-retirement bell
(`PipeWireCodec::SetStageRetirementDoorbell`). Those waits never touch `m_waits`, and with
`spinUs == 0` they now park unconditionally, so `clipark` could print larger than `cli` on
any lane with stage retirement (the SmallRing / E3(e) lane asserts `stageReclaimWaits >= 1`).
**Fix.**
- `Doorbell::Wait` gained a fifth, defaulted parameter `std::atomic<std::uint64_t>* parkTally`.
It is bumped at the same site as the bell's own counter, and both are now guarded by
`chunkMs != 0 && !countedPark`, where `countedPark` is a local hoisted above the park loop. One
park per `Wait`, and only when the call really blocks.
- `SessionProducer` owns `m_parks` and `SessionProducer::Park` passes `&m_parks`, so the client
pair counts the same events on both sides. `Parks()` reads it; `ParkEntries()` is no longer
consulted by any ledger.
- The server half got the same treatment even though its bell has no second waiter today:
`ServerLoop::m_parkBlocks` (+ `ParkBlockCount()`, reset in m-3's per-session block beside
`m_parks`) is passed into the loop's one `Wait`, so `srvpark <= srv` cannot be broken later by
anything else waiting on the consumer doorbell (`SessionConsumer::WaitForWork` waits on that
same bell).
- Comments corrected where they asserted the old, false relation: `Doorbell::ParkEntries()`'s
block now says it is per BELL and names the two waiters that share the client's,
`SessionRings.h`'s `Waits()`/`Parks()` block says why the tally is ours and not the bell's,
and `PipeStats.h` says the subset relation holds only because both halves are counted by the
same path.
**Red-once for the fix** (both new, both deterministic):
- `InProcessTransportTest.AWaitThatParksTwiceOnARememberedNotifyIsStillOnePark` - `Notify()` with
nobody waiting, then one `Wait` whose condition never flips: the first `Park` returns at once on
the remembered wakeup and the loop parks again. `ParkEntries()` must move by exactly 1. Revert
`countedPark` and it moves by 2.
- `SessionTest.TheProducersParkLedgerCountsItsOwnWaitsAndNotTheBellsOtherWaiters` - a foreign
`Wait` on `clientTransport->SelfDoorbell()` (spinUs 0, condition never true - the encoder's
exact shape) must move neither `producer.Waits()` nor `producer.Parks()`; the producer's own
20 ms `WaitForApplied` must move both by exactly one. Revert `Parks()` to `ParkEntries()` and
the first `EXPECT_EQ` on `Parks()` goes red.
## Minors
### m1 - `spinUs == 0` silently changed meaning (both reviews)
**Upheld; stated, and the probe no longer runs on that path.** The old batched loop ran 63
iterations before its first clock read could end it; the new budget is `itersPerUs * spinUs`, so
zero means zero. That is now an explicit `spinUs == 0 ? 0ull : ...` short-circuit with a comment
naming the delta, both callers that pass 0 (PipeWireCodec's SEG_STAGE retirement wait, and any
lane with `MOBILEGL_IPC_SPIN_US=0`), and the reason it is deliberate rather than accidental. The
short-circuit also keeps a zero-spin caller from paying the one-time calibration probe - review
1's second half of the finding. `PipeWireCodec.cpp`'s `0` is left as it is: the retirement wait is
rare, already slow, and wants the park; changing it is a codec decision, not this package's.
### m2 - `ControlIsPending()` no longer means what its name says
**Upheld; documented rather than renamed.** The brief names the accessor, and three call sites'
worth of churn buys nothing, so the header block now states the predicate literally - "posted and
not yet taken" - says that it answers false for the whole duration of `work(user)` while
`m_controlPending` is still true, says it is a PARK PREDICATE and not a liveness query, and notes
that the only in-tree reader besides the test is the apply thread's own `ready` lambda, which by
construction cannot be inside that window. The new test in M1 is built on exactly that window, so
the documented meaning is now the tested one.
### m3 - the calibration clamp's two opposite readings (review 2)
**Upheld.** `perUs` truncates to 0 for any pass longer than 8.192 ms, i.e. the heavily preempted
probe, and that landed on the same `best == 0` arm as "no usable clock" and installed 200 iters/us
- 50x the floor - on the machine least able to afford it. `CalibrateSpinItersPerUs` now carries
`sawUsableClock`, set whenever `elapsedNs > 0`: measured-but-slow clamps UP to
`kMinSpinItersPerUs`, and `kFallbackSpinItersPerUs` is reserved for the case where no pass was
measurable at all. The `Doorbell.h` paragraph already described this rule ("a probe that was
preempted cannot produce a budget smaller than kMinSpinItersPerUs"); it is now true.
### m4 - `Doorbell.h:NNN` citations invalidated by this package's insert (review 2)
**Upheld - and the first report's "already drifted at 85362a85" was wrong.** Checked at baseline:
`139-178` really was the spin loop through the `Dead()` arm, `186-193` really was the `};` plus
NotifyIfParked's PRECONDITION, `211-221` really was CondVarDoorbell/`Kill`. The +60 lines moved all
three, and `check_doc_citations.py` cannot see it because the file only grew. Every in-tree
citation is re-pointed to SYMBOL NAMES rather than to new line numbers, so the next insert cannot
break them again: `ServerLoop.cpp` (x3), `ServerLoop.h`, `SessionRings.h` (x3), `EventRing.h`,
`ClientSession.cpp`, `ClientSession.h` (x2) and `CONTRACT-P5.md:355`. The checker only resolves
`path:line` pairs, so dropping the numbers leaves every gate green.
### m5 (review 2's note, not filed as a finding) - `srv=0` under `spawn`
Not a defect of this patch, but the new `PipeStats.h` comment asserted "PipeStats is the meeting
point precisely because it is below both", which is true in one transport mode. The comment now
says so: under `spawn` the server pair is published into the server process's PipeStats and the
client's line prints `srv=0 srvpark=0` for the whole run - the same property `rsp` already has -
and the round this pair was added for is `inproc`.
## Red-once table, updated
| change | goes red as |
|---|---|
| shadow **set** side dropped | `ServerLoopTest.AControlRequestRunsOnTheApplyThreadAndUnparksIt` (existing) |
| shadow **take-clear** dropped | `ServerLoopTest.TheControlShadowIsClearedWhenThePumpTakesTheRequest` (**new, rewritten**) - reads `ControlIsPending()` while the work is still running, where the defensive clear cannot cover for it |
| park counted per `Park` instead of per `Wait` | `InProcessTransportTest.AWaitThatParksTwiceOnARememberedNotifyIsStillOnePark` (**new**) |
| client parks read off the shared bell again | `SessionTest.TheProducersParkLedgerCountsItsOwnWaitsAndNotTheBellsOtherWaiters` (**new**) |
| spin budget zero / calibration broken | `InProcessTransportTest.DoorbellSpinsWithoutParkingWhenReadyFlipsInsideTheBudget` |
| spin never ends / park unreachable | `InProcessTransportTest.DoorbellStillParksWhenTheSpinBudgetRunsOut` |
| `wait[]` renamed or dropped | `PipeStatsTest.SummaryLineCarriesEveryClassAndGate` (extended) |
## G1 / monolith impact
**Still none.** The fix round touched `Doorbell.{h,cpp}`, `Ring.cpp`, `SessionRings.h`,
`EventRing.h`, `ServerLoop.{h,cpp}`, `ClientSession.{h,cpp}`, `CONTRACT-P5.md` (all MG_Remote or
a doc), three test files, and `PipeStats.h` - the last one inside the same pre-existing
`#if MOBILEGL_PIPE_PUSH` region as before, comment text only. No new include edge; the new `Wait`
parameter is defaulted, so the two call sites that do not pass a tally (`PipeWireCodec.cpp`,
`SessionConsumer::Park` in `Ring.cpp`) are unchanged.
## Not changed, with reasons
- **The defensive clear in `PumpControlRequest` stays.** Review 1 offered "drop it so the
take-clear is load-bearing" as an alternative; dropping a self-heal to make a test work is the
wrong trade when the test can observe the real window instead.
- **`ControlIsPending` is not renamed** (see m2).
- **`PipeWireCodec.cpp`'s `spinUs == 0` is not changed** (see m1).
- **`ARCHITECTURE.md:623`'s summary-line enumeration** still omits `wait[]` - left to the round's
docs pass, as before, so four packages do not collide on one file. The `Doorbell.h` citations
were different: those were made wrong BY this patch, so this patch fixes them.
- Did not build, run, commit or push. No attribution lines anywhere.