iris: turn the phone bind-group-layout crash into a diagnostic, drop force-gles from phone builds

UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.

Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.

Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.

Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 23:05:07 -04:00
1 parent a27fbdb029
commit 46246ea511
11 files changed
+508 -13

No files matched your search

+1
View File
@@ -1757,6 +1757,7 @@ dependencies = [
"fxhash",
"image",
"parley",
"pollster",
"swash",
"wgpu",
]
+1
View File
@@ -1785,6 +1785,7 @@ dependencies = [
"fxhash",
"image",
"parley",
"pollster",
"swash",
"wgpu",
]
@@ -1,6 +1,10 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Context;
import android.view.Gravity;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView;
@@ -33,4 +37,35 @@ public final class IrisView extends RustView {
unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow();
}
/**
* Called from the Rust side (iris/src/android/view.rs's
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
* drawing -- an ordinary instance method rather than a `native` one,
* since this call is Rust reaching into Java rather than the other
* direction. Replaces the whole activity content with plain,
* selectable, scrollable text rather than leaving the last frame (or a
* blank surface) on screen with no way to report what happened:
* UI_RULES.md's "a failure is reported where it happened, and says
* what to do next." No dialog and no styling beyond what is needed to
* read and copy the text -- this path exists for exactly the crash it
* replaces, so it must not depend on anything that could itself fail
* to render.
*/
void showRendererError(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setGravity(Gravity.TOP | Gravity.START);
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
text.setPadding(pad, pad, pad, pad);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
activity.setContentView(scroll);
}
}
+14 -2
View File
@@ -12,13 +12,25 @@
# emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to
# "transcript-screen force-gles bench", P0's exact combination.
# "transcript-screen bench" -- deliberately *without* `force-gles`, unlike
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
# own doc) exists only to force the emulator off its default software
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
# iris's frame time goes") -- it was never meant to reach a real device,
# but this script's old default put it in every arm64 build regardless,
# so the P0 bench APK delivered to Iris's phone forced GLES there too.
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
# run, and GLES is the backend the same box's own SwiftShader finding
# already flagged as the fragile one for this shader's storage buffers.
# Pass `--features "transcript-screen force-gles bench"` explicitly for
# an emulator backend-isolation run; never for a build meant for a phone.
set -eu
cd "$(dirname "$0")"
BUILD_TYPE="debug"
ABI="arm64-v8a"
FEATURES="transcript-screen force-gles bench"
FEATURES="transcript-screen bench"
case "${1:-}" in
debug|release) BUILD_TYPE="$1"; shift ;;
esac
+6
View File
@@ -5,6 +5,12 @@ edition.workspace = true
[dependencies]
wgpu = { workspace = true }
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
# `block_on` turns that one async pop into the same synchronous call shape
# `device_limits()`'s two callers already use for `request_adapter`/
# `request_device`, rather than making this crate's one entry point async.
pollster = { workspace = true }
bytemuck ={ workspace = true }
image = { workspace = true }
parley = { workspace = true }
+42 -3
View File
@@ -4,6 +4,7 @@ use crate::{
util::{HashMap, Vec2},
};
use data::WindowUniform;
use pollster::FutureExt;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
*,
@@ -251,7 +252,34 @@ impl UiRenderNode {
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, queue: &Queue, config: &SurfaceConfiguration) -> Self {
/// Builds every bind group layout, the pipeline, and the two storage
/// buffers this needs -- fallibly, since this is exactly the call that
/// aborted the process on Iris's phone in a release build with no
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
/// behaviour for an uncaptured error is `panic!` with no caller able to
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
/// call below runs inside three nested error scopes (one per
/// `ErrorFilter`) instead: whichever scope catches something, its
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
/// would have printed before Android's crash reporter truncated it) and
/// becomes this function's `Err`. Both callers
/// (`android::render::AndroidRenderer::new`, `default::render::
/// UiRenderer::new`) already call `Device`-creation with
/// `pollster::block_on`, so returning a plain `Result` here rather than
/// making this `async fn` keeps that same synchronous shape.
pub fn new(
device: &Device,
queue: &Queue,
config: &SurfaceConfiguration,
) -> Result<Self, String> {
// Popped in reverse of this order, once every creation call below
// has run -- `Device::push_error_scope`'s own contract.
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
@@ -373,7 +401,18 @@ impl UiRenderNode {
cache: None,
});
Self {
// Reverse of the push order above. Only one of these should ever be
// `Some` in practice -- three separate scopes exist to name *which*
// kind of error it was, not because more than one is expected at
// once.
let internal_err = internal_scope.pop().block_on();
let validation_err = validation_scope.pop().block_on();
let oom_err = oom_scope.pop().block_on();
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
return Err(err.to_string());
}
Ok(Self {
uniform_group,
primitive_layout,
rsc_layout,
@@ -387,7 +426,7 @@ impl UiRenderNode {
move_offsets,
masks_layout,
masks_group,
}
})
}
fn bind_group_0(
+74 -4
View File
@@ -51,7 +51,20 @@ pub struct AndroidRenderer {
}
impl AndroidRenderer {
pub fn new(window: NativeWindow, width: u32, height: u32) -> Self {
/// `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
/// 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
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
/// validates against *this* adapter's downlevel capabilities and
/// limits, which a desktop GPU and the emulator's software renderers
/// never exercised. The caller (`android::view::IrisViewPeer::
/// surface_changed`) logs this one-line-flattened and shows it on
/// screen instead of aborting the process.
pub fn new(window: NativeWindow, width: u32, height: u32) -> 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
@@ -84,6 +97,18 @@ impl AndroidRenderer {
.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.
// Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape".
// `iris_core::device_limits()` is shared between the two backends;
@@ -117,16 +142,61 @@ impl AndroidRenderer {
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &queue, &config);
let ui = match UiRenderNode::new(&device, &queue, &config) {
Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
Self {
Ok(Self {
surface,
device,
queue,
config,
encoder,
ui,
}
})
}
/// The adapter identity plus every limit and downlevel flag
/// `create_bind_group_layout` validates a storage buffer or texture
/// binding against, followed by wgpu's own error text -- everything a
/// person reading this off a screenshot needs to tell "this adapter
/// lacks X" from "this is a bug in the layout." Named explicitly rather
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
/// dozens of fields nobody asked for -- these are exactly the ones
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
/// the one storage buffer here, `move_offsets`, that is visible to the
/// vertex stage).
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
let info = adapter.get_info();
let limits = adapter.limits();
let downlevel = adapter.get_downlevel_capabilities();
format!(
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
max_sampled_textures_per_shader_stage={max_sampled_textures} \
max_bind_groups={max_bind_groups} \
max_bindings_per_bind_group={max_bindings} \
max_storage_buffer_binding_size={max_storage_binding} \
min_storage_buffer_offset_alignment={min_storage_align}\n\
downlevel flags: {flags:?}\n\n\
{wgpu_error}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = info.driver_info,
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
max_bind_groups = limits.max_bind_groups,
max_bindings = limits.max_bindings_per_bind_group,
max_storage_binding = limits.max_storage_buffer_binding_size,
min_storage_align = limits.min_storage_buffer_offset_alignment,
flags = downlevel.flags,
)
}
fn create_encoder(device: &Device) -> CommandEncoder {
+69 -3
View File
@@ -4,7 +4,11 @@ use accesskit_android::Adapter as AccessAdapter;
use android_view::{
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer,
jni::{JNIEnv, JavaVM, objects::GlobalRef, sys::jint},
jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JValue},
sys::jint,
},
ndk::event::{Keycode, MotionAction},
};
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
@@ -337,6 +341,31 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
imm.show_soft_input(env, view, 0);
}
/// Replaces the activity's content with a plain, selectable, scrollable
/// text view holding `report` -- the on-screen half of `surface_changed`'s
/// renderer-failure path (UI_RULES.md: "a failure is reported where it
/// happened, and says what to do next," here "copy this and send it").
/// Goes through an ordinary instance method on the Java side
/// (`IrisView.showRendererError`) rather than a new `native` method: this
/// call is Rust reaching *into* Java, the opposite direction from every
/// `native fn` android-view/`IrisView` declare, and an ordinary virtual
/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the
/// same way any other JNI method call here does. Silently does nothing on
/// any JNI failure -- there is no more-fallback screen to fall back to,
/// and the `log::error!` in `surface_changed` already reached logcat
/// first.
fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) {
let Ok(message) = env.new_string(report) else {
return;
};
let _ = env.call_method(
&view.0,
"showRendererError",
"(Ljava/lang/String;)V",
&[JValue::Object(message.as_ref())],
);
}
impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
fn on_key_down<'local>(
&mut self,
@@ -445,8 +474,45 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// one from the new window -- see `AndroidRenderer`'s doc comment.
let ui_state = self.state.android_state_mut();
ui_state.renderer = None;
ui_state.renderer = Some(AndroidRenderer::new(window, width as u32, height as u32));
self.render(ctx);
// `AndroidRenderer::new` used to panic here through wgpu's own
// default uncaptured-error handler on a bind-group-layout
// validation failure -- exactly what aborted the P0 bench APK on
// Iris's phone with the message truncated to "wgpu error:
// Validation Error" and nothing else recoverable from the crash
// report (RUST.md's P0 box, "iris bench crash on the phone,
// 2026-09-06"). It now returns the full diagnostic instead; this is
// the one place in the app that can turn it into something a
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback.
match AndroidRenderer::new(window, width as u32, height as u32) {
Ok(renderer) => {
self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx);
}
Err(report) => {
// One line for logcat (UI_RULES.md: "the full text for
// whoever can read the log" lives here), the multi-line
// original on screen -- `show_renderer_error` below.
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
// Deferred, not called directly: `Activity::setContentView`
// tears the old view hierarchy down synchronously, which
// fires `IrisView`'s own `onFocusChanged` before
// `setContentView` returns -- straight back into this same
// `IrisViewPeer` through `on_focus_changed` while
// `with_peer` (android-view's dispatch, `view.rs` upstream)
// still holds this peer's `RefCell` borrow for the
// `surface_changed` call in progress. Found by inducing a
// validation error and hitting `RefCell already borrowed`
// at exactly that reentrant call (RUST.md's P0 box).
// `push_dynamic_deferred_callback` runs after `with_peer`
// drops the borrow, which is what every other callback in
// this file that reaches into Java already relies on
// (`raise_if_enabled`, above).
ctx.push_dynamic_deferred_callback(move |env, view| {
show_renderer_error(env, view, &report);
});
}
}
}
fn surface_destroyed<'local>(
+8 -1
View File
@@ -141,7 +141,14 @@ impl UiRenderer {
let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &queue, &config);
// Unlike the Android backend, the desktop backend has no on-screen
// fallback to show a diagnostic through, so a renderer-creation
// failure still panics here -- but now with wgpu's full "Caused
// by:" chain as the message, since `UiRenderNode::new` returns it
// rather than letting wgpu's own default handler panic first (see
// that function's doc comment).
let ui = UiRenderNode::new(&device, &queue, &config)
.expect("Could not create iris render node!");
Self {
surface,