Skip to content

Build the Android backend on Linux - #141

Open
iopass4 wants to merge 20 commits into
lycorp-jp:mainfrom
iopass4:feat/linux-android-build
Open

iopass4 wants to merge 20 commits into
lycorp-jp:mainfrom
iopass4:feat/linux-android-build

Conversation

@iopass4

@iopass4 iopass4 commented Sep 11, 2026

Copy link
Copy Markdown

Summary

Builds the Android half of sim-use on Linux. The Android backend drives devices through adb and the bridge APK's HTTP server and needs
none of the Apple frameworks, so this adds an #if os(Linux) target graph — SimUseCore, AndroidBackend, and a new SimUseLinux ex
ecutable — next to the unchanged macOS manifest. The main use case is agents running in Linux containers, CI, and WSL.

The macOS build is unchanged: the macOS manifest sits verbatim in the #else branch, and every source change is either compiled out
on Apple platforms or behaviour-preserving (details below, verified with make test and on a simulator).

Commits (review one at a time)

  1. fix: write the outline cache with rename(2) on Linux — swift-corelibs-foundation's replaceItemAt fails whether or not the targe
    t exists, so ui → tap @N broke on Linux. Apple platforms keep replaceItemAt. Adds an overwrite test.
  2. fix: report a missing adb binary as adbMissing on Linux — corelibs reports it as Cocoa 260, not 4 / ENOENT. Already covered by
    testRunMapsMissingBinaryToAdbMissing.
  3. build: make SimUseCore and AndroidBackend imports portable — conditional Darwin/Glibc/os, FoundationNetworking; an inter nal LinuxCompat.swift (compiled out on Apple platforms) provides a Darwin namespace forwarding to Glibc, so the daemon's qualified
    Darwin.close(fd) calls compile unchanged, plus an NSLock-backed OSAllocatedUnfairLock.
  4. refactor: move the daemon command into SimUseCore — the command only needed the executable for three hooks (root parser, iOS stal
    e-HID cleanup, liveness probe); they are now injected via Daemon.installPlatformHooks, called at the same point in daemon start. Avo
    ids a second copy for the Linux executable.
  5. feat: accept ui on android describe-ui — matches the top-level / iOS verb; the Linux root depends on it for sim-use ui.
  6. build(linux): add an Android-only Linux target graph — the manifest branch, SimUseLinux, and AndroidCommand registering recor
    d/stream only when SimUseVideo is importable (macOS subcommand order unchanged).
  7. feat: reach the bridge through a remote adb server — adb forward listens on the adb server's host; when ADB_SERVER_SOCKET=tcp: <host>:<port> is remote (e.g. WSL → Windows adb), the bridge is reached there. SIM_USE_BRIDGE_HOST overrides. Pure function + tests (
    IPv6/zone ids). Also applies on macOS; the default (no env) is unchanged.
  8. docs — docs/linux.md (scope, install, WSL setup incl. the security implications of adb -a), README, AGENTS.md, skill, CHANGEL
    OG.
  9. ci — a swift:6.3 ubuntu job running swift build / swift test for the Linux graph.

Scope on Linux

Available: the Android verbs at the top level (ui, tap, type, paste, swipe, button, touch, gesture, multi-touch, keyb oard-state, screenshot, devices), the full android namespace, and the per-device daemon.

Not available: iOS verbs, record-video / stream-video (AVFoundation), long-press / app-state (their Android paths live in the ma
cOS forwarders, which import iOS modules), the Viewer, and init. sim-use devices is the Android listing rather than the unified sche
ma. Bringing long-press / app-state / unified devices over would mean moving their Android halves into AndroidBackend — happy to
do that as a follow-up if you want it.

