Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 |
No files matched your search
Generated
+1
@@ -83,6 +83,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
Generated
+1
@@ -47,6 +47,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()`
|
||||
|
||||
Generated
+1
@@ -721,6 +721,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
Generated
+1
@@ -745,6 +745,7 @@ name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user