Repository navigation
feat(server): EAS Simulator device hosts - #21844
Merged
Merged
Conversation
juliusmarminge
added this pull request to stack #21847
October 19, 2026 18:09
Contributor
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 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.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
juliusmarminge
force-pushed
the
device/eas-host
branch
from
October 19, 2026 18:21
a22ff59 to
cad41e6
Compare
juliusmarminge
force-pushed
the
device/eas-host
branch
2 times, most recently
from
October 19, 2026 21:00
85f8851 to
7dd5473
Compare
juliusmarminge
force-pushed
the
device/eas-host
branch
from
October 19, 2026 22:12
7dd5473 to
8fe7f7d
Compare
juliusmarminge
force-pushed
the
device/eas-host
branch
from
October 19, 2026 23:25
8fe7f7d to
7ed3dd8
Compare
juliusmarminge
force-pushed
the
device/eas-host
branch
from
October 20, 2026 00:09
7ed3dd8 to
c153ac0
Compare
juliusmarminge
force-pushed
the
device/eas-host
branch
2 times, most recently
from
October 20, 2026 01:35
856f7fe to
ae43b5d
Compare
juliusmarminge
force-pushed
the
device/eas-host
branch
2 times, most recently
from
October 20, 2026 02:14
085d283 to
45b3c23
Compare
The generated Expo client selects `__typename` on a session's remote config to tell its union members apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every EAS Simulator reports the same UDID, so a second running session replaced the first in the host's map (leaving it billed and untracked) and a new session matched the device of one Expo had already ended. Running EAS devices now take Expo's session id as their device id, and the serve-sim proxy swaps it for the UDID. The device service also drops a session once its host stops listing the device, so a session Expo ended no longer keeps a panel or floating preview streaming nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`DeviceHostUnavailableError` printed the host's id, a UUID for SSH and EAS hosts. Hosts now carry their label, the device service and the EAS host set it on the error, and the message shows it in place of the id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A start runs in the host's scope, so a client that disconnects while a session boots leaves it tracked instead of dropped. Creating the session runs uninterrupted, and the stop for a start that fails or is interrupted afterwards is in place before anything can interrupt it. - The ledger records each session by name before Expo creates it, is written atomically, and a start fails if it cannot be written. An unreadable ledger is an error, not an empty list. Recorded sessions nothing tracks are found by name and stopped at start and while listing. - A session whose stop fails stays tracked, counted against the limit, and listed, and the next listing retries the stop. - The epoch is read before a start yields, so a stop in that window is seen; a malformed escape in a proxied path is a 404, not a defect. - agent-device selects an EAS simulator by its UDID: hosts report that id with their agent endpoint, and device_open passes it. - Only hosts that end devices themselves drop sessions on devices they stop listing; a local or SSH listing that misses a device keeps them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Device commands for an environment ran one at a time, so an EAS Simulator start, which can take minutes, held up powering off a running session or turning device support off. Opens now queue per device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The periodic sweep took a start still in progress for a leftover: it stopped the session the user was waiting for, or, before Expo listed it, dropped its record. The host now knows every session it owns (starts in progress, tracked sessions, and sessions whose stop after a failed start failed) and the sweep skips them. - A recorded session Expo does not list is forgotten only once Expo must have ended it, so a create that timed out or a session past the first page stays recorded for a later sweep. - A start that fails after creating a session, whose stop then fails, keeps the session counted against the limit and retried by listing. - Stopping the host interrupts starts in progress and waits for them to stop their sessions, instead of letting them run until they are ready. - One failed status poll or preview probe during a start is retried. - A host without an Expo credential is still ready; opening a device then says how to sign in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every EAS Simulator start opens the same catalog device, so a second thread's start waited behind the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…proxy An EAS session's preview host is a third-party origin; a cookie it set was passed through and landed on T3's origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A recovered session whose stop fails (a create whose response failed, then a failed stop) is adopted as unresolved, so it counts against the limit and listing retries it whatever path found it. - A start cut short by a host stop fails with "was stopped while the session started" instead of a bare interrupt the client drops. - The refusal when the cap is reached says when a session is still being stopped, rather than telling the user to close one that is not listed. - Stopping the host also reaps recorded leftovers, so a session a timed out create made is not left to its idle limit. - The startup reap runs in the background, so a slow Expo does not hold the device lock other hosts' operations wait on, and a signed-out host does not re-resolve the credential on every listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en mocks device_open checks agent readiness and reads the thread's workspace before opening, and agentTarget returns the id agent-device selects the device by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A create Expo did not answer, or answered with a server error, may have made a session. Its slot was freed as soon as recovery did not find it, so a host at its limit could start another billed session. Such a create now counts against the limit until a listing finds the session or Expo would have listed it by now (5 minutes); a create Expo refused still frees the slot at once. A session whose stop keeps failing is let go once Expo reports it ended, and the refusal at the limit says what is being cleaned up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After a restart, a session recorded just before the server went, which Expo does not list yet, survived the sweep but counted against nothing, so a host at its limit could start another. Such a record now holds a slot, like a create with an unknown outcome, until Expo lists it or would have by now. The reaper resolves both kinds since it reads the record anyway, which replaces the separate check each listing ran. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Main now lints for it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eep finds it An old record whose session still ran counted against nothing after a restart while the startup sweep was still in flight, so a host at its limit could start another. Every record a previous server left now holds a slot until a sweep looks for it, and a listing sweeps before anything starts. One sweep runs at a time, so two cannot stop the same session twice. A refused create still checks whether Expo lists its session before forgetting the record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A start now resolves the credential before taking a slot, so a host without one says how to sign in rather than that sessions are still being cleaned up. Without a credential no sweep can look for records a previous server left, so a record Expo would have listed by now stops counting against the limit on the next listing anyway. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…find it fails A sweep whose listing failed freed the slots of records past their deadline although it had ruled nothing out, so a host at its limit could start past it while the old sessions still ran. A record now stops counting only once a listing of its app succeeded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its listing says A record whose app can no longer be listed (gone, or another account's) held its slot for good. Once Expo has ended the session by its own limits, the slot and the record go. A refused create keeps its record for the reaper too, so a misread refusal can free a slot early but never lose a running session. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cords as long as one can run A tracked session whose stop kept failing was never asked for its status, so one Expo had ended by itself stayed listed and held its slot. The listing now reads the status after a failed stop and lets an ended session go, as it does for sessions a failed start left. A record also lives as long as a session can run: Expo's longest runs 115 minutes from when it is ready, not from when it was made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A refused create keeps its record for the reaper, and a restart adopted every record as a session that may run, so refused creates held slots for hours once their app could not be listed. The record now says the create was refused, and a restart leaves such records to the reaper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only a successful listing forgot an expired record, so a refused create's record for an app that can no longer be listed stayed for good and cost a failed listing every sweep. The sweep now forgets every expired record it does not own, whatever the listing says. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is not running Expo's run limit counts from when a session is ready, and being ready can wait on Expo's queue, which has no bound, so no age proves a record names nothing that runs. After three hours the record stops counting against the limit, but it is forgotten only once a listing of its app succeeds without it, or after a day, when no queue could have held it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the record Saving a host in Settings replaces its instance under the same id, and both share one record. A listing still running on the old instance could sweep the replacement's new session, which it did not own, and stop it. Once its scope closes an instance neither sweeps nor writes the record; a start on it fails before Expo is asked for anything. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eeps nothing Covers the sweep's own check, which a listing that started before the scope closed depends on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cope closes Closing the scope marked the host closed without waiting for a write already under way, so a replacement could write its own record first and have it overwritten by the old write. Closed is now set under the record lock, after any write in flight. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te in flight Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lock Nothing is listed during the close, and the test waits in real time for the close's own sweep, so only the write in flight can hold it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… made required Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it began A device listing runs before the lifecycle lock, so a session opened while it ran could be pruned by it as one on a device the host no longer lists, although its device had simply not been listed yet (review, Macroscope). Only sessions from before the listing are pruned by it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Configured EAS hosts now appear in the device hub. Opening the host's iPhone starts an EAS Simulator session, waits until it runs, and shows the simulator like any other device. Powering it off stops the session; closing the panel without powering off leaves it running until its idle limit.
Cost bounds. Sessions bill the Expo account until they stop, so a started session is always tracked or stopped:
EAS project. Sessions run under the host's EAS project, or the one found in the thread's project, so the MCP tool and the
device.openRPC pass the thread's workspace (threadWorkspaceRoot).Per-session endpoints. Each session has its own serve-sim preview host and agent-device daemon:
DeviceHostReady.upstreamlets a host route proxied requests itself: the proxy sends stream, input and WebRTC signalling to the right session with that session's token, as aBearerheader or WebSocket subprotocol, and swaps the session id for the UDID serve-sim knows. The token never reaches clients.DeviceOperations.agentEndpointgives per-device agent config files and the id agent-device selects the device by (the UDID).device_openchecks agent access first, opens the device, then targets the opened device, whose daemon only exists once its session runs.endsDevices) drop sessions on devices they stop listing; local and SSH hosts keep theirs through a listing that misses a device.Also: device host errors name the host by its label instead of its id; a host without an Expo credential is still ready, and opening a device says how to sign in; and the client opens each device per thread in its own command lane, so a long EAS start no longer holds up powering off another device, turning device support off, or another thread's start.
Device settings stay unavailable on EAS devices: they run
simctlon the simulator's machine, which EAS does not expose.Part of built-in EAS Simulator support (stack #21847). Layer 5 of 6.
Testing:
Done with Claude Opus 5.5 in Claude Code, running in T3 Code.
🤖 Generated with Claude Code