You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(server): device hub errors show the hub's reason instead of a connection error - #31736
When expo-device-hub answers a request with a non-2xx status, T3 drops the hub's reason and says Could not communicate with device support. Try refreshing devices. The reporter's case: serve-sim's POST /vendor/serve-sim/grid/api/start answers 500 {"ok":false,"error":"Could not release launch state for <udid>: …/launch-<udid>.lock is still locked. …"}, and device_open only says Device attach stream failed: Could not communicate with device support. Try refreshing devices. The hub was reachable, and refreshing doesn't help. hubJson ran filterStatusOk before reading the body and mapped every failure to request_failed. DeviceOperationError built its message from reason alone, so no hub text could reach the MCP tool result or the Device panel. The same masking hit list, boot and shutdown, because they share hubJson.
Change
hubJson (apps/server/src/device/DeviceHubOperations.ts): a non-2xx response now fails with hub_rejected, the reason screenshots already use for status errors. When the body is JSON with an error string, its head (errorHead, bounded by boundDiagnostics, as boot and list already do) goes in the error. Other bodies are not echoed. Transport failures, timeouts and 2xx decode failures stay request_failed.
DeviceOperationError (packages/contracts/src/device.ts) gains an optional diagnostics string, the same shape and 2,000-character cap as DeviceBootError.diagnostics. The message appends it after a blank line. The field is optional, so older clients still decode the error and just show the generic text.
The stale empty serve-sim lock itself lives in expo-device-hub and is not changed here. With this fix, T3 shows the lock path, so the user can see what to clear.
Red test first: reports the hub's error when it rejects attaching a simulator stream (apps/server/src/device/DeviceService.test.ts) fails on main (reason: "request_failed", generic message) and passes here. keeps a hub transport failure distinct from a hub rejection guards the transport path.
vp run check:changed on the merged head: format, lint, knip, typecheck (36 packages) and tests (3 files, 67 tests) pass.
Runtime transcript: a driver runs the server's real makeDeviceHubOperations against a real HTTP server that answers the way expo-device-hub does. The cases are iOS open of a shut-down simulator (boot, then grid start 500), iOS open of a booted simulator (grid start 500), Android boot 500, list 503, iOS shutdown 500 and an Android shutdown 502 with an HTML body. All data is synthetic.
Case
Before (main 7462b16f36)
After (626be5ad6c, merged with main)
A: iOS open, shut-down simulator
request_failed: "Could not communicate with device support."
hub_rejected: "…could not complete the request." + the lock error
B: iOS open, booted simulator
request_failed
hub_rejected + the lock error
C: Android boot 500
request_failed
hub_rejected + the emulator error
D: device list 503
request_failed
hub_rejected + the simctl error
E: iOS shutdown 500, fallback list 503
request_failed
hub_rejected + the list's error (the existing fallback reports it)
F: Android shutdown 502, non-JSON body
request_failed
hub_rejected, no detail: the body is not echoed
Before transcript
# before: main 7462b16f369d36af51af2e3c50bc35b45cd7284a, node v26.13.0
A: device_open on a shut-down simulator (boot, then grid start 500)
hub <- POST /api/devices/boot {"platform":"ios","id":"844CDB49-0000-4000-8000-000000000000","name":"iPhone 17"}
hub <- POST /vendor/serve-sim/grid/api/start {"udid":"844CDB49-0000-4000-8000-000000000000"}
DeviceOperationError reason=request_failed
message: "Device attach stream failed: Could not communicate with device support. Try refreshing devices."
B: device_open on a booted simulator (attach, grid start 500)
hub <- POST /vendor/serve-sim/grid/api/start {"udid":"844CDB49-0000-4000-8000-000000000000"}
DeviceOperationError reason=request_failed
message: "Device attach stream failed: Could not communicate with device support. Try refreshing devices."
C: Android boot answered 500
hub <- POST /api/devices/boot {"platform":"android","id":"Pixel_9","name":"Pixel_9"}
DeviceOperationError reason=request_failed
message: "Device boot failed: Could not communicate with device support. Try refreshing devices."
D: device list answered 503
hub <- GET /api/devices
DeviceOperationError reason=request_failed
message: "Device list failed: Could not communicate with device support. Try refreshing devices."
E: iOS shutdown answered 500, then its fallback device list answered 503
hub <- POST /vendor/serve-sim/grid/api/shutdown {"udid":"844CDB49-0000-4000-8000-000000000000"}
hub <- GET /api/devices
DeviceOperationError reason=request_failed
message: "Device list failed: Could not communicate with device support. Try refreshing devices."
F: Android shutdown answered 502 with a non-JSON body
hub <- POST /api/devices/shutdown {"platform":"android","id":"emulator-5554"}
DeviceOperationError reason=request_failed
message: "Device shutdown failed: Could not communicate with device support. Try refreshing devices."
After transcript
# after: fix/issue-31730 23e2052e9614fa1705ecff4bc6442eab6564d989 (fix commit 626be5ad6c merged with main), node v26.13.0
A: device_open on a shut-down simulator (boot, then grid start 500)
hub <- POST /api/devices/boot {"platform":"ios","id":"844CDB49-0000-4000-8000-000000000000","name":"iPhone 17"}
hub <- POST /vendor/serve-sim/grid/api/start {"udid":"844CDB49-0000-4000-8000-000000000000"}
DeviceOperationError reason=hub_rejected
message: "Device attach stream failed: The device hub could not complete the request.\n\nCould not release launch state for 844CDB49-0000-4000-8000-000000000000: /var/folders/xx/T/serve-sim/launch-844CDB49-0000-4000-8000-000000000000.lock is still locked. Retry cleanup after the active command finishes."
B: device_open on a booted simulator (attach, grid start 500)
hub <- POST /vendor/serve-sim/grid/api/start {"udid":"844CDB49-0000-4000-8000-000000000000"}
DeviceOperationError reason=hub_rejected
message: "Device attach stream failed: The device hub could not complete the request.\n\nCould not release launch state for 844CDB49-0000-4000-8000-000000000000: /var/folders/xx/T/serve-sim/launch-844CDB49-0000-4000-8000-000000000000.lock is still locked. Retry cleanup after the active command finishes."
C: Android boot answered 500
hub <- POST /api/devices/boot {"platform":"android","id":"Pixel_9","name":"Pixel_9"}
DeviceOperationError reason=hub_rejected
message: "Device boot failed: The device hub could not complete the request.\n\nemulator: ERROR: AVD Pixel_9 is already running"
D: device list answered 503
hub <- GET /api/devices
DeviceOperationError reason=hub_rejected
message: "Device list failed: The device hub could not complete the request.\n\nsimctl list timed out after 30s"
E: iOS shutdown answered 500, then its fallback device list answered 503
hub <- POST /vendor/serve-sim/grid/api/shutdown {"udid":"844CDB49-0000-4000-8000-000000000000"}
hub <- GET /api/devices
DeviceOperationError reason=hub_rejected
message: "Device list failed: The device hub could not complete the request.\n\nsimctl list timed out after 30s"
F: Android shutdown answered 502 with a non-JSON body
hub <- POST /api/devices/shutdown {"platform":"android","id":"emulator-5554"}
DeviceOperationError reason=hub_rejected
message: "Device shutdown failed: The device hub could not complete the request."
Driver (not committed; run from apps/server with a node_modules link to it)
// Runs T3's DeviceHubOperations against a real HTTP server that answers like// expo-device-hub 0.19.1 when serve-sim's launch lock is stuck (issue 31730).importhttpfrom"node:http";import*asEffectfrom"effect/Effect";import{HttpClient}from"effect/http";import*asFetchHttpClientfrom"effect/http/FetchHttpClient";constworktree=process.argv[2];const{ makeDeviceHubOperations }=awaitimport(`${worktree}/apps/server/src/device/DeviceHubOperations.ts`);constudid="844CDB49-0000-4000-8000-000000000000";constlockError=`Could not release launch state for ${udid}: /var/folders/xx/T/serve-sim/launch-${udid}.lock is still locked. Retry cleanup after the active command finishes.`;constserver=http.createServer((req,res)=>{letbody="";req.on("data",(c)=>(body+=c));req.on("end",()=>{console.log(` hub <- ${req.method}${req.url}${body}`);constsend=(status,json)=>{res.writeHead(status,{"content-type": "application/json"});res.end(JSON.stringify(json));};if(req.url==="/vendor/serve-sim/grid/api/start")returnsend(500,{ok: false,error: lockError});if(req.url==="/api/devices/boot"&&body.includes("android"))returnsend(500,{ok: false,error: "emulator: ERROR: AVD Pixel_9 is already running"});if(req.url==="/api/devices/boot")returnsend(200,{ok: true,id: udid});if(req.url==="/vendor/serve-sim/grid/api/shutdown")returnsend(500,{ok: false,error: "simctl shutdown failed: Unable to shutdown device in current state: Creating"});if(req.url==="/api/devices/shutdown"){res.writeHead(502,{"content-type": "text/html"});returnres.end("<html>private hub output</html>");}if(req.url==="/api/devices")returnsend(503,{ok: false,error: "simctl list timed out after 30s"});send(404,{ok: false,error: "not found"});});});awaitnewPromise((r)=>server.listen(0,"127.0.0.1",r));constorigin=`http://127.0.0.1:${server.address().port}`;constprogram=Effect.gen(function*(){consthttp=yield*HttpClient.HttpClient;constops=makeDeviceHubOperations({hostId: "local", origin, http,run: ()=>Effect.die("no commands"),androidAvailable: Effect.succeed(false)});constios={id: udid,name: "iPhone 17",version: "iOS 27.2",platform: "ios",booted: false,physical: false};constcases=[["A: device_open on a shut-down simulator (boot, then grid start 500)",ops.boot(ios)],["B: device_open on a booted simulator (attach, grid start 500)",ops.attach({ ...ios,booted: true})],["C: Android boot answered 500",ops.boot({id: "Pixel_9",name: "Pixel_9",version: "Android",platform: "android",booted: false,physical: false})],["D: device list answered 503",ops.list],["E: iOS shutdown answered 500, then its fallback device list answered 503",ops.shutdown({ ...ios,booted: true})],["F: Android shutdown answered 502 with a non-JSON body",ops.shutdown({id: "emulator-5554",name: "Pixel_9",version: "Android",platform: "android",booted: true,physical: false})],];for(const[label,eff]ofcases){console.log(label);constr=yield*Effect.result(eff);if(r._tag==="Success")console.log(" ok",JSON.stringify(r.success).slice(0,120));elseconsole.log(` ${r.failure._tag} reason=${r.failure.reason}\n message: ${JSON.stringify(r.failure.message)}`);}}).pipe(Effect.provide(FetchHttpClient.layer));awaitEffect.runPromise(program);server.close();
Not checked: a real expo-device-hub with a stale serve-sim lock on macOS. That needs a Mac with iOS simulators. The changed code is platform-independent HTTP handling and is exercised above against a real HTTP server.
AI-assisted; verified with the tests and tools listed above.
A non-2xx answer from expo-device-hub to list, boot, attach or shutdown was
reported as "Could not communicate with device support", dropping the hub's
error (for example serve-sim's held launch lock on grid start).
hubJson now fails such responses with reason "hub_rejected" and, when the
body is JSON with an `error` string, carries its bounded head as the new
optional DeviceOperationError.diagnostics, which the message appends. Other
bodies give no detail. Transport failures, timeouts and 2xx decode failures
stay "request_failed".
Fixes#31730
No actionable comments were generated in the recent review.
Walkthrough
hubJson now classifies hub HTTP status failures as hub_rejected and includes available, bounded error details in DeviceOperationError messages. Other request failures remain request_failed. The attach test checks the rejection details and verifies that no command runs.
DeviceOperationError accepts optional diagnostics up to 2,000 characters and appends them to its message when present.
Hub response classification and attach validation apps/server/src/device/DeviceHubOperations.ts, apps/server/src/device/DeviceService.test.ts
hubJson parses non-success response bodies for a string error, limits the diagnostic text, and reports usable details with hub_rejected. Other request failures remain request_failed. The attach test verifies the rejection detail and confirms that no command runs.
sequenceDiagram
participant DeviceService
participant hubJson
participant DeviceHub
DeviceService->>hubJson: Start attach operation
hubJson->>DeviceHub: Send HTTP request
DeviceHub-->>hubJson: Return non-success status and error body
hubJson-->>DeviceService: Raise DeviceOperationError with hub_rejected and diagnostics
Loading
Possibly related issues
pingdotgg/t3code#24033: The shared hub request handling exposes bounded error details from non-success responses, including for screenshot operations.
Possibly related PRs
pingdotgg/t3code#25216: Both changes classify hub HTTP rejections separately from transport failures; that PR applies the classification to screenshots.
pingdotgg/t3code#33868: Both changes use the hub_rejected error category, though this PR focuses on watchOS screenshot outcomes.
pingdotgg/t3code#24040: Both changes classify hub HTTP rejections and preserve actionable device error details; that PR applies the behavior to screenshots and MCP results.
Suggested reviewers:t3dotgg
Merge Risk: Minimal · up to c3d37
Hub rejection messages should now show the hub’s reason. No actionable issue is established that would prevent merging after normal checks.
Pre-merge checks: 3 · 1
Failed checks: 1 inconclusive
Check name
Status
Explanation
Resolution
Out of Scope Changes check
The incremental diff adds iOS 26 screenshot fallback behavior and platform-aware device-list timeout and retention behavior. These changes do not have a clear connection to [#31730]. The whole-PR diff…
A readable whole-PR diff against merge base 9cd7a04 is needed to determine whether the additional screenshot and device-list behavior is in scope for this pull request.
Passed checks: 3 passed
Check name
Status
Explanation
Title check
The title clearly and concisely describes the main change: showing the device hub’s reason instead of a generic connection error.
Description check
The description covers the problem, change, scope, and focused verification, with concrete test results and a clear limitation. The scope section links the issue but does not include explicit maintain…
Linked Issues check
[#31730] The reported device_open attach failure still uses the hub rejection handling. The regression test for an attach response with the hub's lock error remains in the reviewed code. The changes…
Full details: Description check
Explanation
The description covers the problem, change, scope, and focused verification, with concrete test results and a clear limitation. The scope section links the issue but does not include explicit maintainer approval or explain why the fix qualifies for an exception; this is a minor gap in an otherwise complete description.
Full details: Linked Issues check
Explanation
[#31730] The reported device_open attach failure still uses the hub rejection handling. The regression test for an attach response with the hub's lock error remains in the reviewed code. The changes since the previous review do not alter that attach path.
Full details: Out of Scope Changes check
Explanation
The incremental diff adds iOS 26 screenshot fallback behavior and platform-aware device-list timeout and retention behavior. These changes do not have a clear connection to [#31730]. The whole-PR diff against the merge base was unavailable, so I cannot establish whether these additions are PR changes or changes already present through the merge base.
Resolution
A readable whole-PR diff against merge base 9cd7a04 is needed to determine whether the additional screenshot and device-list behavior is in scope for this pull request.
Finishing Touches
Generate docstrings
Commit to this branch
Create a new PR
Generate unit tests (beta)
Commit to this branch
Create a new PR
Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Reviewing files that changed from the base of the PR and between 23e2052 and c3d3764.
Files selected for processing: 3
apps/server/src/device/DeviceHubOperations.ts
apps/server/src/device/DeviceService.test.ts
packages/contracts/src/device.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.
Macroscope's review found this PR approvable — Device hub rejections now show a bounded diagnostic instead of a misleading connection error. Successful operations remain unchanged, and arbitrary response bodies are not echoed.
You can add or adjust custom eligibility rules. Learn more.
The automated pipeline stopped on this pull request and will not merge it:
the capture step could not show the problem on the base commit, so there is nothing to prove fixed.
The required macOS stale-lock device_open reproduction was not run. For a supplementary Linux check on both commits: enable device support with agent access disabled; request device.shutdown for synthetic Android device evidence-missing-device; capture the serialized response; disable support. Both returned DeviceOperationError with reason hub_rejected and no diagnostics. This did not exercise the changed non-2xx response path.
It stays open for a person to finish or close.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
bugfixFix PR opened by an issue lead; enters the PR looppr:gate-failedA land gate failedsize:M30-99 changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.
1 participant
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When expo-device-hub answers a request with a non-2xx status, T3 drops the hub's reason and says
Could not communicate with device support. Try refreshing devices.The reporter's case: serve-sim'sPOST /vendor/serve-sim/grid/api/startanswers500 {"ok":false,"error":"Could not release launch state for <udid>: …/launch-<udid>.lock is still locked. …"}, anddevice_openonly saysDevice attach stream failed: Could not communicate with device support. Try refreshing devices.The hub was reachable, and refreshing doesn't help.hubJsonranfilterStatusOkbefore reading the body and mapped every failure torequest_failed.DeviceOperationErrorbuilt its message fromreasonalone, so no hub text could reach the MCP tool result or the Device panel. The same masking hit list, boot and shutdown, because they sharehubJson.Change
hubJson(apps/server/src/device/DeviceHubOperations.ts): a non-2xx response now fails withhub_rejected, the reason screenshots already use for status errors. When the body is JSON with anerrorstring, its head (errorHead, bounded byboundDiagnostics, asbootandlistalready do) goes in the error. Other bodies are not echoed. Transport failures, timeouts and 2xx decode failures stayrequest_failed.DeviceOperationError(packages/contracts/src/device.ts) gains an optionaldiagnosticsstring, the same shape and 2,000-character cap asDeviceBootError.diagnostics. The message appends it after a blank line. The field is optional, so older clients still decode the error and just show the generic text.The stale empty serve-sim lock itself lives in expo-device-hub and is not changed here. With this fix, T3 shows the lock path, so the user can see what to clear.
Scope and approval
Fixes #31730
Verification
reports the hub's error when it rejects attaching a simulator stream(apps/server/src/device/DeviceService.test.ts) fails on main (reason: "request_failed", generic message) and passes here.keeps a hub transport failure distinct from a hub rejectionguards the transport path.vp run check:changedon the merged head: format, lint, knip, typecheck (36 packages) and tests (3 files, 67 tests) pass.makeDeviceHubOperationsagainst a real HTTP server that answers the way expo-device-hub does. The cases are iOS open of a shut-down simulator (boot, then grid start 500), iOS open of a booted simulator (grid start 500), Android boot 500, list 503, iOS shutdown 500 and an Android shutdown 502 with an HTML body. All data is synthetic.7462b16f36)626be5ad6c, merged with main)request_failed: "Could not communicate with device support."hub_rejected: "…could not complete the request." + the lock errorrequest_failedhub_rejected+ the lock errorrequest_failedhub_rejected+ the emulator errorrequest_failedhub_rejected+ the simctl errorrequest_failedhub_rejected+ the list's error (the existing fallback reports it)request_failedhub_rejected, no detail: the body is not echoedBefore transcript
After transcript
Driver (not committed; run from
apps/serverwith anode_moduleslink to it)Not checked: a real expo-device-hub with a stale serve-sim lock on macOS. That needs a Mac with iOS simulators. The changed code is platform-independent HTTP handling and is exercised above against a real HTTP server.
AI-assisted; verified with the tests and tools listed above.