Author SHA1 Message Date
irisandClaude Fable 5.1 9ed01e2812 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity, input/timing report; queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:04:34 -04:00
irisandClaude Fable 5.1 5be9f1baac iris-android-app: keep the app's own log, put it in Copy report, upload it
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.

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

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

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

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

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

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

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

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

No files matched your search

+1
View File
@@ -83,6 +83,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+1
View File
@@ -47,6 +47,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+5
View File
@@ -37,6 +37,11 @@ ureq = { version = "3", features = ["json"] }
# the same parser at the same version, rather than a hand-written splitter
# that would drift from it.
pulldown-cmark = "0.13.4"
# The logging facade only -- `log_ring` implements a `log::Log` backend and
# wraps whichever real one the platform installed (`android_logger` on the
# phone, `env_logger` on the desktop), which is why neither of those is a
# dependency here. See `log_ring`'s module doc.
log = { version = "0.4.28", features = ["std"] }
[dev-dependencies]
+2
View File
@@ -8,6 +8,8 @@ pub mod config;
pub mod durations;
pub mod event_stream;
pub mod highlight;
pub mod log_ring;
pub mod log_upload;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
+514
View File
@@ -0,0 +1,514 @@
//! The app's own recent log, held in memory so it can be read back
//! without `logcat`.
//!
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
//! no `adb`, and Android forbids one app reading another's logcat, so
//! nothing outside the process can recover what it wrote. The only way a
//! line reaches her is for the app to carry its own copy. This is that
//! copy: a bounded ring every `log::info!` in the process lands in, on top
//! of whichever platform logger was already installed (`android_logger`,
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
//!
//! Two consumers, both reading the same ring rather than each keeping
//! their own: the bench app's `Copy report`/`Diagnostics` (which reads
//! [`LogRing::to_text`] and [`LogRing::summary`]) and the uploader in
//! [`crate::log_upload`] (which reads [`LogRing::since`]). That is why
//! reading does not consume: a line the uploader has sent must still be in
//! the report, and a report taken twice must say the same thing.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// How many lines a default ring holds, and how many bytes of message.
///
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
/// only by bytes can be emptied by a single line.
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
/// One recorded line. `seq` is assigned by the ring and only ever
/// increases, so a reader that remembers where it got to can ask for what
/// came after -- and a gap in the sequence is exactly the lines the bound
/// dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
/// Milliseconds since the unix epoch, from the app's own clock. The
/// app's rather than the receiver's: a line is timestamped when it
/// happened, and an upload can be minutes later or never.
pub at_ms: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
impl LogLine {
/// Roughly what the line costs the ring. The two `String`s dominate;
/// the fixed fields are counted as a flat overhead so a ring of empty
/// messages still has a bound.
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
/// `12:34:56.789 INFO iris::android: the message`, the shape a
/// person skims. Time of day only -- the date is in the report's own
/// header, and a ring never spans one.
pub fn format(&self) -> String {
format!(
"{} {:<5} {}: {}",
clock_time(self.at_ms),
self.level,
self.target,
self.message
)
}
}
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
/// library: the only field this needs is the time of day, and dividing out
/// the day is the whole calculation. Deliberately not local time -- the
/// phone's offset is not knowable here, and a report that says UTC is
/// comparable with the server's log, which is what it gets read against.
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
ms
)
}
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
/// the app down for.
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug)]
struct Inner {
lines: VecDeque<LogLine>,
bytes: usize,
max_lines: usize,
max_bytes: usize,
next_seq: u64,
/// How many lines the bounds have discarded since the ring was made.
/// Reported rather than inferred, so "the log starts here" and "the
/// log was cut off here" are distinguishable -- the unknown state the
/// UI rules ask for.
dropped: u64,
}
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
/// there is one per process and every holder sees the same lines.
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
impl LogRing {
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
assert!(
max_lines > 0 && max_bytes > 0,
"a ring with no room holds nothing"
);
Self(Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
bytes: 0,
max_lines,
max_bytes,
next_seq: 0,
dropped: 0,
})))
}
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
/// [`DEFAULT_MAX_BYTES`].
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
/// A poisoned lock is a bug in a panicking logger, not a reason to
/// take the app down a second time -- the ring is a diagnostic, and
/// losing it must not be worse than the fault it was recording.
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
/// Records a line, evicting the oldest until both bounds hold again.
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
seq: inner.next_seq,
at_ms: now_ms(),
level,
target: target.to_string(),
message,
};
inner.next_seq += 1;
inner.bytes += line.weight();
inner.lines.push_back(line);
// `!is_empty()` rather than `len() > 1`: one line larger than
// the whole byte bound is kept, because dropping it would
// leave the ring silently empty while lines were arriving.
while inner.lines.len() > inner.max_lines
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
{
if let Some(evicted) = inner.lines.pop_front() {
inner.bytes -= evicted.weight();
inner.dropped += 1;
}
}
})
}
/// Every line held, oldest first.
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
/// The lines with a sequence number at or after `seq`, oldest first,
/// and the sequence to ask from next time. Does not consume: see this
/// module's doc for why.
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
.lines
.iter()
.filter(|line| line.seq >= seq)
.cloned()
.collect();
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
(lines, next)
})
}
pub fn len(&self) -> usize {
self.with(|inner| inner.lines.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn dropped(&self) -> u64 {
self.with(|inner| inner.dropped)
}
/// When the newest line was written, in unix milliseconds, or `None`
/// for a ring nothing has been written to.
pub fn last_at_ms(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
/// Every line held, formatted one per line -- what `Copy report`
/// appends.
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n")
}
/// One line for a diagnostics pane: how much is held, how much was
/// dropped, and when the last line arrived. "no lines yet" is its own
/// wording rather than a count of zero with a made-up time, because
/// "nothing has been logged" and "logging is not running" would
/// otherwise look the same.
pub fn summary(&self) -> String {
let (len, dropped, last) = self.with(|inner| {
(
inner.lines.len(),
inner.dropped,
inner.lines.back().map(|line| line.at_ms),
)
});
match last {
None => "app log: no lines yet".to_string(),
Some(at) => {
let dropped = if dropped > 0 {
format!(", {dropped} dropped")
} else {
String::new()
};
format!(
"app log: {len} lines held{dropped}, last {}",
clock_time(at)
)
}
}
}
}
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
/// logger the platform already installs, so nothing that reads the
/// platform's log (`logcat`, a terminal) changes.
///
/// The inner logger is passed in rather than chosen here: `client-core`
/// has no business depending on `android_logger` or `env_logger`, and
/// which one is right is exactly what differs between the two platforms
/// (the sharing rule in AGENTS.md).
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
}
impl RingLogger {
pub fn new(ring: LogRing, inner: Box<dyn log::Log>) -> Self {
Self { ring, inner }
}
}
impl log::Log for RingLogger {
/// True for anything `log`'s own max level lets through: the ring
/// wants everything, even where the platform logger would filter it
/// out. The filter is applied per-logger in [`Self::log`] instead.
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
self.ring
.push(record.level(), record.target(), record.args().to_string());
if self.inner.enabled(record.metadata()) {
self.inner.log(record);
}
}
fn flush(&self) {
self.inner.flush();
}
}
/// Installs a [`RingLogger`] as the process logger and answers the ring it
/// records into.
///
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
pub fn install(
ring: LogRing,
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
) -> Result<(), log::SetLoggerError> {
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner)))?;
log::set_max_level(max_level);
Ok(())
}
/// The one ring this process records into.
///
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
/// anywhere in the binary goes to it. A ring handed around as a parameter
/// would be a *second* answer to "which lines exist" -- the report would
/// show one ring while the logger filled another, and which one a caller
/// got would depend on how far down the call tree it was. The tests above
/// all use their own [`LogRing`], so nothing here needs this to be
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
/// The process's ring, created on first use with the default bounds.
/// Safe to call before [`install_process_logger`] -- it will simply be
/// empty.
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
/// Installs [`process_ring`] as the recording half of the process logger,
/// forwarding to `inner` (the platform's own logger, already configured).
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
/// else is shared.
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
) -> Result<(), log::SetLoggerError> {
install(process_ring().clone(), inner, max_level)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
fn fill(ring: &LogRing, count: usize) {
for n in 0..count {
ring.push(Level::Info, "test", format!("line {n}"));
}
}
#[test]
fn lines_come_back_oldest_first() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 0", "line 1", "line 2"]);
}
#[test]
fn the_line_bound_drops_the_oldest_and_says_how_many() {
let ring = LogRing::new(3, 1 << 20);
fill(&ring, 5);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
assert_eq!(ring.len(), 3);
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
}
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
// Room for 1000 lines but only a few hundred bytes.
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
}
assert!(
ring.len() < 10,
"the byte bound evicted: {} held",
ring.len()
);
assert!(ring.dropped() > 0);
assert!(
ring.snapshot().last().unwrap().message.starts_with('9'),
"and it evicted from the old end"
);
}
/// The case the `len() > 1` guard exists for: one line larger than the
/// whole bound must still be readable, or a ring that is over budget
/// reads as a ring nothing was written to.
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
ring.push(Level::Error, "t", "y".repeat(5000));
assert_eq!(ring.len(), 1);
assert_eq!(ring.dropped(), 0);
}
#[test]
fn sequence_numbers_only_increase_and_survive_eviction() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 5);
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
}
#[test]
fn since_returns_only_what_is_new_and_the_next_cursor() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 3);
let (first, cursor) = ring.since(0);
assert_eq!(first.len(), 3);
assert_eq!(cursor, 3);
let (none, cursor) = ring.since(cursor);
assert!(none.is_empty(), "nothing new yet");
assert_eq!(cursor, 3, "and the cursor does not move");
ring.push(Level::Warn, "test", "later".into());
let (more, cursor) = ring.since(cursor);
assert_eq!(more.len(), 1);
assert_eq!(more[0].message, "later");
assert_eq!(cursor, 4);
}
#[test]
fn reading_does_not_consume() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 2);
let (sent, _) = ring.since(0);
assert_eq!(sent.len(), 2);
assert_eq!(ring.len(), 2, "the report still has them after an upload");
assert_eq!(ring.to_text().lines().count(), 2);
}
#[test]
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
let ring = LogRing::with_defaults();
assert_eq!(ring.summary(), "app log: no lines yet");
assert_eq!(ring.last_at_ms(), None);
assert!(ring.is_empty());
}
#[test]
fn the_summary_names_dropped_lines_only_when_there_are_some() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 2);
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
fill(&ring, 2);
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
}
#[test]
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
// hand rather than against another clock.
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
message: "surface created".into(),
}
.format();
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
/// The forwarding half: a line reaches the ring *and* the logger the
/// platform already had, and one the inner logger filters out is still
/// in the ring.
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
impl Log for Collect {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.1
}
fn log(&self, record: &log::Record) {
self.0.lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
let logger = RingLogger::new(ring.clone(), Box::new(Collect(seen.clone(), Level::Info)));
logger.log(
&log::Record::builder()
.args(format_args!("kept"))
.level(Level::Info)
.target("t")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("filtered"))
.level(Level::Debug)
.target("t")
.build(),
);
assert_eq!(
*seen.lock().unwrap(),
["kept"],
"the inner logger's own filter still applies"
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(held, ["kept", "filtered"], "the ring keeps both");
}
}
+427
View File
@@ -0,0 +1,427 @@
//! Sending [`crate::log_ring`]'s lines to `ai-server`, so a phone with no
//! `logcat` still has a way for a `log::info!` to reach a person.
//!
//! **Where they end up**: `POST /client-log` re-emits each line into
//! `ai-server`'s own `tracing` output, which Dev Updater already shows as
//! that component's *runtime log* (it runs `ai-server` as a `Managed`
//! service, and a managed service's stdout is redirected to a file its
//! service script reports). So this needs no new route, storage or viewer
//! in Dev Updater at all -- see `docs/DECISIONS.md`, 2026-09-07.
//!
//! **Nothing here calls `log!`.** Every line this module logged would land
//! in the ring it is draining and be uploaded, so a server that is down
//! would produce a growing conversation with itself. Failures are recorded
//! in [`UploadStatus`] instead and shown in the app's diagnostics pane,
//! which is where somebody looking for "why is nothing arriving" is
//! already looking (UI_RULES.md: a failure is reported where it happened).
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use crate::api::{ApiError, Body, Transport};
use crate::log_ring::LogRing;
/// The most lines one request carries. A phone that has been offline for
/// an hour has thousands waiting, and one request holding all of them is a
/// body the server has to buffer whole; the rest go in the next batch,
/// which the loop takes immediately rather than after the next interval.
pub const MAX_LINES_PER_BATCH: usize = 500;
/// How much of one message is sent. Long enough for a stack trace line,
/// short enough that one pathological message cannot dominate a batch.
/// Truncation is marked, because a silently shortened line reads as a line
/// that ended there.
pub const MAX_MESSAGE_BYTES: usize = 4096;
/// The route this posts to, on `server/src/routes.rs`'s surface.
pub const CLIENT_LOG_PATH: &str = "/client-log";
/// What the last upload attempt did, for a diagnostics pane. `None` for
/// "nothing has been tried yet", which is deliberately distinct from a
/// success that sent nothing.
#[derive(Debug, Clone, Default)]
pub struct UploadStatus {
pub sent: u64,
pub last_error: Option<String>,
pub attempted: bool,
}
impl UploadStatus {
/// One line for the diagnostics pane, in the same voice as
/// [`LogRing::summary`].
pub fn summary(&self) -> String {
match (&self.last_error, self.attempted) {
(Some(err), _) => format!("log upload: failing -- {err} ({} sent so far)", self.sent),
(None, false) => "log upload: not tried yet".to_string(),
(None, true) => format!("log upload: {} lines sent", self.sent),
}
}
}
/// Drains a [`LogRing`] into `POST /client-log`, remembering how far it
/// got so a line is sent once and stays in the ring for the report.
pub struct LogUploader {
ring: LogRing,
transport: Arc<dyn Transport>,
source: String,
cursor: u64,
status: Arc<Mutex<UploadStatus>>,
}
impl LogUploader {
/// `source` names the build these lines came from -- it is what
/// distinguishes them in `ai-server`'s log from the server's own
/// lines and from another device's.
pub fn new(ring: LogRing, transport: Arc<dyn Transport>, source: impl Into<String>) -> Self {
Self {
ring,
transport,
source: source.into(),
cursor: 0,
status: Arc::new(Mutex::new(UploadStatus::default())),
}
}
/// A handle on what the last attempt did, shareable with the UI.
pub fn status(&self) -> Arc<Mutex<UploadStatus>> {
Arc::clone(&self.status)
}
/// Sends up to [`MAX_LINES_PER_BATCH`] waiting lines. Answers how many
/// went, and whether more are waiting -- the loop uses the second to
/// decide whether to go round again at once.
pub fn flush_once(&mut self) -> Result<(usize, bool), ApiError> {
let (mut lines, mut next) = self.ring.since(self.cursor);
let more = lines.len() > MAX_LINES_PER_BATCH;
if more {
lines.truncate(MAX_LINES_PER_BATCH);
next = lines.last().map(|line| line.seq + 1).unwrap_or(next);
}
if lines.is_empty() {
return Ok((0, false));
}
let body = serde_json::json!({
"source": self.source,
"lines": lines.iter().map(|line| serde_json::json!({
"seq": line.seq,
"at": line.at_ms,
"level": line.level.as_str(),
"target": line.target,
"message": truncate(&line.message),
})).collect::<Vec<_>>(),
});
let result = self
.transport
.request("POST", CLIENT_LOG_PATH, Some(Body::Json(body)));
let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner());
status.attempted = true;
match result {
Ok(response) if (200..300).contains(&response.status) => {
// Only on success: a failed batch is retried from the same
// cursor next time, which is what makes a dropped tunnel
// cost nothing but a delay.
self.cursor = next;
status.sent += lines.len() as u64;
status.last_error = None;
Ok((lines.len(), more))
}
Ok(response) => {
let message = format!("{} from {CLIENT_LOG_PATH}", response.status);
status.last_error = Some(message.clone());
Err(ApiError {
message,
status: Some(response.status),
})
}
Err(err) => {
status.last_error = Some(err.message.clone());
Err(err)
}
}
}
}
/// Cuts a message to [`MAX_MESSAGE_BYTES`] on a character boundary, saying
/// so, rather than letting one line dominate a batch.
fn truncate(message: &str) -> String {
if message.len() <= MAX_MESSAGE_BYTES {
return message.to_string();
}
let mut end = MAX_MESSAGE_BYTES;
while end > 0 && !message.is_char_boundary(end) {
end -= 1;
}
format!("{}… [{} bytes cut]", &message[..end], message.len() - end)
}
/// The background half: a thread that flushes on a timer and on demand.
///
/// Its path out is [`Drop`] -- dropping the handle stops the thread and
/// waits for it, so an app that tears the uploader down does not leave one
/// posting behind it.
pub struct LogUpload {
signal: Arc<(Mutex<Signal>, Condvar)>,
thread: Option<std::thread::JoinHandle<()>>,
status: Arc<Mutex<UploadStatus>>,
}
/// Only "stop": a nudge from [`LogUpload::flush_now`] needs no flag,
/// because the thread's reaction to waking is to flush, and flushing an
/// empty ring costs nothing -- so a spurious wakeup is already correct.
#[derive(Default)]
struct Signal {
stop: bool,
}
impl LogUpload {
/// Starts the loop. `every` is how long it waits between flushes when
/// nobody nudges it -- a compromise between a line arriving promptly
/// and a radio the app woke for one line.
pub fn spawn(
ring: LogRing,
transport: Arc<dyn Transport>,
source: impl Into<String>,
every: Duration,
) -> Self {
let mut uploader = LogUploader::new(ring, transport, source);
let status = uploader.status();
let signal = Arc::new((Mutex::new(Signal::default()), Condvar::new()));
let thread = {
let signal = Arc::clone(&signal);
std::thread::Builder::new()
.name("client-log-upload".into())
.spawn(move || {
loop {
// Keep going while a batch was capped, so a
// backlog drains at once rather than one batch per
// interval.
while let Ok((_, more)) = uploader.flush_once() {
if !more {
break;
}
}
let (lock, condvar) = &*signal;
let state = lock.lock().unwrap_or_else(|e| e.into_inner());
if state.stop {
return;
}
let (state, _) = condvar
.wait_timeout(state, every)
.unwrap_or_else(|e| e.into_inner());
if state.stop {
return;
}
}
})
.expect("spawning the log upload thread")
};
Self {
signal,
thread: Some(thread),
status,
}
}
/// Sends what is waiting now -- what `Copy report` calls, so the lines
/// a person is about to describe are already on the server.
pub fn flush_now(&self) {
let (lock, condvar) = &*self.signal;
let _state = lock.lock().unwrap_or_else(|e| e.into_inner());
condvar.notify_all();
}
pub fn status(&self) -> UploadStatus {
self.status
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
}
impl Drop for LogUpload {
fn drop(&mut self) {
{
let (lock, condvar) = &*self.signal;
let mut state = lock.lock().unwrap_or_else(|e| e.into_inner());
state.stop = true;
condvar.notify_all();
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::RawResponse;
use log::Level;
/// Records every body posted, and answers whatever status the test set.
struct Fake {
posted: Mutex<Vec<serde_json::Value>>,
status: Mutex<u16>,
}
impl Fake {
fn new() -> Arc<Self> {
Arc::new(Self {
posted: Mutex::new(Vec::new()),
status: Mutex::new(200),
})
}
fn bodies(&self) -> Vec<serde_json::Value> {
self.posted.lock().unwrap().clone()
}
}
impl Transport for Fake {
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError> {
assert_eq!(method, "POST");
assert_eq!(path, CLIENT_LOG_PATH);
if let Some(Body::Json(value)) = body {
self.posted.lock().unwrap().push(value);
} else {
panic!("the client log is posted as JSON");
}
Ok(RawResponse {
status: *self.status.lock().unwrap(),
body: Vec::new(),
})
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
unreachable!("the log uploader never streams")
}
}
fn ring_with(count: usize) -> LogRing {
let ring = LogRing::with_defaults();
for n in 0..count {
ring.push(Level::Info, "t", format!("line {n}"));
}
ring
}
#[test]
fn an_empty_ring_posts_nothing() {
let fake = Fake::new();
let mut uploader = LogUploader::new(LogRing::with_defaults(), fake.clone(), "test");
assert_eq!(uploader.flush_once().unwrap(), (0, false));
assert!(
fake.bodies().is_empty(),
"no request at all, not an empty one"
);
}
#[test]
fn a_line_is_sent_once() {
let fake = Fake::new();
let ring = ring_with(3);
let mut uploader = LogUploader::new(ring.clone(), fake.clone(), "test");
assert_eq!(uploader.flush_once().unwrap().0, 3);
assert_eq!(
uploader.flush_once().unwrap(),
(0, false),
"nothing repeats"
);
ring.push(Level::Warn, "t", "later".into());
assert_eq!(uploader.flush_once().unwrap().0, 1);
assert_eq!(fake.bodies().len(), 2);
assert_eq!(ring.len(), 4, "and the report still holds all of them");
}
/// The half the change had no reason to touch: a server that refuses
/// must not lose the lines.
#[test]
fn a_failed_batch_is_retried_from_the_same_place() {
let fake = Fake::new();
*fake.status.lock().unwrap() = 503;
let mut uploader = LogUploader::new(ring_with(2), fake.clone(), "test");
assert!(uploader.flush_once().is_err());
assert!(
uploader.status().lock().unwrap().last_error.is_some(),
"and it says why, where somebody can see it"
);
*fake.status.lock().unwrap() = 200;
assert_eq!(uploader.flush_once().unwrap().0, 2, "the same two lines");
assert!(uploader.status().lock().unwrap().last_error.is_none());
}
#[test]
fn a_backlog_is_capped_per_batch_and_says_there_is_more() {
let fake = Fake::new();
let ring = LogRing::new(MAX_LINES_PER_BATCH * 3, 1 << 30);
for n in 0..(MAX_LINES_PER_BATCH + 7) {
ring.push(Level::Info, "t", format!("{n}"));
}
let mut uploader = LogUploader::new(ring, fake.clone(), "test");
assert_eq!(uploader.flush_once().unwrap(), (MAX_LINES_PER_BATCH, true));
assert_eq!(uploader.flush_once().unwrap(), (7, false));
}
#[test]
fn the_body_carries_the_source_and_each_line_whole() {
let fake = Fake::new();
let ring = LogRing::with_defaults();
ring.push(Level::Error, "iris::android", "surface lost".into());
LogUploader::new(ring, fake.clone(), "iris-bench 1.2")
.flush_once()
.unwrap();
let body = &fake.bodies()[0];
assert_eq!(body["source"], "iris-bench 1.2");
let line = &body["lines"][0];
assert_eq!(line["level"], "ERROR");
assert_eq!(line["target"], "iris::android");
assert_eq!(line["message"], "surface lost");
assert!(line["at"].as_u64().is_some(), "the app's own clock");
}
#[test]
fn an_enormous_message_is_cut_and_says_so() {
let cut = truncate(&"x".repeat(MAX_MESSAGE_BYTES + 100));
assert!(cut.starts_with("xxxx"));
assert!(cut.contains("bytes cut"), "{cut}");
assert!(cut.len() < MAX_MESSAGE_BYTES + 64);
let short = truncate("fine");
assert_eq!(short, "fine", "a short message is untouched");
}
#[test]
fn the_status_line_distinguishes_untried_from_sent_nothing() {
let untried = UploadStatus::default();
assert_eq!(untried.summary(), "log upload: not tried yet");
let sent_none = UploadStatus {
attempted: true,
..Default::default()
};
assert_eq!(sent_none.summary(), "log upload: 0 lines sent");
}
/// The path out: dropping the handle must stop the thread, not leave
/// it posting.
#[test]
fn dropping_the_handle_stops_the_thread() {
let fake = Fake::new();
let upload = LogUpload::spawn(
ring_with(1),
fake.clone(),
"test",
Duration::from_millis(10),
);
upload.flush_now();
drop(upload);
let after = fake.bodies().len();
std::thread::sleep(Duration::from_millis(60));
assert_eq!(fake.bodies().len(), after, "nothing posted after the drop");
}
}
+40
View File
@@ -955,3 +955,43 @@ do not duplicate it there.
the header's bottom must be masked. Fix both with one rule: a row is
drawn if any part of it intersects the viewport, and the viewport is
the list's own region.
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
- [ ] **"You shouldn't be able to scroll below the bottom (or above
top)."** The list's offset is not clamped to its content range while
dragging and/or flinging. Compose's `LazyColumn` never moves content
past its ends -- the overscroll *effect* on Android 12+ is a stretch
drawn over clamped content, not a displacement. Clamp the offset in
one place (`List`'s scroll setter, so drag, fling, page-in and
programmatic scroll all go through it) and end a fling that hits the
clamp. Test at layer 1: a drag past either end leaves the offset at
the end; a fling into the end stops there.
- [ ] **"Flinging now actually works but is slower than Compose's
immediately after releasing the flick (the slow down seems
correct)."** The curve is right, so the *initial velocity* is low.
iris's `VelocityTracker::velocity` is total motion over the window's
span -- an average -- where Compose's `VelocityTracker` (`compose.ui`
`VelocityTracker.kt`, `VelocityTracker1D` with `Strategy.Impulse`,
100ms horizon, 20 samples, `AssumePointerMoveStoppedMilliseconds =
40`) weights the last samples, so a flick that accelerates into the
release reads faster. Port the impulse strategy from source with
checked-in reference values from an independent transcription, as
the spline was; also apply Compose's min/max fling velocity
(`ViewConfiguration`'s 50dp/s and 8000dp/s) so a slow release does
not fling and a wild one is capped. Layer-1 test on
`flick-120hz.touch` asserting the number Compose's code gives.
- [ ] **Input-event and timing report from the phone.** Iris: "add
another button to copy input event info so that I can do some stuff
manually and then send the event log to you ... instrument a lot of
the code with timings so I can give you time reports through the
same button." Build on the log ring (queue: logging through Dev
Updater), not beside it: every `MotionEvent` (action, pointer
position, event time, historical sample count) and every gesture
decision (`iris drag release:` and its siblings) at debug level into
the ring; frame timings (input handled -> layout -> draw submitted,
and the fling tick's `now` against the frame clock) at debug level
into the same ring; "Copy report" already appends the ring. Add a
second button only if the report gets too long to paste -- then
"Copy input log" copies the ring alone. Says which build it came
from.
+186
View File
@@ -43,6 +43,178 @@ gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
### APK size (2026-09-07)
Iris's question: the iris bench APK is about double the Compose bench APK
(20.6 MB vs 10.1 MB, both release). Measured before this pass: 18,088 KiB of
`lib/arm64-v8a/libmain.so`, stored uncompressed (`extractNativeLibs=false`),
plus 2 MB of dex; the Compose APK's dex is 25 MB raw, compressed to 9 MB in
the APK. `iris/android-app/Cargo.toml`'s `[profile.release]` set only
`panic = "abort"` -- no `lto`, no `codegen-units`, no `strip`, default
`opt-level`. All numbers below are `arm64-v8a` release, built with
`./build-apk.sh release` (this checkout, `nice -n 10`, no `CARGO_TARGET_DIR`
override, reusing the incremental `target/`), and are the raw file sizes
(`ls -la`), not what `du` would round to.
| profile.release | APK bytes | `libmain.so` bytes | delta vs previous |
|---|---|---|---|
| `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- |
| + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 |
| + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 |
| + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 |
| + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 |
| + `opt-level = "z"` (not adopted, see below) | 12,507,276 | 10,374,808 | -818,800 |
Adopted: `strip = true`, `lto = "fat"`, `codegen-units = 1`, `opt-level = "s"`.
Baseline to final: `libmain.so` **18,546,488 -> 11,193,608 bytes (-39.7%)**,
APK **20,678,956 -> 13,326,076 bytes (-35.5%)**.
**`opt-level = "z"` was measured but not adopted.** It is smaller still --
another 818,800 bytes off `libmain.so` (7.9 MiB total vs `s`'s 8.7 MiB) --
but `z` trims more aggressively than `s` in ways that can cost frame time
(fewer inlines, more size-motivated codegen choices, per rustc's own docs),
and this pass did not have an iris-side frame-time benchmark run against
it (the app's own `run-bench.sh`/render report was not exercised here,
per this task's scope, and this checkout has no emulator currently up).
Trading an unmeasured runtime cost for ~700 KB more off the download is not
a call to make blind, so `s` is what shipped, and `z` is left as something
to try only alongside a `transcript-bench.sh`/`stream-bench.sh`-equivalent
run for iris to confirm it does not regress.
Not stripped (baseline) had a live `.symtab` (`llvm-readelf -S`): section
25, `SYMTAB`, 0x17ed40 bytes (~1.49 MiB) covering 65,223 raw symbols. `strip
= true` removes it at build time -- notably, `stripReleaseDebugSymbols`
(AGP's own strip task) had already logged "Unable to strip the following
libraries, packaging them as they are: libmain.so" on the baseline, so
Cargo's own strip is also the fix for that.
Baseline section sizes (`llvm-readelf -S`, before any profile change):
| section | bytes |
|---|---|
| `.text` | 6,463,032 |
| `.rodata` | 6,548,192 |
| `.eh_frame` | 721,872 |
| `.data.rel.ro` | 441,328 |
| `.gcc_except_table` | 13,652 |
| `.symtab` | 1,563,456 |
No `bloaty` on this machine (`which bloaty` empty); used
`llvm-nm -S --size-sort -C` on the baseline (unstripped) `.so`, summed by
the symbol's leading crate/module name. 7.02 MB of the 18.5 MB `.so` carries
a name at all (the rest is `.rodata` blobs -- embedded data, padding,
relocations -- that never get a symbol). Top 8 by that accounting:
| crate | bytes (named symbols only) |
|---|---|
| `naga` | 1,100,099 |
| `core` (std) | 698,862 |
| `wgpu_core` | 599,720 |
| `harfrust` | 372,694 |
| `alloc` | 347,324 |
| `read_fonts` | 326,824 |
| `wgpu_hal` | 299,648 |
| `skrifa` | 291,332 |
Also notable further down: `hashbrown` 244,712, `jni` 199,660, `std`
199,126, `serde` 168,256, `zeno` 157,320, `pulldown_cmark` 116,568,
`iris_core` 95,112, `parley` 78,816, `iris` 76,616, `swash` 72,940,
`serde_json` 72,312, `fontique` 45,464.
**The other ~11.5 MB of `.rodata`/unnamed data is mostly the embedded
fonts**: `iris/core/src/primitive/text.rs` `include_bytes!`s six Noto Sans
TTFs (`iris/core/assets/fonts/`) -- Regular/Bold/Italic/BoldItalic for Noto
Sans plus Regular/Bold for Noto Sans Mono -- totalling **3.6 MB** of raw
font data (`du -ch iris/core/assets/fonts/*.ttf`). That is real render
data, not something to strip: unlike the Compose app's Nerd Fonts icon
subset (`app/build-icon-font.sh`, which subsets because the app only ever
draws ~100 fixed glyphs), iris's Noto Sans embedding backs arbitrary text
in a chat transcript, so a subset would have to be a Unicode-coverage
subset (Latin/Latin-Extended/common punctuation, dropping CJK/Cyrillic/etc)
rather than a fixed-codepoint one -- a real behaviour change (text in a
language outside the subset would fall back to tofu or a missing glyph) and
out of scope for a size-only pass. Left as a follow-up, flagged for Iris:
subsetting would plausibly save 1-2 MB but changes what scripts render
correctly, which is a product decision.
**naga/wgpu backend features: investigated, not trimmed, because the
trim would not change the binary.** `iris/core/Cargo.toml` and
`iris/Cargo.toml` depend on `wgpu = "28.0.0"` with default features, which
via `wgpu`'s own defaults (`dx12`, `metal`, `gles`, `vulkan`, `wgsl`,
`webgpu`) forward `naga/hlsl-out`, `naga/msl-out`, `naga/glsl-out`,
`naga/spv-out`, `naga/wgsl-in`, `naga/wgsl-out` -- Cargo feature
unification is not per-target, so all of those are nominally "on" for the
Android build too, not just the ones Android actually uses (`glsl-out` for
GLES, `spv-out` for Vulkan). But `wgpu-hal`'s own `build.rs`
(`cfg_aliases!`) gates the *modules* themselves on the real target:
`dx12: target_os = "windows" AND feature = "dx12"`, `metal: target_vendor =
"apple" AND feature = "metal"` (`wgpu-hal-28.0.0/build.rs`,
`wgpu-hal-28.0.0/src/lib.rs`'s `#[cfg(dx12)] pub mod dx12;` etc). So on
`aarch64-linux-android` the dx12/metal modules never compile, nothing calls
into `naga::back::hlsl` or `naga::back::msl`, and the linker's normal
dead-code elimination already drops them: `grep -c
"naga::back::hlsl\|naga::back::msl\|naga::front::spv\|naga::front::glsl"
/tmp/nm_size.txt` on the **baseline** (no LTO yet) `.so` returns **0** --
none of that code reached the linked binary in the first place. `regex`
(pulled in transitively by `env_filter`, which `android_logger` uses for
`RUST_LOG`-style filtering) is in the same position: present in
`Cargo.lock` but only a handful of small generic-drop symbols in `nm`, not
a real contributor. Neither is worth a Cargo-level feature trim (which
would also need a per-target dependency table to avoid stripping dx12/metal
off the desktop build, adding real complexity for a change that measures
as zero). No emulator use was needed for this finding since no feature
flag changed; the earlier per-step size measurements (strip/LTO/cgu/opt-level)
were likewise not re-verified on the emulator, since this task's brief
scoped emulator use to the naga-trim step specifically, and that step's
answer was "don't."
**`tabs-ui`/`tabs-screen`: also investigated, also already dead.**
`build-apk.sh`'s default features are `"transcript-screen bench"`, passed
without `--no-default-features`, so the crate's own `default =
["tabs-screen"]` (`iris/android-app/Cargo.toml`) is *also* on for every
build this script produces, including the bench APK. `src/lib.rs`'s doc
comment already says the three screens are mutually exclusive at runtime
(`ActiveClient` gives `bench` priority over `transcript-screen`, which
takes priority over the default `tabs-screen`), and checking the actual
`#[cfg(...)]` gates confirms it is mutually exclusive at *compile* time
too: the `Client` struct and its one call to `tabs_ui::build` are behind
`#[cfg(not(feature = "transcript-screen"))]`, which is false whenever
`transcript-screen` is on, so that code does not even get generated, let
alone linked. `llvm-nm -C` on both the baseline and the final `.so` confirm
it: `grep -ci "tabs_ui\|sungals"` is **0** in both. So there is nothing to
trim here either -- `tabs-ui` and its `sungals.png` (8.9 KB) never reach
the linked binary in a `transcript-screen`/`bench` build, regardless of the
feature being nominally "on" in `Cargo.toml`.
**Comparison Iris asked for, honestly**: most of the remaining ~13.3 MB vs
Compose's 10.1 MB is not a build-settings gap, it is what each app links.
Compose's APK carries ~9 MB of *compressed* dex and links Android's own
platform renderer, text shaper (HarfBuzz/Minikin) and font files from the
system image at zero cost to the APK -- none of that is bytes Compose ships.
Iris ships its own copy of all of that: `wgpu`+`naga`+`wgpu_hal` (a
software/hardware-portable GPU backend and shader cross-compiler, roughly
2 MB of named symbols alone), `harfrust`+`read_fonts`+`skrifa`+`swash`+
`parley`+`fontique` (a full third-party font-loading/shaping/rasterizing
pipeline, another ~1.2 MB of named symbols), and 3.6 MB of embedded font
data because it cannot borrow the platform's fonts the way Compose does.
Build settings (this pass) closed real ground -- 39.7% off `libmain.so` --
but did not remove any of those linked systems, because removing them would
mean iris stops being a self-contained native renderer, which is the whole
point of the port (`AGENTS.md`'s "no Dioxus and nothing that draws through
a WebView", `no-dioxus-or-webview-ui-true-native-only` memory). Install size
(what `dumpsys package`/`du` on the installed `lib/arm64-v8a/` directory
would show) was not separately measured this pass: with
`extractNativeLibs=false` the `.so` is mapped directly out of the APK
rather than copied onto disk a second time, so install size tracks the APK
size closely for the native library and is not a second, larger number the
way it would be under the old `extractNativeLibs=true` default -- checking
this precisely needs the emulator, which this task scoped to the naga-trim
verification only.
Committed: `iris/android-app/Cargo.toml`'s `[profile.release]` now reads
`panic = "abort"`, `strip = true`, `lto = "fat"`, `codegen-units = 1`,
`opt-level = "s"`, with a comment naming the measured savings.
### Queue, 2026-09-07 (orchestrator)
In order; two builders at a time. Each is ticked here by the agent that
@@ -59,6 +231,20 @@ closes it.
app's runtime log, design the smallest route (the app keeps its own
recent log; a debug button copies it; Dev Updater reads it), write
the decision in docs/DECISIONS.md, build it.
- [x] APK size: release profile tuned (`42af780`), -35% APK, -40% .so;
see "APK size (2026-09-07)". **Open question for Iris**: 3.6 MB of the
remaining 11.2 MB .so is six embedded Noto Sans TTFs
(`iris/core/src/primitive/text.rs`). Loading the platform's own fonts
instead (fontique's system collection: Roboto/Noto on Android, the
desktop's own on Linux) removes them and makes text match what the
rest of her phone shows (UI_RULES "show what the reader already sees
elsewhere"), at the cost of the app no longer looking identical on
every device. Not done without her verdict.
- [ ] Scroll clamped at both ends, and Compose's impulse velocity
estimator with min/max fling velocity (docs/IRIS_TODO.md, 2026-09-07
later). After the culling fix lands (same file).
- [ ] Input-event and timing instrumentation into the log ring, copied
by the report button. After the logging route lands (same ring).
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
2026-09-07)". A mask references a primitive already drawn
(rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
+1
View File
@@ -721,6 +721,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+1
View File
@@ -745,6 +745,7 @@ name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
+12
View File
@@ -73,6 +73,18 @@ bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
[profile.release]
panic = "abort"
# Measured 2026-09-07 (docs/RUST.md's "APK size" subsection): together these
# take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the APK
# from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a release. `strip`
# also works around AGP's own stripReleaseDebugSymbols failing silently on
# this .so ("packaging them as they are"). `opt-level = "s"` over `"z"`:
# `z` measured another ~800 KB smaller but was not checked against iris's
# own frame-time bench, so it is not worth the unmeasured risk -- see the
# doc for the number and the follow-up this leaves.
strip = true
lto = "fat"
codegen-units = 1
opt-level = "s"
[profile.dev]
panic = "abort"
+97 -24
View File
@@ -22,6 +22,17 @@ fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return;
}
// Where this build sends its own log ring, if anywhere. Emitted for
// every build that has `client-core` (so the `bench` build, which
// returns below, gets it too): the log route is the one thing a bench
// APK on a phone with no `logcat` needs a server for even though it
// opens a checked-in fixture and talks to nothing else.
//
// **Optional, unlike the transcript config below.** A build with none
// of these set still keeps its ring and still shows it in `Copy
// report`; it just has nowhere to send it. So the same `build-apk.sh`
// works on a machine that has not decided where logs go.
emit_log_config();
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
// server at all -- `bench_client.rs` never references the `pinned`
// module this generates, so requiring a live server's host/port/token/
@@ -49,6 +60,84 @@ fn main() {
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
);
let ca_pem = read_pinned_ca();
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let generated = format!(
"// Generated by build.rs from {host}:{port}. Do not edit.\n\
pub const HOST: &str = {host_lit:?};\n\
pub const PORT: u16 = {port};\n\
pub const TOKEN: &str = {token_lit:?};\n\
pub const CA_PEM: &str = {ca_lit:?};\n",
host = host,
port = port
.parse::<u16>()
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
host_lit = host,
token_lit = token,
ca_lit = ca_pem,
);
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
}
/// Writes `log_config.rs` into `OUT_DIR`: the server this build's log ring
/// uploads to, or `None`.
///
/// Read from the environment at build time rather than from anything in
/// the repository, which is the same trust boundary the CA below uses and
/// the reason no token is ever committed. On the host, Dev Updater builds
/// this APK on the machine `ai-server` runs on, so the values are that
/// machine's own -- an APK is good for the server that built it, which is
/// already true of the pinned CA.
fn emit_log_config() {
println!("cargo:rerun-if-env-changed=AI_APP_LOG_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_LOG_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_LOG_TOKEN");
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let host = std::env::var("AI_APP_LOG_HOST").ok();
let port = std::env::var("AI_APP_LOG_PORT").ok();
let token = std::env::var("AI_APP_LOG_TOKEN").ok();
let generated = match (host, port, token) {
(Some(host), Some(port), Some(token)) => {
let port: u16 = port
.parse()
.unwrap_or_else(|e| panic!("AI_APP_LOG_PORT={port:?} is not a u16: {e}"));
let ca_pem = read_pinned_ca();
format!(
"// Generated by build.rs. Do not edit.\n\
pub const LOG_SERVER: Option<LogServer> = Some(LogServer {{\n\
\x20 host: {host:?},\n\
\x20 port: {port},\n\
\x20 token: {token:?},\n\
\x20 ca_pem: {ca_pem:?},\n\
}});\n"
)
}
// All three or none: two of the three is a half-configured build
// that would fail at runtime with nothing on screen saying why.
(host, port, token) => {
assert!(
host.is_none() && port.is_none() && token.is_none(),
"AI_APP_LOG_HOST, AI_APP_LOG_PORT and AI_APP_LOG_TOKEN are set together \
or not at all -- a build with some of them has nowhere to send its log \
and no way to say so"
);
"// Generated by build.rs. Do not edit.\n\
pub const LOG_SERVER: Option<LogServer> = None;\n"
.to_string()
}
};
std::fs::write(out_dir.join("log_config.rs"), generated).unwrap();
}
/// The CA this machine's `ai-server` signs with: `AI_APP_CA`, else
/// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. One reader for both generated
/// configs -- the log destination and the transcript destination are the
/// same server's certificate, and two copies of this would be two ways to
/// disagree about which one was pinned.
fn read_pinned_ca() -> String {
let ca_path = std::env::var_os("AI_APP_CA")
.map(PathBuf::from)
.unwrap_or_else(|| {
@@ -63,34 +152,18 @@ fn main() {
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
panic!(
"no CA certificate at {} ({e}).\n\
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
against a different one.",
Start ai-server once on this machine first -- it generates the CA this \
build pins. Set AI_APP_CA=/path/to/ca.pem to build against a different one.",
ca_path.display()
)
});
let ca_pem = ca_pem.trim();
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
panic!("{} is not a PEM certificate.", ca_path.display());
}
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let generated = format!(
"// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\
pub const HOST: &str = {host_lit:?};\n\
pub const PORT: u16 = {port};\n\
pub const TOKEN: &str = {token_lit:?};\n\
pub const CA_PEM: &str = {ca_lit:?};\n",
host = host,
port = port
.parse::<u16>()
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
ca = ca_path.display(),
host_lit = host,
token_lit = token,
ca_lit = ca_pem,
let ca_pem = ca_pem.trim().to_string();
assert!(
ca_pem.starts_with("-----BEGIN CERTIFICATE-----"),
"{} is not a PEM certificate.",
ca_path.display()
);
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
ca_pem
}
fn require_env(name: &str, what: &str) -> String {
+108
View File
@@ -0,0 +1,108 @@
//! The platform half of this app's logging: what `client_core::log_ring`
//! and `client_core::log_upload` need that only Android can supply.
//!
//! Everything general -- the ring, its bounds, the `log::Log` backend, the
//! batching and the upload -- is in `client-core`, shared with the desktop
//! app (AGENTS.md's sharing rule). What is here is the two things that are
//! genuinely this platform's: `android_logger` as the logger to forward
//! to, and the destination baked in at build time by `build.rs`.
//!
//! **Why an app carries its own log at all**: Iris tests these builds on a
//! GrapheneOS phone with no `adb`, and Android forbids one app reading
//! another's `logcat`. Nothing outside this process can recover what it
//! wrote, so the process keeps a copy and sends it. See
//! `docs/DECISIONS.md`, 2026-09-07.
use client_core::log_ring::{self, LogRing};
pub use client_core::log_upload::LogUpload;
use std::sync::Arc;
use std::time::Duration;
/// Where this build's log goes, or `None` for a build that was not told.
/// Generated by `build.rs` from `AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` and the
/// pinned CA -- never from anything committed.
pub struct LogServer {
pub host: &'static str,
pub port: u16,
pub token: &'static str,
pub ca_pem: &'static str,
}
include!(concat!(env!("OUT_DIR"), "/log_config.rs"));
/// How often the uploader sends what has accumulated.
///
/// Ten seconds rather than per line: a line at a time is a radio wake per
/// `log::info!`, and this app logs per surface change and per benchmark
/// phase. `Copy report` flushes immediately, so the case where somebody is
/// waiting does not wait for this.
const UPLOAD_EVERY: Duration = Duration::from_secs(10);
/// Installs the ring in front of `android_logger`, so `logcat` still sees
/// exactly what it saw before and the ring sees it too.
///
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
/// itself; the message says which caller, since two initialisation paths
/// is a programmer error rather than something to recover from.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(max_level)
.with_tag("iris-android-app"),
);
if log_ring::install_process_logger(Box::new(inner), max_level).is_err() {
// Not a panic: a logger already installed means logging works,
// just without the ring, and taking the app down over a
// diagnostic would be worse than the diagnostic being missing.
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
}
/// The process's ring -- what `Copy report` appends and the diagnostics
/// pane counts.
pub fn ring() -> &'static LogRing {
log_ring::process_ring()
}
/// Starts the upload loop, if this build was told where to send it.
/// `None` is an ordinary answer, not a failure: a build with no
/// destination still keeps its ring and still copies it.
pub fn start_upload(source: &str) -> Option<LogUpload> {
let server = LOG_SERVER.as_ref()?;
let transport = client_core::api::UreqTransport::new(
format!("https://{}:{}", server.host, server.port),
server.token,
server.ca_pem.as_bytes(),
)
.inspect_err(|err| log::warn!("iris app log: no upload -- {}", err.message))
.ok()?;
Some(LogUpload::spawn(
ring().clone(),
Arc::new(transport),
source.to_string(),
UPLOAD_EVERY,
))
}
/// Only the bench build has a diagnostics pane to put this in; the
/// transcript build's screen is the app's own and has no room for a
/// readout. Gated rather than left dead so the build stays warning-clean.
#[cfg(feature = "bench")]
/// One or two lines for the diagnostics pane: how much is held, and what
/// the uploader last did. Both, because "nothing is arriving on the
/// server" has two very different causes and the pane is where somebody
/// looks for which.
pub fn diagnostics_line(upload: Option<&LogUpload>) -> String {
let ring = ring().summary();
match (upload, LOG_SERVER.as_ref()) {
(Some(upload), _) => format!("{ring}\n{}", upload.status().summary()),
(None, None) => format!("{ring}\nlog upload: this build has no server configured"),
// Configured but not started: the client never called
// `start_upload`, or its transport refused the pinned CA.
(None, Some(server)) => format!(
"{ring}\nlog upload: configured for {}:{} but not running",
server.host, server.port
),
}
}
+35 -3
View File
@@ -80,6 +80,12 @@ const KEYBOARD_WAIT_MS: u64 = 1_000;
/// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16;
/// What this build calls itself in `ai-server`'s log. Names the app and
/// the build type, not the device: two phones running this APK are meant
/// to be told apart by what they say, and a device identifier in a log is
/// something to explain rather than something anybody asked for.
const LOG_SOURCE: &str = "iris-bench";
/// How much of the screen a *filled* benchmark report may take before it
/// scrolls instead of growing -- roughly a third of a phone screen, the
/// share the pane used to reserve unconditionally. An empty report takes
@@ -122,6 +128,13 @@ pub struct BenchClient {
/// The status-bar inset `top_bar` was last padded by -- see
/// `on_insets_changed`'s own comment for why this guards the rebuild.
last_top_pad: f32,
/// The background upload of this app's own log ring (`app_log`), where
/// this build was told a server. Held as a field rather than left
/// running for the process's lifetime so its path out is this client
/// being dropped -- `LogUpload`'s `Drop` stops and joins the thread.
/// `None` for a build with no destination, which is the ordinary case
/// for a bench APK built without `AI_APP_LOG_*`.
log_upload: Option<crate::app_log::LogUpload>,
}
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
@@ -283,6 +296,7 @@ impl AndroidAppState for BenchClient {
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
log_upload: crate::app_log::start_upload(LOG_SOURCE),
};
match transcript_fixture::build_screen(rsc) {
@@ -544,7 +558,11 @@ impl BenchClient {
// logcat on her phone, and "the keyboard does not push the
// composer up" cannot be told from "the listener never fired"
// without it (`AndroidUiState::insets_report`).
format!("{renderer}\n{}", self.android_state().insets_report())
format!(
"{renderer}\n{}\n{}",
self.android_state().insets_report(),
crate::app_log::diagnostics_line(self.log_upload.as_ref())
)
}
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
@@ -566,7 +584,7 @@ impl BenchClient {
}
fn copy_report(&mut self) {
let Some(report) = &self.last_report else {
let Some(report) = self.last_report.clone() else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
return;
};
@@ -574,7 +592,21 @@ impl BenchClient {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
if platform.copy_to_clipboard("iris bench report", report) {
// The ring goes on the clipboard, not into the pane: the pane is
// on screen and a thousand log lines in it would bury the report
// somebody pressed the button for, while the clipboard is going
// straight into a message to be read elsewhere.
let report = format!(
"{report}\n\n=== app log ({}) ===\n{}",
crate::app_log::ring().summary(),
crate::app_log::ring().to_text()
);
// And on the server, if this build has one -- so the lines are
// already there by the time the message describing them arrives.
if let Some(upload) = &self.log_upload {
upload.flush_now();
}
if platform.copy_to_clipboard("iris bench report", &report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
+12
View File
@@ -51,6 +51,11 @@ use iris::prelude::*;
use log::LevelFilter;
use std::ffi::c_void;
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
/// tabs demo keeps `android_logger` alone, as it always had.
#[cfg(feature = "transcript-screen")]
mod app_log;
#[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
@@ -119,6 +124,13 @@ extern "system" fn new_view_peer<'local>(
/// mirrors android-view's own demo, which carries the same comment.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
// The ring in front of `android_logger` where there is one (see
// `app_log`), and `android_logger` alone otherwise. Both install the
// same tag and level, so `logcat` cannot tell the two builds apart --
// the ring only adds a second reader.
#[cfg(feature = "transcript-screen")]
app_log::install(LevelFilter::Debug);
#[cfg(not(feature = "transcript-screen"))]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Debug)
@@ -74,6 +74,10 @@ pub struct TranscriptClient {
/// only ever one session here (no list to switch away to), but the
/// guard still matters for the *first* fetch racing a `stop`/`start`.
generation: Arc<AtomicU64>,
/// The background upload of this app's own log ring (`app_log`). Held
/// here so its path out is this client being dropped; see
/// `bench_client`'s field of the same name.
_log_upload: Option<crate::app_log::LogUpload>,
}
impl HasAndroidUiState for TranscriptClient {
@@ -182,6 +186,7 @@ impl AndroidAppState for TranscriptClient {
items: Vec::new(),
session_id: None,
generation: Arc::new(AtomicU64::new(0)),
_log_upload: crate::app_log::start_upload("iris-transcript"),
};
client.spawn_fetch_sessions(rsc);
client
+119
View File
@@ -62,6 +62,9 @@
//! once the account's usage limit lifts
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
//! POST /client-log {source, lines} -- a client's own recent log,
//! re-emitted into this server's log (see
//! `client_log`; the phone has no logcat)
//! GET /defaults {effort} -- what a new session starts at
//! POST /defaults {effort} -- null for the CLI's own default
//! GET /usage cached usage windows per provider
@@ -158,6 +161,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
.route("/sessions/{id}/effort", post(set_effort))
.route("/defaults", get(defaults).post(set_defaults))
.route("/client-log", post(client_log))
.route("/sessions/{id}/notify", post(set_notify))
.route("/sessions/{id}/auto-resume", post(set_auto_resume))
.route("/notifications", get(notifications))
@@ -1407,6 +1411,121 @@ async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defa
})
}
/// How many lines one `POST /client-log` may carry, matching
/// `client_core::log_upload::MAX_LINES_PER_BATCH`. A client that sends more
/// is refused rather than silently shortened: a log with a hole in it that
/// nothing mentions is worse than a rejected batch the client retries.
const CLIENT_LOG_MAX_LINES: usize = 500;
/// The tracing target every re-emitted client line carries.
///
/// **Under `ai_server::`, deliberately.** A bare `client_log` target is
/// filtered out by `RUST_LOG=ai_server=debug` -- the exact filter
/// AGENTS.md tells people to run with -- so every line a phone sent would
/// vanish with nothing saying so. Under the crate's own path it is on
/// wherever the server's own lines are, which is the only filter its
/// reader knows about.
const CLIENT_LOG_TARGET: &str = "ai_server::client_log";
/// One line of a client's own log. `at` is that client's clock, not this
/// machine's -- see [`client_log`].
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogLine {
/// The client's ring sequence. Carried so a gap -- lines its bound
/// dropped -- is visible here rather than looking like a quiet client.
seq: u64,
/// Milliseconds since the unix epoch, from the client's own clock.
at: u64,
level: String,
target: String,
message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogBody {
/// Which client this is -- an app name and build, not a device
/// identifier. It is what tells two phones' lines apart in the log.
source: String,
lines: Vec<ClientLogLine>,
}
/// Takes a client's own recent log lines and re-emits them into this
/// server's `tracing` output.
///
/// **Why a route rather than something on the phone**: Android forbids one
/// app reading another's `logcat`, and the phone this project is tested on
/// has no `adb` at all, so a `log::info!` in the app can only reach a
/// person if the app carries its own copy and sends it somewhere. This
/// server is the somewhere it already has a tunnel, a pinned CA and a
/// bearer token for -- and Dev Updater already shows this server's log as
/// its runtime log, so the line arrives where its reader is already
/// looking with nothing new built there. `docs/DECISIONS.md`, 2026-09-07.
///
/// Each line is emitted separately, at the level the client recorded it
/// at, with the client's own timestamp in the text -- the tracing
/// subscriber stamps the moment of *arrival*, which can be minutes later
/// or on the other side of a tunnel outage, and presenting that as when it
/// happened would be an inferred value shown as a measured one.
async fn client_log(axum::Json(body): axum::Json<ClientLogBody>) -> Result<StatusCode, ApiError> {
if body.lines.len() > CLIENT_LOG_MAX_LINES {
return Err(ApiError::BadRequest(format!(
"{} lines in one batch; the limit is {CLIENT_LOG_MAX_LINES}",
body.lines.len()
)));
}
for line in &body.lines {
let at = client_log_time(line.at);
let source = &body.source;
let target = &line.target;
let seq = line.seq;
let message = &line.message;
// The level is chosen here rather than passed, because a tracing
// macro's level is part of the callsite. Anything unrecognised is
// reported at INFO with the word it sent kept, so a client using a
// level this server has not heard of loses the level rather than
// the line.
match line.level.to_ascii_uppercase().as_str() {
"ERROR" => {
tracing::error!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"WARN" => {
tracing::warn!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"DEBUG" => {
tracing::debug!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"TRACE" => {
tracing::trace!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"INFO" => {
tracing::info!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
other => {
tracing::info!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: <{other}> {message}")
}
}
}
Ok(StatusCode::NO_CONTENT)
}
/// `HH:MM:SS.mmm` UTC from the client's unix milliseconds -- the same
/// formatting `client_core::log_ring` uses, so a line read here and the
/// same line in the app's own copied report say the same time.
fn client_log_time(at_ms: u64) -> String {
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
at_ms % 1000
)
}
/// Sets what a new session's thinking level is. Applied when a session is
/// spawned, so nothing already running changes underneath anybody.
async fn set_defaults(