Skip to content

fix(server): device hub errors show the hub's reason instead of a connection error - #31736

Open
juliusmarminge wants to merge 5 commits into
mainfrom
fix/issue-31730
Open

juliusmarminge wants to merge 5 commits into
mainfrom
fix/issue-31730

Conversation

@juliusmarminge

Copy link
Copy Markdown
Member

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'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.

Scope and approval

Fixes #31730

Verification

  • 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).
import http from "node:http";
import * as Effect from "effect/Effect";
import { HttpClient } from "effect/http";
import * as FetchHttpClient from "effect/http/FetchHttpClient";

const worktree = process.argv[2];
const { makeDeviceHubOperations } = await import(`${worktree}/apps/server/src/device/DeviceHubOperations.ts`);
const udid = "844CDB49-0000-4000-8000-000000000000";
const lockError = `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.`;
const server = http.createServer((req, res) => {
  let body = ""; req.on("data", (c) => (body += c));
  req.on("end", () => {
    console.log(`  hub <- ${req.method} ${req.url} ${body}`);
    const send = (status, json) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(json)); };
    if (req.url === "/vendor/serve-sim/grid/api/start") return send(500, { ok: false, error: lockError });
    if (req.url === "/api/devices/boot" && body.includes("android")) return send(500, { ok: false, error: "emulator: ERROR: AVD Pixel_9 is already running" });
    if (req.url === "/api/devices/boot") return send(200, { ok: true, id: udid });
    if (req.url === "/vendor/serve-sim/grid/api/shutdown") return send(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" }); return res.end("<html>private hub output</html>"); }
    if (req.url === "/api/devices") return send(503, { ok: false, error: "simctl list timed out after 30s" });
    send(404, { ok: false, error: "not found" });
  });
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const origin = `http://127.0.0.1:${server.address().port}`;
const program = Effect.gen(function* () {
  const http = yield* HttpClient.HttpClient;
  const ops = makeDeviceHubOperations({ hostId: "local", origin, http, run: () => Effect.die("no commands"), androidAvailable: Effect.succeed(false) });
  const ios = { id: udid, name: "iPhone 17", version: "iOS 27.2", platform: "ios", booted: false, physical: false };
  const cases = [
    ["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] of cases) {
    console.log(label);
    const r = yield* Effect.result(eff);
    if (r._tag === "Success") console.log("  ok", JSON.stringify(r.success).slice(0, 120));
    else console.log(`  ${r.failure._tag} reason=${r.failure.reason}\n  message: ${JSON.stringify(r.failure.message)}`);
  }
}).pipe(Effect.provide(FetchHttpClient.layer));
await Effect.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
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Nov 5, 2026
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

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.

Changes: Hub rejection handling

Layer / File(s) Summary
Operation error diagnostics
packages/contracts/src/device.ts
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.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

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: Passed 3 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Out of Scope Changes check Inconclusive 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 Passed checks: 3 passed

Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: showing the device hub’s reason instead of a generic connection error.
Description check Passed 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 Passed [#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
Recent review info

Run configuration

Commits

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.

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Nov 5, 2026
@macroscopeapp

macroscopeapp Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at c3d3764

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.

@juliusmarminge juliusmarminge added bugfix Fix PR opened by an issue lead; enters the PR loop pr:gate-failed A land gate failed labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 21.5 KiB 21.5 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 1 1 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.9 KiB 21.9 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 1 1 0 (0.0%) 8 ✅

Baseline: 9cd7a04 · PR result: c3d3764 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

# Conflicts:
#	apps/server/src/device/DeviceHubOperations.ts
AI-assisted; verified with the tests and tools listed above.
@macroscopeapp
macroscopeapp Bot dismissed their stale review November 10, 2026 21:34

Dismissing prior approval to re-evaluate c3d3764

Copy link
Copy Markdown
Member Author

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Fix PR opened by an issue lead; enters the PR loop pr:gate-failed A land gate failed size:M 30-99 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

device_open always fails on a simulator with an empty serve-sim launch lock, and the hub's 500 reason is dropped

1 participant