Author SHA1 Message Date
iris e922b73d7a iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
Iris's phone, 2026-09-07, two screenshots of the transcript at its top
edge wrong in opposite directions: rows already scrolled past still
drawn, over the header bar (`version = "0.1.0"` behind "Run benchmark"),
and a blank band where the row straddling the edge should be. Three
faults, one rule -- `List::intersects_viewport`: a row is drawn if any
part of it is inside the list's own box, and nothing outside that box
reaches the screen.

1. **The walk drew everything between the anchor and the viewport.**
   `scroll` moves the anchor's offset and nothing else, so panning leaves
   the anchor's own row further and further outside the viewport, and
   every row in between was placed *and drawn* on every frame. Measured
   on the bench fixture: 8 scrolls of 3000px left 64 rows drawn for a
   2012px viewport, ~59 of them off screen. `place` now skips a row whose
   height is already known and whose box does not overlap; `rehome_anchor`
   moves the anchor onto a visible row each frame, without moving
   anything drawn, so the walk is O(visible) again whatever distance was
   travelled. `extents` holds only what is on screen, which is what
   `key_at` already claimed of it, asserted at the end of every draw.

2. **Nothing clipped the list.** A straddling row is drawn in full --
   that is the rule -- so the part above the list was on screen. The
   transcript's list is `.masked()` now (the mechanism `examples/
   message_list.rs` and the composer already use, and one that nests as
   of the previous commit), and `List::draw` asserts it has a mask rather
   than leaving that to each caller to remember.

3. **A fling past the first row stayed past it.** `tick_fling` stops a
   fling that has reached an end, wherever the spline's last step had put
   it: `fling_toward_the_start_stops_at_the_first_row` was leaving the
   first row 1398px below a 600px viewport -- a blank screen -- and its
   assertion could not see it, since `extents` then held off-screen rows
   too and `top >= -0.5` is satisfied by +1398. `clamp_to_content` gives
   the gap back from the ends the walk already placed. Only when the
   opposite end is not also in the viewport, so a list shorter than its
   viewport stays bottom-anchored as before.

Layer 1 of the test rig throughout (`transcript-fixture/tests/
top_edge.rs`, the real screen under a bench-app-shaped header): each of
the five fails on its own subject and no other -- culling on the row's
top instead of its bottom fails only `the_row_across_the_top_edge_is_
drawn`, the pre-fix walk fails only the two about what is placed,
dropping `.masked()` fails only `the_list_is_clipped_to_its_own_box`,
dropping the clamp fails only `scrolling_past_the_first_row_settles_on_
it`. The bottom edge and a list shorter than the viewport are the ends
none of this had a reason to touch and are covered too.
2026-09-07 16:05:31 -04:00
iris d507ae4c96 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
`Painter::set_mask` refused a widget any mask of its own once an
ancestor had set one -- `assertion failed: self.mask == MaskIdx::NONE`
-- so clipping was one level deep wherever it was used at all. That is
what stopped the transcript's `List` from being clipped to its own box:
its rows already use `.masked()` themselves (a code fence, a tool card's
one-line title), and giving the list one aborted on the first fence
drawn.

A mask now carries the mask it was set inside (`Mask::parent`) and the
fragment stage walks that chain, so a pixel has to be inside every mask
on it. Chained rather than intersected on the CPU because each mask
moves with its own widget: a fence inside a transcript row carries the
row's scroll and the list's box does not, and one region resolved when
the fence was last drawn gets the second of those wrong as soon as the
row is moved rather than redrawn -- which is every scroll frame. The
child holds one ref on its parent's slot, released where the child's own
slot is, so a chain cannot outlive what it points at. The old assert
survives as the case that is still wrong: the same widget setting two
masks, which since a mask now chains would be a clip loop.

Also `Painter::draw_again`, for a layout that can only discover a
correction to itself by laying out once -- `List::clamp_to_content`, in
the commit after this -- and `Painter::is_masked`, which is how a widget
that draws outside its own box can require something to be clipping it.
2026-09-07 16:05:13 -04:00
irisandClaude Fable 5.1 9ed01e2812 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity, input/timing report; queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:04:34 -04:00
irisandClaude Fable 5.1 5be9f1baac iris-android-app: keep the app's own log, put it in Copy report, upload it
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.

`Copy report` now appends the ring to what goes on the clipboard (not to
the pane, which is on screen and would be buried) and flushes the
uploader first, so the lines are on the server by the time the message
describing them arrives. The Diagnostics pane gains two lines: how many
lines are held and when the last arrived, and what the uploader last did
-- "not tried yet", "failing -- <why>", and "no server configured" are
each their own wording, because "nothing is arriving" has three causes
that look identical otherwise.

Also: the re-emitted lines carry the target `ai_server::client_log`, not
a bare `client_log`. `RUST_LOG=ai_server=debug` -- the filter AGENTS.md
tells people to run with -- drops a bare target, so every line a phone
sent vanished with nothing saying so. Found by running it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:01:30 -04:00
irisandClaude Fable 5.1 977bdb9ee0 client-core: the app's own log ring, and POST /client-log to get it off a phone
Iris tests iris builds on a phone with no adb, and Android forbids one
app reading another's logcat, so a `log::info!` in the app can only reach
her if the app carries its own copy and sends it somewhere.

`client_core::log_ring` is that copy: a bounded ring (2000 lines / 256
KiB, whichever bites first) behind a `log::Log` backend that forwards to
whichever real logger the platform installed, so `logcat` and the desktop
terminal see exactly what they saw before. Reading does not consume --
the report and the uploader are two readers of one ring.

`client_core::log_upload` drains it into ai-server's new `POST
/client-log`, which re-emits each line into the server's own tracing
output. Dev Updater already shows that as ai-server's runtime log, so
nothing new is built there. A failed batch is retried from the same
cursor, and nothing in the upload path calls `log!` -- it would land in
the ring it is draining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:56:20 -04:00
irisandClaude Fable 5.1 9cd1263080 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:47:53 -04:00
iris 42af780639 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
Baseline had panic=abort only. Measured each setting in order (docs/RUST.md's
new "APK size (2026-09-07)" subsection has the full table and crate
breakdown): strip=true, lto="fat", codegen-units=1, opt-level="s" take
libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the release APK
from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a. opt-level="z" was
measured (another ~800KB) but not adopted without a frame-time check.

Investigated naga/wgpu backend features and tabs-ui/tabs-screen as trim
candidates; both are already fully eliminated by the linker on Android
(0 symbols in `llvm-nm` on the baseline .so), so no Cargo feature change
would shrink the binary -- left as documented findings rather than a diff.

Embedded Noto Sans fonts (3.6 MB) and the wgpu/naga/font-shaping stack
account for most of what remains vs. Compose, which borrows the platform's
own renderer and fonts for free; recorded honestly in the doc rather than
trimmed, since subsetting fonts or dropping a backend would change what
iris can render.
2026-09-07 15:46:40 -04:00
26 changed files with 2199 additions and 75 deletions

No files matched your search

+1
View File
@@ -83,6 +83,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+1
View File
@@ -47,6 +47,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+5
View File
@@ -37,6 +37,11 @@ ureq = { version = "3", features = ["json"] }
# the same parser at the same version, rather than a hand-written splitter
# that would drift from it.
pulldown-cmark = "0.13.4"
# The logging facade only -- `log_ring` implements a `log::Log` backend and
# wraps whichever real one the platform installed (`android_logger` on the
# phone, `env_logger` on the desktop), which is why neither of those is a
# dependency here. See `log_ring`'s module doc.
log = { version = "0.4.28", features = ["std"] }
[dev-dependencies]
+2
View File
@@ -8,6 +8,8 @@ pub mod config;
pub mod durations;
pub mod event_stream;
pub mod highlight;
pub mod log_ring;
pub mod log_upload;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
+514
View File
@@ -0,0 +1,514 @@
//! The app's own recent log, held in memory so it can be read back
//! without `logcat`.
//!
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
//! no `adb`, and Android forbids one app reading another's logcat, so
//! nothing outside the process can recover what it wrote. The only way a
//! line reaches her is for the app to carry its own copy. This is that
//! copy: a bounded ring every `log::info!` in the process lands in, on top
//! of whichever platform logger was already installed (`android_logger`,
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
//!
//! Two consumers, both reading the same ring rather than each keeping
//! their own: the bench app's `Copy report`/`Diagnostics` (which reads
//! [`LogRing::to_text`] and [`LogRing::summary`]) and the uploader in
//! [`crate::log_upload`] (which reads [`LogRing::since`]). That is why
//! reading does not consume: a line the uploader has sent must still be in
//! the report, and a report taken twice must say the same thing.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// How many lines a default ring holds, and how many bytes of message.
///
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
/// only by bytes can be emptied by a single line.
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
/// One recorded line. `seq` is assigned by the ring and only ever
/// increases, so a reader that remembers where it got to can ask for what
/// came after -- and a gap in the sequence is exactly the lines the bound
/// dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
/// Milliseconds since the unix epoch, from the app's own clock. The
/// app's rather than the receiver's: a line is timestamped when it
/// happened, and an upload can be minutes later or never.
pub at_ms: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
impl LogLine {
/// Roughly what the line costs the ring. The two `String`s dominate;
/// the fixed fields are counted as a flat overhead so a ring of empty
/// messages still has a bound.
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
/// `12:34:56.789 INFO iris::android: the message`, the shape a
/// person skims. Time of day only -- the date is in the report's own
/// header, and a ring never spans one.
pub fn format(&self) -> String {
format!(
"{} {:<5} {}: {}",
clock_time(self.at_ms),
self.level,
self.target,
self.message
)
}
}
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
/// library: the only field this needs is the time of day, and dividing out
/// the day is the whole calculation. Deliberately not local time -- the
/// phone's offset is not knowable here, and a report that says UTC is
/// comparable with the server's log, which is what it gets read against.
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
ms
)
}
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
/// the app down for.
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug)]
struct Inner {
lines: VecDeque<LogLine>,
bytes: usize,
max_lines: usize,
max_bytes: usize,
next_seq: u64,
/// How many lines the bounds have discarded since the ring was made.
/// Reported rather than inferred, so "the log starts here" and "the
/// log was cut off here" are distinguishable -- the unknown state the
/// UI rules ask for.
dropped: u64,
}
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
/// there is one per process and every holder sees the same lines.
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
impl LogRing {
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
assert!(
max_lines > 0 && max_bytes > 0,
"a ring with no room holds nothing"
);
Self(Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
bytes: 0,
max_lines,
max_bytes,
next_seq: 0,
dropped: 0,
})))
}
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
/// [`DEFAULT_MAX_BYTES`].
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
/// A poisoned lock is a bug in a panicking logger, not a reason to
/// take the app down a second time -- the ring is a diagnostic, and
/// losing it must not be worse than the fault it was recording.
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
/// Records a line, evicting the oldest until both bounds hold again.
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
seq: inner.next_seq,
at_ms: now_ms(),
level,
target: target.to_string(),
message,
};
inner.next_seq += 1;
inner.bytes += line.weight();
inner.lines.push_back(line);
// `!is_empty()` rather than `len() > 1`: one line larger than
// the whole byte bound is kept, because dropping it would
// leave the ring silently empty while lines were arriving.
while inner.lines.len() > inner.max_lines
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
{
if let Some(evicted) = inner.lines.pop_front() {
inner.bytes -= evicted.weight();
inner.dropped += 1;
}
}
})
}
/// Every line held, oldest first.
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
/// The lines with a sequence number at or after `seq`, oldest first,
/// and the sequence to ask from next time. Does not consume: see this
/// module's doc for why.
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
.lines
.iter()
.filter(|line| line.seq >= seq)
.cloned()
.collect();
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
(lines, next)
})
}
pub fn len(&self) -> usize {
self.with(|inner| inner.lines.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn dropped(&self) -> u64 {
self.with(|inner| inner.dropped)
}
/// When the newest line was written, in unix milliseconds, or `None`
/// for a ring nothing has been written to.
pub fn last_at_ms(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
/// Every line held, formatted one per line -- what `Copy report`
/// appends.
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n")
}
/// One line for a diagnostics pane: how much is held, how much was
/// dropped, and when the last line arrived. "no lines yet" is its own
/// wording rather than a count of zero with a made-up time, because
/// "nothing has been logged" and "logging is not running" would
/// otherwise look the same.
pub fn summary(&self) -> String {
let (len, dropped, last) = self.with(|inner| {
(
inner.lines.len(),
inner.dropped,
inner.lines.back().map(|line| line.at_ms),
)
});
match last {
None => "app log: no lines yet".to_string(),
Some(at) => {
let dropped = if dropped > 0 {
format!(", {dropped} dropped")
} else {
String::new()
};
format!(
"app log: {len} lines held{dropped}, last {}",
clock_time(at)
)
}
}
}
}
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
/// logger the platform already installs, so nothing that reads the
/// platform's log (`logcat`, a terminal) changes.
///
/// The inner logger is passed in rather than chosen here: `client-core`
/// has no business depending on `android_logger` or `env_logger`, and
/// which one is right is exactly what differs between the two platforms
/// (the sharing rule in AGENTS.md).
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
}
impl RingLogger {
pub fn new(ring: LogRing, inner: Box<dyn log::Log>) -> Self {
Self { ring, inner }
}
}
impl log::Log for RingLogger {
/// True for anything `log`'s own max level lets through: the ring
/// wants everything, even where the platform logger would filter it
/// out. The filter is applied per-logger in [`Self::log`] instead.
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
self.ring
.push(record.level(), record.target(), record.args().to_string());
if self.inner.enabled(record.metadata()) {
self.inner.log(record);
}
}
fn flush(&self) {
self.inner.flush();
}
}
/// Installs a [`RingLogger`] as the process logger and answers the ring it
/// records into.
///
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
pub fn install(
ring: LogRing,
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
) -> Result<(), log::SetLoggerError> {
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner)))?;
log::set_max_level(max_level);
Ok(())
}
/// The one ring this process records into.
///
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
/// anywhere in the binary goes to it. A ring handed around as a parameter
/// would be a *second* answer to "which lines exist" -- the report would
/// show one ring while the logger filled another, and which one a caller
/// got would depend on how far down the call tree it was. The tests above
/// all use their own [`LogRing`], so nothing here needs this to be
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
/// The process's ring, created on first use with the default bounds.
/// Safe to call before [`install_process_logger`] -- it will simply be
/// empty.
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
/// Installs [`process_ring`] as the recording half of the process logger,
/// forwarding to `inner` (the platform's own logger, already configured).
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
/// else is shared.
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
) -> Result<(), log::SetLoggerError> {
install(process_ring().clone(), inner, max_level)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
fn fill(ring: &LogRing, count: usize) {
for n in 0..count {
ring.push(Level::Info, "test", format!("line {n}"));
}
}
#[test]
fn lines_come_back_oldest_first() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 0", "line 1", "line 2"]);
}
#[test]
fn the_line_bound_drops_the_oldest_and_says_how_many() {
let ring = LogRing::new(3, 1 << 20);
fill(&ring, 5);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
assert_eq!(ring.len(), 3);
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
}
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
// Room for 1000 lines but only a few hundred bytes.
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
}
assert!(
ring.len() < 10,
"the byte bound evicted: {} held",
ring.len()
);
assert!(ring.dropped() > 0);
assert!(
ring.snapshot().last().unwrap().message.starts_with('9'),
"and it evicted from the old end"
);
}
/// The case the `len() > 1` guard exists for: one line larger than the
/// whole bound must still be readable, or a ring that is over budget
/// reads as a ring nothing was written to.
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
ring.push(Level::Error, "t", "y".repeat(5000));
assert_eq!(ring.len(), 1);
assert_eq!(ring.dropped(), 0);
}
#[test]
fn sequence_numbers_only_increase_and_survive_eviction() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 5);
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
}
#[test]
fn since_returns_only_what_is_new_and_the_next_cursor() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 3);
let (first, cursor) = ring.since(0);
assert_eq!(first.len(), 3);
assert_eq!(cursor, 3);
let (none, cursor) = ring.since(cursor);
assert!(none.is_empty(), "nothing new yet");
assert_eq!(cursor, 3, "and the cursor does not move");
ring.push(Level::Warn, "test", "later".into());
let (more, cursor) = ring.since(cursor);
assert_eq!(more.len(), 1);
assert_eq!(more[0].message, "later");
assert_eq!(cursor, 4);
}
#[test]
fn reading_does_not_consume() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 2);
let (sent, _) = ring.since(0);
assert_eq!(sent.len(), 2);
assert_eq!(ring.len(), 2, "the report still has them after an upload");
assert_eq!(ring.to_text().lines().count(), 2);
}
#[test]
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
let ring = LogRing::with_defaults();
assert_eq!(ring.summary(), "app log: no lines yet");
assert_eq!(ring.last_at_ms(), None);
assert!(ring.is_empty());
}
#[test]
fn the_summary_names_dropped_lines_only_when_there_are_some() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 2);
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
fill(&ring, 2);
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
}
#[test]
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
// hand rather than against another clock.
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
message: "surface created".into(),
}
.format();
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
/// The forwarding half: a line reaches the ring *and* the logger the
/// platform already had, and one the inner logger filters out is still
/// in the ring.
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
impl Log for Collect {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.1
}
fn log(&self, record: &log::Record) {
self.0.lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
let logger = RingLogger::new(ring.clone(), Box::new(Collect(seen.clone(), Level::Info)));
logger.log(
&log::Record::builder()
.args(format_args!("kept"))
.level(Level::Info)
.target("t")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("filtered"))
.level(Level::Debug)
.target("t")
.build(),
);
assert_eq!(
*seen.lock().unwrap(),
["kept"],
"the inner logger's own filter still applies"
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(held, ["kept", "filtered"], "the ring keeps both");
}
}
+427
View File
@@ -0,0 +1,427 @@
//! Sending [`crate::log_ring`]'s lines to `ai-server`, so a phone with no
//! `logcat` still has a way for a `log::info!` to reach a person.
//!
//! **Where they end up**: `POST /client-log` re-emits each line into
//! `ai-server`'s own `tracing` output, which Dev Updater already shows as
//! that component's *runtime log* (it runs `ai-server` as a `Managed`
//! service, and a managed service's stdout is redirected to a file its
//! service script reports). So this needs no new route, storage or viewer
//! in Dev Updater at all -- see `docs/DECISIONS.md`, 2026-09-07.
//!
//! **Nothing here calls `log!`.** Every line this module logged would land
//! in the ring it is draining and be uploaded, so a server that is down
//! would produce a growing conversation with itself. Failures are recorded
//! in [`UploadStatus`] instead and shown in the app's diagnostics pane,
//! which is where somebody looking for "why is nothing arriving" is
//! already looking (UI_RULES.md: a failure is reported where it happened).
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use crate::api::{ApiError, Body, Transport};
use crate::log_ring::LogRing;
/// The most lines one request carries. A phone that has been offline for
/// an hour has thousands waiting, and one request holding all of them is a
/// body the server has to buffer whole; the rest go in the next batch,
/// which the loop takes immediately rather than after the next interval.
pub const MAX_LINES_PER_BATCH: usize = 500;
/// How much of one message is sent. Long enough for a stack trace line,
/// short enough that one pathological message cannot dominate a batch.
/// Truncation is marked, because a silently shortened line reads as a line
/// that ended there.
pub const MAX_MESSAGE_BYTES: usize = 4096;
/// The route this posts to, on `server/src/routes.rs`'s surface.
pub const CLIENT_LOG_PATH: &str = "/client-log";
/// What the last upload attempt did, for a diagnostics pane. `None` for
/// "nothing has been tried yet", which is deliberately distinct from a
/// success that sent nothing.
#[derive(Debug, Clone, Default)]
pub struct UploadStatus {
pub sent: u64,
pub last_error: Option<String>,
pub attempted: bool,
}
impl UploadStatus {
/// One line for the diagnostics pane, in the same voice as
/// [`LogRing::summary`].
pub fn summary(&self) -> String {
match (&self.last_error, self.attempted) {
(Some(err), _) => format!("log upload: failing -- {err} ({} sent so far)", self.sent),
(None, false) => "log upload: not tried yet".to_string(),
(None, true) => format!("log upload: {} lines sent", self.sent),
}
}
}
/// Drains a [`LogRing`] into `POST /client-log`, remembering how far it
/// got so a line is sent once and stays in the ring for the report.
pub struct LogUploader {
ring: LogRing,
transport: Arc<dyn Transport>,
source: String,
cursor: u64,
status: Arc<Mutex<UploadStatus>>,
}
impl LogUploader {
/// `source` names the build these lines came from -- it is what
/// distinguishes them in `ai-server`'s log from the server's own
/// lines and from another device's.
pub fn new(ring: LogRing, transport: Arc<dyn Transport>, source: impl Into<String>) -> Self {
Self {
ring,
transport,
source: source.into(),
cursor: 0,
status: Arc::new(Mutex::new(UploadStatus::default())),
}
}
/// A handle on what the last attempt did, shareable with the UI.
pub fn status(&self) -> Arc<Mutex<UploadStatus>> {
Arc::clone(&self.status)
}
/// Sends up to [`MAX_LINES_PER_BATCH`] waiting lines. Answers how many
/// went, and whether more are waiting -- the loop uses the second to
/// decide whether to go round again at once.
pub fn flush_once(&mut self) -> Result<(usize, bool), ApiError> {
let (mut lines, mut next) = self.ring.since(self.cursor);
let more = lines.len() > MAX_LINES_PER_BATCH;
if more {
lines.truncate(MAX_LINES_PER_BATCH);
next = lines.last().map(|line| line.seq + 1).unwrap_or(next);
}
if lines.is_empty() {
return Ok((0, false));
}
let body = serde_json::json!({
"source": self.source,
"lines": lines.iter().map(|line| serde_json::json!({
"seq": line.seq,
"at": line.at_ms,
"level": line.level.as_str(),
"target": line.target,
"message": truncate(&line.message),
})).collect::<Vec<_>>(),
});
let result = self
.transport
.request("POST", CLIENT_LOG_PATH, Some(Body::Json(body)));
let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner());
status.attempted = true;
match result {
Ok(response) if (200..300).contains(&response.status) => {
// Only on success: a failed batch is retried from the same
// cursor next time, which is what makes a dropped tunnel
// cost nothing but a delay.
self.cursor = next;
status.sent += lines.len() as u64;
status.last_error = None;
Ok((lines.len(), more))
}
Ok(response) => {
let message = format!("{} from {CLIENT_LOG_PATH}", response.status);
status.last_error = Some(message.clone());
Err(ApiError {
message,
status: Some(response.status),
})
}
Err(err) => {
status.last_error = Some(err.message.clone());
Err(err)
}
}
}
}
/// Cuts a message to [`MAX_MESSAGE_BYTES`] on a character boundary, saying
/// so, rather than letting one line dominate a batch.
fn truncate(message: &str) -> String {
if message.len() <= MAX_MESSAGE_BYTES {
return message.to_string();
}
let mut end = MAX_MESSAGE_BYTES;
while end > 0 && !message.is_char_boundary(end) {
end -= 1;
}
format!("{}… [{} bytes cut]", &message[..end], message.len() - end)
}
/// The background half: a thread that flushes on a timer and on demand.
///
/// Its path out is [`Drop`] -- dropping the handle stops the thread and
/// waits for it, so an app that tears the uploader down does not leave one
/// posting behind it.
pub struct LogUpload {
signal: Arc<(Mutex<Signal>, Condvar)>,
thread: Option<std::thread::JoinHandle<()>>,
status: Arc<Mutex<UploadStatus>>,
}
/// Only "stop": a nudge from [`LogUpload::flush_now`] needs no flag,
/// because the thread's reaction to waking is to flush, and flushing an
/// empty ring costs nothing -- so a spurious wakeup is already correct.
#[derive(Default)]
struct Signal {
stop: bool,
}
impl LogUpload {
/// Starts the loop. `every` is how long it waits between flushes when
/// nobody nudges it -- a compromise between a line arriving promptly
/// and a radio the app woke for one line.
pub fn spawn(
ring: LogRing,
transport: Arc<dyn Transport>,
source: impl Into<String>,
every: Duration,
) -> Self {
let mut uploader = LogUploader::new(ring, transport, source);
let status = uploader.status();
let signal = Arc::new((Mutex::new(Signal::default()), Condvar::new()));
let thread = {
let signal = Arc::clone(&signal);
std::thread::Builder::new()
.name("client-log-upload".into())
.spawn(move || {
loop {
// Keep going while a batch was capped, so a
// backlog drains at once rather than one batch per
// interval.
while let Ok((_, more)) = uploader.flush_once() {
if !more {
break;
}
}
let (lock, condvar) = &*signal;
let state = lock.lock().unwrap_or_else(|e| e.into_inner());
if state.stop {
return;
}
let (state, _) = condvar
.wait_timeout(state, every)
.unwrap_or_else(|e| e.into_inner());
if state.stop {
return;
}
}
})
.expect("spawning the log upload thread")
};
Self {
signal,
thread: Some(thread),
status,
}
}
/// Sends what is waiting now -- what `Copy report` calls, so the lines
/// a person is about to describe are already on the server.
pub fn flush_now(&self) {
let (lock, condvar) = &*self.signal;
let _state = lock.lock().unwrap_or_else(|e| e.into_inner());
condvar.notify_all();
}
pub fn status(&self) -> UploadStatus {
self.status
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
}
impl Drop for LogUpload {
fn drop(&mut self) {
{
let (lock, condvar) = &*self.signal;
let mut state = lock.lock().unwrap_or_else(|e| e.into_inner());
state.stop = true;
condvar.notify_all();
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::RawResponse;
use log::Level;
/// Records every body posted, and answers whatever status the test set.
struct Fake {
posted: Mutex<Vec<serde_json::Value>>,
status: Mutex<u16>,
}
impl Fake {
fn new() -> Arc<Self> {
Arc::new(Self {
posted: Mutex::new(Vec::new()),
status: Mutex::new(200),
})
}
fn bodies(&self) -> Vec<serde_json::Value> {
self.posted.lock().unwrap().clone()
}
}
impl Transport for Fake {
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError> {
assert_eq!(method, "POST");
assert_eq!(path, CLIENT_LOG_PATH);
if let Some(Body::Json(value)) = body {
self.posted.lock().unwrap().push(value);
} else {
panic!("the client log is posted as JSON");
}
Ok(RawResponse {
status: *self.status.lock().unwrap(),
body: Vec::new(),
})
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
unreachable!("the log uploader never streams")
}
}
fn ring_with(count: usize) -> LogRing {
let ring = LogRing::with_defaults();
for n in 0..count {
ring.push(Level::Info, "t", format!("line {n}"));
}
ring
}
#[test]
fn an_empty_ring_posts_nothing() {
let fake = Fake::new();
let mut uploader = LogUploader::new(LogRing::with_defaults(), fake.clone(), "test");
assert_eq!(uploader.flush_once().unwrap(), (0, false));
assert!(
fake.bodies().is_empty(),
"no request at all, not an empty one"
);
}
#[test]
fn a_line_is_sent_once() {
let fake = Fake::new();
let ring = ring_with(3);
let mut uploader = LogUploader::new(ring.clone(), fake.clone(), "test");
assert_eq!(uploader.flush_once().unwrap().0, 3);
assert_eq!(
uploader.flush_once().unwrap(),
(0, false),
"nothing repeats"
);
ring.push(Level::Warn, "t", "later".into());
assert_eq!(uploader.flush_once().unwrap().0, 1);
assert_eq!(fake.bodies().len(), 2);
assert_eq!(ring.len(), 4, "and the report still holds all of them");
}
/// The half the change had no reason to touch: a server that refuses
/// must not lose the lines.
#[test]
fn a_failed_batch_is_retried_from_the_same_place() {
let fake = Fake::new();
*fake.status.lock().unwrap() = 503;
let mut uploader = LogUploader::new(ring_with(2), fake.clone(), "test");
assert!(uploader.flush_once().is_err());
assert!(
uploader.status().lock().unwrap().last_error.is_some(),
"and it says why, where somebody can see it"
);
*fake.status.lock().unwrap() = 200;
assert_eq!(uploader.flush_once().unwrap().0, 2, "the same two lines");
assert!(uploader.status().lock().unwrap().last_error.is_none());
}
#[test]
fn a_backlog_is_capped_per_batch_and_says_there_is_more() {
let fake = Fake::new();
let ring = LogRing::new(MAX_LINES_PER_BATCH * 3, 1 << 30);
for n in 0..(MAX_LINES_PER_BATCH + 7) {
ring.push(Level::Info, "t", format!("{n}"));
}
let mut uploader = LogUploader::new(ring, fake.clone(), "test");
assert_eq!(uploader.flush_once().unwrap(), (MAX_LINES_PER_BATCH, true));
assert_eq!(uploader.flush_once().unwrap(), (7, false));
}
#[test]
fn the_body_carries_the_source_and_each_line_whole() {
let fake = Fake::new();
let ring = LogRing::with_defaults();
ring.push(Level::Error, "iris::android", "surface lost".into());
LogUploader::new(ring, fake.clone(), "iris-bench 1.2")
.flush_once()
.unwrap();
let body = &fake.bodies()[0];
assert_eq!(body["source"], "iris-bench 1.2");
let line = &body["lines"][0];
assert_eq!(line["level"], "ERROR");
assert_eq!(line["target"], "iris::android");
assert_eq!(line["message"], "surface lost");
assert!(line["at"].as_u64().is_some(), "the app's own clock");
}
#[test]
fn an_enormous_message_is_cut_and_says_so() {
let cut = truncate(&"x".repeat(MAX_MESSAGE_BYTES + 100));
assert!(cut.starts_with("xxxx"));
assert!(cut.contains("bytes cut"), "{cut}");
assert!(cut.len() < MAX_MESSAGE_BYTES + 64);
let short = truncate("fine");
assert_eq!(short, "fine", "a short message is untouched");
}
#[test]
fn the_status_line_distinguishes_untried_from_sent_nothing() {
let untried = UploadStatus::default();
assert_eq!(untried.summary(), "log upload: not tried yet");
let sent_none = UploadStatus {
attempted: true,
..Default::default()
};
assert_eq!(sent_none.summary(), "log upload: 0 lines sent");
}
/// The path out: dropping the handle must stop the thread, not leave
/// it posting.
#[test]
fn dropping_the_handle_stops_the_thread() {
let fake = Fake::new();
let upload = LogUpload::spawn(
ring_with(1),
fake.clone(),
"test",
Duration::from_millis(10),
);
upload.flush_now();
drop(upload);
let after = fake.bodies().len();
std::thread::sleep(Duration::from_millis(60));
assert_eq!(fake.bodies().len(), after, "nothing posted after the drop");
}
}
+40
View File
@@ -955,3 +955,43 @@ do not duplicate it there.
the header's bottom must be masked. Fix both with one rule: a row is
drawn if any part of it intersects the viewport, and the viewport is
the list's own region.
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
- [ ] **"You shouldn't be able to scroll below the bottom (or above
top)."** The list's offset is not clamped to its content range while
dragging and/or flinging. Compose's `LazyColumn` never moves content
past its ends -- the overscroll *effect* on Android 12+ is a stretch
drawn over clamped content, not a displacement. Clamp the offset in
one place (`List`'s scroll setter, so drag, fling, page-in and
programmatic scroll all go through it) and end a fling that hits the
clamp. Test at layer 1: a drag past either end leaves the offset at
the end; a fling into the end stops there.
- [ ] **"Flinging now actually works but is slower than Compose's
immediately after releasing the flick (the slow down seems
correct)."** The curve is right, so the *initial velocity* is low.
iris's `VelocityTracker::velocity` is total motion over the window's
span -- an average -- where Compose's `VelocityTracker` (`compose.ui`
`VelocityTracker.kt`, `VelocityTracker1D` with `Strategy.Impulse`,
100ms horizon, 20 samples, `AssumePointerMoveStoppedMilliseconds =
40`) weights the last samples, so a flick that accelerates into the
release reads faster. Port the impulse strategy from source with
checked-in reference values from an independent transcription, as
the spline was; also apply Compose's min/max fling velocity
(`ViewConfiguration`'s 50dp/s and 8000dp/s) so a slow release does
not fling and a wild one is capped. Layer-1 test on
`flick-120hz.touch` asserting the number Compose's code gives.
- [ ] **Input-event and timing report from the phone.** Iris: "add
another button to copy input event info so that I can do some stuff
manually and then send the event log to you ... instrument a lot of
the code with timings so I can give you time reports through the
same button." Build on the log ring (queue: logging through Dev
Updater), not beside it: every `MotionEvent` (action, pointer
position, event time, historical sample count) and every gesture
decision (`iris drag release:` and its siblings) at debug level into
the ring; frame timings (input handled -> layout -> draw submitted,
and the fling tick's `now` against the frame clock) at debug level
into the same ring; "Copy report" already appends the ring. Add a
second button only if the report gets too long to paste -- then
"Copy input log" copies the ring alone. Says which build it came
from.
+186
View File
@@ -43,6 +43,178 @@ gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
### APK size (2026-09-07)
Iris's question: the iris bench APK is about double the Compose bench APK
(20.6 MB vs 10.1 MB, both release). Measured before this pass: 18,088 KiB of
`lib/arm64-v8a/libmain.so`, stored uncompressed (`extractNativeLibs=false`),
plus 2 MB of dex; the Compose APK's dex is 25 MB raw, compressed to 9 MB in
the APK. `iris/android-app/Cargo.toml`'s `[profile.release]` set only
`panic = "abort"` -- no `lto`, no `codegen-units`, no `strip`, default
`opt-level`. All numbers below are `arm64-v8a` release, built with
`./build-apk.sh release` (this checkout, `nice -n 10`, no `CARGO_TARGET_DIR`
override, reusing the incremental `target/`), and are the raw file sizes
(`ls -la`), not what `du` would round to.
| profile.release | APK bytes | `libmain.so` bytes | delta vs previous |
|---|---|---|---|
| `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- |
| + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 |
| + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 |
| + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 |
| + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 |
| + `opt-level = "z"` (not adopted, see below) | 12,507,276 | 10,374,808 | -818,800 |
Adopted: `strip = true`, `lto = "fat"`, `codegen-units = 1`, `opt-level = "s"`.
Baseline to final: `libmain.so` **18,546,488 -> 11,193,608 bytes (-39.7%)**,
APK **20,678,956 -> 13,326,076 bytes (-35.5%)**.
**`opt-level = "z"` was measured but not adopted.** It is smaller still --
another 818,800 bytes off `libmain.so` (7.9 MiB total vs `s`'s 8.7 MiB) --
but `z` trims more aggressively than `s` in ways that can cost frame time
(fewer inlines, more size-motivated codegen choices, per rustc's own docs),
and this pass did not have an iris-side frame-time benchmark run against
it (the app's own `run-bench.sh`/render report was not exercised here,
per this task's scope, and this checkout has no emulator currently up).
Trading an unmeasured runtime cost for ~700 KB more off the download is not
a call to make blind, so `s` is what shipped, and `z` is left as something
to try only alongside a `transcript-bench.sh`/`stream-bench.sh`-equivalent
run for iris to confirm it does not regress.
Not stripped (baseline) had a live `.symtab` (`llvm-readelf -S`): section
25, `SYMTAB`, 0x17ed40 bytes (~1.49 MiB) covering 65,223 raw symbols. `strip
= true` removes it at build time -- notably, `stripReleaseDebugSymbols`
(AGP's own strip task) had already logged "Unable to strip the following
libraries, packaging them as they are: libmain.so" on the baseline, so
Cargo's own strip is also the fix for that.
Baseline section sizes (`llvm-readelf -S`, before any profile change):
| section | bytes |
|---|---|
| `.text` | 6,463,032 |
| `.rodata` | 6,548,192 |
| `.eh_frame` | 721,872 |
| `.data.rel.ro` | 441,328 |
| `.gcc_except_table` | 13,652 |
| `.symtab` | 1,563,456 |
No `bloaty` on this machine (`which bloaty` empty); used
`llvm-nm -S --size-sort -C` on the baseline (unstripped) `.so`, summed by
the symbol's leading crate/module name. 7.02 MB of the 18.5 MB `.so` carries
a name at all (the rest is `.rodata` blobs -- embedded data, padding,
relocations -- that never get a symbol). Top 8 by that accounting:
| crate | bytes (named symbols only) |
|---|---|
| `naga` | 1,100,099 |
| `core` (std) | 698,862 |
| `wgpu_core` | 599,720 |
| `harfrust` | 372,694 |
| `alloc` | 347,324 |
| `read_fonts` | 326,824 |
| `wgpu_hal` | 299,648 |
| `skrifa` | 291,332 |
Also notable further down: `hashbrown` 244,712, `jni` 199,660, `std`
199,126, `serde` 168,256, `zeno` 157,320, `pulldown_cmark` 116,568,
`iris_core` 95,112, `parley` 78,816, `iris` 76,616, `swash` 72,940,
`serde_json` 72,312, `fontique` 45,464.
**The other ~11.5 MB of `.rodata`/unnamed data is mostly the embedded
fonts**: `iris/core/src/primitive/text.rs` `include_bytes!`s six Noto Sans
TTFs (`iris/core/assets/fonts/`) -- Regular/Bold/Italic/BoldItalic for Noto
Sans plus Regular/Bold for Noto Sans Mono -- totalling **3.6 MB** of raw
font data (`du -ch iris/core/assets/fonts/*.ttf`). That is real render
data, not something to strip: unlike the Compose app's Nerd Fonts icon
subset (`app/build-icon-font.sh`, which subsets because the app only ever
draws ~100 fixed glyphs), iris's Noto Sans embedding backs arbitrary text
in a chat transcript, so a subset would have to be a Unicode-coverage
subset (Latin/Latin-Extended/common punctuation, dropping CJK/Cyrillic/etc)
rather than a fixed-codepoint one -- a real behaviour change (text in a
language outside the subset would fall back to tofu or a missing glyph) and
out of scope for a size-only pass. Left as a follow-up, flagged for Iris:
subsetting would plausibly save 1-2 MB but changes what scripts render
correctly, which is a product decision.
**naga/wgpu backend features: investigated, not trimmed, because the
trim would not change the binary.** `iris/core/Cargo.toml` and
`iris/Cargo.toml` depend on `wgpu = "28.0.0"` with default features, which
via `wgpu`'s own defaults (`dx12`, `metal`, `gles`, `vulkan`, `wgsl`,
`webgpu`) forward `naga/hlsl-out`, `naga/msl-out`, `naga/glsl-out`,
`naga/spv-out`, `naga/wgsl-in`, `naga/wgsl-out` -- Cargo feature
unification is not per-target, so all of those are nominally "on" for the
Android build too, not just the ones Android actually uses (`glsl-out` for
GLES, `spv-out` for Vulkan). But `wgpu-hal`'s own `build.rs`
(`cfg_aliases!`) gates the *modules* themselves on the real target:
`dx12: target_os = "windows" AND feature = "dx12"`, `metal: target_vendor =
"apple" AND feature = "metal"` (`wgpu-hal-28.0.0/build.rs`,
`wgpu-hal-28.0.0/src/lib.rs`'s `#[cfg(dx12)] pub mod dx12;` etc). So on
`aarch64-linux-android` the dx12/metal modules never compile, nothing calls
into `naga::back::hlsl` or `naga::back::msl`, and the linker's normal
dead-code elimination already drops them: `grep -c
"naga::back::hlsl\|naga::back::msl\|naga::front::spv\|naga::front::glsl"
/tmp/nm_size.txt` on the **baseline** (no LTO yet) `.so` returns **0** --
none of that code reached the linked binary in the first place. `regex`
(pulled in transitively by `env_filter`, which `android_logger` uses for
`RUST_LOG`-style filtering) is in the same position: present in
`Cargo.lock` but only a handful of small generic-drop symbols in `nm`, not
a real contributor. Neither is worth a Cargo-level feature trim (which
would also need a per-target dependency table to avoid stripping dx12/metal
off the desktop build, adding real complexity for a change that measures
as zero). No emulator use was needed for this finding since no feature
flag changed; the earlier per-step size measurements (strip/LTO/cgu/opt-level)
were likewise not re-verified on the emulator, since this task's brief
scoped emulator use to the naga-trim step specifically, and that step's
answer was "don't."
**`tabs-ui`/`tabs-screen`: also investigated, also already dead.**
`build-apk.sh`'s default features are `"transcript-screen bench"`, passed
without `--no-default-features`, so the crate's own `default =
["tabs-screen"]` (`iris/android-app/Cargo.toml`) is *also* on for every
build this script produces, including the bench APK. `src/lib.rs`'s doc
comment already says the three screens are mutually exclusive at runtime
(`ActiveClient` gives `bench` priority over `transcript-screen`, which
takes priority over the default `tabs-screen`), and checking the actual
`#[cfg(...)]` gates confirms it is mutually exclusive at *compile* time
too: the `Client` struct and its one call to `tabs_ui::build` are behind
`#[cfg(not(feature = "transcript-screen"))]`, which is false whenever
`transcript-screen` is on, so that code does not even get generated, let
alone linked. `llvm-nm -C` on both the baseline and the final `.so` confirm
it: `grep -ci "tabs_ui\|sungals"` is **0** in both. So there is nothing to
trim here either -- `tabs-ui` and its `sungals.png` (8.9 KB) never reach
the linked binary in a `transcript-screen`/`bench` build, regardless of the
feature being nominally "on" in `Cargo.toml`.
**Comparison Iris asked for, honestly**: most of the remaining ~13.3 MB vs
Compose's 10.1 MB is not a build-settings gap, it is what each app links.
Compose's APK carries ~9 MB of *compressed* dex and links Android's own
platform renderer, text shaper (HarfBuzz/Minikin) and font files from the
system image at zero cost to the APK -- none of that is bytes Compose ships.
Iris ships its own copy of all of that: `wgpu`+`naga`+`wgpu_hal` (a
software/hardware-portable GPU backend and shader cross-compiler, roughly
2 MB of named symbols alone), `harfrust`+`read_fonts`+`skrifa`+`swash`+
`parley`+`fontique` (a full third-party font-loading/shaping/rasterizing
pipeline, another ~1.2 MB of named symbols), and 3.6 MB of embedded font
data because it cannot borrow the platform's fonts the way Compose does.
Build settings (this pass) closed real ground -- 39.7% off `libmain.so` --
but did not remove any of those linked systems, because removing them would
mean iris stops being a self-contained native renderer, which is the whole
point of the port (`AGENTS.md`'s "no Dioxus and nothing that draws through
a WebView", `no-dioxus-or-webview-ui-true-native-only` memory). Install size
(what `dumpsys package`/`du` on the installed `lib/arm64-v8a/` directory
would show) was not separately measured this pass: with
`extractNativeLibs=false` the `.so` is mapped directly out of the APK
rather than copied onto disk a second time, so install size tracks the APK
size closely for the native library and is not a second, larger number the
way it would be under the old `extractNativeLibs=true` default -- checking
this precisely needs the emulator, which this task scoped to the naga-trim
verification only.
Committed: `iris/android-app/Cargo.toml`'s `[profile.release]` now reads
`panic = "abort"`, `strip = true`, `lto = "fat"`, `codegen-units = 1`,
`opt-level = "s"`, with a comment naming the measured savings.
### Queue, 2026-09-07 (orchestrator)
In order; two builders at a time. Each is ticked here by the agent that
@@ -59,6 +231,20 @@ closes it.
app's runtime log, design the smallest route (the app keeps its own
recent log; a debug button copies it; Dev Updater reads it), write
the decision in docs/DECISIONS.md, build it.
- [x] APK size: release profile tuned (`42af780`), -35% APK, -40% .so;
see "APK size (2026-09-07)". **Open question for Iris**: 3.6 MB of the
remaining 11.2 MB .so is six embedded Noto Sans TTFs
(`iris/core/src/primitive/text.rs`). Loading the platform's own fonts
instead (fontique's system collection: Roboto/Noto on Android, the
desktop's own on Linux) removes them and makes text match what the
rest of her phone shows (UI_RULES "show what the reader already sees
elsewhere"), at the cost of the app no longer looking identical on
every device. Not done without her verdict.
- [ ] Scroll clamped at both ends, and Compose's impulse velocity
estimator with min/max fling velocity (docs/IRIS_TODO.md, 2026-09-07
later). After the culling fix lands (same file).
- [ ] Input-event and timing instrumentation into the log ring, copied
by the report button. After the logging route lands (same ring).
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
2026-09-07)". A mask references a primitive already drawn
(rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
+1
View File
@@ -721,6 +721,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+1
View File
@@ -745,6 +745,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+12
View File
@@ -73,6 +73,18 @@ bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
[profile.release]
panic = "abort"
# Measured 2026-09-07 (docs/RUST.md's "APK size" subsection): together these
# take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the APK
# from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a release. `strip`
# also works around AGP's own stripReleaseDebugSymbols failing silently on
# this .so ("packaging them as they are"). `opt-level = "s"` over `"z"`:
# `z` measured another ~800 KB smaller but was not checked against iris's
# own frame-time bench, so it is not worth the unmeasured risk -- see the
# doc for the number and the follow-up this leaves.
strip = true
lto = "fat"
codegen-units = 1
opt-level = "s"
[profile.dev]
panic = "abort"
+97 -24
View File
@@ -22,6 +22,17 @@ fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return;
}
// Where this build sends its own log ring, if anywhere. Emitted for
// every build that has `client-core` (so the `bench` build, which
// returns below, gets it too): the log route is the one thing a bench
// APK on a phone with no `logcat` needs a server for even though it
// opens a checked-in fixture and talks to nothing else.
//
// **Optional, unlike the transcript config below.** A build with none
// of these set still keeps its ring and still shows it in `Copy
// report`; it just has nowhere to send it. So the same `build-apk.sh`
// works on a machine that has not decided where logs go.
emit_log_config();
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
// server at all -- `bench_client.rs` never references the `pinned`
// module this generates, so requiring a live server's host/port/token/
@@ -49,6 +60,84 @@ fn main() {
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
);
let ca_pem = read_pinned_ca();
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let generated = format!(
"// Generated by build.rs from {host}:{port}. Do not edit.\n\
pub const HOST: &str = {host_lit:?};\n\
pub const PORT: u16 = {port};\n\
pub const TOKEN: &str = {token_lit:?};\n\
pub const CA_PEM: &str = {ca_lit:?};\n",
host = host,
port = port
.parse::<u16>()
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
host_lit = host,
token_lit = token,
ca_lit = ca_pem,
);
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
}
/// Writes `log_config.rs` into `OUT_DIR`: the server this build's log ring
/// uploads to, or `None`.
///
/// Read from the environment at build time rather than from anything in
/// the repository, which is the same trust boundary the CA below uses and
/// the reason no token is ever committed. On the host, Dev Updater builds
/// this APK on the machine `ai-server` runs on, so the values are that
/// machine's own -- an APK is good for the server that built it, which is
/// already true of the pinned CA.
fn emit_log_config() {
println!("cargo:rerun-if-env-changed=AI_APP_LOG_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_LOG_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_LOG_TOKEN");
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let host = std::env::var("AI_APP_LOG_HOST").ok();
let port = std::env::var("AI_APP_LOG_PORT").ok();
let token = std::env::var("AI_APP_LOG_TOKEN").ok();
let generated = match (host, port, token) {
(Some(host), Some(port), Some(token)) => {
let port: u16 = port
.parse()
.unwrap_or_else(|e| panic!("AI_APP_LOG_PORT={port:?} is not a u16: {e}"));
let ca_pem = read_pinned_ca();
format!(
"// Generated by build.rs. Do not edit.\n\
pub const LOG_SERVER: Option<LogServer> = Some(LogServer {{\n\
\x20 host: {host:?},\n\
\x20 port: {port},\n\
\x20 token: {token:?},\n\
\x20 ca_pem: {ca_pem:?},\n\
}});\n"
)
}
// All three or none: two of the three is a half-configured build
// that would fail at runtime with nothing on screen saying why.
(host, port, token) => {
assert!(
host.is_none() && port.is_none() && token.is_none(),
"AI_APP_LOG_HOST, AI_APP_LOG_PORT and AI_APP_LOG_TOKEN are set together \
or not at all -- a build with some of them has nowhere to send its log \
and no way to say so"
);
"// Generated by build.rs. Do not edit.\n\
pub const LOG_SERVER: Option<LogServer> = None;\n"
.to_string()
}
};
std::fs::write(out_dir.join("log_config.rs"), generated).unwrap();
}
/// The CA this machine's `ai-server` signs with: `AI_APP_CA`, else
/// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. One reader for both generated
/// configs -- the log destination and the transcript destination are the
/// same server's certificate, and two copies of this would be two ways to
/// disagree about which one was pinned.
fn read_pinned_ca() -> String {
let ca_path = std::env::var_os("AI_APP_CA")
.map(PathBuf::from)
.unwrap_or_else(|| {
@@ -63,34 +152,18 @@ fn main() {
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
panic!(
"no CA certificate at {} ({e}).\n\
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
against a different one.",
Start ai-server once on this machine first -- it generates the CA this \
build pins. Set AI_APP_CA=/path/to/ca.pem to build against a different one.",
ca_path.display()
)
});
let ca_pem = ca_pem.trim();
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
panic!("{} is not a PEM certificate.", ca_path.display());
}
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let generated = format!(
"// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\
pub const HOST: &str = {host_lit:?};\n\
pub const PORT: u16 = {port};\n\
pub const TOKEN: &str = {token_lit:?};\n\
pub const CA_PEM: &str = {ca_lit:?};\n",
host = host,
port = port
.parse::<u16>()
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
ca = ca_path.display(),
host_lit = host,
token_lit = token,
ca_lit = ca_pem,
let ca_pem = ca_pem.trim().to_string();
assert!(
ca_pem.starts_with("-----BEGIN CERTIFICATE-----"),
"{} is not a PEM certificate.",
ca_path.display()
);
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
ca_pem
}
fn require_env(name: &str, what: &str) -> String {
+108
View File
@@ -0,0 +1,108 @@
//! The platform half of this app's logging: what `client_core::log_ring`
//! and `client_core::log_upload` need that only Android can supply.
//!
//! Everything general -- the ring, its bounds, the `log::Log` backend, the
//! batching and the upload -- is in `client-core`, shared with the desktop
//! app (AGENTS.md's sharing rule). What is here is the two things that are
//! genuinely this platform's: `android_logger` as the logger to forward
//! to, and the destination baked in at build time by `build.rs`.
//!
//! **Why an app carries its own log at all**: Iris tests these builds on a
//! GrapheneOS phone with no `adb`, and Android forbids one app reading
//! another's `logcat`. Nothing outside this process can recover what it
//! wrote, so the process keeps a copy and sends it. See
//! `docs/DECISIONS.md`, 2026-09-07.
use client_core::log_ring::{self, LogRing};
pub use client_core::log_upload::LogUpload;
use std::sync::Arc;
use std::time::Duration;
/// Where this build's log goes, or `None` for a build that was not told.
/// Generated by `build.rs` from `AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` and the
/// pinned CA -- never from anything committed.
pub struct LogServer {
pub host: &'static str,
pub port: u16,
pub token: &'static str,
pub ca_pem: &'static str,
}
include!(concat!(env!("OUT_DIR"), "/log_config.rs"));
/// How often the uploader sends what has accumulated.
///
/// Ten seconds rather than per line: a line at a time is a radio wake per
/// `log::info!`, and this app logs per surface change and per benchmark
/// phase. `Copy report` flushes immediately, so the case where somebody is
/// waiting does not wait for this.
const UPLOAD_EVERY: Duration = Duration::from_secs(10);
/// Installs the ring in front of `android_logger`, so `logcat` still sees
/// exactly what it saw before and the ring sees it too.
///
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
/// itself; the message says which caller, since two initialisation paths
/// is a programmer error rather than something to recover from.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(max_level)
.with_tag("iris-android-app"),
);
if log_ring::install_process_logger(Box::new(inner), max_level).is_err() {
// Not a panic: a logger already installed means logging works,
// just without the ring, and taking the app down over a
// diagnostic would be worse than the diagnostic being missing.
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
}
/// The process's ring -- what `Copy report` appends and the diagnostics
/// pane counts.
pub fn ring() -> &'static LogRing {
log_ring::process_ring()
}
/// Starts the upload loop, if this build was told where to send it.
/// `None` is an ordinary answer, not a failure: a build with no
/// destination still keeps its ring and still copies it.
pub fn start_upload(source: &str) -> Option<LogUpload> {
let server = LOG_SERVER.as_ref()?;
let transport = client_core::api::UreqTransport::new(
format!("https://{}:{}", server.host, server.port),
server.token,
server.ca_pem.as_bytes(),
)
.inspect_err(|err| log::warn!("iris app log: no upload -- {}", err.message))
.ok()?;
Some(LogUpload::spawn(
ring().clone(),
Arc::new(transport),
source.to_string(),
UPLOAD_EVERY,
))
}
/// Only the bench build has a diagnostics pane to put this in; the
/// transcript build's screen is the app's own and has no room for a
/// readout. Gated rather than left dead so the build stays warning-clean.
#[cfg(feature = "bench")]
/// One or two lines for the diagnostics pane: how much is held, and what
/// the uploader last did. Both, because "nothing is arriving on the
/// server" has two very different causes and the pane is where somebody
/// looks for which.
pub fn diagnostics_line(upload: Option<&LogUpload>) -> String {
let ring = ring().summary();
match (upload, LOG_SERVER.as_ref()) {
(Some(upload), _) => format!("{ring}\n{}", upload.status().summary()),
(None, None) => format!("{ring}\nlog upload: this build has no server configured"),
// Configured but not started: the client never called
// `start_upload`, or its transport refused the pinned CA.
(None, Some(server)) => format!(
"{ring}\nlog upload: configured for {}:{} but not running",
server.host, server.port
),
}
}
+35 -3
View File
@@ -80,6 +80,12 @@ const KEYBOARD_WAIT_MS: u64 = 1_000;
/// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16;
/// What this build calls itself in `ai-server`'s log. Names the app and
/// the build type, not the device: two phones running this APK are meant
/// to be told apart by what they say, and a device identifier in a log is
/// something to explain rather than something anybody asked for.
const LOG_SOURCE: &str = "iris-bench";
/// How much of the screen a *filled* benchmark report may take before it
/// scrolls instead of growing -- roughly a third of a phone screen, the
/// share the pane used to reserve unconditionally. An empty report takes
@@ -122,6 +128,13 @@ pub struct BenchClient {
/// The status-bar inset `top_bar` was last padded by -- see
/// `on_insets_changed`'s own comment for why this guards the rebuild.
last_top_pad: f32,
/// The background upload of this app's own log ring (`app_log`), where
/// this build was told a server. Held as a field rather than left
/// running for the process's lifetime so its path out is this client
/// being dropped -- `LogUpload`'s `Drop` stops and joins the thread.
/// `None` for a build with no destination, which is the ordinary case
/// for a bench APK built without `AI_APP_LOG_*`.
log_upload: Option<crate::app_log::LogUpload>,
}
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
@@ -283,6 +296,7 @@ impl AndroidAppState for BenchClient {
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
log_upload: crate::app_log::start_upload(LOG_SOURCE),
};
match transcript_fixture::build_screen(rsc) {
@@ -544,7 +558,11 @@ impl BenchClient {
// logcat on her phone, and "the keyboard does not push the
// composer up" cannot be told from "the listener never fired"
// without it (`AndroidUiState::insets_report`).
format!("{renderer}\n{}", self.android_state().insets_report())
format!(
"{renderer}\n{}\n{}",
self.android_state().insets_report(),
crate::app_log::diagnostics_line(self.log_upload.as_ref())
)
}
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
@@ -566,7 +584,7 @@ impl BenchClient {
}
fn copy_report(&mut self) {
let Some(report) = &self.last_report else {
let Some(report) = self.last_report.clone() else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
return;
};
@@ -574,7 +592,21 @@ impl BenchClient {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
if platform.copy_to_clipboard("iris bench report", report) {
// The ring goes on the clipboard, not into the pane: the pane is
// on screen and a thousand log lines in it would bury the report
// somebody pressed the button for, while the clipboard is going
// straight into a message to be read elsewhere.
let report = format!(
"{report}\n\n=== app log ({}) ===\n{}",
crate::app_log::ring().summary(),
crate::app_log::ring().to_text()
);
// And on the server, if this build has one -- so the lines are
// already there by the time the message describing them arrives.
if let Some(upload) = &self.log_upload {
upload.flush_now();
}
if platform.copy_to_clipboard("iris bench report", &report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
+12
View File
@@ -51,6 +51,11 @@ use iris::prelude::*;
use log::LevelFilter;
use std::ffi::c_void;
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
/// tabs demo keeps `android_logger` alone, as it always had.
#[cfg(feature = "transcript-screen")]
mod app_log;
#[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
@@ -119,6 +124,13 @@ extern "system" fn new_view_peer<'local>(
/// mirrors android-view's own demo, which carries the same comment.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
// The ring in front of `android_logger` where there is one (see
// `app_log`), and `android_logger` alone otherwise. Both install the
// same tag and level, so `logcat` cannot tell the two builds apart --
// the ring only adds a second reader.
#[cfg(feature = "transcript-screen")]
app_log::install(LevelFilter::Debug);
#[cfg(not(feature = "transcript-screen"))]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Debug)
@@ -74,6 +74,10 @@ pub struct TranscriptClient {
/// only ever one session here (no list to switch away to), but the
/// guard still matters for the *first* fetch racing a `stop`/`start`.
generation: Arc<AtomicU64>,
/// The background upload of this app's own log ring (`app_log`). Held
/// here so its path out is this client being dropped; see
/// `bench_client`'s field of the same name.
_log_upload: Option<crate::app_log::LogUpload>,
}
impl HasAndroidUiState for TranscriptClient {
@@ -182,6 +186,7 @@ impl AndroidAppState for TranscriptClient {
items: Vec::new(),
session_id: None,
generation: Arc::new(AtomicU64::new(0)),
_log_upload: crate::app_log::start_upload("iris-transcript"),
};
client.spawn_fetch_sessions(rsc);
client
+13
View File
@@ -56,6 +56,19 @@ pub struct Mask {
/// primitive's own corners, so a mask and the content clipped by it
/// can move independently. See LAYOUT.md section 2b.
pub move_idx: MoveIdx,
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
/// clipping nests: the fragment stage walks the chain and a pixel has
/// to be inside every mask on it. Chained rather than intersected on
/// the CPU because each mask moves with its own widget -- a code fence
/// inside a transcript row carries the row's scroll, the list's own
/// box does not, and one region resolved when the fence was last drawn
/// gets the second of those wrong as soon as the row moves.
///
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
/// released when the child's own slot goes
/// (`UiRenderState::remove`), so the chain cannot outlive what it
/// points at.
pub parent: MaskIdx,
}
/// One widget's cumulative on-screen translation, and the slot of the
+14 -2
View File
@@ -34,6 +34,10 @@ struct Mask {
x: UiSpan,
y: UiSpan,
move_idx: u32,
/// The mask this one is nested inside, or `4294967295u`. Mirrors
/// `Mask::parent` in data.rs; walked below with the same bound the
/// move chain uses.
parent: u32,
}
/// One widget's cumulative on-screen translation and the slot of the
@@ -196,8 +200,15 @@ fn fs_main(
color = vec4(1.0, 0.0, 1.0, 1.0);
}
}
if in.mask_idx != 4294967295u {
let mask = masks[in.mask_idx];
// Every mask on the chain, not just the innermost: a widget that set
// its own mask inside another is clipped by both, and each carries its
// own move slot (`Mask::parent` in data.rs).
var mask_idx = in.mask_idx;
for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) {
if mask_idx == 4294967295u {
break;
}
let mask = masks[mask_idx];
let mask_delta = resolve_move(mask.move_idx);
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
@@ -207,6 +218,7 @@ fn fs_main(
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
color *= 0.0;
}
mask_idx = mask.parent;
}
return color;
}
+52 -4
View File
@@ -53,8 +53,11 @@ impl<'a> Painter<'a> {
}
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One per widget: a second call would need the two to be
/// intersected, which nothing here does.
/// `region`. One call per widget; a widget drawn inside another
/// widget's mask nests instead -- the new mask chains to the inherited
/// one (`Mask::parent`) and the fragment stage requires a pixel to be
/// inside both, which is what lets a transcript row's code fence clip
/// to itself *and* to the list it scrolls inside.
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
@@ -62,24 +65,69 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
debug_assert!(
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
"set_mask called twice while drawing one widget: the second would replace the first \
rather than nest inside it",
);
let parent = self.mask;
let mask = Mask {
region,
move_idx: self.move_slot,
parent,
};
if self.own_mask == MaskIdx::NONE {
let old_parent = if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
MaskIdx::NONE
} else {
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
old
};
// The chain link's own ref, taken before the old one is dropped so
// that re-chaining to the same slot cannot free it in between.
// Released here when the link changes, and in
// `UiRenderState::remove` when this widget's slot goes.
if old_parent != parent {
if parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(parent);
}
if old_parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.remove(old_parent);
}
}
self.mask = self.own_mask;
}
/// Ask for this widget to be drawn again on the next frame, from
/// inside its own `draw` -- for a layout that can only discover a
/// correction to itself by laying out once (`List::clamp_to_content`,
/// which learns how far past its content the list is from the walk it
/// has just done). The mark is the same one `Widgets::get_dyn_mut`
/// sets, so `UiRenderState::update` picks it up exactly as it does any
/// other dirty widget; it does **not** by itself ask the platform for
/// a frame, which is the caller's own `RequestRedraw` handle.
///
/// The correction it asks for must converge, or this is a widget that
/// redraws forever.
pub fn draw_again(&mut self) {
self.rsc.widgets_mut().needs_redraw.insert(self.id);
}
/// Whether anything is clipping what this widget draws -- its own
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
/// a widget whose contents may legitimately extend past its own box
/// (`iris::widget::List`, which draws a row straddling an edge in
/// full) asserts before relying on being cut off there.
pub fn is_masked(&self) -> bool {
self.mask != MaskIdx::NONE
}
/// Draws a widget within this widget's region, returning the size it
/// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
+11 -5
View File
@@ -324,10 +324,9 @@ impl UiRenderState {
// own new one -- and `ActiveData::mask`'s only consumer is
// `redraw`, which feeds it back in as the *inherited* mask. Storing
// the set one instead handed a `Masked` its own mask on every
// targeted redraw, tripping `set_mask`'s nested-mask assert:
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
// first time the composer's scroll area was redrawn on the
// emulator.
// targeted redraw -- an abort the first time the composer's scroll
// area was redrawn on the emulator, and now (masks nest) a mask
// whose parent is itself, which `set_mask`'s own assert names.
let inherited_mask = mask;
let mut painter = Painter {
state: self,
@@ -514,8 +513,15 @@ impl UiRenderState {
// section 2's lifecycle note).
if active.own_mask != MaskIdx::NONE {
// The self-ownership ref `Painter::set_mask` took when
// it allocated this widget's own mask slot.
// it allocated this widget's own mask slot, and the
// chain link's ref on the mask this one nests inside
// -- read from the arena entry, for the same reason
// the move slot's parent is.
let outer = rsc.ui().masks[active.own_mask.idx()].parent;
rsc.ui_mut().masks.remove(active.own_mask);
if outer != MaskIdx::NONE {
rsc.ui_mut().masks.remove(outer);
}
}
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot);
+2 -2
View File
@@ -150,8 +150,8 @@ fn hit_testing_follows_a_scrolled_widget() {
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
/// it set for itself -- `redraw` feeds it straight back in as the inherited
/// mask, so storing the set one hands a `Masked` its own mask the second
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
/// time round -- which `Painter::set_mask` asserts against, since a mask
/// that chains to itself is a clip loop. That was an abort the first time
/// the composer's new scroll area was redrawn on the emulator; a targeted
/// redraw of a `Masked` is what any real screen does whenever anything
/// inside it changes.
+299 -29
View File
@@ -94,11 +94,22 @@
//! row that has never been measured, so this stays independent of how many
//! rows exist outside the loaded window.
//!
//! **What is deliberately not solved here.** No overscroll clamping: a
//! `scroll()` past the first or last row leaves a gap rather than rubber-
//! banding back (mirrors `Scroll`'s own documented one-frame-lag
//! tolerance in LAYOUT.md, just not even auto-corrected -- there is
//! nothing to measure "how much content is left" without walking it).
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
//! One rule, `intersects_viewport`, used by both halves of that sentence:
//! a row straddling either edge is drawn in full and clipped by the
//! `.masked()` its caller must place it in (`List::draw` asserts that),
//! and a row that has left the viewport is not drawn at all. The walk
//! still traverses whatever lies between the anchor and the viewport, and
//! `rehome_anchor` moves the anchor back onto a visible row every frame so
//! that "whatever lies between" stays empty however far the list is
//! panned.
//!
//! **Overscroll is taken back on the next frame, never rubber-banded.** A
//! `scroll()` or a fling past the first or last row leaves a gap for one
//! frame; `clamp_to_content` measures it from the ends the walk already
//! placed and gives it back (the same one-frame-lag `Scroll`'s content
//! length has, per LAYOUT.md). A list shorter than its viewport is not
//! overscrolled and is left alone, still bottom-anchored.
use crate::prelude::*;
use iris_core::util::HashMap;
@@ -415,8 +426,9 @@ impl List {
/// Move the anchor's edge by `amt` pixels. Positive moves later
/// content into view (mirrors `Scroll::scroll`'s sign convention).
/// Deliberately unclamped -- see the module doc's "what is not
/// solved here."
/// Unclamped here, on purpose: it is one write, and there is nothing
/// at this point that knows where the content ends. The next `draw`
/// gives back whatever this moved past ([`Self::clamp_to_content`]).
pub fn scroll(&mut self, amt: f32) {
if let Some(a) = &mut self.anchor {
a.offset -= amt;
@@ -790,6 +802,115 @@ impl List {
}
}
/// Move the anchor onto a row that is actually on screen, without
/// moving anything that is drawn: the row it re-homes to keeps the
/// exact top edge this frame's layout gave it.
///
/// [`Self::scroll`] moves the anchor's *offset* and nothing else, so
/// panning away from the anchor's own row leaves that row further and
/// further outside the viewport, and every row between it and the
/// viewport has to be walked on every frame from then on -- before
/// `place`'s intersection test, drawn too. Measured on the bench
/// fixture before this: 8 scrolls of 3000px left **64 rows** placed in
/// a 2012px viewport, ~59 of them off-screen, and the ones above it
/// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07).
/// Re-homing each frame makes the walk O(visible) again whatever
/// distance was travelled, which is what the module doc claims.
///
/// Only when the anchor's own row has left the viewport, so
/// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom
/// edge at the viewport's own bottom, which intersects it -- is left
/// exactly as it is rather than rewritten into a top-edge anchor that
/// no longer reads as flush with the end.
fn rehome_anchor(&mut self) {
let Some(anchor) = self.anchor else {
return;
};
if self.extents.values().any(|e| e.slot == anchor.slot) {
return;
}
// The topmost row on screen, so the anchor's offset stays a small
// number near the viewport's own leading edge rather than
// whatever the last row's bottom happens to be.
let Some(first) = self
.extents
.values()
.min_by(|a, b| a.top.total_cmp(&b.top))
.copied()
else {
// Nothing on screen at all -- a list scrolled past its own
// content (`scroll` is deliberately unclamped). There is no
// on-screen row to re-home to, and inventing one would move
// the list; leave the anchor where it is and let the next
// scroll or `repair_anchor` bring content back.
return;
};
self.anchor = Some(Anchor {
slot: first.slot,
edge: Edge::Top,
offset: first.top,
});
}
/// Take back an empty band at one edge that content on the other side
/// of the viewport could fill -- the correction that makes a `scroll`
/// or a fling past the end of the content settle *on* the end rather
/// than beyond it.
///
/// `top`/`bottom` are the extreme edges this frame's walk actually
/// placed, so the gap is already measured: `at_start` means nothing is
/// above `top`, and if `top` is nevertheless below the viewport's own
/// leading edge then those pixels are empty and always will be. This
/// is the whole of what the module doc used to list as deliberately
/// unsolved ("no overscroll clamping ... nothing to measure how much
/// content is left without walking it") -- true of *total* content
/// height, but the walk hands back both ends of the loaded run for
/// free, which is all a clamp needs. `tick_fling` stops a fling that
/// has reached an end, but stops it wherever the spline's last step
/// had already put it: a hard fling to the top of the bench fixture
/// left the first row **1398px below** a 600px viewport, i.e. the
/// whole screen blank, and it stayed there (docs/IRIS_TODO.md,
/// 2026-09-07: "black from the header down").
///
/// **Only when the opposite end is not also inside the viewport.**
/// Both at once means the content is shorter than the viewport, where
/// the space is not overscroll at all -- it is a bottom-anchored list
/// with three rows in it, and pulling those to the top would be this
/// widget rejecting its own default (`repair_anchor`).
///
/// Applied to the anchor, so it lands on the *next* frame rather than
/// re-running this one: the same one-frame-lag `Scroll` accepts for
/// its content length, and one frame is 8ms on the phone.
fn clamp_to_content(&mut self, painter: &mut Painter, top: f32, bottom: f32) {
if self.at_start == self.at_end {
return;
}
// `at_start`/`at_end` already carry the sign of their own gap
// (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap
// itself, positive to move content toward the leading edge.
let gap = if self.at_start {
top
} else {
bottom - self.viewport_len
};
// Sub-pixel gaps are what floating-point row heights leave behind
// every frame; correcting one would ask for another frame, which
// would leave another, and the list would never stop redrawing.
if gap.abs() < 0.5 {
return;
}
self.scroll(gap);
// Nothing else will ask: the frame this correction was discovered
// in has already been laid out, and a fling that ran out at an end
// (`tick_fling`'s `hit_bound`) has stopped requesting frames --
// which is exactly the case that left the list parked past its own
// first row.
painter.draw_again();
if let Some(redraw) = &self.redraw {
redraw.request_redraw();
}
}
fn update_snap_end(&mut self) {
self.snap_end = match self.anchor {
Some(a) => {
@@ -801,6 +922,22 @@ impl List {
};
}
/// **The one rule for what this list draws**: a row is on screen if
/// any part of it is, so a row straddling either edge is drawn *in
/// full* and one that has left the viewport entirely is not drawn at
/// all. Both halves matter and they failed in opposite directions on
/// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled
/// past were still being drawn, over the header above the list, and
/// the part of a straddling row above the viewport had nothing
/// clipping it. The viewport here is the list's own box -- `0 ..
/// viewport_len`, `painter.region()` in window terms -- which is the
/// same box `List::draw` requires a mask on, so that what this test
/// admits and what the clip keeps are one region rather than two that
/// can disagree.
fn intersects_viewport(&self, top: f32, bottom: f32) -> bool {
bottom > 0.0 && top < self.viewport_len
}
fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion {
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
UiRegion::from_axis(axis, span, UiSpan::FULL)
@@ -865,6 +1002,26 @@ impl List {
let key = self.slot_key(slot);
let cached = key.and_then(|k| self.heights.get(&k).copied());
// A row entirely outside the viewport is traversed but not drawn
// -- see `intersects_viewport`. The walk still has to *pass
// through* it, because its height is what says where the rows
// behind it land, but nothing about it reaches the screen, so
// drawing it costs a redraw (and, unclipped, paints over whatever
// is above the list) for content nobody can see. Only possible
// for a row whose height is already known: a first-time row has
// to be drawn to be measured at all, which is why the extent
// below is recorded from the intersection test rather than from
// "was this drawn".
if let Some(h) = cached {
let (top, bottom) = match placement {
Placement::Top(top) => (top, top + h),
Placement::Bottom(bottom) => (bottom - h, bottom),
};
if !self.intersects_viewport(top, bottom) {
return (top, bottom);
}
}
let (top, bottom, height) = match (placement, cached) {
(Placement::Top(top), Some(h)) => {
// Offered a box sized to the *cached* height (cheap to
@@ -928,7 +1085,13 @@ impl List {
};
if let Some(k) = key {
self.heights.insert(k, height);
self.extents.insert(k, RowExtent { slot, top, bottom });
// `extents` is what is *on screen* (`key_at`'s doc, and
// `rehome_anchor` below reads it as exactly that), so a
// first-time row that had to be drawn to be measured and
// turned out to be off-screen does not go in it.
if self.intersects_viewport(top, bottom) {
self.extents.insert(k, RowExtent { slot, top, bottom });
}
}
(top, bottom)
}
@@ -961,6 +1124,21 @@ impl Widget for List {
// density, and `draw` is where this widget meets the only thing
// that knows it. See `fling`.
self.density = painter.density();
// A row that straddles either edge is drawn in full
// (`intersects_viewport`), so the part of it outside this list's
// box is on screen unless something clips it -- and with nothing
// clipping it, a transcript panned to its top edge drew code and
// paragraphs straight through the header bar above it on Iris's
// phone (docs/IRIS_TODO.md, 2026-09-07). Clipping is `.masked()`,
// one mechanism, applied by whoever places the list -- a `List`
// cannot set the mask itself, since `Painter::set_mask` allows one
// mask per widget and rows of this list already use their own
// (`transcript-ui`'s `row.rs`, `tool.rs`). So it checks instead.
debug_assert!(
painter.is_masked(),
"a `List` must be drawn inside something `.masked()`: it draws rows straddling both \
edges in full, so the parts outside its own box reach the screen otherwise",
);
let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
@@ -1013,6 +1191,23 @@ impl Widget for List {
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
// Both halves of `intersects_viewport`'s rule, checked where they
// are cheap to check: what this frame put on screen is exactly
// what overlaps the viewport, and nothing above or below it can
// be seen. The first failed silently for a whole build -- an
// off-screen row draws correctly, it is just in the wrong place.
debug_assert!(
self.extents
.values()
.all(|e| self.intersects_viewport(e.top, e.bottom)),
"a row outside the viewport (0..{}) is recorded as on screen: {:?}",
self.viewport_len,
self.extents
.values()
.find(|e| !self.intersects_viewport(e.top, e.bottom)),
);
self.rehome_anchor();
self.clamp_to_content(painter, top, bottom);
self.update_snap_end();
Size::REST
}
@@ -1068,9 +1263,59 @@ mod tests {
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
/// draws.
///
/// The root is a `Masked` around the list rather than the list
/// itself, because that is what every real caller has to do -- a
/// `List` draws the row straddling each edge in full and asserts
/// something is clipping it (`List::draw`). The mask is the full
/// window here, which is also the list's own box.
fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget<List>, StrongWidget) {
let strong = rsc.ui.widgets.add_strong(list);
(strong.weak(), strong.any())
let weak = strong.weak();
let root = rsc.ui.widgets.add_strong(Masked {
inner: strong.any(),
});
(weak, root.any())
}
/// The case the top-edge cull and the overscroll clamp both had no
/// reason to touch: fewer rows than fit. Every one of them is drawn
/// (nothing here is outside the viewport), and `clamp_to_content`
/// leaves the list bottom-anchored -- the gap above the first row is
/// not overscroll, it is where this widget puts a short list, and
/// pulling it to the top would be the clamp overriding
/// `repair_anchor`'s own default.
#[test]
fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
// Several frames, since the clamp acts on the frame *after* the
// one that measured a gap: a wrong one would walk the rows up the
// screen 40px at a time rather than settle.
for _ in 0..4 {
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(
list_ref.extents.len(),
3,
"every row of a short list is on screen"
);
let first = list_ref.extents[&0];
let last = list_ref.extents[&2];
assert!(
(first.top - 40.0).abs() < 0.01 && (last.bottom - 100.0).abs() < 0.01,
"a 60px list in a 100px viewport moved off the bottom: rows {}..{}",
first.top,
last.bottom,
);
}
}
#[test]
@@ -1142,7 +1387,7 @@ mod tests {
bg_ids.push(bg_id);
list.push_back(ListRow::new(key, row));
}
let root = rsc.ui.widgets.add_strong(list).any();
let (_, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
@@ -1323,7 +1568,12 @@ mod tests {
render.update(&root, &mut rsc);
render.take_counters();
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
// Backwards, into content that exists: a list opens flush with
// its newest end, so scrolling *forward* from there is
// overscroll, and `clamp_to_content` lays out a second time to
// give it back -- a correct extra pass, but not the ordinary
// scroll tick whose cost this test is about.
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters();
@@ -1616,9 +1866,28 @@ mod tests {
/// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling
/// tests below.
/// How far a `build_flingable_list` list has scrolled from its very
/// first row, in pixels: read off the topmost row on screen, whose
/// content position is exactly `slot * ROW_H` because every row there
/// is that tall. Measures the list's own accumulated movement (the
/// thing `scroll`/`tick_fling` write) rather than the spline's
/// bookkeeping, and unlike a single row's extent it stays defined
/// however far the list travels -- `extents` holds only what is
/// on screen (`List::intersects_viewport`).
fn scroll_position(list: &List) -> f32 {
let top = list
.extents
.values()
.min_by(|a, b| a.top.total_cmp(&b.top))
.expect("something is on screen");
top.slot as f32 * FLING_ROW_H - top.top
}
const FLING_ROW_H: f32 = 20.0;
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
let mut list = List::new(Axis::Y);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
let (list_weak, root) = add_list(rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 600.0));
@@ -1764,32 +2033,22 @@ mod tests {
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
let start = Instant::now();
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
let mut prev = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
let mut deltas = Vec::new();
for step in 1..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
let Some(top) = rsc
.ui
.widgets
.get(&list_weak)
.unwrap()
.extents
.get(&0)
.map(|e| e.top)
else {
break; // row 0 scrolled out of the loaded extents
};
deltas.push((prev_top - top).abs());
prev_top = top;
let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
deltas.push((at - prev).abs());
prev = at;
if !still {
break;
}
}
assert!(
deltas.len() >= 3,
"fling settled or left row 0's extent before collecting enough samples"
"fling settled before collecting enough samples"
);
// Skip the first tick (the slop-transition jump the arbiter
// applies is a `List::fling`-adjacent concern, not this curve,
@@ -1871,15 +2130,26 @@ mod tests {
break;
}
}
// The frame that gives back whatever the fling's last step spent
// past the first row -- `clamp_to_content` writes the anchor at
// the end of a draw, so it lands on the next one.
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(
list_ref.at_start,
"fling should have clamped at the first row"
);
// Not `>= -0.5`: `extents` used to hold every row the walk placed,
// on screen or not, so that read was satisfied by a first row
// sitting *1398px below* a 600px viewport with the whole screen
// blank -- the assertion could not fail in the direction the bug
// actually went. Both edges, so neither an overshoot past the top
// nor one left uncorrected can pass.
let first = list_ref.extents[&0];
assert!(
first.top >= -0.5,
"clamped fling overshot the first row's top: {}",
first.top.abs() < 0.5,
"a fling stopped at the start must leave the first row flush with the top, not {}px \
from it",
first.top
);
}
+229
View File
@@ -0,0 +1,229 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for the transcript's
//! own edges: the real screen over the real fixture, under a header bar
//! like the bench app's, driven by `iris::harness`.
//!
//! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report --
//! rows scrolled above the viewport still drawn, over the header, and a
//! blank band where the row straddling the top edge should be. Both are
//! one rule (`List::intersects_viewport`): a row is drawn if any part of
//! it is inside the list's own box, and nothing outside that box reaches
//! the screen.
use iris::harness::Harness;
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// A header band above the transcript, as `bench_client.rs` puts one --
/// the surface the rows were drawing over on the phone. Its exact height
/// does not matter; what matters is that the list's own box does not
/// start at the top of the window, so "above the viewport" and "off the
/// screen" are different places.
const HEADER_H: f32 = 300.0;
const HEADER: UiColor = UiColor::new(28, 28, 34, 255);
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let (opened, tree) = transcript_fixture::build_screen(&mut h.rsc).expect("the fixture folds");
let content = WidgetPtr::new().add(&mut h.rsc);
content(&mut h.rsc).set(tree);
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(&mut h.rsc)
.any();
h.state.set_root(root);
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
/// The list's own on-screen box, in window pixels.
fn list_box(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> PixelRegion {
h.render
.window_region(&screen.list.id(), &h.rsc)
.expect("the list is on screen")
}
/// Every row the list drew this frame, as `(top, bottom)` window pixels,
/// topmost first. A `List`'s direct children are exactly its rows, and
/// `draw_inner`'s old-children diffing means a row it did not place this
/// frame is not among them.
fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> {
let mut rows: Vec<(f32, f32)> = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.iter()
.filter_map(|id| h.render.window_region(id, &h.rsc))
.map(|px| (px.top_left.y, px.bot_right.y))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows
}
/// Scrolls `amount` (negative walks back through older rows) and runs the
/// frame it asks for, returning the time of the next one.
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
(screen.list)(&mut h.rsc).scroll(amount);
h.frame(t);
t + PHONE_FRAME_MS
}
/// (a) of docs/IRIS_TODO.md's reproduction: with a row across the top
/// edge, that row is placed -- the viewport's first pixel belongs to
/// something. A rule that culled a row once its *top* left the viewport
/// would leave a blank band here, which is the second of Iris's two
/// screenshots.
#[test]
fn the_row_across_the_top_edge_is_drawn() {
let (mut h, screen) = opened();
let top = list_box(&h, &screen).top_left.y;
let mut t = PHONE_FRAME_MS * 2;
// 40px a frame, the shape a finger pan arrives in, through a straddle
// and out the other side of it many times over.
for _ in 0..60 {
t = scrolled(&mut h, &screen, -40.0, t);
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("something is on screen");
assert!(
first.0 <= top + 0.5,
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
at {top:.1}",
first.0 - top,
first.0,
);
assert!(
first.1 > top,
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
{top:.1}",
first.1,
);
}
}
/// (b): what falls outside the list's box is clipped rather than drawn
/// over whatever is there. The straddling row above is drawn *in full*,
/// so the only thing between its earlier lines and the header bar is this
/// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run
/// benchmark" button.
#[test]
fn the_list_is_clipped_to_its_own_box() {
let (h, screen) = opened();
let active = h.render.active.get(&screen.list.id()).expect("drawn");
assert!(
active.mask != MaskIdx::NONE,
"the transcript's list is drawn with nothing clipping it",
);
let clip = h.rsc.ui.masks[active.mask.idx()].region.to_px(h.size());
let list = list_box(&h, &screen);
assert!(
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
edge still draws past it",
);
}
/// A row that has left the viewport entirely is not drawn at all. Before
/// the fix the walk ran from the anchor -- which `scroll` leaves wherever
/// it was, however far outside the viewport that ends up -- and drew
/// every row on the way: 8 scrolls of 3000px left **64 rows** placed for
/// a 2012px viewport, ~59 of them off screen and painting over the
/// header.
///
/// Asserted strictly on the way *back*, because a row whose height has
/// never been measured has to be drawn to be measured (`List::place`'s
/// doc), which on the outbound leg is every row entering from the top.
/// The return leg crosses the same rows with every height already known,
/// which is also the ordinary state of a transcript being panned around
/// in. The bound on how many rows are placed at once holds on both.
#[test]
fn rows_that_have_left_the_viewport_are_not_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
// A handful of rows whatever distance has been travelled -- the
// module doc's own claim about this widget.
assert!(
rows.len() <= 24,
"{leg} {step}: {} rows drawn for one 2012px viewport",
rows.len(),
);
};
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
bounded(&drawn_rows(&h, &screen), "back", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step);
for &(top, bottom) in &rows {
assert!(
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
"forward {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
{list:?} and was drawn anyway",
);
}
}
}
/// The end the fix had no reason to touch: the row across the *bottom*
/// edge, where the composer starts. Same rule, other direction -- and the
/// list opens pinned there, so this is the ordinary state of the screen
/// rather than a scrolled-to one.
#[test]
fn the_row_across_the_bottom_edge_is_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 {
t = scrolled(&mut h, &screen, -37.0, t);
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen");
assert!(
last.1 >= list.bot_right.y - 0.5,
"a band of {:.1}px above the composer belongs to no row",
list.bot_right.y - last.1,
);
assert!(
last.0 < list.bot_right.y,
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
{:.1}",
last.0,
list.bot_right.y,
);
}
}
/// Panning past the first row settles *on* it rather than beyond it. The
/// list is scrolled far further back than the fixture is long, which is
/// what a hard fling toward the top does; before `List::clamp_to_content`
/// it stayed wherever that left it -- the phone's "black from the header
/// down", and a whole blank screen in `iris`'s own
/// `fling_toward_the_start_stops_at_the_first_row`.
#[test]
fn scrolling_past_the_first_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, -100_000.0, t);
}
// The correction is written at the end of a draw and lands on the
// next one.
h.frame(t);
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("the first row is on screen");
assert!(
(first.0 - list.top_left.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
first.0 - list.top_left.y,
);
}
+6 -5
View File
@@ -90,11 +90,12 @@ where
// where the bar actually was.
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
// clip that keeps six lines' worth of a longer message inside the
// bar. The mask is the caller's job rather than `Scroll`'s own,
// because `Painter::set_mask` allows exactly one mask per widget and
// a `Scroll` nested under another masked area would abort on the
// second -- `.masked()` is the one mechanism for clipping and this is
// one more use of it (tabs-ui's message area is the other).
// bar. The mask is the caller's job rather than `Scroll`'s own:
// `.masked()` is the one mechanism for clipping and this is one more
// use of it (tabs-ui's message area is the other). A `Scroll` nested
// under another masked area used to abort here; since 2026-09-07 the
// inner mask chains to the outer one (`Mask::parent`) and the content
// is clipped by both.
// Without it the overflow paints *above* the bar, over the
// transcript: measured before this change at 58px of stray text for a
// 475px message in a 417px box.
+7 -1
View File
@@ -422,7 +422,13 @@ where
let (composer, composer_bar) = composer::build_composer(rsc);
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
// `.masked()`: the list draws the row straddling each of its edges in
// full (`List::intersects_viewport`), so without a clip the top of
// that row is drawn above the list -- through whatever the app put
// there, which on the phone is the header bar (docs/IRIS_TODO.md,
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
// The same clip is what `List::draw` asserts it has.
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
+119
View File
@@ -62,6 +62,9 @@
//! once the account's usage limit lifts
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
//! POST /client-log {source, lines} -- a client's own recent log,
//! re-emitted into this server's log (see
//! `client_log`; the phone has no logcat)
//! GET /defaults {effort} -- what a new session starts at
//! POST /defaults {effort} -- null for the CLI's own default
//! GET /usage cached usage windows per provider
@@ -158,6 +161,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
.route("/sessions/{id}/effort", post(set_effort))
.route("/defaults", get(defaults).post(set_defaults))
.route("/client-log", post(client_log))
.route("/sessions/{id}/notify", post(set_notify))
.route("/sessions/{id}/auto-resume", post(set_auto_resume))
.route("/notifications", get(notifications))
@@ -1407,6 +1411,121 @@ async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defa
})
}
/// How many lines one `POST /client-log` may carry, matching
/// `client_core::log_upload::MAX_LINES_PER_BATCH`. A client that sends more
/// is refused rather than silently shortened: a log with a hole in it that
/// nothing mentions is worse than a rejected batch the client retries.
const CLIENT_LOG_MAX_LINES: usize = 500;
/// The tracing target every re-emitted client line carries.
///
/// **Under `ai_server::`, deliberately.** A bare `client_log` target is
/// filtered out by `RUST_LOG=ai_server=debug` -- the exact filter
/// AGENTS.md tells people to run with -- so every line a phone sent would
/// vanish with nothing saying so. Under the crate's own path it is on
/// wherever the server's own lines are, which is the only filter its
/// reader knows about.
const CLIENT_LOG_TARGET: &str = "ai_server::client_log";
/// One line of a client's own log. `at` is that client's clock, not this
/// machine's -- see [`client_log`].
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogLine {
/// The client's ring sequence. Carried so a gap -- lines its bound
/// dropped -- is visible here rather than looking like a quiet client.
seq: u64,
/// Milliseconds since the unix epoch, from the client's own clock.
at: u64,
level: String,
target: String,
message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogBody {
/// Which client this is -- an app name and build, not a device
/// identifier. It is what tells two phones' lines apart in the log.
source: String,
lines: Vec<ClientLogLine>,
}
/// Takes a client's own recent log lines and re-emits them into this
/// server's `tracing` output.
///
/// **Why a route rather than something on the phone**: Android forbids one
/// app reading another's `logcat`, and the phone this project is tested on
/// has no `adb` at all, so a `log::info!` in the app can only reach a
/// person if the app carries its own copy and sends it somewhere. This
/// server is the somewhere it already has a tunnel, a pinned CA and a
/// bearer token for -- and Dev Updater already shows this server's log as
/// its runtime log, so the line arrives where its reader is already
/// looking with nothing new built there. `docs/DECISIONS.md`, 2026-09-07.
///
/// Each line is emitted separately, at the level the client recorded it
/// at, with the client's own timestamp in the text -- the tracing
/// subscriber stamps the moment of *arrival*, which can be minutes later
/// or on the other side of a tunnel outage, and presenting that as when it
/// happened would be an inferred value shown as a measured one.
async fn client_log(axum::Json(body): axum::Json<ClientLogBody>) -> Result<StatusCode, ApiError> {
if body.lines.len() > CLIENT_LOG_MAX_LINES {
return Err(ApiError::BadRequest(format!(
"{} lines in one batch; the limit is {CLIENT_LOG_MAX_LINES}",
body.lines.len()
)));
}
for line in &body.lines {
let at = client_log_time(line.at);
let source = &body.source;
let target = &line.target;
let seq = line.seq;
let message = &line.message;
// The level is chosen here rather than passed, because a tracing
// macro's level is part of the callsite. Anything unrecognised is
// reported at INFO with the word it sent kept, so a client using a
// level this server has not heard of loses the level rather than
// the line.
match line.level.to_ascii_uppercase().as_str() {
"ERROR" => {
tracing::error!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"WARN" => {
tracing::warn!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"DEBUG" => {
tracing::debug!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"TRACE" => {
tracing::trace!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"INFO" => {
tracing::info!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
other => {
tracing::info!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: <{other}> {message}")
}
}
}
Ok(StatusCode::NO_CONTENT)
}
/// `HH:MM:SS.mmm` UTC from the client's unix milliseconds -- the same
/// formatting `client_core::log_ring` uses, so a line read here and the
/// same line in the app's own copied report say the same time.
fn client_log_time(at_ms: u64) -> String {
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
at_ms % 1000
)
}
/// Sets what a new session's thinking level is. Applied when a session is
/// spawned, so nothing already running changes underneath anybody.
async fn set_defaults(