Testing

  • Linux (Swift 6.3.3 on WSL2 Ubuntu, and the swift:6.3 image from a clean git archive, i.e. without the bridge APK — the same th
    ing the new CI job runs): swift build, swift test — 262 XCTest + 133 swift-testing, all green. Commits 6 and 7 also build and pass o
    n their own.
  • macOS (macOS 26.3, Xcode 26.6): scripts/build.sh dev, make build, make test — 311 XCTest and 1106 swift-testing tests pass.
    One test, ProcessControlTests "cancellableSleep wakes early when the flag is cancelled", failed in 2 of 3 full runs (5.6 s / 9.6 s aga
    inst its 5 s bound). It fails the same way on main on the same machine (5.6 s) and passes 5/5 in isolation, so it looks like a pre-exi
    sting timing flake under full-suite load rather than something this PR introduces.
  • macOS, live: on an iPhone 17 simulator (iOS 26.5), ui spawns the daemon through the new installPlatformHooks path, and its out
    put is identical to the main binary's; daemon status / daemon stop --all work as before.
  • Linux, live: against a real Android device over network adb — android init, android ping, ui twice (cache created, then over
    written), and tap @N resolving from the cache; daemon start / status / stop through the shared command; scripts/install-linux.s h into a scratch prefix.

iopass4 and others added 9 commits September 11, 2026 11:53
swift-corelibs-foundation's FileManager.replaceItemAt fails with "file
doesn't exist" whether or not the target exists, so every cache write
threw there. describe-ui only warns when the write fails, but every
@n / #N tap then reads a missing cache. Keep replaceItemAt on Apple
platforms and use rename(2) elsewhere — the same atomic swap.

The new test covers the overwrite path: every ui after the first
replaces an existing cache file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
swift-corelibs-foundation surfaces a missing executable as
NSCocoaErrorDomain 260 (NSFileReadNoSuchFileError), not 4 or ENOENT,
so Linux users got a generic transport error instead of the adb
install hint. AdbRunnerTests.testRunMapsMissingBinaryToAdbMissing
already pins this; it fails on Linux without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
No behaviour change on Apple platforms; this is what lets the two
targets compile with swift-corelibs-foundation on Linux.

- import Darwin / os only where they exist, Glibc otherwise
- import FoundationNetworking where URLSession / HTTPURLResponse are
  used — they live outside Foundation in swift-corelibs-foundation
- LinuxCompat.swift (internal, compiled out on Apple platforms): a
  Darwin namespace forwarding to Glibc, so the daemon's qualified
  Darwin.close(fd)-style calls compile unchanged, and an NSLock-backed
  OSAllocatedUnfairLock for ProcessControl
- tests: same conditional imports; the AdbStreamingProcess test is
  compiled only where os exists (the Linux build leaves
  AdbStreamingProcess out)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
The daemon command only needs the executable for three hooks: its root
parser, the iOS stale-HID cleanup, and the per-platform liveness probe.
Move the command to SimUseCore and have the entry point inject those
through Daemon.installPlatformHooks, called by `daemon start` exactly
where the hooks were wired before. Behaviour is unchanged; a second
executable can now register the same command instead of copying it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
The top-level and iOS describe-ui already answer to `ui`; the Android
one is registered directly at the top level of the Linux build, where
`sim-use ui` depends on this alias.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
The Android backend drives devices through adb and the bridge APK's
HTTP server and needs none of the Apple frameworks, so Package.swift
gains an #if os(Linux) branch declaring SimUseCore, AndroidBackend and
a new SimUseLinux executable; the macOS manifest in #else is
unchanged.

- AndroidBackend leaves out its three video-capture files (host-side
  muxing/encoding needs AVFoundation via SimUseVideo), and
  AndroidCommand registers record-video / stream-video only when
  SimUseVideo is importable, keeping the macOS subcommand order.
- SimUseLinux registers the Android implementations of the top-level
  verbs under the same names (ui, tap, type, swipe, screenshot, ...),
  the android namespace, and the shared daemon command with
  Android-only hooks.
- SimUseCoreTests and AndroidBackendTests build on Linux; the record
  argument tests are left out with the command they test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
