Found during an end-to-end pass over the v0.19.0 + 7 commits release candidate, on a clean single-tool-server host (iPhone 17 Pro, iOS 26.5).
Summary
When an injected app is running but not in the foreground, native-devtools-status reports a fully healthy connection:
{ "envSetup": true, "appRunning": true, "connected": true,
"requiresRestart": false, "nextLaunchWillBeInjected": true, "injectable": true }
but every app-scoped native tool then hangs for its full timeout and fails with an opaque:
[Tool:native-describe-screen] ViewInspector RPC timed out: ViewHierarchy.describeScreen
Nothing in either response mentions that the app is backgrounded, which is the actual cause.
Reproduction
- Launch an injectable app with
restart-app and confirm native-devtools-status → connected: true.
- Foreground a different app (in my case a flow run whose
launch targeted com.apple.Preferences; any launch-app does it).
- Call
native-describe-screen with the original bundleId.
Result: the RPC times out. Reproduced twice in a row, so it is not a transient.
restart-app the original bundle to bring it forward, then call the same tool again.
Result: succeeds immediately, returning the expected element list.
native-devtools-status returns the same healthy payload at every step above, including while the RPCs are failing.
Why this is worse than a plain timeout
The tool's own contract routes recovery off requiresRestart:
If requiresRestart is true: call restart-app, then proceed with the native feature.
Here requiresRestart is false and connected is true, so the documented decision procedure says "everything is fine, proceed" — while the thing you are about to call cannot work. An agent following the contract has no signal to act on and simply burns a timeout per call.
Compare with the non-injectable path, which is handled well: injectable: false is explicitly documented as terminal, tells the caller not to retry, and names the alternative (describe / screenshot). That is the standard this path does not meet.
There is also a real cost per occurrence: the failure is a full RPC timeout rather than a fast rejection, so a multi-step investigation stalls for a long time before producing an error that does not say what to do.
Suggested fix
Two parts, either of which helps on its own:
- Report app state in
native-devtools-status. connected currently means "the dylib registered a connection", which stays true across backgrounding. Either add a foreground/app-state field, or make connected reflect whether the runtime can actually service a request.
- Make the timeout diagnosable. When the target bundle is not the foreground app, fail fast with that reason and the remedy (
restart-app / launch-app to bring it forward) instead of a bare ViewInspector RPC timed out: <method>. As a fallback, the timeout message should at least name the bundle and its observed app state.
Notes
This is not the stuck-injection failure in #561 — that one pins connected: false and never recovers without a tool-server restart. This one reports connected: true, and foregrounding the app fixes it immediately. Both were observed in the same session and are distinct.
Found during an end-to-end pass over the
v0.19.0+ 7 commits release candidate, on a clean single-tool-server host (iPhone 17 Pro, iOS 26.5).Summary
When an injected app is running but not in the foreground,
native-devtools-statusreports a fully healthy connection:{ "envSetup": true, "appRunning": true, "connected": true, "requiresRestart": false, "nextLaunchWillBeInjected": true, "injectable": true }but every app-scoped native tool then hangs for its full timeout and fails with an opaque:
Nothing in either response mentions that the app is backgrounded, which is the actual cause.
Reproduction
restart-appand confirmnative-devtools-status→connected: true.launchtargetedcom.apple.Preferences; anylaunch-appdoes it).native-describe-screenwith the originalbundleId.Result: the RPC times out. Reproduced twice in a row, so it is not a transient.
restart-appthe original bundle to bring it forward, then call the same tool again.Result: succeeds immediately, returning the expected element list.
native-devtools-statusreturns the same healthy payload at every step above, including while the RPCs are failing.Why this is worse than a plain timeout
The tool's own contract routes recovery off
requiresRestart:Here
requiresRestartisfalseandconnectedistrue, so the documented decision procedure says "everything is fine, proceed" — while the thing you are about to call cannot work. An agent following the contract has no signal to act on and simply burns a timeout per call.Compare with the non-injectable path, which is handled well:
injectable: falseis explicitly documented as terminal, tells the caller not to retry, and names the alternative (describe/screenshot). That is the standard this path does not meet.There is also a real cost per occurrence: the failure is a full RPC timeout rather than a fast rejection, so a multi-step investigation stalls for a long time before producing an error that does not say what to do.
Suggested fix
Two parts, either of which helps on its own:
native-devtools-status.connectedcurrently means "the dylib registered a connection", which stays true across backgrounding. Either add a foreground/app-state field, or makeconnectedreflect whether the runtime can actually service a request.restart-app/launch-appto bring it forward) instead of a bareViewInspector RPC timed out: <method>. As a fallback, the timeout message should at least name the bundle and its observed app state.Notes
This is not the stuck-injection failure in #561 — that one pins
connected: falseand never recovers without a tool-server restart. This one reportsconnected: true, and foregrounding the app fixes it immediately. Both were observed in the same session and are distinct.