Ph slice (1) (ID-P7-1). Three `::waitpid(active, nullptr, WNOHANG)` calls in the `--serve`
loop threw the status away, so a session child that died of SIGABRT inside SessionFail and
one that returned from its control loop and _exit(0)'d were the SAME EVENT to the
supervisor: `active = -1`, no line, nothing to count. ID-P7-1 declines the literal 98-site
latch translation because P6.5's fork-per-session already delivers "the next connection is
served as usual"; what was missing is that anyone could TELL a faulted session from a
finished one, which is what an operator, a fuzz arm and a flake triage all actually need.
One `ReapActiveSession()` where there were three call sites, asking WIFEXITED / WIFSIGNALED /
WTERMSIG - the same three questions ServerSpawn.cpp:289 asks of the same kind of child - and
logging `pid=<n> reaped exit=<c>` or `signal=<n>` with the supervisor's lifetime
`sessionsFaulted` beside it. A refused handshake exits 0 and is NOT a fault, which is right:
protocol.fbs:86 says a rejection is a normal session outcome. The running total is on every
reap line as well as on the summary, because the fixtures end a supervisor with SIGTERM and a
figure only printed at a chosen exit would never be seen.
THE IDENTIFIER ROW'S PREMISE IS WRONG, MEASURED. The plan asks for
CtrlEnvelopeBufferHasIdentifier at ServerMain.cpp:193 because ServerSession::ParseEnvelope
asks it and this loop did not - but the generated VerifyCtrlEnvelopeBuffer already IS
`VerifyBuffer<CtrlEnvelope>(CtrlEnvelopeIdentifier())` (protocol_generated.h:2378), so a
wrong identifier was refused here and ParseEnvelope's second call is redundant too. Recorded
for the integrator. What was real is that both refusals were SILENT: a peer whose
post-Welcome frame was garbage had its session ended with no line in the log, and a fuzz arm
that cannot tell "refused, by name" from "the child went away" measures nothing. So the
identifier is now asked first - it is the specific diagnosis where the verifier's is the
general one - with the length guard that asking it first requires, and both say so.
scripts/ci/tcp_supervisor_smoke.py has been in the tree since P6.5 asserting four supervisor
controls and was referenced by NOTHING - no ctest entry, no workflow step - so nobody could
have said whether those four were green. It is now `TcpLane.SupervisorProtocolControls`
beside the TCP lane's own fixtures, labelled integration-tcp, under the lane's resource lock
(a second supervisor's child would answer the other's peers with Busy). It needs flatc, which
is deliberately not in the build graph, so it resolves one gen_protocol.py's way and exits 77
- ctest SKIP - when there is none, rather than passing on a run that did not happen.
Two cases added to it: a session child killed with SIGABRT must be named by pid and
`signal=6` with `sessionsFaulted` one higher than before, and the next Hello must still be
welcomed; and a steady-state frame carrying the wrong file identifier must be named rather
than silently dropped.
Red-once, both ways, against the real image: making the reap discard the status again reds
with "the supervisor did not name session pid=N as killed by signal=6", and deleting the
identifier branch reds with "read a frame with the wrong file identifier without saying so".
Five deaths in the split server image raised their own std::abort(). Three of them are
funnels - MagmaWireFatal (Renderer/WireFramebuffer.inc), WireDescriptorFatal
(Renderer/UniformManager.cpp) and WireBufferLegacyFatal (Renderer/WireDraw.inc) - and the
fifteen `@P7` refusals on the tree (8 in UniformManager.cpp, 4 in WireFramebuffer.inc, 2 in
WireDraw.inc, 1 in VulkanRenderer.cpp; plan section 1.3's count confirmed) all die through
two of them. Every one of those deaths published NO SessionFault frame, so the peer read a
bare EOF; bumped NO SessionFaultCount(), so exit gate S8 could neither confirm nor falsify
it; and carried a hand-typed family word with no path to FatalFamilies.def's projection.
MG_Backend may not name an MG_Remote symbol on its own account, so the route is a seam in
MG_Pipe with MGP_TRIP_WIRE_REPORT's shape (PipeApply.cpp:66, and the new code sits beside
it): MG_Pipe/PipeSessionFail.h declares MGPipeSessionFail, PipeApply.cpp defines it inside
`#if MOBILEGL_BUILD_DISAGGREGATED`, the DEFAULT behaviour is byte-identical to what the
three funnels did on their own, and MG_Remote::InstallPipeSessionFailHook() points it at
SessionFail. The install is in InitServerRoleCommon (MG_Backend/Init.cpp), step 0, because
that is the one function BOTH server roles run - the inproc role and the spawn/TCP session
child - so there is no per-transport install to get wrong.
MGPipeFatalFamily is a two-value boundary enum, NOT MGFatalFamily cast to an integer: the
.def's enumerators are positional and a row inserted in its middle would silently re-point
every hook call at a different family. FatalFunnel.cpp's adapter is the one place the two
vocabularies meet, as a switch with a named default.
The family word stays UnmigratedVerb rather than becoming a new MagmaWireRefusal. Three
reasons: the message strings are required to stay byte-identical (5.2), and the retrace
refusal censuses recorded since P5b count these deaths by that word - a rename invalidates
every `@P7` measurement taken against them; `Magma:` in the detail already separates the
Magma arm from the GLES UnmigratedVerb deaths, so a new word adds no discrimination a grep
does not have; and its wire projection would be ProtocolCorruption, exactly UnmigratedVerb's.
Recorded for the integrator.
Ph slice (2) in the same commit: Server/StagedShadow.h's RequireCovered and
Server/StagedTextureStore.h's RequireLevelBytes were MGLOG_F + bare abort. Both are inside
MG_Remote already, so they call SessionFail directly, message verbatim;
check_include_closure.py's four probes do not reach either header and the gate is green.
Census: 83 abort sites -> 79 over 20 files (five sites retired, one gained for the seam's
no-hook default, which is argued into FUNNEL_SITES), baseline rewritten in this commit - the
downward ratchet's notice is what asked for it. Two gate bugs the widening exposed and this
commit fixes: the SessionFail call regex also matched `MGPipeSessionFail(` (a lookbehind
now), and a funnel's anchor needed a wider window than a marker's (kFunnelAnchorWindow).
Red-once, three ways. Pointing MagmaWireFatal back at its own abort reds the census naming
WireFramebuffer.inc 0 -> 1. Making the seam skip the hook reds both new death cases -
`TheMagmaWireVerbDeathReachesSessionFailAndBumpsTheFaultCount` on the exit code, because it
reads SessionFaultCount() out of a SIGABRT handler and gets 0 instead of 1, and
`TheSeamHandsTheInstalledHookItsFamilyAndItsLineVerbatim` on the line the hook echoed.
`scripts/ci/fatal_census.py` scanned `MG_Remote/` and saw four abort sites. CONTRACT-P6
§12.1 already says what the server image is - "P6's server image is the whole
libMobileGL.so" - and the server process was executing seventy-nine more deaths the gate
could not see: 15 in DirectGLES.cpp, 12 in Managers.cpp, 11 in VkBufferManager.cpp, 7 in
PipeRoute.cpp. SCAN_ROOTS is now MG_Remote / MG_Pipe / MG_Backend / MG_Impl/Pipe /
MG_State, `.inc` joins `.cpp`/`.h` (the Magma wire arms and the generated wire records are
.inc files, and 20 sites are reachable only through one), and MG_Benchmark /
MG_IntegrationTest join MG_Test in the exclusion.
Four rules now, where there were two:
1. an abort names its family within twelve lines, OR it is argued into FUNNEL_FILES /
the new FUNNEL_SITES table. FUNNEL_SITES exempts an individual funnel inside a file
that has ordinary named deaths around it, and only when one of the file's anchors
appears in the same window - so the exemption follows the funnel if it moves, expires
if it is deleted, and does not cover an unrelated abort added to the same file. Two
entries, one line of reason each: PipeApply.cpp's MGP_TRIP_WIRE_REPORT and
PipeWire.inc's MGPipeWireProtocolFatal.
2. unchanged: a WireLogFatal / SessionFail call carries a `Fatal{` in its string.
3. NEW: every family word found in the perimeter has a row in FatalFamilies.def. Eleven
words did not, and now do (see below).
4. NEW: every `Refuse{Word}` names a RefuseCode enumerator. This is what catches
`Refuse{AuthenticationRequired}` (SocketTransport.cpp:161), a word protocol.fbs has
never had - PH-7 (2). Renamed to the enum's own `Authentication`, and
SocketTransportTest pins the spelling to `EnumNameRefuseCode` so the two cannot
drift again.
`abort_sites` was recorded and never compared. It is now a DOWNWARD ratchet with a
per-file map beside it, so "83 became 84" reports as "VkBufferManager.cpp went 11 -> 12,
here are its lines"; fewer sites - the direction `@P7` retirement moves the number - asks
for a re-baseline rather than blocking one.
Eleven new `.def` rows for the eleven rowless words. Ten are ProtocolCorruption for the
reason that bucket already states. ResourceUnavailable is ServerCrashed, not SegmentMismatch:
SegmentMismatch means a shared SEGMENT was the wrong size, and telling a peer that when a
VkBuffer allocation failed would be a lie about whose geometry was wrong. A twelfth word was
collapsed rather than given a row - `InitialBytesNotCarried` (PipeFill.cpp) said the same
thing as `UncarriedInitialBytes` (WireTables.cpp) about the same record, and two words for
one family is the drift a6 censused.
Red-once, four ways: an unnamed `std::abort()` appended under MG_State reds naming the file
and the line; deleting the `Fatal{` from PipeRoute.cpp's ResidualBlockSize reds; putting
`Refuse{AuthenticationRequired}` back reds rule 4; a typo'd family word reds rule 3 and the
vocabulary ratchet together.
wave 0 item 5. Three instruments P7's device window needs, and the CI steps that
can make each of them red.
run_tcp_matrix.py --backend DirectVulkan end to end. --backend already existed and
already filtered by each case's ci_backends; what did not exist is a per-backend
GOLDEN. The two backends shared `golden` plus an optional `alternate_golden`, and
alternate_golden is an OR across backends, not a per-backend key - so a
DirectVulkan run that matched the DirectGLES picture passed, which is the "went
green on the wrong arm" shape the exit gate exists to catch. trace_cases.json
gains `backend_overrides.<backend>` ({golden, alternate_golden, ssim_threshold}
only: restating target_call or a crop would stop the two backends retracing the
same frame through the same window). A per-backend golden REPLACES the shared
pair, matching another backend's declared image is refused BY NAME, and a --case
named explicitly that does not run a requested backend is an error instead of one
plan fewer and no message. The CMake emitter and the APK matrix refuse a case that
declares the key rather than silently emitting one golden for both arms.
DirectGLES is unchanged and that is asserted, not asserted-in-prose: a case with no
override resolves to itself, and the key is dropped from the resolved case even for
the backend that overrides nothing - so giving DirectVulkan its own golden does not
move the DirectGLES identity fingerprint and does not invalidate its checkpoints.
Each results.json row now records the golden, alternate golden and threshold it was
scored against.
run_android_retrace_local.py gets --matrix (the CI split subset, 39 cases, each
running only its own ci_backends - not --all, which adds the rd12 workload ID-P7-4
excludes), --repeat N, and --archive-dir. The archive is the point: the result root
is keyed by case and backend and by NOTHING else, so repeat 2 overwrites repeat 1
and the only artefact that survives a three-repeat run is the last one, which makes
"were the three repeats the same picture?" unanswerable after the fact - in the one
place where re-running to find out is expensive. It also refuses --benchmark with a
non-monolith --transport, which has always produced a monolith run under a spawn
label (run_benchmark_case passes args.env alone; p6/README.md records the pitfall).
compare_actuals.py computes the pair result.json cannot: SSIM between two actual
PNGs, plus a case x repeat x ssim-vs-golden x ssim-vs-first-repeat table over an
archive. It is the retrace gate's OWN SSIM - a transcription of ComputeChannelSsim
/ ComputeRgbSsim (trace_replay_core.cpp:630/:671), the only SSIM in the tree - and
the crop comes from each repeat's own result.json, so the two columns describe the
same rectangle. PNG decode is stdlib zlib, because an unavailable Pillow in the
device window would turn "the repeats diverged" into "the tool did not run".
TraceReplayActivity: resolveSpawnServerPath's two decisions were String.contains()
on the whole joined K=V;K=V list and neither was entry-aware. The one that bit:
`--env MOBILEGL_IPC_SERVER_PATH` with no '=' is the documented UNSET spelling
(trace_env_overrides_test.cpp:92) and the only way to ask for "spawn with no image"
and watch LaunchServer refuse by name - it did not match "MOBILEGL_IPC_SERVER_PATH="
so a resolved path was appended AFTER it and, because ApplyEnvOverrides applies in
order with setenv(...,1), the deliberate unset was silently undone. That is exactly
the "silently replacing it would make that knob untestable" the method's own comment
forbids. Two more: the key inside another entry's VALUE flipped both decisions the
wrong way, and the transport match was case-sensitive while ConfigLoader lower-cases
the value before matching (ConfigLoader.cpp:322). The parse moves to
SpawnServerPath, a class with no android.* import, so test_android_lifecycle.py
compiles and runs it with plain javac - the parse had no host-side coverage at all,
and its on-device failure mode is "the knob had no effect".
Only spawn gets a path, deliberately: it is the only transport that execs an image.
Red-once, all four: a stub case whose result names the DirectGLES golden fails a
--backend DirectVulkan run with "matched a golden another backend declares"; a
synthetic PNG pair with a closed-form SSIM (identical = 1.0 exactly, constant
black vs white = C1/(255^2+C1), inverted halves = (C2-2v)/(2v+C2)); the pre-fix
resolveSpawnServerPath body fails eight of SpawnServerPathTest's checks; and the
DirectVulkan TCP proof battery is the DirectGLES one under the other prefix,
because an empty prefix selection raises nothing and reads as a clean pass.
CI: test_run_tcp_matrix.py and test_compare_actuals.py join the R-16 step. The
matrix driver's stub regressions had NO CI step - they were run by hand and
recorded in a note - and they guard the tool that decides whether a device golden
run passed.
Counts in WSL: test_run_tcp_matrix 12 -> 18, tcp_lane_tools 4 -> 6,
test_compare_actuals 12 new, test_android_lifecycle green with the new Java unit.
build-linux-verify could not compile PipeStatsTest: RoleDerivedJsonDumpPathForTesting is DISAGGREGATED-only and the verify flavour is deliberately monolith. The case name stays registered and GTEST_SKIPs there, matching the PIPE_PUSH pattern, so ctest -N still matches between flavours.
runtime_mode_proof.py logs opened MOBILEGL_LOG_FILE_PATH as a file. After the split that name is a base and the sink writes <base>.client.log / <base>.server.log, so the inproc proof died on FileNotFoundError after a green lane. It now reads through split_log_paths' role helpers and names the paths it looked for when none exist.
The per-role log split renamed BOTH halves so that an un-updated reader would
fail loudly instead of silently reading half a session. It worked: CI found
four readers this way. This is them.
- MG_Util/Debug/Log.h: the role vocabulary now exists in the PULL build too,
header-only. It was wholly inside #if MOBILEGL_BUILD_DISAGGREGATED, so
PipeInputsTest - which builds in the verify flavour - could not name
ReadRoleLogs and took the whole build-linux-verify job down at compile time.
Inline rather than out-of-line because of G1: nothing in the library calls
these, so an inline definition emits code into the readers and NOTHING into
libMobileGL.so. Verified: pull symbols 30570 -> 30570, 0 added / 0 removed,
.text still 0xa52203, and zero role-log symbols in the pull library.
- PipeStatsWindow.h: the harness copy of the naming rule is now flavour-aware.
It derived `.client` in BOTH flavours, so in the verify build - one role,
MOBILEGL_LOG_FILE_PATH written unchanged - it opened a file that never
existed. The copy has to stay a copy: MG_Util's RoleLogPath is `t` (local)
in the shipping .so this module links, so calling it does not link.
Added LogMark/MarkLaneLog/ReadLaneLogSince: one offset PER ROLE, because a
single scalar over the concatenation slides by whatever the other role wrote.
- The four arming scenarios: they asserted on lines the BACKEND emits, and
under inproc the backend runs on the apply thread - the server role - so the
line lands in the server's log. Reading only the client's half reported the
emulation unarmed, i.e. accused the product of a defect the reader invented.
Four copies of the same private helper pair now delegate to the harness.
- scripts/ci/magma_runahead_checks.py: it unlinked and read the base name as
if it were a file, so the negative arm saw an empty log and could not prove
the knob took. It now imports split_log_paths' derivation - the authoritative
one - rather than becoming a third copy.
Local verification (WSL, lk-split + c6-pull):
- the 4 CI-red entries: 10/10 PASS under inproc, 10/10 under monolith
- unit role-log readers: 510/510 PASS
- magma_runahead_checks negative: run_ahead_0 EXPECTED_RED, restored GREEN
- the 5 affected TUs compile clean with MOBILEGL_BUILD_DISAGGREGATED=0
- G1: 0/0/0/0, .text 0xa52203
Red-once is the CI run itself (35677991453), which failed for each of these
reasons before the change.
Step two of CONTRACT-P6 5.2. The ~90 scattered `MGLOG_F("...Fatal{...}"); std::abort();`
pairs under MG_Remote/ become one call each to SessionFail(MGFatalFamily, fmt, ...),
which logs, echoes to stderr, counts, and aborts in one place. a6's census
found no single chokepoint existed; now there is one, and step three hangs the
SessionFault-to-peer publish off it.
THE MESSAGE STRING IS PASSED VERBATIM. The contract requires the log lines, the
CI greps and every red-once byte-identical, so SessionFail does not compose the
line from the family - it takes the SAME string the site handed MGLOG_F and
forwards it unchanged. The family enum rides alongside, redundantly, so the
funnel has the coarse wire FatalCode without parsing the string. The census now
checks that redundancy the way it checks WireLogFatal: every SessionFail call's
string must carry a Fatal{ word, so the enum and the string cannot silently
disagree.
The conversion was mechanical - 89 sites by a transform that injects the family
as MGLOG_F's new first argument and drops the trailing abort - with three sites
fixed by hand that the transform would have broken: a stub macro, and two
if/else-then-shared-abort blocks (PersistentMapTracker's adopt-tier and
SurfaceOpCodec's three surface-error branches) where a naive rewrite stole the
abort from the earlier branches and would have let them fall through. The
death tests are the safety net for byte-identity, and every one stayed green.
Four std::abort() remain under MG_Remote/, all inside the two funnels
(WireLog.cpp, FatalFunnel.cpp); the census exempts both files because their
callers carry the family word, and its baseline drops from 93 aborts to 4.
SessionFaultCount() is the one telemetry point S8 will assert zero over a good
run.
G1 re-measured: 0 symbol diff, .text unchanged at 0xa52203 (FatalFunnel and
FatalFamily live under MG_Remote/, compiled only in the disaggregated build).
Unit 2358 (one pre-existing environmental failure), integration-split 186/186,
integration-spawn 102/102, retrace 6/6, census 0 unmarked.
The spawn work had the two roles append to one MOBILEGL_LOG_FILE_PATH under
O_APPEND. It was correct - two processes at one offset would corrupt the file,
O_APPEND made each write atomic - but it put two sessions' lines in one place
and made every per-side assertion a search. This splits the sink: the client
role writes <base>.client.log and the server role writes <base>.server.log.
THE BASE NAME NAMES NO FILE ANY MORE, AND THAT IS THE POINT. Leaving the
client at the old unsuffixed path would have been the compatible choice and the
wrong one: a reader that was not updated would go on finding a file, go on
passing, and go on seeing only half the session - and a refusal census that
reports a confident zero for the half it cannot see is worse than one that
fails. With both names moved, an un-updated reader gets "file not found" and
says so. There were 34 such readers; every one is moved here.
THE ROLE IS PER-THREAD, not per-process, because under inproc both roles are
threads of one process. Log.cpp keys the sink on a thread-local role: the apply
thread declares itself server (ServerLoop.cpp), a spawned image is server for
its whole life (MOBILEGL_IPC_ROLE=server, read once), and everything else is
the client. The naming rule (RoleLogPath) and the two whole-log operations
(ReadRoleLogs / TruncateRoleLogs) are EXPORTED from the library, so no reader
reimplements the derivation - a second copy is the copy that falls behind.
Readers, by how they read:
- unit mains set MOBILEGL_LOG_FILE_PATH and read a role file: they keep the
base in the env and derive the client path from the library helper.
- the death-test child runners (ReadLog) now concatenate BOTH roles: a death
test asserts the CHILD said something, and the applier's refusals - the very
diagnostics these check - are written under the server role.
- the offset-based Emit readers derive the client path once after setenv.
- PipeStatsWindow reads both, because draws/vbs increment in the backend's
PrepareForDraw - the apply thread, the server role - so the summary line is
in the server file even under inproc.
- run_trace_case.cmake's split census reads both files; the client keeps the
transport markers.
- split_log_paths.py (and its self-test, and the control stub) derive both
role files from the declared base.
- the Android script pulls both role files off the device and concatenates
them, so every downstream check is unchanged - and it judges a role file by
CONTENT, because `run-as test -f` through `exec-out` returned 0 for a missing
file on the device and `exec-out` folds cat's own not-found line into stdout.
The monolith arm legitimately has no server file, and that is a silent skip
rather than a one-line server log.
Guarded so a pull build is untouched: RoleLogPath and the role sink are wholly
inside MOBILEGL_BUILD_DISAGGREGATED; a non-disaggregated build writes the base
path exactly as before. G1 re-measured - 0 symbol diff, .text unchanged at
0xa52203.
Verified on device 2f7cbe2e: monolith writes only the client log (no phantom
server log), inproc and spawn each write both and pass their transport proofs
(spawn: server role in pid 16125). Unit 2353 (one pre-existing environmental
failure), integration-split 186/186, integration-spawn 102/102, retrace 6/6,
control smoke 12/12, split-log-paths self-test 9/9.
Exit gates 9.2 and 9.3. Until now nothing ran the integration suite across a
process boundary at all - the spawn shape was proven only by the retrace lane,
which exercises one trace per case and no scenario assertions.
integration-spawn: 102 cases, 102 green.
THE SET IDENTITY IS THE POINT, AND IT IS NOT MAINTAINED BY HAND. 9.2 asks that
the spawn lane's case-name set equal the split lane's. Registering the spawn
arm as a second copy of each block would satisfy that today and drift the first
time somebody adds a scenario to one of them - and a spawn lane covering LESS
goes green while proving less, with nothing about a smaller green lane looking
wrong. The eleven split-environment blocks become eleven calls to one macro
that emits BOTH arms from one filter, so a case added to either is added to
both or to neither.
scripts/ci/spawn_lane_parity.py asserts it anyway, because a macro is one edit
from a hand-written block. It compares SETS rather than counts - a count can
agree while the membership does not - and carries exactly one sanctioned
asymmetry, named in the file: integration-split also labels the PUSH-MONOLITH
arm, whose scenario is monolith-only by construction. R-16: introduce a
split-only block and it reds naming the case and the macro to use.
THREE CASES SKIP UNDER SPAWN, BY NAME. CtWireScenario reads PipeApplier's
in-process tallies through ServerVerbs(); under spawn those live in the SERVER's
process and this one's copy is 0 forever, so the assertions were failing about
somebody else's memory. That is a harness limit, not a product defect - the
records themselves cross, which the retrace lane proves. They are SKIPPED and
not deregistered: a case dropped from the registration would make the two sets
differ silently, which is the drift the macro exists to prevent. What retires
the skip is cross-process telemetry, which is also what S8's SessionFaultCount
needs.
TWO REAL REGRESSIONS THE EXISTING GATES CAUGHT, both from this refactor:
* the macro dropped the per-block TEST_LIST variables, which were what
SplitLogPaths fed to give every entry a PRIVATE MOBILEGL_LOG_FILE_PATH.
SplitLogPaths.PrivateAndDistinct went red naming the first orphan. The
macro accumulates list NAMES now, so a block added later is covered without
anyone remembering to extend a list - and the names have to be unique,
because gtest_discover_tests' TEST_LIST is populated when CTEST runs, not
at configure time, so one shared variable held only the last block's
entries. That matters more on the spawn arm: there TWO PROCESSES append to
the path, so a shared file interleaves two sessions rather than merely
being ambiguous.
* the configure_file that substitutes those names ran BEFORE the F1 and Ct
blocks registered, so it captured neither. Same gate, naming
F1WireScenario.ClearBufferfiPixels. It now runs last.
junit_tally gains --require-spawn-ran beside --require-split-ran: the prefix IS
the arm, and a shared flag would let one lane tally the other's entries and call
itself run. Verified to discriminate - the spawn junit reads 96/0/5 under its
own flag and 0/0/0 under the split flag, which correctly fails.
Two death tests follow the ADOPT_TIER message the census commit retagged with
Fatal{UnimplementedAdoptTier}; their regexes asserted the old wording.
Unit 2353 (one pre-existing environmental failure), integration-split 186/186,
integration-spawn 102/102, retrace 6/6, fatal census 0 unmarked, G1 0 symbols
and .text unchanged at 0xa52203.
Exit gate S7, and the precondition for CONTRACT-P6 5.2's funnel rather than
its replacement.
S7 says "a bare abort() added outside Session::Fail -> the census gate goes
red". Session::Fail does not exist: it is the 93-site refactor 5.2 describes,
and it is not in this commit. What IS checkable today is the property that
funnel exists to guarantee and that every consumer already depends on - every
death under MG_Remote/ names a FAMILY, in a Fatal{Word} marker a grep can
find. run_trace_case.cmake reds a split retrace on any `Fatal{` line, the
retrace lane's refusal census counts them by name, and MEASUREMENTS quotes
the distinct words. A death with no word is invisible to all three.
TWO RULES, BECAUSE ONE WOULD LAUNDER THE OTHER. An abort must carry a marker
within twelve lines; and every WireLogFatal call must carry one in its format
string. WireLog.cpp's own abort is exempt from the first rule ONLY because its
five callers satisfy the second - a funnel that accepted an unmarked string
would be a hole shaped exactly like the rule.
THE BASELINE BOUNDS SILENCE, NOT DEATHS. Abort sites may grow freely; a new
family word may not appear unrecorded. Freezing the total would make every new
guard a gate change, which is how a gate stops being run. The wire's FatalCode
has seven values and the internal vocabulary is thirty-two, which is the
divergence D2 exists to bound - so growing it has to be an act, not an
accident.
THE CENSUS ALSO CORRECTS ITSELF. Its first run reported WireLog.h's own PROSE
as an unmarked death: the comment explaining why the stderr echo exists
contains the words std::abort(). So the scanner strips comments before
matching, with a character-level pass rather than a regex - a `"http://"`
inside a string literal is not a comment - while KEEPING string literals,
because the markers it looks for live in them. Real numbers after that fix:
93 abort sites, 32 family words, 0 unmarked. a6 had counted 92 and 30 from a
scan that did not make the distinction.
AND THE ONE REAL FINDING. a6 named two aborts with no marker at all;
WireLog.cpp's is the sanctioned funnel, so the genuine one is
PersistentMapTracker's invalid-MOBILEGL_IPC_ADOPT_TIER death. It now carries
Fatal{UnimplementedAdoptTier}, which is what makes the census zero.
R-16 both ways, run: a bare std::abort() reds it naming the file and line, and
a new family word reds it naming the word and how to record it.
It was one case on one backend under one transport: OpenRA, DirectGLES,
inproc. It is now every CI case, both backends, inproc AND spawn - 154 CI
entries, 156 local ctest entries - because every defect the spawn work turned
up was a question about the SERVER's process and not about any one trace or
either backend.
THE DirectVulkan ARM. tools/trace_replay/CMakeLists.txt carried "a
DirectVulkan arm would be measuring a server nobody has written yet". True
when P5 wrote it; false since P5f/P7 landed Magma's server consumers and its
run-ahead readiness gate. Leaving it would have shipped a split shape whose
second backend nothing ever ran - the same class of hole as a lane that runs
monolith under a name that says split. Magma now brings up a server process
of its own and OpenRA retraces across it at ssim=1.000000, which is only
possible because this branch also taught the child to load its config: before
that, a DirectVulkan session brought up a DirectGLES server and the two
halves disagreed about which backend the capability mask describes.
`split` BECOMES AN OPT-OUT. It began as an opt-in naming one case, because
one case was the work. Marking 39 cases `"split": true` would carry no
information and would reinstate the hole the key was written to close, only
mirrored: a case added later would get the monolith arms and be silently
absent from the split ones, and absent is the one state nothing reds on. A CI
case is now in the subset unless it says `"split": false`, which is a
reviewable claim that a trace cannot cross a role boundary.
THE CI MATRIX GAINS A THIRD DIMENSION. The job took {backend, case} and
hard-coded MOBILEGL_TRANSPORT=inproc in its own run block, so `spawn` had no
way into CI at all; the .SPAWN ctest entries existed only for a local build.
It is {backend, case, transport} now, the backends are the case's own
ci_backends rather than a hard-coded DirectGLES, and the artifact names carry
the transport or the two arms of one {backend, case} would overwrite each
other's uploads.
inproc STAYS AS THE CONTROL. Same library, same codec, same applier, same
picture; the only difference is which process the server role runs in.
Running only spawn would leave "did the transport break it?" and "did the
phase break it?" answerable only together - which is the question every
failure in this phase has turned on, including the SIGSEGV that D1 explained.
BOTH NEGATIVE CONTROLS FOLLOW THE ARM. retrace_pull_library_control.sh and
retrace_drop_draw_control.sh each exported MOBILEGL_TRANSPORT=inproc
themselves, so on a spawn job they would have gone on proving something about
the other arm while spawn ran unguarded. They read SPLIT_TRANSPORT now, and
default to inproc so a caller that sets nothing behaves exactly as before.
scripts/ci/control_smoke_test.sh: 22 passed, 0 failed against the stubbed
ctest, including the two modes that matter most - a red whose SSIM never fell
and a knob that armed and dropped nothing.
The spawn arm needs a second binary and the inproc arm needs none, so the job
places libMobileGLServer.so only on the spawn arm and a missing one is a
named failure there rather than a LaunchServer refusal 78 jobs deep.
Measured locally as this lands: 39 of the 78 SPAWN entries complete, all
green. The rest are still running.
BRIEF-P5E section 3 item 4 calls for three negative controls. Running them showed E1
(MOBILEGL_IPC_VERB_BARRIER=0) had THREE problems stacked, with the first two hiding the third.
This fixes the first two and narrows the third into something executable.
1. It selected a case that can never run in this lane.
E1's selection regex is (Triangle|ClearThenReadPixels), and the positive control gl added
under ID-115, TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame, matches it.
That case reads a PipeStats window out of the lane's OWN private log, so it runs only in the
strict-arming lane and skips everywhere else BY DESIGN, whatever this knob is set to. ID-62
makes a SKIPPED SELECTED entry a hard failure - correctly, because a control that silently
loses its subjects proves nothing. So E1 reported "the knob killed the pre-flight, not the
entry" while the knob was in fact working: four other selected entries went red with the
abort in the same run.
The fix excludes a case that can never run here, rather than teaching E1 to tolerate skips,
which would have thrown ID-62 away. ctest -R is POSIX ERE with no negative lookahead, so
run_control gains a fifth positional: a real ctest -E.
2. ctest and the log helper disagreed about who was selected.
run_control passed -E to ctest, but split_log_paths.py still filtered on -R alone, so it
counted the excluded entry as selected and then reported it as "did not run". The helper now
reads the same exclusion (SPLIT_LOG_EXCLUDE). An env var rather than another positional
because the helper's positional layout differs per mode - and a control whose two halves
disagree about its own subject set is exactly the class of bug this script exists to
prevent.
3. The real defect. ID-122 was right, and there is now a sharper statement of it.
With the first two fixed E1 is still red, and red on "10 selected entries did not fail": 4
of 14 went red, AND Fatal{BarrierViolation} appears ZERO times in the entire run - those 4
are red for other reasons. The evidence E1 demands has never appeared. The Fatal at
ClientSession.cpp:878 asserts an overlap OBSERVED BY THE APPLY THREAD, and at
MOBILEGL_IPC_BATCH_WAITS's default of 1 the barrier is taken at the next residual-reading
verb, so that overlap does not happen.
Further: with run-ahead armed, VERB_BARRIER=0 ALSO disarms run-ahead (it is the first
conjunct of RunAheadArmed(), ID-114), so the arm this knob produces is neither lockstep nor
run-ahead - it cannot serve as the negative control for either side.
E1 needs its falsification target REDEFINED, not its threshold adjusted. Left as a named
outstanding item, recorded in ID-122 and the open items.
E1's counting rule ("every selected entry must go red") is deliberately NOT relaxed here: it
was set to block the "any non-zero exit counts" false green, and loosening it before knowing
what E1 should falsify would only trade one false green for another.
- ID-116's static rule failed on a row no package in this phase may touch. pa retired the
per-draw program pull, which unmasked GetTransformFeedbackProgram@DrawArrays (11 entries):
BARRIER_PULLED, on kWaitNone draw_vbo, and ruled out of P5e entirely by CONTRACT-P5E section
5.7, with a retiring phase of "P3b/P4b (Espryt), P7 (Magma)". The rule was wrong, not the code.
- A pull is now admitted if the field is BARRIER_PULLED, the verb has a stamp row, the field is
in the verb class's may-read mask, and EITHER the verb's op is statically barriered OR the
field's retiring phase does not name P5e. This table is only ever consulted on a record the
server already stamped BARRIERED - CountBarrierPull's unbarriered arm is [[noreturn]] and
fires first - so the pull is legal by construction and the only open question is whose debt it
is. The retiring-phase column is exactly that answer and was already in the table.
- A verb with NO stamp row still admits nothing under either disjunct: the server stamps only
MGP_VERB_OP_LIST's ops, so any other bit would be a row nothing can exercise. 111 -> 174 pairs.
- The phase column is matched as a TOKEN, not a substring, so a "P5" or a future "P5e2" cannot
answer for "P5e".
- All six of ID-125's survivors checked: GetFramebufferBindingSlot@ReadPixels and
GetTextureUnitObject@CopyTexImage2D by disjunct 1; GetTransformFeedbackProgram@DrawArrays
(P3b/P4b), GetTextureObject@CopyImageSubData (P7) and ValidateProgramName@ShaderStorageBlockBinding
(P9) by disjunct 2. GetTextureObject@CopyImageSubData holds by BOTH, asserted separately.
GetProgramForDraw@DrawArrays, GetBoundVertexArray@DrawArrays and
GetProgramForDispatch@DispatchCompute stay rejected.
- Magma's GetFramebufferBindingSlot@Clear is NOT admitted and cannot be: the retiring-phase
column is one string for both backends and it names P5e for Espryt. The DirectVulkan entries
get their own lane label instead (item 7), which keeps the Espryt allowlist about Espryt.
- StrictErrorsAlsoPromotesTheStickyForwards is RENAMED to
StrictErrorsReachTheStickyForwardsAndNameThemByField and restated. Not one of the seven sticky
forwards names P5e (P7, P7/P9, P7/P13, P9), so on a stamped verb they are all admitted now.
The claim that still has teeth is that the detector REACHES them - asserted by rsp moving and
by the marker naming the field and its phase, so "did not abort" cannot pass for "never
detected". The Fatal half keeps its own case next door on GetBoundVertexArray, a P5e row.
- Six new derivation controls, including one that makes GetTransformFeedbackProgram this phase's
debt and requires it to leave the unbarriered verb's allowlist - without which disjunct 2
could be a constant `true` and every other control would still hold.
- ra landed a hand-kept 13-row copy of CONTRACT-P5E section 7 in .github/workflows/test.yml, and
it was wrong in BOTH directions at once: it omitted GetFramebufferBindingSlot@ReadPixels (21
entries, the lane's largest survivor) and carried GetTextureObject@CopyImageSubData, which
resource_copy_region's kWaitNone makes a genuine unbarriered read of client memory. Neither
error is visible by reading the list. Both are obvious once the list is derived.
- gen_pipe_field_ownership.py now joins the three tables ID-84 already states the rule in -
kMGPipeFieldOwnership, kMGPipeWaitClasses (generated/PipeWire.inc) and kMGPipeClassFieldMask /
kMGPipeVerbClass (generated/PipeFillPoints.inc) - and emits kMGPipeAdmittedPullMask plus
MGPipeBarrierPullAdmitted(field, verb): 101 admitted pairs across 12 barriered verbs.
- The three inputs are READ, never restated, so the allowlist moves with the code: retiring a
verb's wait class narrows it with no edit anywhere, and a new BARRIER_PULLED row widens it only
on verbs that wait. A verb with no stamp row admits nothing - only MGP_VERB_OP_LIST's ops can
ever be the record a pull is reported against.
- --print-admitted gives the CI shell the SAME table the C++ answers from, which is what makes
the hand copy unnecessary rather than merely corrected.
- Three new negative controls (each source renamed away must stop the script - a missing source
yields a silently EMPTY allowlist, which reads as rigour and is blindness) and five derivation
controls that move an input and read the allowlist back.
- Red-once: dropping the wait-class conjunct from build_admitted fails the self-test by name -
"3 admission-derivation control(s) did not hold: unbarriering ReadPixels' op removes every
@ReadPixels pair; unbarriering ResourceCopyRegion removes every @CopyImageSubData pair; no pair
is admitted on an unbarriered verb (DrawArrays)" - and the new unit case fails with
"GetBoundVertexArray@DrawArrays is admitted on a verb no barriered op stamps".
- MGPShaderBuffers::WritableMask is Uint32[3] and the POD grows 32 -> 40 (ID-104). One
Uint32 could describe only the first 32 of an 84-point window, so a storage buffer bound
at point 32 or above read as unwritable with nothing able to see it; the ruling widens the
field rather than narrowing the window, which would change what an application may bind.
MGPipeShaderBufferMaskHas / ...Set are the one spelling of the mask, so the emitter, the
applier and the four backend sites cannot disagree about which word a point lives in.
- MGPipeApplySetShaderBuffers has its body: per-class window, Start/Count validated against
the capacity, the mask copied by word, ONE ShaderBuffersSerial advanced on every applied
record and ADVANCED - never zeroed - by both resets, which also clear the three windows.
- Route rows for opcode 38 on both arms (Mono_ and Wire_) and the server sink in the wire
codec's decode switch, which is where a kCtxState row's sink lives - PipeApplier.cpp's
ServerVerbSink has no method for it and the brief's "OnSetShaderBuffers" names no code.
The codec also bounds Class and Start+Count where Count becomes a byte length.
- New MG_Impl/Pipe/ShaderBufferEmit.h: one record per class, window = the touched
high-water mark clamped to 84, zero early-out before any hash, a BASE binding travelling
as kMGPipeWholeBuffer so the server re-resolves the extent at use, three suppressor slots
(ruling 11), and the writable mask that replaces the backend's GPU-write walk.
- BufferState gains a per-target bind-point generation (push-only, G1) and dirty bits
15/16/17 shutter on it instead of the buffer CONTENT aggregate. That aggregate answered
the wrong question in both directions: any glBufferSubData fired all three, and
glBindBufferBase fired none of them, because it mutates a binding point through a returned
reference. Same defect class as P4a's glBindSampler hole.
- Init.cpp publishes consumer bit 13 for DirectGLES and not for Magma (ID-106), in the same
commit as the wired constant, and PipeFill's liveness gate asks R-8 for bit 13 in
particular plus bit 13-requires-bit-7.
- `Touch` joins the dirty-surface scanner's mutator prefixes; TouchBufferBindingPoint gets
its row and SetNamedTransformFeedbackBinding's is rewritten now that bit 17 reads a
generation a binding moves. Negative control 6 is re-pointed: the pair it used to refute
is a live answer, and a control that has quietly become positive is what the self-test is
for.
- MGPipeBarriered(op, payload, applierState), IMPLEMENTED, not stubbed: the static wait-class
column plus the two payload-derived escalations the contract names (an open XFB span, a draw
carrying kDrawClientArrays). One function and two callers is the point - the emit table and
the sink must reach the same answer or the client runs on while the server reads a value that
has already moved. set_context_values now mirrors IsTransformFeedbackActive into the applier
state as well, because a predicate that read it out of gPipeInputs would be reading the very
block run-ahead stops filling.
- MGPipeApplierCurrentRecordIsBarriered() with the writer beside it. It answers true by default
and that IS the landed lockstep answer, not a stub: on a server without kCapRunAheadApply
every record is barriered and CONTRACT-P5C's two exemption scopes keep working unchanged.
- The sb applier fields (BoundShaderBuffers[3][84], the three windows, the writable masks and
ShaderBuffersSerial) and the pg record fields (the three binding tails, Signature,
BindingsSerial) with the two entry points MGPipeApplySetShaderBuffers /
MGPipeApplySetProgramBindings declared and ABORTING BY NAME.
- The by-handle backend seams in Managers.h with aborting bodies in one block: ResolveVaoTwin,
ResolveTextureTwin, ResolveProgramTwin, SyncTextureToBackendByHandle,
SyncMipmapsToBackendByHandle, and the FBO / RBO / program SyncToBackendByHandle overloads.
Five packages sit on opposite sides of these calls - tx2 writes what fb calls, fb writes what
tx2 needs, id rekeys the registries all three read - so the signature is fixed once here and
each family replaces a body. Abort and not null: a resolver that answered null renders a
blank draw and a sync that did nothing renders stale pixels, and both are green lanes.
- Coverage.def's GetBufferBindingPoint -> SetShaderBuffers row with BOTH of its PipeFill.cpp
switch arms and the pairing asserts, shape-only and inert. It lands with the contract package
because PipeFill.cpp belongs to the contract package for the whole phase - an emitter
enumerator whose dispatch arm lived in the family's worktree is the merge trap that file's
own comment was written about.
- FieldOwnership.def re-annotates the six object-class rows plus GetProgramForDispatch to
"P5e (Espryt unbarriered), P7 (Magma)", and the strict-abort case follows the string, which
IS the debt entry rather than decoration. kMGPipeMaxBufferBindingPoints is pinned against
BufferState.h's 84 where both are visible. gen_pipe_field_ownership's refusal count is 8.
- PipeCalls.def gains a FIFTH COLUMN, WaitClass, on every row, and gen_pipe.py generates
MGPipeWaitClassFor(op) beside kMGPipeCallFlags. It is a VALUE and not a new op: opcode
order and the flag column are untouched, and the point of generating it is that the emit
table and the sink compute the barriered predicate's static half from ONE table instead
of two copies that agree until they do not.
- Two new generator gates, each with its own --self-test negative control: a token that is
not an MGPipeWaitClass enumerator, and - both ways - a kReplySlot row that does not wait
for its reply or a kWaitReply row that owns no slot. The first would be a waiter that
hangs, the second a block on an answer nobody posts. Twelve controls trip now, up from
nine.
- set_program_bindings appended as opcode 80 (kCtxState, kVarTail|kHostSpan): the three
POST-LINK mutable reflection fields, which live inside the LinkArtifacts the archive
snapshots and all move after the link, so no existing row can carry them. It lands with
a NULL ROUTE and a sink arm that validates and declines, exactly as set_shader_buffers
has sat catalogued-and-dead since P4a.
- The codec's record layout grows to THREE tails for it, with each declared count refused
against its bound rather than truncated, and the third tail's name spans walked through
CheckHostSpanIsHonest in the decode arm - they are members of a 40-byte element, so the
encoder's blanket second-tail pass would read one span and a quarter of garbage.
- PipeCatalogueTest: the new sizes (MGPProgramDesc 200), the opcode-80 and null-route pins,
the draw flag, and a wait-class case that re-states CONTRACT-P5E section 2.2's table BY
NAME rather than reading the def - every other statement about the column is derived
from the def and moves with it, so this is the second place a demoted row has to be
argued. The ABI fingerprint moves with MGPWireOp::kOpCount, which the handshake case
already pins.
VD12 Minecraft on the Redmi (FCL, DirectGLES): the split's in-world frame rate
13 -> 60-66 fps against monolith's 110-114, and persistent-map wire bytes
25 MB/frame -> ~0.23 MB/frame. The residual split-specific costs are named at
the end, not hidden: the R-1 serialization (WaitForApplied ~12%, P11's
versioned-inputs design) and a split-only mutex complex still under
investigation.
Persistent-map push, three arms picked per buffer at map time:
- mprotect dirty tracker: the mapped range's page-aligned interior is
protected PROT_READ; the app's next write faults, the SIGSEGV handler marks
the page in the slot's bitmap and unprotects, and the push ships one 64 KB
block per block containing a faulted page. Ownership is three tests -
SEGV_ACCERR only (a userspace raise and an unmapped page chain verbatim),
inside a live tracked range, and not a refault on a still-set bit (a write
we unprotected succeeds, so that is a non-write access and it crashes
honestly). The alignment is INWARD: an outward-aligned edge page holds
foreign bytes, and a signal-blocking worker thread faulting on one is a
process kill - that was the ctest-only SmallRing segfault. Edge spans
(at most two per map) are hashed per push and ship their containing block
only on change (unconditional edges measured 112 MB/frame). Re-arm is one
mprotect per contiguous run. The first push ships everything once.
- hash-suppress arm (MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS, default 1):
per-block XXH3 against the last push's hashes; untracked buffers only.
- PushDrawConsumers: only the buffers the upcoming draw/dispatch can read
(VAO attribs/element, indexed binding points, indirect/parameter slots,
TexBuffer backing - the last also retires the per-frame-cadence cloud
twitch), plus the fault-epoch skip: an unmoved epoch proves no interior
byte changed, tracked edges are hashed in place, and any hash-arm member
vetoes. The membership map caches the tracker slot pointer (a 64-slot
rescan per buffer per draw was measured and removed).
MOBILEGL_IPC_BATCH_WAITS (default 1; MOBILEGL_PIPE_VERIFY forces 0): a
value-class record (kCtxState/kCtxCso/kCtxObject with no reply slot) is
published without waiting for its own apply; the barrier is taken at the next
pull-reading verb, the only place BARRIER-PULLED fields are read, so the R-1
fence posts are untouched and only the round trips between them change. Two
consequences are pinned rather than argued: kCtxObject verbs the tests read
synchronously get explicit fences (GenerateMipmapFieldsCross,
FramebufferDeathCrosses), and a frontend object whose last SharedPtr drops on
the apply thread - impossible before batching - now enqueues its
(kind, lifetimeId) on the deferred destroy queue and the GL thread replays
the DestroyAndFree helper at the verb hooks (CopyImageLayeredScenario aborted
Fatal{RoleViolation, MGPipeSlots} without it).
Doorbell::Wait's spin reads the clock once per 64 yields: on the workload
device a steady_clock read is a real syscall (no vDSO) and the per-iteration
reads were ~20% of the whole process at ~1000 waits/frame.
Gates: gen_pipe --check/--self-test green; unit 2188/2188;
integration-split 110/111, the one red being GenerateMipmapDepthPixels whose
DirectGLES and DirectVulkan MONOLITH siblings flake identically with every
code path of this package provably inert (PushIsArmed false, no ClientSession,
deferral's monolith early-out).
set_context_values (opcode 79, MGPContextValues 96B, kCtxState) carries the active texture
unit, the max touched unit, the fifteen touched-binding-point counts and the five value-class
XFB rows, emitted at validate under the subsystem bit and hash-suppressed as a whole; the
emit gate and the residual-skip gate read the same answer so they can never disagree.
MGPipeApplySetContextValues writes gPipeInputs (the PackState shape). MGPAttribValue grows
24->56B to carry the frontend's converted three views, and MGPipeApplySetVertexAttribDefaults
finally writes them (the 'ignores MGPAttribValue::ValueClass' warning dies), so
GetCurrentVertexAttribute leaves the refusal set. The three texture shutters answer the
applier's own serials under a server-stamped verb (APPLIER_DERIVED), storage answer kept
verbatim elsewhere. FieldOwnership.def: nine value-class rows become derived RECORD_SUPPLIED,
the three shutters APPLIER_DERIVED, the nine object-class rows keep their phases;
FieldOwnershipTest pins that the remaining pulls are exactly the object-class list.
gen_pipe.py gains SCAN_EXEMPT_ACCESSORS with per-name reasons and retiring phases (it repairs
the --check gate the hd merge's two G6 probes had left red).
Evidence: unit 2175/2175; integration-split 111/111; both generators' --check and
--self-test green; rsp on the Xfb capture scenario 36 -> 22; red-once - gating the emission
off turns seven named scenarios red, restoring returns 111/111. CONTRACT-P5C section 5.3.