Read-only review of ba2afba..origin/rustify (the fling spline and Lsq2 velocity, list culling/clamp/anchor re-homing, nested masks, the headless harness, insets/targetSdk, platform fonts, and the client-core log ring with POST /client-log). The three that matter most: the app's own log ring is installed at LevelFilter::Debug while the same day added three ungated per-frame `log::debug!` callsites, so the 2000-line ring wraps in under ten seconds and the route built to get Iris's logs to her carries frame spam instead; POST /client-log inherits the router's 32 MiB body limit with no per-message or rate cap, so an authenticated client can fill the host's disk through ai-server's runtime log; and every invariant added today is a `debug_assert!` while the phone and the bench APK are both release builds, so none of the new guards can fire where the defects were found. Also: the input clock anchors on the first MotionEvent's own event_time, so that event's historical samples date before the anchor and are silently clamped onto one instant; the "before" fling velocity quoted in four docs (11750 px/s) is not what velocity_reference.py prints (12250); masks clip drawing but not hit-testing, so a straddling row is now invisible above the list and still tappable through the header. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 KiB
Review, 2026-09-07 — ba2afba..origin/rustify
Read-only review of the day's 24 commits: the glyph-atlas fix, the fling
spline and Lsq2 velocity estimator, keyboard/IME insets and targetSdk,
historical touch samples and the input clock, list culling / clamp /
anchor re-homing, nested masks and draw_again, the headless harness +
transcript-fixture + rig-input, desktop density, the release profile,
platform fonts + the Android monospace patch, and the client-core log ring
with POST /client-log.
Verified while reviewing (working tree, which also carries three other
agents' uncommitted edits — iris/src/sense.rs, iris/core/src/ui/render_state.rs,
iris/src/lib.rs, iris/core/src/orientation/axis.rs, and an untracked
iris/src/diagnostics.rs): cargo fmt --check clean in iris/,
client-core/ and server/; cargo clippy --all-targets clean in iris/
and client-core/; cargo test --lib -p iris 101 passed, cargo test -p transcript-fixture 10 passed. The iris doctest target fails to link
(extern location for iris_core does not exist) — a stale build artefact,
not a code fault, but worth knowing before trusting cargo test -p iris
as a whole.
The work is unusually well documented and the two "a test that compared the code with itself" findings the authors made themselves are real and were fixed correctly. What follows is what is left.
Counts: 5 defects, 7 risks, 3 tests that cannot fail in the bug's direction, 7 rule findings, 2 nits.
Defects
D1 — the app's own log ring is drowned by the same day's per-frame debug! lines, so the route built to get Iris's logs to her carries almost none of them
iris/android-app/src/lib.rs:132 installs the ring at LevelFilter::Debug,
and client-core/src/log_ring.rs:279 (RingLogger::enabled) returns
true unconditionally by design, so every log::debug! in the process
lands in a 2000-line / 256 KiB ring. In the same commit range that ring
became the only way a line reaches Iris, three ungated per-frame debug!
callsites are live:
iris/src/android/view.rs:446and:509— two lines per rendered frame.iris/src/widget/list.rs:576—iris fling tick:, one line per fling tick.iris/src/widget/text/mod.rs:81— one per text shape (many per frame while rows compose).
Failure scenario. Iris flicks the transcript on a 120 Hz phone. That is
~240–360 debug lines a second; the ring's 2000-line bound is exhausted in
under ten seconds, so by the time she presses Copy report every
log::info! about what she was actually investigating has been evicted.
The uploader makes it worse: it sends at most the ring per 10 s wake
(2000 lines ≈ 200 lines/s) against ~350 lines/s produced, so it also runs
permanently behind and pushes tens of KB/s of frame spam over the tunnel.
Note that another agent has already built the right mechanism — the
untracked iris/src/diagnostics.rs has set_trace/trace_enabled, a
default-off gate, and its module doc states this exact problem in as many
words. It gates iris::input/iris::frame; it does not gate the four
callsites above.
Fix: put List::tick_fling's line and view.rs's two render(): lines
behind iris::diagnostics::trace_enabled() (the mechanism that already
exists for exactly this), and/or record into the ring at Info while
leaving android_logger at Debug.
D2 — POST /client-log can make ai-server write an unbounded runtime log at an authenticated client's request
server/src/routes.rs:1473 bounds the line count (500) and nothing
else. The route sits inside the router that applies
DefaultBodyLimit::max(32 * 1024 * 1024) at server/src/routes.rs:179
(raised for phone photos), so one request may carry 500 lines of ~64 KiB
each, and each is re-emitted verbatim into tracing. There is no
per-message cap on the server, no rate limit, and the runtime log
ai-server writes is the file Dev Updater tails and never rotates.
MAX_MESSAGE_BYTES (4096) exists only in the client
(client-core/src/log_upload.rs:33), i.e. the server trusts a value the
attacker controls.
Failure scenario. A buggy client (a log::debug! in a loop is enough —
see D1) or one holding a leaked bearer token posts 32 MiB every 10 s; the
host's disk fills and every other component's log goes with it.
Fix: give the route its own DefaultBodyLimit (the attachments route at
:175 is the precedent for a per-route limit) and truncate each message
server-side to the same 4096 bytes rather than assuming the client did.
D3 — lines the ring drops before the uploader sends them vanish with nothing saying so
LogRing::since (client-core/src/log_ring.rs:169) filters seq >= cursor
and silently returns fewer lines when eviction has passed the cursor;
LogUploader::flush_once (:94) then advances to whatever came back.
dropped is counted (log_ring.rs:109) and shown in the local
diagnostics pane, but it is never put in the upload body, and
ClientLogBody has no field for it.
Failure scenario. The tunnel is down for two minutes; the ring wraps.
When it comes back, the server log jumps from #812 to #5106 with no
line saying anything was lost. This is precisely the "unknown state
sharing a value with the empty state" UI_RULES asks to design first, and
the module doc for dropped claims it is "reported rather than inferred"
— it is, but only on the half of the path nobody is reading.
Fix: carry dropped (or firstSeq) in the batch and have client_log
emit one warn! when the sequence is not contiguous with the last batch
from that source.
D4 — the input clock anchors on the first event's own time, so that event's historical samples are dated before the anchor: the ordering assert fires, and release silently collapses them onto one instant
iris/src/android/view.rs:628 takes the anchor as
(Instant::now(), event.event_time_nanos()) from the first MotionEvent
the view ever sees, and at() computes
anchor_at + (sample_time - anchor_nanos).max(0). Historical samples of
that same event are by definition earlier than its own event_time.
Failure scenario. The first event this view receives is an
ACTION_MOVE (the DOWN was delivered to another view, or the view was
attached mid-gesture). Its historical samples are, say, 12 ms before
anchor_nanos; at() clamps all of them to anchor_at, so the tracker
receives three samples with identical timestamps, the Lsq2 fit is
degenerate, and the flick reads 0 px/s. In a debug build the
debug_assert!(ht >= previous) at :653 fires first — but previous
starts at anchor_nanos (:651), which is a value from a different
event, so that assert is also the wrong comparison for the first sample of
every later event.
Fix: anchor on the earliest sample of the first event
(historical_event_time_nanos(0) when history_size() > 0, else
event_time), and seed previous from the previous event's last sample
rather than from the anchor.
D5 — the "before" velocity quoted in four places is not what the reference script prints
iris/benches/velocity_reference.py, run today, prints 12250 px/s for
flick-120hz.touch's average and 12500 px/s for "press and one move
frame". Four places say 11750 for both:
docs/RUST.md:900(flick-120hz.touch | 11750 px/s)docs/RUST.md:905(press + one move frame | 11750 px/s)docs/IRIS_TODO.md:1026iris/transcript-fixture/tests/phone_screen.rs:55
iris/src/sense.rs:1406 has the correct 12250, so the two halves of the
same change disagree. The file that carries the wrong number is the one
that says "every number below is printed by velocity_reference.py … do
not 'fix' one by running the Rust and copying what it said". One of the
two rows also being 11750 for a completely different sample set is the
tell.
Fix: replace 11750 with the script's own 12250 / 12500 in those four places, or say which run produced 11750.
Risks
R1 — every new invariant guard is a debug_assert!, and the phone runs release
The five guards added today —
iris/src/widget/list.rs:1156 (a List must be inside a .masked()),
:1218 (extents holds only on-screen rows),
iris/src/android/view.rs:653 (historical sample ordering),
iris/src/sense.rs:1076 (poly_fit_least_squares sample count), and
iris/core/src/ui/painter.rs's doubled-set_mask check — are all
debug_assert!. docs/RUST.md records that the bench APK must be
installed as release on the emulator (the debug libmain.so is 325 MB
and will not install) and Iris's phone gets release too. So none of these
can fire on any build anybody actually runs; in release a List drawn
without a mask silently paints over its surroundings again — the exact
fault e922b73 was written to fix.
Fix: for the two that are cheap and once-per-draw (is_masked, the
extents check), consider a plain assert! or a one-shot log::error!, so
the guard survives into the build the defect was found in.
R2 — a straddling row is now invisible above the list and still tappable through the header
Masks are applied in the fragment shader
(iris/core/src/render/shader.wgsl:203); the CPU hit path
(UiRenderState::resolved_region, iris/core/src/ui/render_state.rs:709)
does not consult masks at all. Before today the top of a straddling row
was drawn over the header and hit-testable there; now it is clipped away
but still hit-testable, which is worse — a tap on "Run benchmark" can land
on an invisible link in the row behind it. docs/LAYOUT.md:1012 ("Hit-
testing applies the shape") is design, not code.
Fix: until LAYOUT.md's mask redesign lands, intersect a widget's hit
region with its mask chain in resolved_region; the chain walk already
exists on the GPU side.
R3 — three copies of one wire contract, none of them linked
client-core/src/log_upload.rs:28 (MAX_LINES_PER_BATCH = 500) and
server/src/routes.rs:1418 (CLIENT_LOG_MAX_LINES = 500) must agree, in
different crates, with only a comment saying so; the body itself is built
by hand with serde_json::json! on one side and parsed by a
#[serde(deny_unknown_fields)] struct on the other. This project already
has the mechanism for exactly this — event-model, a crate both server
and client-core depend on precisely so "the app hand-mirroring it" stops
happening (server/Cargo.toml:16 says so).
Failure scenario. Somebody raises the client's batch to 1000. Every upload now returns 400, the uploader retries the same batch from the same cursor forever, and the only sign is one line in a diagnostics pane on a phone.
Fix: move ClientLogLine/ClientLogBody and the batch constant into a
shared crate.
R4 — build.rs bakes in a CA it never asks Cargo to watch, and the bench build now has no rebuild trigger at all
emit_log_config (iris/android-app/build.rs:92) calls read_pinned_ca()
but emits only rerun-if-env-changed for AI_APP_LOG_HOST/_PORT/_TOKEN —
no rerun-if-changed for the CA file, and (because the bench build
returns at :65, before the transcript path's declarations) no
rerun-if-env-changed=AI_APP_CA/XDG_CONFIG_HOME either. Emitting any
rerun-if-* directive turns off Cargo's default "rerun when anything in
the package changes" heuristic, so the bench build lost the only trigger it
had.
Failure scenario. ~/.config/ai-app is wiped (AGENTS.md calls this the
one-way door), ai-server mints a new CA, the APK is rebuilt — and
build.rs does not re-run, so the APK still pins the dead CA and every
upload fails with a TLS error nobody can attribute.
Fix: println!("cargo:rerun-if-changed={}", ca_path.display()) inside
read_pinned_ca, and move the AI_APP_CA/XDG_CONFIG_HOME declarations
above the bench early-return.
R5 — desktop density is read once and never updated
iris/src/default/mod.rs:254 reads content_scale(window) at startup and
sets it on both rsc.ui.text.density and render. WindowEvent:: ScaleFactorChanged is not handled, and UiRenderer::resize deliberately
no longer consults scale_factor. Dragging the window to a monitor with a
different scale leaves every dp(...) and every rasterised glyph at the
old density — the same class of disagreement the commit removed elsewhere.
It is invisible here (every display on this machine is 1.0), which is why
it needs writing down.
R6 — removing the bundled fonts removed the guard for a fault that was found on the phone, and the check was run on the desktop
iris/core/src/primitive/text.rs's register_bundled_fonts existed
because "bold spans on a real phone rendered as blank gaps of the correct
advance width" — the deleted doc says so. Its removal is Iris's own call
and is recorded properly in docs/DECISIONS.md, but the verification
recorded there is "checked with CJK + emoji on desktop", which is the
half that cannot fail: the fault was Android's font enumeration resolving
a weight/style. iris/transcript-ui/src/tool.rs:110's comment is honest
that CLOSED_MARK/OPEN_MARK/UP_MARK (U+25B8/BE/B4) are now "a bet"
that the platform monospace face has them — which is UI_RULES' "don't rely
on characters the platform might not have", stated and then accepted.
Fix: before the next phone build, look at a bold run and the three chevrons on Iris's device specifically; the emulator's font set is not evidence for hers.
R7 — the least-squares fit clamps a degenerate norm instead of detecting it
iris/src/sense.rs:1105: 1.0 / dot(...).sqrt().max(1e-6). Compose's
polyFitLeastSquares treats norm < 1e-6 as "vectors are linearly
dependent, no solution" and bails; clamping instead produces a q row of
zeros, a zero on r's diagonal, and a 0/0 that the is_finite check at
:1059 happens to catch. It works, but it works by accident and the escape
is not the one the source it is transcribed from takes.
Tests that cannot fail in the direction the bug would go
T1 — iris/transcript-fixture/tests/phone_screen.rs:64 computes the expected fling duration with the calculator under test, and asserts it one-sidedly
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity); then
assert!(ran_for <= expected + 2 frames). This is the same
"calculator compared with itself" shape the fling-spline commit
(73f956f) identified and fixed elsewhere, and the direction it can fail in
is "the fling ran too long" — never "the fling stopped dead", which is
literally Iris's reported symptom. The companion
assert_ne!(before, after) passes on one pixel of travel. A fling that
settles on the first tick passes this test.
Fix: add a lower bound from velocity_reference.py's number (a fling at
-15250 px/s at density 2.55 must run ≥ ~1.4 s and travel ≥ ~6000 px), not
from FlingCalculator.
T2 — top_edge.rs:150 checks a row count on the leg where the culling bug appeared, and the box only on the other leg
rows_that_have_left_the_viewport_are_not_drawn asserts rows.len() <= 24
on the outbound leg and the per-row inside the box predicate only on the
return leg. The doc explains why (an unmeasured row must be drawn to be
measured), which is correct — but it means the test's name is only true of
half of it, and a regression that draws 20 rows in the wrong place on the
outbound leg passes.
T3 — top_edge.rs:116 checks that a mask exists and where it is, not that it reaches anything
the_list_is_clipped_to_its_own_box asserts active.mask != MaskIdx::NONE
and that the mask's region lies within the list's box. It never checks the
row primitives actually reference that mask, so a broken Mask::parent
chain — the thing d507ae4 introduced — would leave this green while a code
fence inside a row drew unclipped again.
Fix: assert that a row primitive's mask chain contains the list's mask slot.
Rules
iris/src/widget/list.rs:576is a second mechanism for per-frame instrumentation.iris::diagnostics::trace_enabledexists for exactly "a default-offdebug!in a hot path" and this line does not use it. (Cause of D1; the gate is in the untrackeddiagnostics.rs, so at the reviewed commit the line is simply ungated.)server/src/routes.rs:1518(client_log_time) duplicatesclient-core/src/log_ring.rs:76(clock_time) — the same arithmetic written twice in two crates, with a comment noting they must agree. Same shared-crate answer as R3.client-core/src/log_ring.rs:301's doc claims more than the code delivers: "the caller is named in the error so it is findable" —log::SetLoggerErrornames nobody.iris/android-app/src/app_log.rs:44repeats the claim.- Stale comment:
iris/src/android/view.rs:624citesVelocityTracker::add_sample's debug assert; the method was renamed toadd_positionin the same commit range. MOVE_CHAIN_LIMITnow bounds two different chains (move offsets and masks) under a name that says one, in bothiris/core/src/ui/render_state.rs:63andshader.wgsl:97. The shader's comment already calls it "the bound on the parent walk"; the constant should say that too, or masks should get their own.iris/src/sense.rs:1434's stated negative control is not reproducible as written. "Revertingvelocitytototal / spanfails exactly this one, the flick recording, andphone_screen.rs" — butsamplesnow holds positions, sototal / spanover them gives 2750 for the steady drag too, and the commit message for the same change says "exactly seven tests". Two numbers for one experiment.iris/android-app/src/bench_client.rs:393'sime_visibleis right and its sibling one line up is not.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom))still infers "make room" from amax, so during the slide-in the composer is padded by the system-bar inset whileime_visiblealready says the keyboard is up. Harmless today; it is the same conflation the comment beside it warns about.
Nits
iris/src/sense.rs:798computesself.velocity.velocity()twice on a release wheninfologging is on (once for the outcome, once for the log line) — a full Lsq2 fit each.iris/transcript-ui/src/selection.rs:303callsui.ui_mut().animate(id)even whenfling()bailed (|v| <= 1.0, or no anchor). Harmless — the firsttickunregisters — but it registers an animation that is known not to exist.
Commits reviewed
7e4e26a iris: resolve fontique's Android monospace generic family ourselves
84a13e8 iris: a fling starts at Compose's velocity, which is a curve fit and not an average
452c442 docs/RUST.md: queue -- logging landed; iris app enrolment ...
238057a docs: the phone-logging decision, how to use it, and two build-apk traps
896c93a iris: drop bundled Noto Sans, match Compose's platform-font fonts
690161e docs: the transcript's edges were three faults, and what the rig found
e922b73 iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
d507ae4 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
9ed01e2 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity ...
5be9f1b iris-android-app: keep the app's own log, put it in Copy report, upload it
977bdb9 client-core: the app's own log ring, and POST /client-log to get it off a phone
9cd1263 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
42af780 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
4274b8b Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
73f956f iris: the fling curve was the identity function, and the keyboard was a targetSdk
038f6a3 docs: the test rig's layers 1 and 2, with their commands and their limits
1121d7c docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape ...
232de0e iris: a phone-shaped desktop window, driven by the same touch recordings
e430880 docs: phone report 2026-09-07, rows at the transcript's top edge culled early ...
a999bd1 docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue
6840edf iris-android-app: the bench's fixture half comes from transcript-fixture
3332201 iris: a headless in-process harness, and the bench fixture as a shared crate
7f4ea7e docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range
591128e AGENTS.md: the phone app and the planned desktop app share widgets and styling