3 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 50e69995b6 docs/RUST.md: the emulator crash loop was the missing GLES fallback, with the panic-hook note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:50:06 -04:00
irisandClaude Fable 5.1 85869d02f8 iris: the Android renderer falls back to GLES, and every failure reports
The bench app crash-looped on this checkout's emulator with the default
features (RUST.md's queue item). Not the surface lifecycle and not "once
backgrounded": a build without `force-gles` never got a first frame.
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
contain `GL`, and this emulator advertises a Vulkan ICD with no adapter
behind it -- `NotFound { active_backends: VULKAN, no_adapter_backends:
VULKAN, supported_backends: VULKAN | GL }`, `.expect`ed, so SIGABRT, so
the launcher restarts it. iris was refusing a device whose only usable
adapter is a GLES one.

It now probes for a `PRIMARY` adapter and rebuilds the instance on
`Backends::GL` when there is none. The probe runs on an instance that
never touches the window on purpose: **an Android window can be
connected to one graphics API only**, so one instance carrying both
backends fails worse -- measured here on the way to this fix, Vulkan's
`vkCreateAndroidSurfaceKHR` claims the window in `create_surface` and
the GLES surface from the same window then reports `In
Surface::configure / Invalid surface`, aborting a frame later in
`Surface::get_current_texture_view`. Vulkan still wins wherever it has
an adapter (`PowerPreference::None` does not sort, and Vulkan is
enumerated first), so nothing changes on the phone.

Second half, the same rule applied to the whole set: the surface,
adapter and device requests all report through the `Result<Self,
String>` this function already returns, where two of the three used to
panic. `surface_changed` puts that string on screen and in the log
ring, which is what the Result was added for.

Emulator evidence (API 36 x86_64, debug): after, `iris renderer: no
Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU) adapter on this
device, falling back to GLES` then `new renderer built (Gl)` and
frames. Clean on both the default and a `force-gles` build for the
cases this had no reason to touch: two background/return cycles,
rotation there and back (the `already_live=true` reuse branch), a
background/return after the rotation, and cold starts. Vulkan could not
be exercised here -- that this emulator has no Vulkan adapter is the
defect itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:50:02 -04:00
irisandClaude Fable 5.1 f99ae4c366 iris-android-app: a panic hook, so an abort says something Iris can read
Checked before writing anything: under `panic = "abort"` (this crate's
Cargo.toml) a panic's message reaches the tombstone's `Abort message`
and nowhere else -- not `log`, so not `client_core::log_ring`, so not
Dev Updater's Runtime tab. That tab is the only surface Iris has on a
phone with no `adb`, so every `assert!` and `expect!` in these builds
has been failing silently as far as she is concerned; the adapter crash
fixed in the next commit looked like the app simply relaunching.

`install_panic_hook` (called from `app_log::install`) writes the
message and its location at `error` level. The ring is memory only and
the process is about to die, so it also writes `last-panic.txt` in the
app's private directory; `set_crash_dir`, called from
`nativeSetFilesDir`, replays that into the ring at `error` level on the
next start and deletes it. A crash loop therefore explains itself in
the run that is still up, which is the run somebody can look at.

Verified on this checkout's emulator against the unfixed renderer:
`iris panic at .../render.rs:140:14: Could not get adapter!: NotFound
{...}` on the run that died, and `iris app log: the previous run died
-- ...` on the next one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:49:45 -04:00
5 changed files with 203 additions and 31 deletions

No files matched your search

+22
View File
@@ -8,6 +8,28 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
include `GL`. A device that offers a Vulkan driver with no adapter behind
it -- this checkout's emulator -- therefore had no adapter at all, and the
`.expect` on that turned into a crash loop with nothing on screen. It now
probes for a `PRIMARY` adapter first and falls back to `Backends::GL` when
there is none, so **Vulkan still wins wherever it has an adapter** and
nothing changes on a phone.
The probe deliberately runs on an instance that never touches the window:
an Android window can be connected to one graphics API only, so an
instance carrying both backends lets Vulkan claim the window and leaves
the GLES surface unusable. That is why this is a second instance rather
than one wider `Backends` value.
The other half a caller sees: `AndroidRenderer::new` already returned
`Result<Self, String>`, and now **every** way it can fail goes through
that -- no surface, no adapter, no device, as well as the bind-group
validation failure it was originally written for. `surface_changed` puts
that string on screen and in the log ring instead of aborting.
## 2026-09-07: `VelocityTracker` takes positions, not deltas
A flick released at the wrong speed because the tracker averaged. It now
+60 -5
View File
@@ -716,11 +716,66 @@ closes it.
devlog agent shrank the label type 18 -> 13 to make room (UI_RULES:
never shrink text to fit). Put the controls in two rows or make the
header wrap; restore the size. Sonnet.
- [ ] Bench app crash-loops on this checkout's emulator once
backgrounded (seen by the devlog agent, pre-existing; the phone's
resume works since ba2afba). Reproduce on the emulator's GLES path,
read the crash from the devlog/`adb logcat`, fix. Opus if it is in
the surface lifecycle.
- [x] **Bench app crash-loops on this checkout's emulator (done
2026-09-07).** Not the surface lifecycle at all, and not "once
backgrounded" -- a build with the **default features** (no
`force-gles`) never got a first frame. `AndroidRenderer::new`
(`iris/src/android/render.rs`) asked wgpu for `Backends::PRIMARY`,
which does not contain `GL`, and this emulator advertises a Vulkan ICD
with no adapter behind it: `RequestAdapterError::NotFound {
active_backends: VULKAN, no_adapter_backends: VULKAN,
supported_backends: VULKAN | GL }`, `.expect`ed, so SIGABRT, so the
launcher restarts it -- the loop. A `force-gles` build was never
affected, which is why the crash looked like it belonged to whatever
else was going on. **Root cause: iris refused a device whose only
usable adapter is a GLES one.**
**Fix, two halves.** (1) `AndroidRenderer::new` now probes for a
`PRIMARY` adapter with an instance that never touches the window and
rebuilds the instance on `Backends::GL` when there is none. The probe
is surface-free deliberately: **an Android window can be connected to
one graphics API only**, so a single instance carrying both backends
fails differently and worse -- Vulkan's
`vkCreateAndroidSurfaceKHR` claims the window in `create_surface` and
the GLES surface built from the same window then reports `In
Surface::configure / Invalid surface`, aborting one frame later in
`Surface::get_current_texture_view` ("Surface is not configured for
presentation"). That was measured here on the way to the fix, not
reasoned about. Vulkan still wins wherever it has an adapter, so
nothing changes on the phone. (2) The surface, adapter and device
requests all report through the `Result<Self, String>` this function
already returns, instead of two of the three panicking -- one rule for
the set, and `surface_changed` already puts that string on screen and
in the log ring.
**Evidence**, this checkout's emulator (API 36, x86_64, debug):
before, default features aborted on first launch with `Abort message:
'Could not get adapter!: NotFound {...}'`; after, `iris renderer: no
Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU) adapter on this
device, falling back to GLES` then `new renderer built (Gl)` and
frames. Then the cases the fix had no reason to touch, clean on both
the default build and a `force-gles` one: two background/return
cycles, rotate to landscape and back (`already_live=true`, the reuse
branch), a background/return after the rotation, and cold starts.
**Vulkan could not be exercised here** -- the probe's own answer is
that this emulator has no Vulkan adapter, which is the whole defect;
Vulkan remains only testable on Iris's phone.
**Also landed with it, and worth more than the fix**: a panic hook in
`iris/android-app/src/app_log.rs`. Checked first, rather than assumed:
under `panic = "abort"` a panic's message reaches the tombstone's
`Abort message` and **nothing else** -- not `log`, so not the ring, so
not Dev Updater's Runtime tab, which is the only surface Iris has on a
phone with no `adb`. The hook writes the message and its location at
`error` level, and -- because the ring is memory only and the process
is about to die -- also to `last-panic.txt` in the app's private
directory, which `set_crash_dir` (called from `nativeSetFilesDir`)
replays into the ring at `error` level on the next start and deletes.
So a crash loop now explains itself in the Runtime tab of the run
that is still up. Verified on the emulator by building the *unfixed*
renderer with the hook: `iris panic at .../render.rs:140:14: Could not
get adapter!: NotFound {...}` in the ring on the run that died, and
`iris app log: the previous run died -- ...` on the next one.
- [ ] 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()`
+59
View File
@@ -34,6 +34,7 @@ pub fn install(max_level: log::LevelFilter) {
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
}
/// The process's ring -- what `Copy report` appends, what the diagnostics
@@ -63,3 +64,61 @@ pub fn diagnostics_line() -> String {
};
format!("{}\n{where_to_read}", ring().summary())
}
/// Where the panic hook leaves its one line, under the app's private
/// directory. Read back and dropped by [`set_crash_dir`] on the next
/// start.
const CRASH_FILE: &str = "last-panic.txt";
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Installs a `log`-level panic hook, so a panic's message and location
/// reach the ring and `logcat` rather than only the tombstone.
///
/// **Why this is needed at all**: these builds are `panic = "abort"`
/// (`Cargo.toml`), and the default hook writes to `stderr` plus
/// `android_set_abort_message` -- the crash report. Iris runs these on a
/// phone with no `adb`, so the crash report is exactly the surface she
/// cannot read, and an `assert!` that fired said nothing anywhere she
/// could see it. Routing it through `log::error!` puts it in front of
/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev
/// Updater.
///
/// The ring is memory only, so after an abort the process that holds it
/// is gone -- hence the file half. [`set_crash_dir`] replays it.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
};
// `info`'s own `Display` repeats the location and a newline;
// the payload alone keeps this to the one line the ring wants.
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
// Best effort by design: a panic is already the failure, and
// failing to record it must not become a second one.
let _ = std::fs::write(path, &line);
}
previous(info);
}));
}
/// Tells the panic hook where to leave its line, and replays the line a
/// previous run left there into the ring before deleting it.
///
/// Called from `nativeSetFilesDir`, which is the first moment the app's
/// private directory is known. The replay is at `error` level and says
/// it is from the previous run, so a crash loop shows the reason it is
/// looping in the Runtime tab of the run that is still up.
pub fn set_crash_dir(dir: &std::path::Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = std::fs::read_to_string(&path) {
log::error!("iris app log: the previous run died -- {}", previous.trim());
let _ = std::fs::remove_file(&path);
}
let _ = CRASH_PATH.set(path);
}
+3
View File
@@ -180,7 +180,10 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir
return;
};
#[cfg(feature = "transcript-screen")]
{
app_log::set_crash_dir(std::path::Path::new(&dir));
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}
+59 -26
View File
@@ -88,9 +88,10 @@ pub struct FrameDiagnostics {
}
impl AndroidRenderer {
/// `Err` holds a full, human-readable report -- wgpu's own error text
/// (`UiRenderNode::new`'s doc comment) plus the adapter identity and
/// the limits/downlevel flags bind-group-layout validation checks
/// `Err` holds a full, human-readable report for **every** way this
/// can fail -- no surface, no adapter, no device, or wgpu's own error
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
/// and the limits/downlevel flags bind-group-layout validation checks
/// against -- rather than the panic wgpu's default error handler would
/// otherwise raise with no caller able to see it. This is what aborted
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
@@ -107,29 +108,67 @@ impl AndroidRenderer {
height: u32,
content_scale: f32,
) -> Result<Self, String> {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
// build, to isolate whether the backend itself explains the frame
// time gap against Compose. `cfg!` rather than a runtime switch:
// there is no way to hand an env var to an already-launched Android
// process on this machine (see the feature's doc in Cargo.toml).
let backends = if cfg!(feature = "force-gles") {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
// the build to GLES, to isolate whether the backend itself explains
// the frame time gap against Compose. `cfg!` rather than a runtime
// switch: there is no way to hand an env var to an already-launched
// Android process on this machine (see the feature's doc in
// Cargo.toml).
//
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
// offering only a GLES adapter had no adapter at all and this
// function aborted the process -- this checkout's emulator, whose
// Vulkan ICD carries no adapter behind it (`NotFound {
// active_backends: VULKAN, no_adapter_backends: VULKAN,
// supported_backends: VULKAN | GL }`), and the crash loop in
// RUST.md's queue.
//
// The choice is made *before any surface exists*, with an instance
// that never touches the window, because **an Android window can be
// connected to one graphics API only**. One instance carrying both
// backends does not work: `create_surface` builds a raw surface per
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
// first, and the GLES surface made from the same window then fails
// `configure` as lost -- measured here as "In Surface::configure /
// Invalid surface" followed by an abort in
// `Surface::get_current_texture_view`, "Surface is not configured
// for presentation".
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
};
let instance = Instance::new(&InstanceDescriptor {
let mut instance = Instance::new(&InstanceDescriptor {
backends,
..Default::default()
});
// A build already pinned to GLES has nowhere to fall back to.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(&InstanceDescriptor {
backends,
..Default::default()
});
}
// SAFETY: the `NativeWindow` outlives the surface built from it --
// android-view drops the old renderer (and this surface with it)
// before handing over a new window, in `surface_changed` below.
let surface = instance
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
.expect("Could not create android surface!");
.map_err(|error| format!("Could not create the android surface: {error}"))?;
// Every step from here to a live device reports rather than
// panics, for the one reason: on the phone these builds run on
// there is no `adb`, so an abort's message reaches a tombstone
// nobody can read and the launcher simply restarts the app --
// which is what a crash loop with no explanation is. The caller
// (`android::view::IrisViewPeer::surface_changed`) puts this
// string on screen and in the app's own log ring instead.
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
@@ -137,19 +176,7 @@ impl AndroidRenderer {
force_fallback_adapter: false,
})
.block_on()
.expect("Could not get adapter!");
// Requesting the device itself still panics on failure: that is a
// `RequestDeviceError` (a limit or feature the adapter cannot grant
// at all), a different and already-diagnosable failure from the one
// this function now recovers from -- `RUST.md`'s "Software mode ...
// crashes for a third, different reason" is exactly that class, and
// its message already names the limit and the requested/allowed
// values with no truncation risk (it never reaches wgpu's
// uncaptured-error path). What this function's `Result` return
// covers is the *next* class of failure: the adapter grants the
// device, and validation only fails once a specific bind group
// layout is checked against it.
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
// Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape".
@@ -161,7 +188,13 @@ impl AndroidRenderer {
..Default::default()
})
.block_on()
.expect("Could not get device!");
.map_err(|error| {
format!(
"The adapter {} ({:?}) refused a device: {error}",
adapter.get_info().name,
adapter.get_info().backend,
)
})?;
// wgpu's default handler for an error raised outside `UiRenderNode::
// new`'s own error scopes (i.e. everything past device creation --