adb forward opens its listener on the machine that runs the adb
server, which is only this machine when the server is local. When
ADB_SERVER_SOCKET points at a remote server (tcp:<host>:<port> — e.g.
WSL using the Windows host's adb so USB devices stay visible), the
bridge is now reached on that host instead of 127.0.0.1.
SIM_USE_BRIDGE_HOST overrides the host. IPv6 hosts come back
bracketed with any zone id escaped, so the result always forms a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
docs/linux.md covers what the Linux build offers, building and
installing it (scripts/install-linux.sh), and reaching devices from
WSL through the Windows host's adb server, including the exposure
that `adb -a` creates. README, AGENTS.md, the skill and the
changelog point at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
Runs swift build / swift test in the swift:6.3 image on ubuntu —
SimUseCore, AndroidBackend, SimUseLinux and their two test targets —
so a change that breaks the Linux build is caught on the PR. It needs
no XCFrameworks and runs alongside the macOS job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
@onevcat

onevcat commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution!

That is actually what on our plan list and the implementation looks good for the first glance. I will try to take a deeper look soon and see if we need more modification before we can determine the next action.

I will reach you back soon.

@onevtail onevtail left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Linux cache fix is supported by the reported reproduction and concurrent read/write checks. Review of head 27b558c09c785b6940c592d3469ad1f23d2ddc76 identified two blocking issues in remote adb connection scoping:

  1. [P1] A warm daemon keeps targeting the previous adb server. BridgeClient.swift derives its host from the daemon's environment, while daemon lookup uses only the device serial and forwarded requests carry no connection configuration. Starting with server A and then switching the client to B with the same serial returned ok:true for both button home commands, but both button requests reached A. Please include the connection configuration in daemon identity or validate it before reuse; recalculating the host inside the existing daemon would still read its unchanged environment.

  2. [P1] Restarting the daemon can send the previous session's credentials to the new host. Session restoration restores the port and token by serial, allowing the URL construction to combine B's host with A's cached port and token. With A using port 18080 and B expected to use 18081, the cold-start probe reached an unrelated mock listener at B:18080 carrying A's synthetic token. Reconnecting only after a connection failure does not cover an old port that still responds. Please persist and validate the session's connection identity, and recreate forwarding and credentials when it changes or is absent from a legacy cache.

Please add regression coverage for switching servers with a warm daemon, switching with a persisted session, and an unrelated listener occupying the cached port. These reproductions used simulated adb/HTTP services and synthetic credentials; real Android/WSL end-to-end behavior remains unverified.

onevtail - an assistant to @onevcat

iopass4 and others added 3 commits September 17, 2026 09:56
The bridge host follows ADB_SERVER_SOCKET, but the warm daemon and
bridge.json were keyed by serial alone, so a client switched to another
adb server could still be served through the old one, or send the old
session's token to whatever listened at its cached port.

- A daemon now reports the connection it started under (adb server and
  bridge host) in _ping, and the reuse gate restarts it when the
  client's connection differs.
- bridge.json records that connection. A session from another
  connection, or a cache that predates the field, is ignored, and a
  matching one is used only while adb forward --list still shows its
  port for the serial; otherwise the forward and token are recreated.

The new tests cover a warm daemon after switching servers, a persisted
session after switching, a legacy cache, and an unrelated listener on
the cached port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
The connection scoping missed two cases:

- The daemon connection identity was only reported for serials that
  PlatformRouter.looksLikeAndroid accepts. Wireless-debugging (mDNS)
  serials exceed its 32-character cap but still reach a per-device
  daemon, so a daemon started against another adb server kept serving
  them. The identity is now withheld only for iOS simulator and device
  UDIDs.
- A failed adb forward --list was treated as the cached forward being
  gone, so each failure opened another forward and left the old one
  registered on the adb server. The failure now surfaces and the
  cached session is kept for the next call to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
Dropping a stale bridge forward ran `adb forward --remove tcp:<port>`
without a serial. With more than one device on the adb server, adb
rejects that with "more than one device/emulator", and the error was
swallowed, so every reconnect left the old forward registered. The
removal now names the device.

`android init` always printed `localhost` as the HTTP endpoint, which
is wrong when the bridge is reached through a remote adb server. It
now prints the resolved bridge host and adds `bridgeHost` to the
`--json` result.

Both were found testing against a real device through a remote adb
server with two devices attached. The new tests cover forward removal
naming the serial and the init output naming the host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
@iopass4

iopass4 commented Sep 17, 2026

Copy link
Copy Markdown
Author

Thanks for the careful repro. I could reproduce both issues. The fixes are in three commits on top of 27b558c:

e3c7365, scope cached connections to the adb server

  • Warm daemon: the daemon reports the connection it was started with (adb server + bridge host) in _ping, and the client restarts the daemon when its own connection is different.
  • Persisted session: bridge.json stores the connection too. A cached session is reused only if the connection matches and adb forward --list still shows its port for that serial. Otherwise the forward and token are recreated. Old cache files without the field are ignored.

8175e51, two gaps in the first fix

  • The connection check now applies to every adb serial, including long mDNS serials that looksLikeAndroid rejects but that still get a daemon.
  • If adb forward --list fails, the error is returned and the cached session is kept, instead of opening another forward.

4583430, found while testing on a real device

  • Removing a stale forward now passes -s <serial>. Without it, adb rejects the command when the server has more than one device, so old forwards piled up.
  • android init prints the actual bridge host instead of always localhost, and --json includes bridgeHost.

Tests
The three cases you asked for are covered: switching servers with a warm daemon (DaemonConnectionGateTests), and switching with a persisted session plus an unrelated listener on the cached port (BridgeConnectionScopingTests).

Verification

  • Your scenarios against the real binary, with fake adb servers and mock listeners: before the fix, B's requests went to A, or to the unrelated listener with A's token. After it, they reach B with B's token.
  • A real phone from WSL, with the same serial connected to both the Windows adb server and WSL's own adb server: with 27b558c, a warm daemon kept using the first server after the client switched. With the fix, the daemon restarts and the forward is created on the new server, both with a warm daemon and from a persisted session. A live cached forward on the same server is still reused.
  • swift test passes on Linux. On macOS (Xcode 26.6), make build passes, and make test passes except for the ProcessControlTests "cancellableSleep wakes early" timing check, which also fails intermittently on main.

@iopass4
iopass4 requested a review from onevtail September 17, 2026 03:11
@iopass4

iopass4 commented Sep 17, 2026

Copy link
Copy Markdown
Author

hi,
The macOS failure is ProcessControlTests.swift:42 (cancelled sleep took 6.2s, limit 5s). This test is already flaky upstream: it failed the same way on main at 397dcb0 (run 35058898057). This PR doesn't change that code on macOS. It only wraps import os in #if canImport(os).

@onevcat

onevcat commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

@iopass4

Yes! The failing test is unrelated to this PR.

I’ll try to find some time to go through this in detail and see how it all works. Thank you for the great work—I really appreciate it!

@onevcat onevcat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fixes — both P1s from the previous round are resolved. I verified this round end to end:

  • macOS: make build / make test green (1447 tests); live checks on an iOS 26.5 simulator and an Android emulator are unchanged from main.
  • Linux (swift:6.3, clean git archive): swift build / swift test green (276 XCTest + 140 swift-testing).
  • Remote adb (ADB_SERVER_SOCKET=tcp:<host>:5037 from a container): android init / ui / tap / screenshot work; switching ANDROID_ADB_SERVER_PORT or SIM_USE_BRIDGE_HOST restarts the warm daemon and recreates the forward and session, as designed.
  • End to end on Linux: the Linux binary, its daemon, and a local adb server in a container, adb connect to an emulator, driven by the existing Android E2E suites through SIM_USE_TEST_BINARY. 17 of 20 tests pass. All 3 failures come from long-press / app-state being absent on Linux.

Remaining items before we can claim Linux support:

1. app-state (and long-press) on Linux. The bundled skill tells agents to run sim-use app-state --reset after an intentional relaunch (skills/sim-use/SKILL.md L157), so the skill's loop breaks on the Linux build. You offered to move the Android halves of long-press / app-state into AndroidBackend as a follow-up; I would like that to land in this PR, or before we announce Linux support.

2. ANDROID_ADB_SERVER_ADDRESS selects a remote server but not the bridge host. BridgeConnection.init includes ANDROID_ADB_SERVER_ADDRESS / ANDROID_ADB_SERVER_PORT in adbServer, and docs/linux.md lists them as scoping inputs, but BridgeClient.resolveBridgeHost only reads SIM_USE_BRIDGE_HOST and ADB_SERVER_SOCKET. Reproduced: with only ANDROID_ADB_SERVER_ADDRESS=<remote> set, adb devices works, adb forward opens on the remote host, but requests go to 127.0.0.1:<port> (Failed to connect to 127.0.0.1 port …), and every attempt leaves another forward on the remote server. This is the same class as the earlier P2: a local listener on that port would receive the token. Please derive the host from ANDROID_ADB_SERVER_ADDRESS when ADB_SERVER_SOCKET is unset (adb's own precedence) and add cases to BridgeHostTests.

3. Unsupported verbs give misleading errors on Linux. sim-use long-press -x 1 -y 1 --device … prints Unknown option '-x', and sim-use app-state --device … prints Unknown option '--device'. Agents read these errors literally. Please add a redirect table to SimUseLinux, similar to iOSOnlyVerbRedirects in Sources/SimUse/main.swift, that names the verb and says it is not available in the Linux build (ios, ios-device, record-video, stream-video, viewer, init, plus long-press / app-state until item 1 lands).

4. Forwards leak when the connection changes (minor). When a cached session is rejected for a different identity, its forward stays on the adb server. I switched between the default environment and ANDROID_ADB_SERVER_PORT=5037 4 times and got 4 extra forwards. The two configurations are the same server, but adb=default and adb=tcp:localhost:5037 compare as different identities, so each switch also restarts the daemon without need. Normalizing the identity (e.g. resolve default to tcp:localhost:5037) fixes the common case; the cross-server leak is acceptable if documented.

5. Containers without an init process (docs). In a container whose PID 1 does not reap children (e.g. docker run … sleep infinity), an exited daemon stays a zombie, kill(pid, 0) still succeeds, and daemon stop reports stopped=false. With docker run --init everything is correct. Since containers are the main use case, please add a note to docs/linux.md.

Nits

  • Sources/SimUseCore/Daemon/DaemonClient.swift: the new shouldRestartForConnection / connectionIdentityProvider / connectionIdentity(for:) are inserted between the doc comment of shouldRestartForVersion and its declaration, so the "Pure comparator…" comment now documents shouldRestartForConnection and shouldRestartForVersion has none.
  • resolveBridgeHost lives on BridgeClient but only BridgeConnection calls it; moving it into BridgeConnection keeps the dependency in one direction.
  • The branch has a small conflict with main in skills/sim-use/SKILL.md (from #145).

The red macOS check is the known ProcessControlTests timing flake and is unrelated.

iopass4 and others added 7 commits October 2, 2026 22:27
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>

# Conflicts:
#	skills/sim-use/SKILL.md
With only ANDROID_ADB_SERVER_ADDRESS set, adb talks to the remote
server and `adb forward` listens there, but the bridge host stayed on
127.0.0.1: requests failed, and each attempt left another forward on
the remote server. The bridge host now follows adb's own precedence:
SIM_USE_BRIDGE_HOST, then ADB_SERVER_SOCKET, then
ANDROID_ADB_SERVER_ADDRESS. The resolver moves from BridgeClient into
BridgeConnection, its only caller.

The connection identity compared the adb server spec as written, so
the default server and ANDROID_ADB_SERVER_PORT=5037 looked like two
servers. Switching between them restarted the daemon and orphaned the
cached forward. Equivalent specs now normalise to one form
(tcp:localhost:5037; localhost aliases, lowercase hosts, bracketed
IPv6), and anything unparseable is kept verbatim.

Sessions also record their adb server. When a cached session is
rejected because only the bridge host changed, its forward is on the
current server and is removed before a new one is opened. A forward
on a different server is left alone, since this process cannot
address that server; docs/linux.md says how to clear it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
In a container whose PID 1 does not reap children (for example
`docker run ... sleep infinity`), an exited daemon stays a zombie,
`kill(pid, 0)` still succeeds, and `daemon stop` reports
stopped=false. Containers are the main Linux use case, so docs/linux.md
now says to run them with `--init`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
The connection-identity helpers were inserted between
shouldRestartForVersion's doc comment and its declaration, so the
comment documented shouldRestartForConnection instead. It is back on
its function, and connectionIdentity(for:) gets its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
The bundled skill tells agents to run `sim-use app-state --reset`
after an intentional relaunch, so the Linux build broke the skill's
loop. Both verbs kept their Android paths inside the macOS forwarders,
which import the iOS modules.

The Android halves now live in AndroidBackend as
AndroidLongPressCommand and AndroidAppStateCommand, and the macOS
forwarders call their static entry points for Android serials. The
app-state result types, mapping and text output move to SimUseCore
(AppStateReport), and the help strings of both verbs to LongPressHelp /
AppStateHelp, so the two surfaces share one implementation, one JSON
shape and one help text. macOS commands, flags and output are
unchanged.

The Linux root registers both at the top level. Like the other Linux
verbs they take AndroidDeviceOptions, so a missing --device asks for
an adb serial instead of failing in simctl, and iOS identifiers are
rejected before they reach adb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
On Linux, `sim-use long-press -x 1 -y 1` printed "Unknown option '-x'"
and `sim-use app-state --device ...` printed "Unknown option
'--device'", because a verb the root does not know is parsed as its
argument. Agents read those errors literally.

The Linux entry point now checks the first argument against a table
of verbs this build does not ship (ios, ios-device, record-video,
stream-video, viewer, init), in the style of iOSOnlyVerbRedirects on
macOS, and prints an error that names the verb and says it is not
available in the Linux build, with a hint where one helps, exiting 64.
long-press and app-state are not in the table, since the previous
commit brings them over. The table and message live in SimUseCore so
a Linux test target covers them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
When a request fails, BridgeClient drops its forward and retries once
through a new one. If the retry also fails (nothing answers at the
bridge host, e.g. a wrong SIM_USE_BRIDGE_HOST, a firewall, or a device
that was never initialised), the error was thrown with the retry's
forward still registered. No token had been fetched, so no session was
saved for it and nothing ever removed it: every failing attempt left
one more forward on the adb server, which is how the reviewer's
ANDROID_ADB_SERVER_ADDRESS repro accumulated forwards.

The final failure now computes the error first (its probe uses adb
shell, not the forward), then invalidates, removing that forward. A
test makes the bridge unreachable and checks that every forward the
client opened is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Yang <2832943+iopass4@users.noreply.github.com>
@iopass4

iopass4 commented Oct 3, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough round. All items are addressed in new commits on top of 4583430 (plus a merge of main for the SKILL.md conflict):

  1. app-state / long-press on Linux (bacbe4e). The Android halves moved into AndroidBackend (AndroidAppStateCommand, AndroidLongPressCommand), and the macOS forwarders now call them for Android serials, so both builds share one implementation. Result types, text output and help strings live in SimUseCore, so JSON and --help can't drift. macOS output is unchanged. On Linux they use AndroidDeviceOptions like the other verbs, so a missing --device asks for an adb serial instead of failing in simctl.
  2. ANDROID_ADB_SERVER_ADDRESS (3eb6940). The bridge host now follows adb's precedence: SIM_USE_BRIDGE_HOST, then ADB_SERVER_SOCKET, then ANDROID_ADB_SERVER_ADDRESS. New cases are in BridgeHostTests. resolveBridgeHost moved into BridgeConnection.
  3. Unsupported verbs on Linux (358c995). There is a redirect table for ios, ios-device, record-video, stream-video, viewer and init. It names the verb, says it is not in the Linux build, and exits 64.
  4. Forward leaks (3eb6940, 34ea016). Equivalent server specs normalise to one identity (default = tcp:5037 = tcp:localhost:5037 = ANDROID_ADB_SERVER_PORT=5037), so switching between them keeps the daemon and the forward. When only the bridge host changes, the old forward is removed from the same server. A forward on a different server is left alone and documented. I also found that when the bridge stays unreachable after the reconnect, the retry's forward was never removed. That is why each attempt in your repro added one, and it is fixed in 34ea016.
  5. Containers (db39bd1). docs/linux.md now has a note to run with --init.

Nits: the doc comment is back on shouldRestartForVersion (03188c3), and resolveBridgeHost moved as above.

Verification:

  • Linux: swift build / swift test pass (282 XCTest + 156 swift-testing).
  • macOS (Xcode 26.6): make build passes. make test passes except the known ProcessControlTests timing flake.
  • Linux binary against a real Pixel 6 (API 37) over network adb, on a separate adb server port:
    • long-press @N opens the launcher icon menu. --label ... --json returns {"x":…,"y":…}.
    • app-state, --bundle-id and --reset work.
    • With only ANDROID_ADB_SERVER_ADDRESS / ANDROID_ADB_SERVER_PORT set against a server started with -a, android init --json reports that host as bridgeHost, and ui / app-state work through it.
    • Switching between 5 spellings of the same server keeps the same daemon pid and forward count.
    • A host-only switch replaces the forward instead of adding one.
    • Repeated attempts with an unreachable SIM_USE_BRIDGE_HOST no longer add forwards.

@iopass4
iopass4 requested a review from onevcat October 4, 2026 15:50

@onevclaw onevclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 34ea016052079fae05b9fae94f001f9c35926364. The Linux build and connection-scoping changes have substantial test coverage, but one blocking isolation defect remains.

[P1] A timed-out identity probe allows requests to execute against the wrong adb environment. In DaemonClient.swift, _ping times out after two seconds and the failure path still permits the business request to reach the existing daemon. Because the daemon processes connections serially, a busy daemon can trigger this without being unhealthy.

This was reproduced on Linux and macOS with a fake adb:

  1. Environment A starts a daemon and runs app-state, with its adb ps delayed for four seconds.
  2. Environment B requests app-state for the same serial while A is busy.
  3. B's identity probe times out, but its business request subsequently executes in A's daemon and returns ok: true with com.example.a. All recorded adb calls use A's environment.

Please prevent business requests from executing when connection identity has not been confirmed. Carrying the expected identity in each request and validating it in the daemon before execution would also address the race between probing and execution. Please add a regression test through the actual invoke path with a busy A daemon and a B request sharing the same serial; B's operation must never execute in A.

Separately, please narrow the absolute credential-safety claim in CHANGELOG.md. Forward ownership is checked when restoring the disk cache, but an already-running client can continue using a port after its forward changes. This is a pre-existing weakness rather than a regression introduced here, so the underlying behavior can be tracked separately.

Validation included a passing Linux build and test suite and successful macOS builds. The full macOS test run had one timing-test failure that passed in isolation; Android device/E2E validation remains incomplete.

onevclaw - an assistant to @onevcat

@onevcat

onevcat commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

@iopass4 Thank you for the changes. I can verify all points mentioned in the last comment are resolved. However, the new P1 issue found by @onevclaw is still blocking the merge.

Can you also try to verify and fix it before we can merge it?

@onevclaw

onevclaw commented Oct 5, 2026

Copy link
Copy Markdown

I reproduced the P1 through the real DaemonClient.invoke path: while A occupies the serial daemon queue, B's two-second _ping times out; without a request-level guard, B's business command then executes in A's daemon.

I implemented and verified the minimal fail-closed fix locally:

  • Snapshot the caller's connection identity at the start of invoke and attach it to every business request, including the one transient-boot retry.
  • Keep management commands available, but have the daemon reject a business request before parsing or executing it unless its identity exactly matches the daemon's startup identity.
  • Add a regression test with a four-second A request followed by B. B now receives a permanent connection-mismatch error and the business handler executes exactly once (for A).

Verification: the focused invoke-path suite passes (6/6), make build passes, and the isolated ProcessControlTests pass (4/4). The complete suite ran 1,473 tests successfully; its only failure was the existing cancellableSleep timing flake (5.26 s against a 5 s bound), which also passes in isolation and is outside this change.

I could not push the validated commit to this cross-repository branch: the required onevclaw account is denied write access to the contributor fork. I did not substitute another identity. Please apply the request-level identity gate above, or grant onevclaw write access if you want me to push the ready patch.

🧵 mh_1791166171431_poll-one · 🐾 main → main
🪝 gh:lycorp-jp:sim-use:issue:141:main:m202610
🆔 b6769f06-771b-4df7-a59f-91c6784fe5b1

@iopass4

iopass4 commented Oct 6, 2026

Copy link
Copy Markdown
Author

Thanks for picking this up and keeping the commits as they are. I compared 36013d5 with the fix I had locally and it's the same approach, so I'm not pushing anything more to #141.

Please merge with a merge commit as noted, so the original authorship stays. I'm fine with closing #141 once this lands.

@onevcat

onevcat commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@iopass4 Thanks for the feedback! Absolutely—the new PR will include your contribution. I still have a few final tests to run in a real Linux/Android environment. Once those are done, we should be ready to merge it!

It’s great to have you involved. Thanks again for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants