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
+506 -11

No files matched your search

+29
View File
@@ -8,6 +8,35 @@ 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 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. it helps judge the change without the session that made it. Newest first.
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
`iris_core::UiRenderNode::new(device, queue, config)` now returns
`Result<Self, String>` instead of `Self`. Why: it used to let a bind-group-
layout validation failure reach wgpu's default error handler, which panics
with no way for a caller to intervene -- exactly what aborted the P0 bench
APK on Iris's phone with the crash report truncated to "wgpu error:
Validation Error" and nothing else recoverable. It now runs its creation
calls inside wgpu error scopes and returns the full error text (wgpu's own
"Caused by" chain) as `Err` instead.
Both callers changed to match: `android::render::AndroidRenderer::new`
itself now returns `Result<Self, String>` too, building a fuller report
(adapter identity, the limits/downlevel flags a layout validates against,
then wgpu's text) on failure -- its caller,
`android::view::IrisViewPeer::surface_changed`, logs that report as one
logcat line and shows it on screen (a new `IrisView.showRendererError`,
called via an ordinary JNI method call rather than a new `native fn`)
instead of letting the process abort. `default::render::UiRenderer::new`
(the winit/desktop backend) still panics on failure -- there is no
on-screen fallback there -- but the panic message is now the same full
text rather than whatever wgpu's own handler would have printed.
No change for an app that never constructs a `UiRenderNode` directly (every
current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who
does needs an `?`/`.expect()`/`match` at the call site now. Full audit and
the named hypothesis for what actually failed on the phone are in
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half) ## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
Added a second, optional lifecycle method to `iris::android::AndroidAppState` Added a second, optional lifecycle method to `iris::android::AndroidAppState`
+229
View File
@@ -3906,6 +3906,235 @@ device.
a one-line build-date/commit note so Iris can tell which build she a one-line build-date/commit note so Iris can tell which build she
has. has.
**iris bench crash on the phone, 2026-09-06.** The delivered APK
(`dev.iris.android.demo.bench`, arm64, release) aborted on Iris's own
phone (a Pixel, GrapheneOS, Mali GPU) on the very first
`surface_changed`: `AndroidRenderer::new` -> `UiRenderNode::new` ->
`create_bind_group_layout` -> wgpu's `default_error_handler` panics
with `wgpu error: Validation Error`, and Android's crash report
truncated the message right there, so the actual validation failure
was unknown. Ran fine on the emulator's Vulkan (SwiftShader) and GLES
(`force-gles`/virgl) and on the desktop GPU. Nobody on this session
has the phone or `adb` access to it; this pass worked from the crash
report alone plus reading wgpu-core's own validation source
(`wgpu-core-28.0.0/src/{binding_model,device/resource}.rs`, the
version this workspace pins).
**1. Diagnostic, not guesswork -- what actually happens is now
visible.** `iris_core::UiRenderNode::new` (`core/src/render/mod.rs`)
wraps every `create_bind_group_layout`/pipeline call in three nested
wgpu error scopes (one per `ErrorFilter`: `OutOfMemory`,
`Validation`, `Internal`), pops them in reverse once creation is
done, and returns `Result<Self, String>` -- the `String` is wgpu's
own `Display` text for whichever scope caught something, which is
already wgpu-core's `format_error` output (`"Validation Error\n\n
Caused by:\n ..."`, confirmed by reading
`wgpu-28.0.0/src/backend/wgpu_core.rs`'s `format_error` -- the exact
text the panic would have printed, just no longer thrown away).
`android::render::AndroidRenderer::new` turns a failure into a full
report: the adapter's name/backend/driver
(`Adapter::get_info`), the limits bind-group-layout validation
checks a storage/texture binding against
(`max_storage_buffers_per_shader_stage`,
`max_sampled_textures_per_shader_stage`, `max_bind_groups`,
`max_bindings_per_bind_group`, `max_storage_buffer_binding_size`,
`min_storage_buffer_offset_alignment`), and
`DownlevelCapabilities.flags` (`Adapter::get_downlevel_capabilities`)
-- then wgpu's own error text. `android::view::IrisViewPeer::
surface_changed` logs it as one logcat line (`iris renderer init
failed: ...`, newlines replaced with ` | `) and shows the full
multi-line text on screen: a new `IrisView.showRendererError(String)`
(an ordinary instance method Rust calls into via JNI, not a `native`
one -- the direction is Rust reaching into Java, the opposite of
every `native fn` this view already declares) swaps the activity's
whole content for a plain, selectable, scrollable `TextView`, opening
with "Copy this text and send it to Iris" (UI_RULES.md: a failure is
reported where it happened and says what to do next). Desktop's
`UiRenderer::new` keeps panicking on failure (no on-screen fallback
exists there) but now with wgpu's full chain as the message, since it
no longer relies on wgpu's own handler getting there first.
**A real, separate reentrancy bug turned up while testing this, and
is fixed alongside it.** Calling `Activity::setContentView` directly
from inside `surface_changed` deadlocked -- not literally, but hit
Rust's `RefCell already borrowed` abort: `setContentView` tears the
old view hierarchy down synchronously, which fires `IrisView`'s own
`onFocusChanged` *before* `setContentView` returns, straight back
into the same `IrisViewPeer` through `on_focus_changed` while
android-view's own dispatch (`with_peer` in its `view.rs`) still
holds this peer's `RefCell` borrow for the `surface_changed` call in
progress. Found by deliberately inducing a validation error (see
below) and watching it abort a different way than the crash this
pass was fixing. Fixed by moving the Java call into
`ctx.push_dynamic_deferred_callback`, which android-view already
runs only after dropping the borrow (confirmed by reading
`with_peer`'s body) -- the same mechanism `raise_if_enabled` (this
file's AccessKit push) already relies on for the identical reason.
Left as a comment at the call site rather than only here, since the
next thing that reaches into Java from inside a `ViewPeer` callback
needs the same warning.
**2. The audit -- every bind-group-layout entry, checked against
wgpu-core's actual validation, not guessed.** `CreateBindGroupLayoutError`
(`wgpu-core::binding_model`) has seven variants; the ones a static,
no-`count`, no-feature layout like this crate's can hit are
`Entry { error: MissingDownlevelFlags(_) }` and
`Entry { error: MissingFeatures(_) }`. Walked every entry in
`uniform_layout`, `primitive_layout`, `rsc_layout`, `masks_layout`
(all four in `UiRenderNode::new`):
- `uniform_layout` (group 0): one uniform buffer, `VERTEX|FRAGMENT`.
Uniform buffers need no downlevel flag or feature at any
visibility. Not it.
- `primitive_layout` (group 1, `rects`/`glyphs`): two storage
buffers, both `FRAGMENT`-only (confirmed against `shader.wgsl`:
`rects`/`glyphs` are read only in `fs_main`). `FRAGMENT`-visible
storage buffers need `DownlevelFlags::FRAGMENT_STORAGE`, which
every backend in wgpu-hal grants unconditionally for a
non-write-only binding (`ty: Storage { read_only: true }` here).
Not it.
- `rsc_layout` (group 2, atlas/image/sampler): a `D2Array` texture,
a `D2` texture, a `NonFiltering` sampler, all `FRAGMENT`, no
`count`. `Bt::Texture`'s only feature requirement
(`TEXTURE_BINDING_ARRAY`) gates on `count.is_some()`, which none
of these set. Not it.
- `masks_layout` (group 3, `masks`/`move_offsets`): `masks` is
`FRAGMENT`-only (read only in `fs_main`'s mask lookup). But
`move_offsets` is `VERTEX | FRAGMENT` -- `shader.wgsl`'s
`resolve_move` is called from both `vs_main` (a primitive's own
corners) and `fs_main` (a mask's move chain) -- and it is a
storage buffer, which is exactly what
`wgpu-core/src/device/resource.rs` gates on
`DownlevelFlags::VERTEX_STORAGE` whenever `visibility` contains
`VERTEX` (confirmed by reading that check directly, not inferring
it from the flag's name). **This is the one entry among all four
layouts whose validity is device-dependent rather than static.**
**Named hypothesis: the delivered build forced GLES, and GLES's
`VERTEX_STORAGE` is not unconditional the way Vulkan's is.**
Read `wgpu-hal-28.0.0`'s two backends' own downlevel-flag
construction (`vulkan/adapter.rs`, `gles/adapter.rs`):
- **Vulkan** grants `Df::VERTEX_STORAGE` unconditionally for any
Vulkan 1.0 device, alongside `COMPUTE_SHADERS`/`FRAGMENT_STORAGE`
and others in one unconditional `Df::empty() | ... ` -- there is
no `.set(VERTEX_STORAGE, <device check>)` call anywhere in that
file. This is why the emulator's SwiftShader-Vulkan run and the
desktop's real Vulkan both pass: **on Vulkan, this exact layout
cannot fail this check, on any conforming device.**
- **GLES** computes it explicitly:
`downlevel_flags.set(VERTEX_STORAGE, max_storage_block_size != 0
&& max_storage_buffers_per_shader_stage != 0 &&
(vertex_shader_storage_blocks != 0 || vertex_ssbo_false_zero))` --
i.e. it depends on the driver actually reporting a nonzero
`GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS`. This is a known-weak spot
on Android GLES drivers specifically (vertex-stage SSBO support
lags fragment-stage support even on ES 3.1+ hardware), and this
exact document already found the adjacent failure mode once this
pass: SwiftShader's ES 3.0 GL path reports zero storage-buffer
capacity at all (`max_storage_buffer_binding_size: 0`, this box's
item 2 under "The three remaining I5 verifications"). A phone
that negotiates a GLES context with no (or driver-buggy)
vertex-stage SSBO support hits precisely this: `masks_layout`'s
`move_offsets` entry, `MissingDownlevelFlags(VERTEX_STORAGE)`.
**And the delivered build does force GLES.** `~/repos/
ai-app-bench`'s own README (committed alongside the P0 APKs)
says so directly: "`force-gles` matches I5's own ... finding: the
default Vulkan backend has no adapter under a plain `-gpu host`
boot on this AVD" -- true and reasoned correctly *for this VM's
emulator*, but `build-apk.sh`'s default `FEATURES` applied the
same flag to every arm64 build regardless of target, so the exact
same cfg-locked GLES-only path that was a deliberate, documented
emulator workaround shipped to a real Mali phone with no way to
turn it back to Vulkan short of a rebuild. `force-gles`'s own doc
comment (`iris/Cargo.toml`) only ever talks about the emulator
("the same build can be measured against SwiftShader's software
Vulkan ICD ... or virgl's GLES path") -- real hardware was never
the case it was written for.
**What would confirm or kill this, from the on-screen report
alone**: `backend: Gl` (confirms GLES was in fact what ran) and
`downlevel flags: DownlevelFlags(...)` *not* containing
`VERTEX_STORAGE` in the list. If a future report instead shows
`backend: Vulkan` with `VERTEX_STORAGE` present, this hypothesis is
wrong and the "Caused by" chain in that same report names the real
one directly -- which is the entire point of doing (1) first.
**Fix applied**: `build-apk.sh`'s default `--features` dropped
`force-gles` (now `"transcript-screen bench"`, was
`"transcript-screen force-gles bench"`), with a comment explaining
why and telling a future emulator-isolation run to pass it back
explicitly. This is a build/delivery fix, not a shader rewrite --
the shader itself is untouched, since moving `move_offsets` off a
storage buffer is real scope this document already declined once
this pass for the adjacent SwiftShader-ES-3.0 finding, and the
actual defect here is that a debug-only backend override reached a
real device, not that the shader's design is wrong. The rebuilt
arm64 APK (below) uses the default backend, i.e. Vulkan on a real
phone -- if it still fails, (1)'s on-screen report is what comes
back this time, not a truncated abort.
**Verified, this checkout's emulator, cold `emu up`
(`ai-app-2`):**
- GLES (`force-gles`, matching every prior P0 GLES reading's
backend): `run-bench.sh` end to end, no crash, report unchanged
in shape from the pre-fix readings above
(`frames=691 janky%=58.90 p50=19.2ms p90=43.7ms p99=62.4ms
worst=69.3ms cpu_p50=4.7ms gpu_wait_p50=12.6ms`, 24/24 swipes,
400/400 streamed events) -- the diagnostic wrapper adds no
measurable cost or behaviour change on the success path.
- **Induced failure, confirmed the fix works end to end**: added a
temporary `count: Some(NonZeroU32::new(2))` to `uniform_layout`'s
one entry (an artificial `ArrayUnsupported`/`MissingFeatures`,
chosen because it is guaranteed to fail on every backend rather
than depending on this VM's flaky adapter enumeration), rebuilt,
installed, launched: logcat showed the full one-line report
(adapter `Android Emulator OpenGL ES Translator (virgl ...)`,
every named limit, the downlevel flags, and wgpu's "Caused by"
chain naming `Binding 0 entry is invalid` / the missing
`BUFFER_BINDING_ARRAY` feature) and `ui-trace elements` confirmed
a `TextView` labelled with that exact report text was on screen
-- process alive, no abort. This is also what caught the
reentrancy bug above (the first attempt aborted a different way,
`RefCell already borrowed`, fixed, then reproduced clean). The
temporary `count: Some(...)` was reverted before anything else.
- **Vulkan (no `force-gles`) could not be re-verified this pass**:
this cold `emu up` enumerates zero Vulkan adapters
(`wgpu_core::instance: enabled backend 'Vulkan' has no adapters`,
the *unrelated*, already-panicking `.expect("Could not get
adapter!")` path this fix does not touch) even with the default
`-gpu host` boot the same README quoted above once relied on --
matching this document's own prior notes that Vulkan
availability on this VM's emulator is flaky across cold boots,
not something this pass's diff caused (confirmed by checking the
panic message is byte-for-byte the pre-existing
`RequestAdapterError` shape, not a new one). Not chased further:
it is orthogonal to the crash this pass fixes, and the real test
of "does Vulkan work" is the phone itself, not this VM.
**Checks, all clean**: `cargo fmt --all -- --check` and
`cargo clippy --workspace --all-targets` (iris workspace, zero
warnings beyond the pre-existing wgpu/winit future-incompat
notice), `cargo test --workspace` (unchanged counts, all passing --
nothing here touched logic under test), `cargo ndk -t x86_64 -P 26
clippy --features "transcript-screen force-gles bench" --lib -- -D
warnings` (clean), `cargo ndk -t arm64-v8a -P 26 build --release
--features "transcript-screen bench"` (clean, arm64-only
`jniLibs`).
**Redelivered, 2026-09-06.** New arm64 APK (Vulkan, no
`force-gles`), same `dev.iris.android.demo.bench` id, same
`CN=ai-app` signing cert, copied to `~/host/bench/
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
dated entry explaining both changes (the diagnostic and the
`force-gles` removal) so Iris can tell this build apart from the
one that crashed. **Not done this pass**: confirming the fix on
Iris's actual phone -- nobody on this session has it or `adb`
access to it, so this is read from the crash report and wgpu's
source, verified as far as this VM's tooling reaches, and handed
back with a diagnostic that will say the real story on the next
run either way.
- [ ] **P1 — session screen parity.** History paging backward (with the - [ ] **P1 — session screen parity.** History paging backward (with the
page-boundary healing `client-core` does not have yet, below), page-boundary healing `client-core` does not have yet, below),
`TranscriptSource`-backed cache/server stitching, jump-to-latest, `TranscriptSource`-backed cache/server stitching, jump-to-latest,
+1
View File
@@ -1757,6 +1757,7 @@ dependencies = [
"fxhash", "fxhash",
"image", "image",
"parley", "parley",
"pollster",
"swash", "swash",
"wgpu", "wgpu",
] ]
+1
View File
@@ -1785,6 +1785,7 @@ dependencies = [
"fxhash", "fxhash",
"image", "image",
"parley", "parley",
"pollster",
"swash", "swash",
"wgpu", "wgpu",
] ]
@@ -1,6 +1,10 @@
package dev.iris.android.demo; package dev.iris.android.demo;
import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.view.Gravity;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView; import org.linebender.android.rustview.RustView;
@@ -33,4 +37,35 @@ public final class IrisView extends RustView {
unregisterInsetsNative(mViewPeer); unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow(); 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 # emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass # build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to # 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 set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
BUILD_TYPE="debug" BUILD_TYPE="debug"
ABI="arm64-v8a" ABI="arm64-v8a"
FEATURES="transcript-screen force-gles bench" FEATURES="transcript-screen bench"
case "${1:-}" in case "${1:-}" in
debug|release) BUILD_TYPE="$1"; shift ;; debug|release) BUILD_TYPE="$1"; shift ;;
esac esac
+6
View File
@@ -5,6 +5,12 @@ edition.workspace = true
[dependencies] [dependencies]
wgpu = { workspace = true } 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 } bytemuck ={ workspace = true }
image = { workspace = true } image = { workspace = true }
parley = { workspace = true } parley = { workspace = true }
+42 -3
View File
@@ -4,6 +4,7 @@ use crate::{
util::{HashMap, Vec2}, util::{HashMap, Vec2},
}; };
use data::WindowUniform; use data::WindowUniform;
use pollster::FutureExt;
use wgpu::{ use wgpu::{
util::{BufferInitDescriptor, DeviceExt}, util::{BufferInitDescriptor, DeviceExt},
*, *,
@@ -251,7 +252,34 @@ impl UiRenderNode {
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); 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 { let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"), label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()), source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
@@ -373,7 +401,18 @@ impl UiRenderNode {
cache: None, 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, uniform_group,
primitive_layout, primitive_layout,
rsc_layout, rsc_layout,
@@ -387,7 +426,7 @@ impl UiRenderNode {
move_offsets, move_offsets,
masks_layout, masks_layout,
masks_group, masks_group,
} })
} }
fn bind_group_0( fn bind_group_0(
+73 -3
View File
@@ -51,7 +51,20 @@ pub struct AndroidRenderer {
} }
impl 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 // `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same // the software-Vulkan (SwiftShader) path for GLES/virgl on the same
// build, to isolate whether the backend itself explains the frame // build, to isolate whether the backend itself explains the frame
@@ -84,6 +97,18 @@ impl AndroidRenderer {
.block_on() .block_on()
.expect("Could not get adapter!"); .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 // Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape". // binding-array features, see TEXTURES.md's "Recommended shape".
// `iris_core::device_limits()` is shared between the two backends; // `iris_core::device_limits()` is shared between the two backends;
@@ -117,16 +142,61 @@ impl AndroidRenderer {
surface.configure(&device, &config); surface.configure(&device, &config);
let encoder = Self::create_encoder(&device); 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, surface,
device, device,
queue, queue,
config, config,
encoder, encoder,
ui, 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 { fn create_encoder(device: &Device) -> CommandEncoder {
+68 -2
View File
@@ -4,7 +4,11 @@ use accesskit_android::Adapter as AccessAdapter;
use android_view::{ use android_view::{
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context, AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer, 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}, ndk::event::{Keycode, MotionAction},
}; };
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the // `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); 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> { impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
fn on_key_down<'local>( fn on_key_down<'local>(
&mut self, &mut self,
@@ -445,9 +474,46 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// one from the new window -- see `AndroidRenderer`'s doc comment. // one from the new window -- see `AndroidRenderer`'s doc comment.
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
ui_state.renderer = None; ui_state.renderer = None;
ui_state.renderer = Some(AndroidRenderer::new(window, width as u32, height as u32)); // `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); 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>( fn surface_destroyed<'local>(
&mut self, &mut self,
+8 -1
View File
@@ -141,7 +141,14 @@ impl UiRenderer {
let encoder = Self::create_encoder(&device); 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 { Self {
surface, surface,