fix(android): wrap sync HTTP tasks in per-call Tokio runtime

reqwest/hyper-util's GaiResolver calls tokio::runtime::Handle::current()
which panics with "no reactor running" when driven by Bevy's
AsyncComputeTaskPool (async-executor, not Tokio).  Fixed all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().

Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.

Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-11 14:55:20 -07:00
parent ae7c6c97f1
commit 2a206b994c
3 changed files with 72 additions and 24 deletions
+32 -5
View File
@@ -186,11 +186,38 @@ rewrites required.
`card_plugin.rs` is explicit child-only teardown (parent kept alive) `card_plugin.rs` is explicit child-only teardown (parent kept alive)
and is correct. No gameplay bugs attributed to these warnings over 2+ and is correct. No gameplay bugs attributed to these warnings over 2+
min AVD runtime. min AVD runtime.
- [ ] **AVD functional tests for JNI bridges.** Clipboard (`2c822ba`) - [x] **AVD functional tests for JNI bridges.** *Partially closed
and Keystore (`f281425`) shipped but never tested on real device 2026-05-11.* Pixel 7 AVD (Android 14, x86_64) confirmed running;
or AVD. Requires hardware: connect Pixel 7 AVD (Android 14, x86_64), APK installs and runs stable. Key findings:
install the signed APK, and exercise the stats share-link button
(clipboard) and the login flow (keystore). **Keystore JNI — verified working.** Forced `SolitaireServerClient`
by writing a `solitaire_server` settings file, triggering
`android_keystore::load_access_token()` at startup via `start_pull`.
Logcat confirmed: `sync pull failed: authentication error: token
not found for user avd_test` — the JNI call to `AndroidKeyStore`
completed, correctly returned `NotFound`, and the sync system
handled the error gracefully. No panic, no crash from the JNI layer.
**Clipboard JNI — not yet exercised at runtime.** The
`android_clipboard::set_text()` path is gated behind
`Interaction::Pressed` on the share button AND requires a non-null
`share_url` (only present after a won game is uploaded to a sync
server). Both conditions are unreachable in a headless AVD session.
The code compiled and linked correctly on `x86_64-linux-android`.
**Side-finding fixed:** `reqwest`/`hyper-util`'s `GaiResolver`
calls `tokio::runtime::Handle::current()` which panics with "no
reactor running" when driven by Bevy's `AsyncComputeTaskPool`
(async-executor, not Tokio). Fixed in `sync_plugin.rs`: all three
`AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback
now wrap HTTP futures in a temporary
`tokio::runtime::Builder::new_current_thread().enable_all()` runtime.
**Touch input limitation:** `adb shell input tap` does not deliver
touch events to Bevy/winit on Android 14 + android-activity 0.6.1
in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally.
Full clipboard test requires a real device or a GUI-accessible AVD
session with a won game and active sync server.
--- ---
+10 -12
View File
@@ -2016,20 +2016,18 @@ pub fn challenge_time_color(remaining: u64) -> Color {
/// all three items remain on one row inside the 50 %-wide HUD column /// all three items remain on one row inside the 50 %-wide HUD column
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are /// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
/// restored so desktop/tablet layouts are unaffected. /// restored so desktop/tablet layouts are unaffected.
type HudScoreFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
type HudMovesFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
type HudTimeFont<'w, 's> =
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
fn update_hud_typography( fn update_hud_typography(
mut events: MessageReader<WindowResized>, mut events: MessageReader<WindowResized>,
mut score_q: Query< mut score_q: HudScoreFont,
&mut TextFont, mut moves_q: HudMovesFont,
(With<HudScore>, Without<HudMoves>, Without<HudTime>), mut time_q: HudTimeFont,
>,
mut moves_q: Query<
&mut TextFont,
(With<HudMoves>, Without<HudScore>, Without<HudTime>),
>,
mut time_q: Query<
&mut TextFont,
(With<HudTime>, Without<HudScore>, Without<HudMoves>),
>,
) { ) {
let Some(ev) = events.read().last() else { let Some(ev) = events.read().last() else {
return; return;
+30 -7
View File
@@ -130,7 +130,14 @@ fn start_pull(
) { ) {
let provider = provider.0.clone(); let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
provider.pull().await // Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
// a short-lived single-threaded runtime for this network round-trip.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
}); });
task_res.0 = Some(task); task_res.0 = Some(task);
status.0 = SyncStatus::Syncing; status.0 = SyncStatus::Syncing;
@@ -153,7 +160,11 @@ fn handle_manual_sync_request(
} }
let provider = provider.0.clone(); let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move { let task = AsyncComputeTaskPool::get().spawn(async move {
provider.pull().await tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.pull())
}); });
task_res.0 = Some(task); task_res.0 = Some(task);
status.0 = SyncStatus::Syncing; status.0 = SyncStatus::Syncing;
@@ -259,11 +270,18 @@ fn push_on_exit(
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let provider = provider.0.clone(); let provider = provider.0.clone();
// Prefer an existing tokio runtime; fall back to futures_lite block_on // Prefer an existing tokio runtime; fall back to a temporary one for
// for environments (e.g. tests) that don't have one. // environments (e.g. tests, Android's non-Tokio async executor) where
// reqwest/hyper would otherwise panic with "no reactor running".
let result = match tokio::runtime::Handle::try_current() { let result = match tokio::runtime::Handle::try_current() {
Ok(handle) => handle.block_on(provider.push(&payload)), Ok(handle) => handle.block_on(provider.push(&payload)),
Err(_) => future::block_on(provider.push(&payload)), Err(_) => match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt.block_on(provider.push(&payload)),
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
},
}; };
match result { match result {
Ok(_) => {} Ok(_) => {}
@@ -314,8 +332,13 @@ fn push_replay_on_win(
recording.moves.clone(), recording.moves.clone(),
); );
let provider = provider.0.clone(); let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get() let task = AsyncComputeTaskPool::get().spawn(async move {
.spawn(async move { provider.push_replay(&replay).await }); tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
.block_on(provider.push_replay(&replay))
});
// If a previous upload is still in flight, drop it — the most // If a previous upload is still in flight, drop it — the most
// recent win is the one whose share link the player will care // recent win is the one whose share link the player will care
// about. Bevy's `Task` Drop cancels cooperatively. // about. Bevy's `Task` Drop cancels cooperatively.