Commit Graph
3838 Commits
Author SHA1 Message Date
swung0x48 1987bdff5c [Feat] (Android): the render server screen and binary ship in the plugin APK too, not just trace 2026-09-28 22:10:56 -04:00
swung0x48 2fa58940e4 [Fix] (ci): the trace APK is a launchable app; assert exactly one launcher activity
The "Trace APK must not expose a launcher activity" check predates the render-server
screen: 1f72597dd gave the trace APK a launcher on purpose and 884f6aac9 moved the entry
icon back to PostActivity with a bottom navigation bar between the two screens, so the
apk.yml build job has been red ever since - taking the whole retrace matrix down with it
(skipped, not failed), which hid the distinction.

What is left of the old isolation contract is the count: exactly one
android.intent.action.MAIN in the merged manifest (a second one means a screen grew a
launcher entry by accident), and it belongs to PostActivity. The fclPlugin metadata ban is
unchanged.
2026-09-28 21:54:16 -04:00
swung0x48 35dddc0c89 [Fix] (MG_Remote): the socket-io stats are LinkMetrics free functions, not StreamLink members
6bccf98d7 put TakeSendStats/TakeReadStats on the concrete StreamLink class, so
ClientSession.cpp and ServerLoop.cpp had to include <MG_Remote/Transport/StreamLink.h> -
which the P6.5 seam gate (scripts/ci/link_seam_purity.py, "concrete link included above
Transport") rejects, and the MGPipe generators and hygiene gates job has been red since.

The counters stay in StreamLink.cpp beside the recv/sendmsg loops that feed them; only the
struct and the two Take functions move, to LinkMetrics.h as LinkIoStats +
LinkMetricsTakeReadStats/TakeSendStats. The readers drop the concrete include; the hot
loops are untouched.
2026-09-28 21:54:08 -04:00
swung0x48 3bdd565dc6 [Chore] (docs): Remove orphaned CS_Refactor notes accidentally committed in 4e48702f2 2026-09-28 21:16:35 -04:00
swung0x48 884f6aac96 [Feat] (Android): trace APK boots to POST; bottom nav bar switches to the render server
The trace flavor manifest removed PostActivity and made ServerControlActivity
the launcher, so the APK opened straight into the render server screen. Keep
both screens: PostActivity is the launcher again, and a bottom navigation bar
(NavBar, plugin flavor unaffected) switches between them via
FLAG_ACTIVITY_REORDER_TO_FRONT with transitions suppressed, so each screen
keeps its state. ServerControlActivity is singleTop now and handles extras in
onNewIntent so adb-driven starts still work. The POST screen also shows an
indeterminate progress bar while the self-test runs.
2026-09-28 21:06:44 -04:00
swung0x48 b9a89967b0 [Docs] (Disaggregated): five-arm transport perf comparison and bottleneck attribution
rd12/openra on-device matrix (monolith/inproc/spawn+shm/spawn+tcp),
tcp-localhost transport_wait decomposition (present credit=1 ~74% +
futex wakeup tax; SPIN_US=2000 alone 2.9x), cross-host control-plane
hang evidence chain blocking P12 acceptance B, Wi-Fi bandwidth ceiling,
and the prioritized knob/code optimization list.
2026-09-28 20:55:45 -04:00
swung0x48 141227e8ba [Feat] (tools): add the disaggregated-mode device perf matrix and profiling harness
run_matrix.sh drives interleaved monolith/inproc/spawn+shm/spawn+tcp
benchmark rounds on an adb device with per-run arm-marker verification;
profile_matrix.sh layers simpleperf, ftrace wakeup capture and a /proc
sampler on top; make_report.py aggregates runs into REPORT.md. The wifi
kit carries the cross-host control-plane hang repro and phone-side
strace/fd-reconcile helpers. .trace-work/ (run output) is now ignored.
2026-09-28 19:36:30 -04:00
swung0x48 930c29e9e0 [Feat] (Android): the render server screen takes intent extras for prefill and autostart
listen/token/env/onscreen prefill the form, --ez start true starts the
server, --ez stop true stops it - adb drives the screen without the IME,
which is how the on-screen unix path (@name + shm, ServerOwned window)
was verified end to end from the UI.
2026-09-27 08:36:57 -04:00
swung0x48 c16fb5a8ac [Feat] (MG_Remote): the in-process display server serves unix endpoints too
mobilegl_server_serve_inprocess refused anything but tcp:// because its
only supervisor was the TCP one's thread hand-off. A unix endpoint
(@abstract name or filesystem path) now runs a supervisor of the same
shape minus the TCP exposure machinery: AcceptPair pairs a client's
control and SCM_RIGHTS connections by arrival order, there is no
DataBind to route and no pre-auth gate, sessions run on threads one at
a time with Busy for a second client, the latch resets between sessions
and g_inProcessStop ends it. That gives the on-screen server the
on-device fast path: ServerOwned windows over a unix socket with shm
data, no TCP copies.

The control screen's tcp-only guard for the on-screen mode goes with
the refusal it mirrored.
2026-09-27 07:43:56 -04:00
swung0x48 e3e890ed5d [Feat] (Trace): the replay runner builds as an adb-shell executable for Android, and unix endpoints carry a headless window
MOBILEGL_BUILD_TRACE_REPLAY now also produces mobilegl_trace_replay on
ANDROID: the same CLI as desktop, linked against the APK runner's
glws/glproc glue. On the device it dials a render server with no
Activity at all - MOBILEGL_IPC_CONTROL=unix:@name with shm data to the
offscreen supervisor (a cross-uid abstract connect needs root or an
SELinux waiver; loopback TCP works from plain adb shell), or
tcp:// with MOBILEGL_IPC_SURFACE=server to the on-screen display server.

glws_android grows the desktop glws_egl's server-owned null-window
createWindowSurface, gated on MOBILEGL_TRACE_SURFACE=window so the APK's
pbuffer mode keeps its old answer, and the config carries EGL_WINDOW_BIT
for it. The control screen refuses unix endpoints in on-screen mode:
the in-process display server serves tcp:// only.

Verified on-device with an OpenRA retrace, ssim=1.0 both ways:
offscreen service on @mgl-cli with the CLI as root, and the display
Activity on tcp://127.0.0.1:40613 with the replay's frames on the
phone's screen.
2026-09-27 06:28:00 -04:00
swung0x48 bf6feebb22 [Perf] (MG_Remote): The producer's transport park is timed as its own per-frame number, and a reducer pairs the two role logs into a frame-time breakdown
transport_wait_ns measures wall time in Ring::Wait's flush/spin/sleep, so
the client's parked time is read directly instead of inferred by
subtracting CPU clocks that run concurrently with the server.
frame_stats.py matches client and server present serials into per-frame
avg/p50/p99 splits; the README documents the MOBILEGL_PIPE_STATS=1
workflow.
2026-09-27 04:45:07 -04:00
swung0x48 f10db410e8 [Feat] (Android): the render server screen also starts the on-screen display server
An On-screen window checkbox switches Start from the offscreen service
to MobileGLDisplayActivity (process :mglwin), which serves in its own
process and renders a client's window surface onto its SurfaceView.
Stop ends either shape: stopService for the supervisor, killProcess for
:mglwin, and the running check covers both. Verified end to end with an
OpenRA retrace: the client dialed tcp://127.0.0.1:40613 with
MOBILEGL_IPC_SURFACE=server from a freeform window while the display
Activity held the screen, frames landed on the SurfaceView and the run
passed with ssim=1.0.
2026-09-27 04:39:01 -04:00
swung0x48 aaa50c88e1 [Feat] (Android): the render server screen takes unix endpoints, and is titled for what it serves
The endpoint field accepts @abstract names and filesystem paths beside
tcp://, matching what mobilegl_server_main's Listen already binds - on
Android the abstract namespace is the on-device rendezvous that always
works, and a relative path lands in the app's files dir, the service's
working directory. Unix endpoints skip the token rule (same-device
reach by construction) and are probed with LocalSocket instead of a
TCP connect. The screen is now titled MobileGL Render Server.
2026-09-27 04:39:00 -04:00
swung0x48 1f72597dd8 [Feat] (Android): a launcher screen starts and stops the offscreen TCP server
ServerControlActivity (trace flavor) collects the listen endpoint, auth
token and extra env, then drives MobileGLServerService with the same
extras adb used. Non-loopback listens are held to the 16-byte token the
server enforces, and a connect probe confirms the supervisor bound its
socket. The screen is the APK's launcher icon, which the trace flavor
lacked since PostActivity is removed there.
2026-09-27 04:39:00 -04:00
BZLZHH 6bccf98d7f [Perf] (MG_Remote): Frame time is accounted on both sides, down to the socket reads and the client's waits. 2026-09-26 17:57:48 +08:00
BZLZHH 3a6c5c7f04 [Perf] (MG_Remote): The create window ships at 10, and the server now reports where its own frame time goes. 2026-09-26 15:30:48 +08:00
BZLZHH b017ec1c1f [Test] (MG_IntegrationTest/Scenarios): The P4a white-box applier reads fence on a readback. 2026-09-26 12:53:10 +08:00
BZLZHH 102f2ca7d5 [Perf] (MG_Remote/Client): The create window's shipped depth is 6, and its ceiling 8. 2026-09-26 11:26:54 +08:00
BZLZHH 1f1ece8a33 [Test] (MG_IntegrationTest/Scenarios): Fence the object-death gates on a readback instead of a clear. 2026-09-26 09:36:37 +08:00
BZLZHH 677689454a [Test] (P12): the window's gates move onto the arm it ships on - a real stream session with an answering peer, and it goes red by name when the store is a bounded ring
THE STRUCTURAL FIX, not just one more case. Three rounds of this feature were green in
the lanes while being inert on the device, because every window-level gate in the tree
runs in RemoteClientControls, whose session is TransportMode::InProcess - ShmLink - and
since P12 the window is OFF there by design. So the lanes and the device ran different
transports, and nothing in the tree could tell.

StreamClientPublicationTest.EveryDeferredCreateAnswerComesBackOnTheShippingArm runs on a
real StreamLink with a real ClientSession and a peer that ANSWERS: a scripted servo pops
each record, replies with its seq, advances and flushes the watermark - i.e. exactly what
the drain's WaitForApplied oracle needs, and nothing more (nothing applies a record).

IT IS PROVED TO GO RED, and by the right name. Reverting the store to "keep the last 8
arrivals" makes it die on the diagnostic added with the store:

    MGPipe: Fatal{ReplyMissing, "create window"} - seq 1 was declared and APPLIED, and
    its answer is no longer in the link's store; the window cannot advance past it and
    nothing recovers a lost answer

AND THE FIRST VERSION OF THIS GATE WAS WORTHLESS, which is the part worth keeping: with
24 creates and nothing else it passed against the ring as well, because between a push
and its read only a couple of answers arrive. What killed the device was TRAFFIC - the
server is behind and its late answers to earlier records fill a bounded buffer first - so
the case now emits twelve MORE records whose answers are wanted (declared
SetTextureParams rows) between the first create and the rest, waits for the peer to
answer them, and only then lets the drain come back for the create. Two things were
needed to make that deterministic: the settlement wait (otherwise it is a race the ring
can win by luck) and glFlush(), because a published record reaches the peer on a flush
and not on PublishAndNotify - the neighbouring case in the same file is built on that.

ALSO IN THIS COMMIT: the assertion is the CONSERVATION LAW, not "taken == creates".
Nothing drains the window when no further create arrives, so the last few answers stay
held - taken + pending == creates, with pending <= window, is the honest contract, and
taken == creates would be asserting a drain on a path the row does not have.

318 tests green across five lanes (172 + 15 + 22 + 103 + 6).
2026-09-26 03:41:48 +08:00
BZLZHH 3a2555805a [Perf] (P12): the create window lands as the default - declared-answer retention replaces the ring, and the load frame goes 28.66 s -> 16.52 s (11.02 s at window 4)
The three things that made the deferral inert are all fixed, in the order they were
found: the reply ring could not hold an answer across the ~1,000 records between two
creates; a residency budget could not help because what displaces an answer is what
the SERVER sends, not what the client emits; and so the mechanism had to be retention.

WHAT LANDED
  * ILink::DeclareReplyRead + LinkCapabilities::RetainsReplies. The wire declares a
    seq will be read BEFORE it publishes, so the declaration cannot race its own
    answer; a link with the capability false ignores it, and the window does not
    defer on one (CreateWindowEffective() is gated on it).
  * StreamLink's ring of the last 8 answers becomes a declared-answer store: a
    monotone whitelist plus a cursor, answers kept until their reader TAKES them
    (take-and-delete under one lock, bytes swapped into the scratch), and a capacity
    of 24 that is FATAL to exceed rather than evicting. An evicted wanted answer is
    unrecoverable - re-latching by sending another create wipes the server's record
    (PipeApply.cpp:2033) and its PendingUploads (PipeApply.h:332) while the client
    cleared its dirty flags at emission (PipeApply.h:298-304) - so the capacity is
    sized so it cannot be reached and reaching it says its own name.
  * An answer nobody declared is DROPPED, which is what keeps the store bounded by the
    client's deferral depth instead of by the server's answer rate. Retain-on-arrival
    grows ~11.3 entries per create against a server that ignores the no-reply bit.
  * A read that fails after WaitForApplied has returned Reached is now Fatal{ReplyMissing}
    naming the seq: on the stream arm the reply frame strictly precedes the progress
    frame that publishes appliedSeq, so that combination is a broken invariant, and
    retrying it (what the row used to do) wedges the window for the rest of the
    process while leaving every deferred object latched on a provisional accept.
  * The residency budget is deleted (WireTables.cpp keeps its history), the window
    retirement branch drains once before falling back - it could otherwise strand the
    answers it was holding - and Detach clears the store.

MEASURED ON THE DEVICE, load frame, both ends rebuilt, frame-sent identical per op
(create 4,536, records 103,097) throughout:

    window 1 (R-5's shape)              4,536 takes   28.66 s
    window 2 explicit                   2,500         17.27 s
    window 2 AS THE SHIPPED DEFAULT     2,563         16.52 s
    window 4                            1,644         11.02 s
    window 2 against an OLD SERVER      2,663         16.38 s   (zero Fatals)

The last row is the version-skew case the design review named as a must: a server
built before the no-reply bit answers ~55,903 records a frame, the whitelist drops
every answer nobody declared, and the deferral still reads its own back.

GATES (172 + 15 green), and the important one is proved to go red:
  * ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer - reverting the
    store to "keep the last 8 arrivals" fails it immediately with "the declared answer
    did not survive 64 intervening ones". That is the line between the ring-1024
    stand-in and a bounded mechanism.
  * AnAnswerNobodyDeclaredIsNotKeptAtAll - red if everything is retained.
  * TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers - on the in-process (ShmLink)
    arm the knob at 4 must still block every create, red if the capability gate goes.
  * The refused-create case now reads the repair that exists on THAT arm (the latch),
    and asserts the window's own books are empty there.

NOT DONE, and named: the window has no unit gate on the STREAM arm yet - only the
device runs. Three rounds of this feature were green in the lanes while inert on the
device because the lanes and the device run different transports; the transport-level
retention gates close that for the store, and the window-level one should be built on
StreamClientPublicationTest's real stream session next.
2026-09-26 03:31:14 +08:00
BZLZHH 1ef7439625 [Perf] (P12): with the budget off and a deep reply buffer the window pays - 28.66 s -> 11.45 s at W=4, and the round trip does NOT grow, so the ~5 ms is a wakeup the server batches away
The second one-line experiment, and the one that decides whether the retention design
is worth building. Ring 1024 plus the residency budget DISABLED (its constant raised
out of reach), W=2 and W=4:

  arm                        waits   rtt_mean   by_op 2    wall
  W=1 (today's default)      4,543   5,672 us     4,536   28.66 s
  budget on + ring 8         9,078   2,598 us     9,071   27.04 s
  ring 1024 + budget 7       3,450   5,367 us     3,443   23.04 s
  ring 1024 + budget OFF W=2 2,560   4,916 us     2,553   16.80 s
  ring 1024 + budget OFF W=4 1,691   4,224 us     1,684   11.45 s

rtt_mean does NOT grow (4,916 / 4,224 us, both BELOW the 5,672 us baseline) while the
waits fall by 2x and 2.7x and the wall follows. So the ~5 ms is a WAKEUP round trip and
the server drains a batch per wakeup - which is exactly what makes W pay, and it is the
question the audit listed as undecidable from the repo and the watershed for whether
this is worth doing at all. It is.

AND IT BEATS THE HANDOFF'S OWN FLOOR: handoff-2 section 0.4 put the window's landing at
15-20 s, treating the client's ~13.4 s CPU as ADDITIVE with the waits. This frame turns
out to be wait-dominated, so amortising four creates per wakeup lands at 11.45 s, 3.5 s
below that estimate.

1024 IS A STAND-IN, NOT THE DESIGN: it works only because 1024 happens to be deep enough
for this workload, and 1024 retained answers is ~2 GiB worst case (a ReadPixels answer can
be 2 MiB). Retention gives the same property - the answer is still there - with a bound
tied to the client's own deferral depth, which is why the implementation the audit
specified is what gets built, not "make the ring 1024".

Also corrects the note's section 7 path 1 (audit F15): on the stream arm SEG_REPLY and the
Hello's ReplySlotCount have nothing to do with answers - they travel as Reply frames and
StreamLink::ReadReply reads the client's own store - so "deepen the pool" does not address
the arm that was measured, and should not be paid for.

Reverted (ring 8, budget 7, probes deleted); 172 + 13 green.
2026-09-26 03:01:20 +08:00
BZLZHH 82de3f6826 [Docs] (P12): the ring-1024 experiment - every deferred answer comes back (readfail 8,872 -> 0), so eviction was the whole cause and the residency budget is now the brake
One-line experiment before writing the retention table: StreamLink's kReplyRing 8 -> 1024,
nothing else changed, same load frame on the same device.

                       ring = 8 (today)   ring = 1024
    readfail                    8,872              0
    taken                          63          4,499 / 4,500
    blocked (fell back)         4,435              0
    by_op 2                     9,071          3,443
    wall                        27.9 s         23.04 s
    frame-sent        create 4,536, records 103,097   identical per op

TWO CONCLUSIONS. First, eviction was the ONLY cause: with a buffer deep enough, every
deferred answer was read back and not one create fell through to the blocking path.
The wedge (want=310) and the useless budget (budget=4,451) were two faces of "eight
slots is not enough". Second, what brakes now is the BUDGET, not the buffer: by_op 2 =
3,443 matches the number of times the budget forced an early read (3,395), i.e. every
one of those is a real blocking wait. Its constant is 7, sized for the 8-slot pool, so
against a 1,024-deep buffer it became the only throttle and the wall only moved 27.9 ->
23.0 s.

WHAT IT MEANS FOR THE DESIGN. Once the answers are RETAINED until read, correctness no
longer depends on how long one survives in a bounded buffer, so the budget must be
sized to the RETENTION CAPACITY rather than to the pool - or be replaced by "retained
means safe". by_op 2 = records/W then puts W=2 at ~2,268 waits and the wall at ~15 s,
which is the goal's own success criterion.

Experiment reverted (ring back to 8, probes deleted); 172 + 13 + 22 + 103 green.
2026-09-26 02:44:30 +08:00
BZLZHH b1ef484116 [Perf] (MG_Remote): the residency budget does not make the window usable either - the answer is gone before the client next looks, so a deferral needs RETENTION, not a budget
Steps 1 and 2 of the plan the design review set out, landed and measured on the
device. Step 1 answers the question step 2 depended on; step 2 answers the one the
whole line depended on, and the answer is that the window's shape is wrong.

STEP 1 - CAN AN ANSWER BE LOST IN DELIVERY? NO. ReplyPool now counts what the link
took (PostedReplies) and what it refused (FailedReplies), and a non-OK result is
NAMED instead of swallowed: it checked only BUFFER_TOO_SMALL and let
TRANSPORT_CLOSED vanish with no line and no counter, so "the server never sent it"
and "it was sent and the buffer overwrote it" were the same symptom with opposite
fixes. Measured: posted=4501 failed=0. The twenty-answer gap between the server's
tally and the client's was a sampling difference, not a drop - and the server's
m_answered counts ATTEMPTS (incremented before PostReply), which is why it could
never have settled it.

STEP 2 - DOES A RESIDENCY BUDGET FIX THE WINDOW? NO.
Wire_ResourceCreate now measures how many reply-owning records the client has
emitted since the oldest window entry was pushed (ClientSession::ReplyPostings, a
new clock) and takes the oldest answer back as soon as that approaches the reply
buffer's depth, instead of deferring past the point where the answer can still
exist. On the device:

    P12win call=4501 cfg=2 eff=2 pending=2 taken=63 pushed=65 blocked=4435
           budget=4451 readfail=8872 posts=54377
    kind=frame-op frame=14 wait_replies=9078 by_op=2=9071   wall 27.87 s

budget=4451: it fires on essentially EVERY create, the window stops deferring
(pushed=65), and the drain still reads nothing back. THE REASON IS STRUCTURAL, not
a constant:

  * the window only gets control WHEN A CREATE ARRIVES. Between two creates this
    client emits on the order of a thousand records - at the FIRST failed read the
    reply ring already spanned [1175..1504] while the head was seq 310 - and nothing
    looks at the window in that gap;
  * what displaces a deferred answer is not what this client emits, it is WHAT THE
    SERVER ANSWERS. The server is the one behind (one answer per ~5 ms wakeup while
    the client runs ahead), so the replies arriving during the gap are its LATE
    ANSWERS TO THE EARLIER CREATES - they fill the 8 slots first;
  * so the check fires too late by construction. Tightening it does not fix that; it
    only removes the deferral entirely, which is what pushed=65 is.

WHAT THAT LEAVES. A deferral is readable only if the client KEEPS the answers it
deferred. The buffer is bounded by COUNT and the client cannot bound how many
answers the server will post between two of its creates, so hoping the buffer still
holds one is a race the client loses - which is also why the earlier two diagnoses
(reply-pool churn, then FIFO-with-no-way-out) were both describing symptoms of the
same thing. The shape that can work is retention: the reader keeps the answers for
the seqs still outstanding (a handful), drops them when read, and reports a LOSS
when a read asks for one it no longer has. That is a transport change - StreamLink's
ring becomes a retention map with a cap and a lost counter - and it is the next step.

BOTH STEPS STAY: step 1 is a net gain on its own, and step 2 is the safe direction
(it can only degrade the window to no-deferral, i.e. the pre-window shape) and its
clock is the one the retention design needs. MOBILEGL_IPC_CREATE_WINDOW stays at 1.

172/172 and 13/13 after the probes were removed. The measurements and the revised
conclusion are in notes/p12/CREATE-WINDOW-MEASURED.md section 9.
2026-09-26 02:35:05 +08:00
BZLZHH 4e48702f26 [Feat] (MG_Remote): a record can tell the server its answer will never be read - the bit works and cuts a frame's replies by 92%, and it proves the window's real blocker is the FIFO drain, not the pool depth
The create window could not collect its deferred answers. The previous commit
diagnosed that as the reply pool being 8 slots deep against 55,903 replies in one
frame, and left a no-reply bit as the way out. This lands that bit, measures it on
the device, and reports that the diagnosis was half wrong.

WHAT LANDED. ClientSession::EmitAndWaitTails takes a second question beside
wantReply: willReadReply. They are not the same question and the window is the case
that separates them - it emits with wantReply=false and READS the answer later, so
its records must still be answered. kRecNoReply (RingRecordFlags bit 5, which
aliases kOptional in the call-flag space; the flag-space asserts were extended and
the alias named) is stamped when willReadReply is false, and the decoder skips
PostReply while keeping every acceptance counter - "nobody reads the answer" is not
"nothing happened". The two illegal combinations are Fatal by name.

Three call sites say it: SetTextureParams for a confirmed object, the respecify
row's fire-and-forget half, and sub-data where MGPipeSubDataWantsItsReply decides.

MEASURED ON THE DEVICE (aikjs4s8cys8ba7l, rebuilt APK + client, both halves new):
    server:  skipped=50,001   answered=4,521        (a frame's replies: 55,903 -> 4,543)
    client:  Reply frames received seen=4,501, latest seq=97,181
    frame-sent: identical per op, records untouched
    create row: by_op 2 = 9,070 (was 4,536)  - because the drain's blocking take is
               now counted, so the row's number stays honest instead of reading as
               "create stopped waiting" while the waiting merely moved
AND THE WINDOW STILL READ BACK NOTHING: readfail 8,951 -> 8,872, i.e. -0.9%.

THE REAL CAUSE, from the miss probe:
    P12miss n=1     want=310 replySeq=1504   ring=[1175..1504]
    P12miss n=9001  want=310 replySeq=102915 ring=[102866..102915]
want=310 every time. The drain is STRICTLY FIFO and a failed read has no way out -
it retries the same head for ever. Any reply buffer is bounded by design (8 pool
slots, or the 8-frame ring the stream link now keeps), so with 4,536 deferred
answers at least one is overwritten before it is read, and that one wedges the
window permanently. Cutting the churn by two orders of magnitude does not help: it
turns a certainty into a certainty. That is the constraint, not the depth.

THE FIX IS NAMED AND NOT TAKEN (the goal this work ran under stops here on purpose).
On a failed read of the head, abandon it: MGPipeNoteHandleUnpublished +
NoteCreateSuspect + pop - the DECLINE path reused for "unknown". The window then
self-heals through the blocking path and the object merely gets slower. It needs its
own gate: a failed read must let the window ADVANCE.

Gates, all green (172 + 13 + 22 + 103):
  PipeWireCodecTest.TheNoReplyBitSkipsTheAnswerAndKeepsTheAcceptanceRecord - the skip,
    the kept tally, the answered/skipped split, and the negative control that a record
    WITHOUT the bit is still answered (a server that stopped answering everything
    would otherwise pass)
  RemoteClientControls.ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - now also
    asserts the answers CAME BACK (answers_taken=11, outstanding=1, fallbacks=0 against
    12 records). RED-ONCE PROVED: giving the window's creates the literal
    "fire-and-forget => skip" predicate makes this case fail.
  RingTest - the sixth and last flag alias, and the hand-written exhaustiveness list
    extended (with a note that it is only as exhaustive as that list).
  Server side: AnsweredRecords()/SkippedAnswers() - the number that made this
    debuggable, since a wire change whose effect is invisible is undebuggable.

Client-first is safe: an old server ignores bit 5 and replies as before, so the two
cross-version directions degrade to today's behaviour - this is "both ends must
upgrade to take EFFECT", not "to avoid breaking". Hit that in practice first.
2026-09-26 02:00:44 +08:00
BZLZHH 7c822cdc85 [Docs] (P12): the no-reply flag reviewed - the arithmetic holds and the flag is cheap, but the predicate has to be "this answer will never be read", not "fire-and-forget"
An independent review of the one design door the measurement left open (stop sending
the ~51,000 answers nobody reads, so the 8-slot pool stops churning and the create
window becomes collectable). Verdict: feasible, worth doing, and the proposal AS
WRITTEN would disable the thing it exists to enable.

THE CATCH. The windowed create is wantReply=false AND its answer is read -
WireTables.cpp's drain reads exactly those seqs. A bit keyed on "fire-and-forget"
therefore stops the server answering the creates the window is waiting for: the
window goes from "cannot be collected" to "there is nothing to collect", and the
readfail counter that is the only way to diagnose it disappears with it. It fails
silently, not loudly. The predicate has to be per-record "this answer will never be
read" - true of params / respecify-escape / sub-data, false of a windowed create -
carried as a separate input from wantReply (willReadReply), with
willReadReply && !rowCarriesReplySlot rejected rather than ignored.

VERIFIED HERE, not taken on trust (the reviewer's file:line claims, checked):
  - the flag space is RingRecordFlags (Ring.h:281-285), bits 0-4 taken, BIT 5 FREE,
    and the header's own table (PipeWire.inc:35-65) says "bit 5 kOptional vs (nothing)
    no ring counterpart" - so a new ring bit aliases kOptional, which is exactly what
    the assert at PipeWireCodec.cpp:142-145 refuses;
  - the three static_asserts that will trip are real and deliberate
    (PipeWireCodec.cpp:106, :142-145, RingTest.cpp:740);
  - WireFingerprint mixes payload-layout, catalogue, render-state digests + OpCount,
    none of which a ring bit moves - so no protocol revision bump is required, and
    both cross-version directions degrade to today's behaviour. That makes this
    "must upgrade both ends to take EFFECT", not "must upgrade both ends not to
    BREAK" - one notch cheaper than handoff-2 section 3.2's protocol surface;
  - StreamLink.cpp:564-571 is the independent progress flush (>= 64 records or
    >= 1 ms) and :577 is PostReply piggybacking SendProgress on the reply - so
    skipping replies coarsens progress granularity. Bounded, but unmeasured.

AND IT CORRECTED TWO OF MY OWN NUMBERS. The frame's reply count is 55,903, not
59,673: op 4 ResourceDestroy carries kNone in the catalogue (PipeWire.inc:179) and
PostReply Fatals on a row with no kReplySlot, so it never answers - I had added its
3,770 records to the total. The derived figures move with it (12.3 replies per create,
0.65 of a create of survival, 6,988 pool churns), and every copy is fixed here:
Config.h, ConfigLoader.cpp, WireTables.cpp's section comment, CONTRACT-P5E.md,
CREATE-WINDOW-MEASURED.md.

TWO MORE THINGS THE FOLLOW-UP MUST CARRY, both recorded in the note's section 7:
  - LinkMetricsBeginReply counts ownsReplySlot, so once the flag lands by_op 2 falls
    to ~0 - and section 1 of the note tells the reader to use by_op 2 to check whether
    the window engaged. That check becomes a FALSE GREEN; the flag needs a
    server-side "how many times did this op answer" counter beside it.
  - the ~112,000 sendmsg/frame the flag removes are on the apply thread, so the
    flag's own value is a measurable claim that nobody has measured.

172/172 and 13/13 after the comment corrections.
2026-09-26 01:13:31 +08:00
BZLZHH 7205509a9a [Docs] (P12): the create window is dead by arithmetic, and the two debts it exposed (the applier can no longer say it dropped a record; the picture still has no real control) 2026-09-26 00:51:56 +08:00
BZLZHH d011395939 [Docs] (MG_Test, P12): the dropped-records repair is gone, not merely narrowed - the first-use work removed the last row that read an applier refusal, and the case that pins the debt
handoff-2 section 3.3 asks for a gate on the one scope that empties the applier's
object records while the frontend objects live on (MGPipeApplierReleaseObjectRecords,
"latent" because its only caller in the tree is MG_Test). The gate was expected green
for a CHANGED descriptor: the respecify is refused, RespecifyOnce turns the refusal
into a create plus one retry. It is not green.

MEASURED:
    dropped-records probe: creates=0 applier_records=0 published=1

Nothing is emitted at all. The refusal can only reach the emitter through an answer
the row READS, and P12 made both rows answer themselves for a "confirmed" object:
the respecify is fire-and-forget (MGPipeResourceRespecifyWantsItsReply) and the
params row writes a PROVISIONAL kStatusOk (Wire_SetTextureParams). "Confirmed" is
MGPipeHandleIsPublished, which answers "did a create GO OUT" - and it stays true
after the records are dropped, which is the scope's whole definition. The only row
left that reads the real answer is create, and create is only sent when the latch is
clear - the one thing that stops being true.

So section 3.1's "clearing the latch is not enough" is the smaller half: nothing
clears the latch either, and nothing can until the applier can say "I hold nothing for
this handle" - section 3.2's third option, the reverse channel, i.e. the protocol
surface the handoff hoped to avoid paying for. That hope was priced on the trigger
being unreachable; it is still unreachable (no production caller), so the call stands,
but the cost of a production caller is now known to be the whole thing.

THE CASE IS WRITTEN AS A DEBT RATHER THAN A SPEC, and says so in its name and its
comment: it pins today's three numbers, so whoever lands the signal sees THIS case go
red and knows the repair started working. Removing the heal from RespecifyOnce would
not make it red - nothing reaches the heal - which is exactly why the assertion is on
the applier's table and the latch rather than on a count (handoff-2 section 3.3: "a
count cannot show this class of error").

Also rewritten: notes/p12/UNPUBLISH-GUARD-SIGNALS.md sections 4 and 6 now carry this
as 4.2 (sections 2 and 3 stand: neither caps generation nor FreshlyPrimed is a signal).
172/172.
2026-09-26 00:50:00 +08:00
BZLZHH d2efd1d79d [Perf] (MG_Remote/Client): the create window lands OFF - the deferral cannot be collected, 8 reply slots against 59,673 replies in one frame - measured on the device at 4,536 blocking creates either way
The window was the handoff's remaining item: let up to N resource_create answers be
outstanding, read a provisional accept, collect the answers in order, and un-latch an
object whose late answer is DECLINED. It is a sound mechanism and the gates below pin
it - and on the device it does nothing at all, which is why the default is 1.

MEASURED, same device, same frame, same 101,421,396 B staged, same binary:

    MOBILEGL_IPC_CREATE_WINDOW=4   by_op 2 = 4,536   wall 28.64 s
    MOBILEGL_IPC_CREATE_WINDOW=1   by_op 2 = 4,536   wall 28.64 s
    (the handoff's pre-window baseline: 31.3 s)

by_op 2 counts the creates that took the BLOCKING path (LinkMetricsBeginReply is
called with ownsReplySlot = rowCarriesReplySlot && wantReply), so it not moving means
no create deferred. A temporary counter in the row said why: pushed=24, blocked=4,476,
took=20, readfail=8,951, pending pinned at 4 - the drain READ the answers back and
failed every time.

WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its pool slot
is reused, and the pool is kDefaultReplySlotCount = 8. The server answers EVERY
reply-owning record, not only the rows that wait, because it cannot know which those
are (ClientSession.cpp:1897-1900: "The server posts either way"). This frame posts
59,673 of them: create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 +
the rest. 8 slots against 59,673 replies churns 7,459 times per frame, i.e. 13.2
replies between two creates, so an answer is overwritten after 8 replies - 0.61 of a
create. The deferral is not slow, it is UNREADABLE, and no window depth fixes that.

AND IT IS NOT NEUTRAL: with the window on, the provisional accept latches an object
the applier may have refused, and the un-latch that corrects it lives in that same
unreadable drain. A refusal would become an object the client believes is published -
the silent divergence the suspect list exists to prevent. So the machinery is kept
(one experiment away from usable: a deeper pool, or a "do not answer me" bit, both
wire changes) and the default is off.

Worktree note: the window implementation itself was uncommitted in the tree at
82ed1df9 (MOBILEGL-CS-HANDOFF-2.md section 2, "can compile, gates mostly green, never
run on a device"). This commit lands it in the state the measurement supports. The
row also gains the heal the suspect list was missing - a BLOCKING create that lands
takes its object back out of it, or kCreateSuspectMax recoveries would retire the
window for the rest of the process - and the clamp that ties the environment's value
to the array, with a static_assert tying the array to half the reply pool so "someone
raises kCreateWindowMax to 8 and steps on the pool" is a compile error.

Gates (RemoteClientControls.inc), all green, 171/171 RemoteClientTest:
  ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - window on = 12 records 0 waits;
    the SAME CHILD turns the knob to 1 and it is 12 records 12 waits (the negative
    control); pending <= effective after every create; 99 clamps to the array; the
    array is <= half the pool
  ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks - a real refusal (the
    server's consumed mask narrowed) clears the latch, +1 suspect, the object's next
    create BLOCKS (2 records 2 waits), and restoring the consumer HEALS it (latch
    back, suspect -1)

Docs: CONTRACT-P5E.md section 2.5 rewritten (it still said all three rows keep
kWaitReply), plus
docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md (the numbers, the arithmetic,
the picture evidence: both arms SSIM 0.977) and
docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md (handoff section 3: neither
caps generation nor FreshlyPrimed is a signal for "the applier dropped its records",
and the residual gap is one line - the dedup at TextureEmit.h:838 does not read the
latch, so an UNCHANGED descriptor after a drop produces no record and therefore no
refusal to self-heal from).
2026-09-26 00:47:19 +08:00
BZLZHH a2c1f5f292 [Fix] (MG_Remote/Transport): StreamLink::ReadReply answers by seq instead of "is it the newest" - the contract ShmLink already kept, and the reason a deferred answer could never be read back
ILink::ReadReply takes a SEQ (ILink.h:285) and ShmLink implements it against a
ReplySlotPool, so any answer the pool still holds is readable. StreamLink kept ONE
reply - replySeq/replyStatus/reply - and then asked the wrong question of it:

    x.replyReady.wait(lock, [&]{ return closed || x.replySeq >= seq || appliedSeq >= seq; });
    if (x.replySeq != seq) return ... MOBILEGL_ERR_PROTOCOL_MISMATCH;

The wait is written as "at least this seq"; the check is "exactly this seq". Every
answer older than the newest was therefore unreadable, which nothing noticed because
every caller read the answer it had just waited for - i.e. always the newest one. It
is the transport half of the P12 create window's failure: the window defers up to
four answers and collects them in order, and this link could not hand one back.

THE RING, AT THE POOL'S OWN DEPTH. kReplyRing = kDefaultReplySlotCount (8), because
that is the pool this link stands in for: an answer a reader has not taken by the time
eight newer ones have arrived is one the shm arm has already overwritten too, so the
two transports now lose the same answers and no more. Vectors are sized per reply, so
an idle link pays eight empty ones.

This does NOT make the create window work - on the device it changed nothing, because
the pool itself is 8 slots against 59,673 replies in one frame (see the next commit).
It is kept because the two transports disagreeing about ILink's contract is a bug in
its own right, and because the single slot silently failed any read that raced a newer
reply.

Gate: StreamLinkTest.AnOlderReplyIsStillReadableByItsSeq posts three replies and reads
them oldest-first - the order the window's drain takes them in.
2026-09-26 00:47:06 +08:00
BZLZHH 82ed1df90e [Perf] (MG_Pipe, MG_Remote/Client): a texture respecify for a CONFIRMED object stops waiting - the row pays its round trip once, at first use - and the frame drops from 130.9 s to 31.3 s
- The predicate is one function again: MGPipeResourceRespecifyWantsItsReply (PipeRoute.h) is read
  by the wire emitter, so the two halves of this row cannot disagree about whether an answer is
  owed. The buffer half never owes one (both call sites discard the Bool, PipeFill.cpp:1105/:1142);
  the texture half owes one exactly until MGPipeHandleIsPublished, and that latch is set only by an
  ACCEPTED create - which EmitResourceRespecify already sends, blocking, when it is clear
  (TextureEmit.h:869-879). So the object pays once, at first use, and every record after it is
  fire-and-forget.
- MEASURED on the device, same frame, same 101,421,396 B staged, stock power policy: waits
  25,154 -> 4,543 and wall 130.9 s -> 31.3 s. The op split is the proof of what changed and what
  did not: waits by_op 3=20611 -> absent (only 2=4536, 5=6, 9=1 remain), while the RECORDS sent
  are unchanged - frame-sent still shows 3=20633 and 103,094 records total. The round trips went
  away; the work did not.
- It also lands on an independent prediction: an audit run beside this work (mgl-device/scratch/
  scheme-review.md) computed 4,543 remaining waits and a 24-37 s credible range (midpoint ~30 s)
  from the archived logs, a host-inproc round trip of 35.4 us and a measured 43.1 MiB/s link.
  Measured: 31.3 s, at the midpoint.
- Cumulative on that frame since this work started: 43,232 waits / 221.5 s -> 4,543 / 31.3 s (7.1x).
  What is left is the create row (4,536 waits, ~23 s) - the confirmation itself, not waste - plus
  hard floors of ~13.4 s client CPU and ~2.2 s of transfer. Going below that means batching the
  create row, which keeps real answers.
- TWO DEBTS, STATED: (1) the invalidation guard for the one silent-loss case is NOT wired; the
  audit confirmed independently that no production path can drop a texture record today, and if
  one appears the notice it needs is protocol-surface (a tenth reverse callback). (2) No SSIM or
  visual check has been taken since these rows stopped asking - frame-sent agreement is strong
  evidence but it is not a picture.
- Two stale controls in older cases were fixed rather than left: the item-B gate now names the
  CREATE row (its old total-based control only moved because a respecify was still waiting), and
  the by-op measurement asserts records-sent for the rows that deliberately stopped waiting.
  RemoteClientTest 169/169; red-once for the predicate is forcing it back to "always wait".
2026-09-25 23:29:08 +08:00
BZLZHH f2b55b9d2d [Perf] (MG_Remote/Client): the buffer half of resource_respecify stops waiting - its answer is discarded at both call sites - and the measurement says the texture half is where the other 20,611 waits live
- The two halves of this row are not the same row. PipeFill.cpp:1105 and :1142 both call
  MGPipeRouteResourceRespecify(desc, nullptr) and drop the Bool, so for a buffer the wait bought a
  value that was thrown away. It goes fire-and-forget with the provisional-accept shape
  resource_subdata has had since item B.
- The texture half keeps its answer ON PURPOSE: there the Bool is the self-heal trigger AND the
  gate on the acceptance mirror, and TextureEmit.h:885-888 (ID-18 M3) says advancing that mirror on
  an unconfirmed answer is how the next identical call gets suppressed "against a record that was
  never stored". That half needs the provisional/really-accepted distinction plumbed through the
  reply mailbox before it can stop waiting; it is not a flag flip.
- MEASURED, same frame, same 101,421,396 B staged: waits 25,176 -> 25,154 and by_op 3=20633 ->
  3=20611. So the buffer half is worth 22 waits (0.1%) and the remaining 20,611 are texture
  respecifies - which is what says the next step is the mirror design, not another flag. Wall
  134.4 s -> 130.9 s.
- The ERROR-is-not-a-decline case moves to the texture half: a row that reads no answer cannot fold
  ERROR into a decision, so the rule it exists for belongs to the half that still waits (the
  transport fault is caught at the next row that does). New case
  ABufferRespecifyTakesNoReplyWhileATextureOneStillDoes pins both directions; red-once with the
  branch removed is exit 155. RemoteClientTest 169/169.
2026-09-25 22:53:38 +08:00
BZLZHH 5ad8370b37 [Docs] (P12): the first-use confirmation measured on the device - 43,232 waits to 25,176, 221.5 s to 134.4 s, op 47 gone and every other row identical 2026-09-25 22:36:51 +08:00
BZLZHH b62709604d [Perf] (MG_Impl/Pipe, MG_Remote/Client): a parameter change for an object whose existence is confirmed takes no reply - the round trip moves to the object's FIRST USE
- The device run put 18,056 of one atlas frame's 43,232 round trips on this row, at ~5 ms each, for
  records carrying a few hundred bytes. The acceptance it waited for says exactly one thing - "the
  applier holds a record for this handle" - and the client already keeps its own copy of that fact
  in MGPipeHandleIsPublished, which is set when the create was ACCEPTED and is never cleared while
  the record lives (the applier keeps texture records across a make-current, PipeFill.cpp:3756-3759).
  The wait was the client asking the server a question it had already answered.
- SO THE CONFIRMATION MOVES TO THE FIRST USE. EmitTextureParams now checks the latch BEFORE it
  sends: an unpublished object gets HealUnpublishedRecord - the same create-with-no-storage, the
  same storage respecify, the same one re-send the refusal branch has always run, extracted so both
  callers share it - and that create is the ONE blocking record the object owes. Every parameter
  record after it is emitted fire-and-forget and the caller reads a provisional accept, exactly as
  resource_subdata has done since item B. Wire_SetTextureParams reads the same latch to decide, so
  the two halves cannot disagree about whether a wait is owed.
- THE FALLBACK IS THE OLD PATH, unchanged: an object whose create was refused stays unpublished, so
  its next parameter record still takes the real answer and the existing one-retry heal still runs.
  That is also the red-once: with the unconditional EmitAndWait put back, the new case fails -
  RemoteClientControls.AParameterChangeForAConfirmedObjectTakesNoReply exits 153, having asserted
  that the eight parameter records still go OUT while none of them takes a reply.
- WHAT IS GIVEN UP, stated rather than implied: a loss the client believes is published (a bug, or a
  served-context teardown whose only caller today is a test) is now silent on the CLIENT - it stays
  counted and named on the server, which is the same residue item B accepted for the upload row.
- The measurement case's "the counter is live" control moves to create/respecify, because this row's
  zero is now the fix rather than a dead counter. 168/168 in RemoteClientTest; the unit lane is
  2617/2617. Tracks: docs/Disaggregated/notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md.
2026-09-25 22:31:16 +08:00
BZLZHH c05e702d8c [Docs] (P12): the server's spin budget is the portable half of the idle-exit fix - 221.5 s -> 182.8 s on the same frame, no root and no code change
- 2.9 established that a record's ~5 ms is mostly the DEVICE waking out of a deep idle state.
  That points at a knob that is already wired: how long the server's apply thread spins before
  parking. Raised from 50 us to 2000 us (via the Activity's existing `env` extra, which
  ServerEnvironment.apply parses as KEY=VALUE;KEY=VALUE and which does not scrub
  MOBILEGL_IPC_SPIN_US), the same 43,232-record frame goes 221.5 s -> 182.8 s (-17%), with
  rtt_mean 5,040 -> 4,145 us. Pinning the device and disabling its deep idle states is worth
  166.5 s (-25%) but needs root; the spin needs neither root nor a rebuild.
- The server log proves the knob landed (`mgl-srv-apply started ... spin 2000 us`) rather than
  leaving the reader to trust an env var, and the note carries the exact am start line.
- So the load stall now has three ordered levers with numbers behind them: batch the waits
  (client-only, not built), keep the server hot (this commit, usable today, trades device CPU for
  wall clock on a frame that is 94% waiting), and pin the device for measurement discipline.
2026-09-25 21:36:39 +08:00
BZLZHH 7c72dfde2b [Docs] (P12): the ~5 ms per round trip is the DEVICE's deep-idle exit, not the link and not the client - which is what the handoff measured and could not explain
- Three one-variable experiments on the same device, same workload (the 43k-record frame): a 40x
  larger client spin moves the per-wait mean by ~10% (so it is not the client park/wake); the
  same frames over adb forward - a link whose raw RTT is several times lower - by ~25-30% (so it
  is not the link, which is what the handoff saw in 3.6 and could not account for); and pinning
  the device (governor performance on all eight cores, cpuoff_l / clusteroff_l / mcusysoff /
  s2idle disabled) by 26% per wait and 25% of the whole frame: 221.5 s -> 166.5 s for 43,218
  records and 100.5 MB staged, against 43,232 and 101.4 MB in the baseline.
- The mechanism: the server's apply thread parks between records, the SoC drops into a deep idle
  state, and every record pays the exit latency - milliseconds, once per record, which is the
  entire shape of the frame. It also matches the server profile the handoff took (65% socket
  syscalls, ~15% doorbell spin: waiting and waking, not working).
- Stated where it matters: the handoff's 249 s was taken on an UNPINNED device, so its absolute
  numbers carry this idle tax - the project has a pinning discipline for exactly this reason.
- The lever list is now ordered: batch the waits (K <= 8 by the reply-slot pool, client-only),
  keep the server hot between records (its own spin budget, which the APK does not wire yet), and
  measure split numbers on a pinned device. The device was restored to stock afterwards.
2026-09-25 21:28:13 +08:00
BZLZHH 6e7c0ce710 [Docs] (P12): the per-wait 5 ms is not the link - the same frames over adb forward move it by only a quarter, which is what the handoff measured and could not explain 2026-09-25 21:14:15 +08:00
BZLZHH f99415a493 [Docs] (P12): the atlas window's exact composition, and the negative result that kills the record-dedup idea before anyone builds it 2026-09-25 21:03:42 +08:00
BZLZHH 1d363eb109 [Perf] (MG_Remote): the client counts RECORDS SENT per op beside the waits each row PAID, and the pair says item B stopped the waiting without stopping the sending
- A wait count alone cannot say whether a frame is slow because it sends too much or because it
  waits too often. The device run made that the next question: one atlas frame paid 43,232 waits
  while the upload row paid none.
- LinkMetricsNoteRecord is called at the encoder's one commit point (PipeWireCodec.cpp, beside the
  existing CallClass::WireRecords count, for the comment's reason: after chunking no emitter knows
  how many records its call produced). LinkMetricsRecordsFor reads it back, and every frame that
  emitted something now logs `P65LinkMetrics kind=frame-sent frame=N records=M by_op=...`.
- The host measurement now prints and asserts both halves for the same workload. An atlas-shaped
  load (64 sub-uploads into one level) sends 128 ResourceSubData records and waits for ZERO of
  them, while ResourceCreate 76/76, ResourceRespecify 130/130 and SetTextureParams 130/130 are
  one wait per record. That is item B stated exactly: the records still go out; the round trips do
  not. It also says what the next thing to fix is - the record COUNT of those three rows, not their
  waits, which the contract rules they must keep.
2026-09-25 20:52:01 +08:00
BZLZHH d057e1cb8a [Docs] (P12): the first device run of A and B - the crash path walked twice without a crash, and the atlas frame says item B is necessary but not sufficient 2026-09-25 20:46:23 +08:00
BZLZHH 170b789e7f [Perf] (MG_Remote/Transport): the per-op reply-wait split prints with every frame, because a whole-session total cannot name the rows behind one frame
- The device run that motivated this made the point: 43,232 of a session's 43,629 reply waits were
  in ONE atlas frame, and the exit summary never appeared at all - a SIGTERM does not take the
  session's clean stop path, so LinkMetricsEnd is never reached. The split added one commit ago
  was therefore invisible on exactly the run that needed it.
- Emit now writes a second line for any frame that bought a wait:
  `P65LinkMetrics kind=frame-op frame=N wait_replies=M by_op=<id>=<count>,...`. The formatting is
  factored into FormatOpSplit and shared with the exit line. Frames that bought nothing stay
  quiet, so a steady-state session does not double its log.
- Measured on the device (MC 26.2 into a world over TCP, MOBILEGL_PIPE_STATS=1): the steady state
  is one wait per frame and it is op 9 (Present); the upload-heavy startup frames are
  `2=50,3=68,47=62` - ResourceCreate / ResourceRespecify / SetTextureParams, with ResourceSubData
  (48) absent, which is item B working on the wire it was written for.
2026-09-25 20:45:18 +08:00
BZLZHH e734880cf2 [Docs] (P12): the device page names the by_op ids, and the note says where the per-op line comes from 2026-09-25 20:20:24 +08:00
BZLZHH 618395529f [Perf] (MG_Remote/Transport): the per-op reply-wait split also lands in the exit summary, because a spawn client never prints a frame line
- The tally added beside this is only useful if somebody can read it where the problem is. The
  handoff says why that was hard: under spawn the client does not advance the frame window, so the
  per-frame P65LinkMetrics lines never print, and a SIGKILL takes the exit-time dump with it -
  "only the dump at exit, and SIGKILL does not get it".
- LinkMetricsEnd writes the split on its own line from the one place a client that ends the
  ordinary way always reaches: P65LinkMetrics kind=per-op wait_replies=N by_op=<id>=<count>,... .
  Ids stay raw (this file includes only <cstdint> and friends, not the op table) and the reader
  names them: 2 ResourceCreate, 3 ResourceRespecify, 47 SetTextureParams, 48 ResourceSubData. The
  mapping is written down in mgl-device/README.md, which is the page the device run reads.
2026-09-25 20:20:04 +08:00
BZLZHH ce0235b190 [Docs] (P12): the three rows that keep their wait get a ruling with evidence behind it, and the handoff's open op-split question gets its numbers
- What was open: the handoff's 4.3 called ResourceCreate and SetTextureParams "a second problem of
  the same kind, needing its own analysis" and left it there; 5.4 said the per-op split of the
  55,428 waits was never quantified. The measurement committed beside this answers both.
- The ruling (CONTRACT-P5E.md 2.5): ResourceCreate, ResourceRespecify and SetTextureParams stay
  kWaitReply, because each answer carries a fact about the SERVER's record table that the client's
  own tracker provably cannot know. RespecifyOnce says it outright - "the applier's REFUSAL is the
  only signal that says 'I hold nothing for this handle'", which is the scope
  MGPipeApplierReleaseObjectRecords leaves for a served context whose frontend objects live on;
  EmitTextureParams uses its own refusal as the same self-heal trigger; PublishCreate may not take
  the publication latch on a refused create (D-I1/c0b) or the death path emits a resource_destroy
  for a record that does not exist. Unlike the upload half these are not "the emission gate already
  answered it" rows, so removing their waits removes the signal.
- The numbers, both shapes a stitcher produces: an atlas stitch goes 64 -> 0 reply waits; a texture
  per sprite goes 384 -> 320 with ResourceSubData now zero and the remainder all three of those
  rows. So item B hits exactly the shape it was aimed at, and what is left is per-resource /
  per-call rather than per-chunk.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md gains 2.4 with the table and the ruling, and its "second
  problem of the same kind" entry is closed rather than carried; MEASUREMENTS.md records the two
  shapes beside the 1 -> 0 single-upload number.
2026-09-25 20:17:22 +08:00
BZLZHH fa8b500ba8 [Perf] (MG_Remote/Transport): the reply-wait metric gains a per-op tally, and the measurement says which rows actually buy the waits
- The handoff measured the total (55,428 reply waits in one Minecraft frame) and left "which rows"
  open: a total is the same number whether the waits are uploads, creates or parameter sets, and
  those three have different fixes - so knowing the split is what says whether P12 item B is the
  whole story or a fraction of one.
- LinkMetricsBeginReply now takes the op and Window carries a per-op tally, so
  LinkMetricsReplyWaitsFor(op) reads back what the P65LinkMetrics line prints as `wait_replies`
  split by row. Same activation as everything else in that file (MOBILEGL_PIPE_STATS=1), one array
  in the window, and a value past the table lands on the last slot so the per-op numbers still SUM
  to the total.
- RemoteClientControls.ReplyWaitsByOpForAnAtlasShapedLoad drives the two shapes a stitcher
  produces and prints the table: an atlas stitch (64 glTexSubImage2D into one level) goes from 64
  reply waits to ZERO, and a texture per sprite (64 textures, 256 glTexParameteri) from 384 to 320
  with the remainder all ResourceCreate 76 / ResourceRespecify 130 / SetTextureParams 130.
- It prints before it asserts: the table is the point of the case, and a gate that fired first
  would hide the numbers on exactly the run that needs them - the red one. The assertions are the
  structural ones that can be wrong about the mechanism (upload rows zero, the other rows not, the
  per-op tally summing to the total); the numbers themselves are recorded, not gated.
- The A/B is a real run: with the wire-arm arm test removed the case goes red with the full table,
  which is where the 64 and 384 above come from.
2026-09-25 20:17:13 +08:00
BZLZHH bfd6392668 [Docs] (P12): the P5E contract records the texture half's paid-off wait, and the round's note carries the measured G1
- CONTRACT-P5E.md 2.5 said "the TEXTURE half keeps its wait because DrainTextureSubData clears the
  level's dirty flag on the accepted reply (D-D5; trailing)". The trailing is paid, so the clause
  now states the three facts that make that answer DERIVABLE on the wire arm (the emission gate IS
  the consumer question the applier's own acceptance belt asks; what remains is a loud bug class;
  a client-cancelled record still answers "not accepted"), where the wait moves to (SEG_STAGE),
  that MGPipeSubDataWantsItsReply stays the one place that decides, and the gate with its red-once.
  ResourceCreate and SetTextureParams are named as analyzed separately rather than swept in.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md: 2.3 now carries the G1 A/B this machine ran - .text
  13872002 bytes on both sides, 31746 symbols, 0 added / 0 removed, no per-symbol difference -
  instead of deferring it to CI, and 3 trades its "G1 not run" item for the device tooling
  (mgl-device/: launch-mc.py, gl-aliases.sh, start-phone-server.sh) the next round needs.
2026-09-25 20:00:44 +08:00
BZLZHH 3b9f536dd2 [Docs] (P12): the device-lost reply tolerance and the texture-upload wait granularity get their own record - what landed, the gates that can go red, and what is still owed
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md: the two items, their root causes with file:line anchors,
  the five cases that pin them, the red each one produced when the fix was taken back out, and the
  four things this round did NOT do (device end-to-end, local G1, the ResourceCreate /
  SetTextureParams pair, and the sandbox's missing /dev/dri - which reds the two EventForfeitPeer
  lanes for reasons that have nothing to do with the change).
- p12/README.md: a line in the summary and the new file in this directory's table.
- MEASUREMENTS.md: the P12 conclusion and row now carry the number the gate pins - one reply wait
  per pure texture upload becomes zero - with the device wall clock named as the measurement still
  owed rather than left implied.
2026-09-25 19:44:51 +08:00
BZLZHH 33e02f4bcf [Perf] (MG_Pipe, MG_Remote/Client): on the wire arm a texture upload stops owning a reply - the wait moves from every record to the SEG_STAGE window
- MGPipeSubDataWantsItsReply is the ONE place that decides whether a resource_subdata record waits
  for its answer (WireTables.cpp:370 and PipeRoute.h:328 both read it, and the comment beside it
  says that a second spelling is how the client comes to wait for an answer the emitter told the
  server not to bother with). The buffer half has answered "no" since P5e; the texture half now
  does too, ON THE WIRE ARM ONLY.
- THE ANSWER IS DERIVABLE THERE, which is the argument - not that nobody reads it:
  * The emission is already gated on the answer's content. A texture record is produced only where
    FamilyIsLive(kMGPipeSubsystemTextureResources, ...) holds, and under split that gate IS
    CapsMirrorInstance().ServerConsumes(bit 10) (PipeFill.cpp:1719-1740) - the same published fact
    the applier's own acceptance belt reads (PipeApply.cpp:1408-1419, NoP4aConsumer). So the one
    refusal this acceptance exists to carry, "no consumer" (ID-39 / D-D5 / ID-18 M3, which is why
    the texture half ever asked), cannot be the answer to a record this client emitted.
  * What remains is a bug class, and it is loud already: a record that names no texture, declares
    no texels or carries no bytes is an MGP_TRIP_WIRE_REPORT (PipeApply.cpp:1201-1218, :2245-2252),
    and a handle the applier does not hold is counted and named (ResolveResourceIn, :721-728) with
    the client's own tracker preventing it from arising (PipeFill.cpp:1045-1051).
  * Every answer the client can still get for free, it still gets: the emitter reads this route's
    Bool exactly as before, so a record the CLIENT cancelled (the device-lost latch, an exhausted
    stage) still answers "not accepted" and the level stays dirty and is retried - D-D5's safe
    direction, unchanged.
- THE WAIT MOVES TO THE STAGE. A run that does not fit SEG_STAGE reclaims retired bytes and parks
  on the stage doorbell (PipeWireCodec.cpp StageAllocate / ReclaimStagedBytes) - the one wait a
  run-ahead client owes, and it is proportional to the WINDOW rather than to the call count. On
  Minecraft 26.2's atlas frame that is the handoff's 55,428 reply waits at ~4.4 ms each: 96 MiB of
  uploads through a 32 MiB window.
- THE GATE IS THE NUMBER ITSELF. LinkMetricsReplyWaits is added so a case can read back the same
  `wait_replies` the per-frame P65LinkMetrics line prints - LinkMetrics.cpp:76-80 counts exactly
  the records that own a reply slot. TextureUploadsStopOwningAReplyOnTheWireArm asserts that a pure
  glTexSubImage2D crosses the wire and moves it by ZERO, while a resource_create row - kWaitReply,
  and it always was - still moves it, so the zero cannot be "nothing was counted". Red-once run:
  with the arm test removed the child exits 142.
- Monolith and every non-wire arm are untouched, and that is pinned twice: the existing
  RemoteRunAhead.OnlyTheTextureHalfOfResourceSubDataWantsItsReply still reads true with no arm
  installed, and the new test sits inside MOBILEGL_BUILD_DISAGGREGATED with the original expression
  as its fall-through, so a pull build preprocesses to the line it had (G1).
- Verification debt, stated rather than implied: the host gate is the COUNT, not a wall clock.
  In-process control sessions run lockstep (the caps peer publishes no kCapRunAheadApply), so the
  verb barrier still waits there; the end-to-end wall-clock win is the device measurement the
  handoff describes and it has not been taken yet.
2026-09-25 19:44:38 +08:00
BZLZHH 096fa1a8c1 [Fix] (MG_Remote/Client): a DECLINED reply is an answer, not a corruption - the fence and query consumers stop folding status 1 into the abort, and the uncarried-bytes self-check spares a session that has latched device-lost
- ReadFenceReply (EmitTables.cpp) and ReadQueryReply (QueryEmit.inc) tested `status != kStatusOk`,
  which is two values in one branch. ReplySink's own header says "DECLINED IS A REAL ANSWER, not
  a failure" (PipeWireCodec.h:457-460), and the escape rows are held to the other half of the same
  rule - "ERROR IS NOT A DECLINE ... an escape may not fold 2 into `false`" (WireTables.cpp:446-450).
- BOTH VALUES OF 1 ARE REACHABLE WITHOUT A BYTE OF CORRUPTION: the server declines these five rows
  by design whenever it cannot answer them (PipeWireCodec.cpp:1820-1830 for the two fence rows,
  :1841-1861 for the three query rows - a zero-length answer to a kWaitReply row, R-5), and the
  client produces the same status itself once the session stops carrying records
  (ClientSession.cpp:1837 the device-lost latch, :2025 a doorbell that died during teardown, where
  the comment says outright that "the verb did not happen" is reported as DECLINED and not ERROR).
- WHAT THE FOLD COST: after a clean device loss Minecraft 26.2's next glClientWaitSync - the
  chunk-upload fences - died of Fatal{ProtocolCorruption, "Fence.reply"} (rc=134) instead of taking
  the no-op the design promises ("GL calls become no-ops", design/08-runtime-and-platform.md:33).
  The query rows had the same shape waiting for them.
- The no-op is the one this frontend already gives for a sync it cannot wait on: GL_Sync.cpp:103-106,
  PipeApplier.cpp:175-177 and DirectGLES.cpp:16489 all answer GL_ALREADY_SIGNALED. A query row
  answers its own "no answer yet" value - availability false, Produced 0 (which the caller already
  returns as false with the out-parameter untouched), timestamp 0.
- ERROR and a short OK keep the named Fatal they always had: a transport fault is not the server
  saying no, and an OK answer that arrived empty is a reply nobody can read. ReadPixels keeps its
  deliberate Fatal{ReadbackDeclined}: unlike these rows a blocking readback cannot succeed short of
  complete pixels, and that ruling is untouched.
- PipeFill.cpp's UncarriedInitialBytes self-check now spares a session that has latched device-lost.
  The resource_subdata it counts is DECLINED by design there, so "it emitted nothing" is the latch
  working rather than bytes this client forgot to carry - and there is no server left for them to
  exist on. P12's exit gate (a) is that killing the server gives a CLEAN device-lost latch, not a
  corpse at the next glBufferData that carries data.
- Four cases in RemoteClientControls.inc drive the production consumers over a live session whose
  peer declines them. Red-once run for all three Fatals (Fence.reply, Query.reply,
  UncarriedInitialBytes), and ErrorFenceReplyStillRefusesByName is the control that keeps the
  tolerance from becoming blanket. RemoteClientTest 166/166; the host unit lane 2610/2610.
- Split only: both consumer files compile under MOBILEGL_BUILD_DISAGGREGATED and the PipeFill hunk
  sits inside its `#if`, so a pull build preprocesses to exactly what it had (G1).
2026-09-25 19:43:40 +08:00
BZLZHH c984d95205 [Fix] (MG_Remote): CapsSnapshot.apiVersion carries the backend's own API version string, so a split client's GL_RENDERER names the real device instead of the target GL version (split build only; the pull build is unchanged, G1)
- PublishCapsSnapshot filled that field with info.RendererGLInfo.TargetGLVersion.toString(). The
  client reads it back through BackendObject_Remote::GetBackendAPIVersionString (CapsMirror's
  ApiVersion) and prints it in the parentheses of GL_RENDERER, which is the only place a real
  device is named; a monolith build fills the same parentheses from the backend's own
  GetBackendAPIVersionString. A split client therefore answered "Espryt (MobileGL Core) (4.6.0)"
  where monolith answered "Espryt (MobileGL Core) (Adreno (TM) 750, OpenGL ES 3.2)".
- The target version still crosses the wire inside the renderer-info blob (CapsCodec), which is
  where GL_VERSION reads it, so no reader loses it.
- Content only: the field already existed and the blob layout is untouched, so the wire
  fingerprint needs no bump, and the file compiles only under MOBILEGL_BUILD_DISAGGREGATED.
- Verified with a PC client against a phone server: Minecraft and Sodium both log
  "Espryt (MobileGL Core) (Adreno (TM) 750, OpenGL ES 3.2)".
2026-09-25 17:25:21 +08:00