Skip to content

feat(provider)!: run usbmuxd inside the container - #41

Closed
Dinip wants to merge 4 commits into
mainfrom
feat/provider-usbmuxd-in-container
Closed

feat(provider)!: run usbmuxd inside the container#41
Dinip wants to merge 4 commits into
mainfrom
feat/provider-usbmuxd-in-container

Conversation

@Dinip

@Dinip Dinip commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Unplugging one iPhone from a Linux provider host takes iOS down until someone cleans up by hand. Bind-mounting the host's /var/run/usbmuxd is the cause: Docker binds the socket's inode, so the mount goes stale when usbmuxd exits at the last unplugged device, and a missing socket at container start has Docker create a directory there that the host's usbmuxd can then never bind.

So the container runs its own usbmuxd, like it already runs its own adb server, supervised in a restart loop. Mask the host's copy. docs/PROVIDER.md has the why.

Hotplug means restarting usbmuxd, not rescanning

A rescan cannot see a replug, because the list it walks is libusb's and libusb's is frozen. libusb learns about a plug or an unplug from udevd's netlink broadcast, and that reaches only udevd's own network namespace — so inside a container its device list is whatever the bus held when usbmuxd started. A phone that comes back on a new bus address is invisible, and the one it replaced stays in the list forever, failing to open on every attempt with LIBUSB_ERROR_IO and errno=19. Only a fresh libusb — meaning a fresh process — sees the bus as it now is.

sysfs, unlike libusb's cache, is the host's and always current, so that is what a watchdog polls: every /sys/bus/usb/devices entry with Apple's vendor ID, plus its devnum, since re-enumeration is what a replug looks like. When that set changes, usbmuxd is restarted. Because a restart costs every other iPhone its usbmuxd connection, a change has to survive one more tick before it counts — a re-enumerating device passes through intermediate states that would otherwise each be their own restart. PROVIDER_USBMUXD_WATCH_SECONDS paces it, default 2.

-z goes with the rescan it replaced: supervised, usbmuxd should stay up with no devices attached so the socket the backend polls is always there. adb needs none of this — its server walks /dev/bus/usb itself once a second rather than asking libusb.

/var/lib/lockdown is now a volume, holding the pair records. Without it every iPhone asks to Trust This Computer again after a recreate.

PROVIDER_START_ADB and PROVIDER_START_USBMUXD gate the daemons, so one host can run a provider per platform. Also fixes USBMUXD_SOCKET_ADDRESS never honouring the UNIX:/path form.

Breaking

The provider's env vars lose the YARD_ prefix: PROVIDER_TOKEN, PROVIDER_LOG_LEVEL, PROVIDER_CONFIG. Rename them in a provider host's .env before pulling.

Test plan

  • cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings
  • bun run check, bun run typecheck
  • docker compose --profile provider config, and with the macOS overlay
  • Replug cycle on a Linux host: an iPhone unplugged and plugged back in returns on its own, which it did not before
  • /dev/bus/usb has to be bind-mounted for any of this to work. privileged: true alone gives the container a snapshot of the host's device nodes, taken at creation — it survives until the first replug and never recovers after one

🤖 Generated with Claude Code

Dinip and others added 4 commits August 25, 2026 20:56
libusbmuxd documents the unix form as `UNIX:/path`, which contains a colon —
so it was read as host:port, failed to resolve, and fell back to the default
socket, silently ignoring the one setting that had been made. Only a bare path
worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bind-mounting the host's /var/run/usbmuxd socket is a trap in both directions.
Docker binds the socket's inode, so the mount goes stale the moment usbmuxd
exits at the last unplugged device — and if the socket is missing when the
container starts, Docker creates a *directory* at that path, after which the
host's udev-triggered usbmuxd can never bind it again. Unplugging one iPhone
took iOS down until someone cleaned up by hand.

So the container runs its own, the way it already runs its own adb server, and
the host masks its copy. usbmuxd is supervised in a restart loop rather than
fire-and-forget, since exiting at the last device is its normal behaviour.

Hotplug inside a container needs a nudge: usbmuxd polls the bus each second
only until it registers for libusb hotplug events, and that registration
succeeds in a container while delivering nothing — libusb on Debian watches
udevd's netlink broadcast, which reaches only udevd's own netns. It runs as
`usbmuxd -f -z` with a SIGUSR2 every two seconds, which means "rescan the bus":
the same device-list walk the poll it replaces was doing. `-n` is not the
answer, since disabling hotplug disables the poll along with it.

/var/lib/lockdown is a volume for the same reason /root/.android is. It holds
the SystemBUID/HostID the host pairs under and one pair record per device, so
an empty one is a new computer to every iPhone in the rack — each asking to
Trust This Computer again, by hand, at the device.

PROVIDER_START_ADB and PROVIDER_START_USBMUXD gate the two daemons, defaulting
to `auto`: start it if /dev/bus/usb is mounted, so the macOS shape still points
at the host's daemons untouched. Setting one to `no` splits a host into a
provider per platform, which does not contend for the bus — a claim is per USB
interface, and adb and usbmuxd open disjoint devices.

BREAKING CHANGE: the provider's env vars lose the YARD_ prefix, since the
entrypoint's own knobs are not the binary's config. YARD_PROVIDER_TOKEN is now
PROVIDER_TOKEN, YARD_LOG is PROVIDER_LOG_LEVEL and YARD_CONFIG is
PROVIDER_CONFIG. A provider host's .env needs renaming before the next pull;
an unset PROVIDER_TOKEN fails at startup rather than at the first request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unplugging an iPhone and plugging it back in took it out until the container was
restarted: usbmuxd sat retrying the address the device used to be at, logging
LIBUSB_ERROR_IO with errno=19 every rescan, and never noticed the one it came
back as.

The SIGUSR2 rescan cannot fix that, because the list it walks is libusb's, and
libusb's is frozen. It learns about a plug or an unplug from udevd's netlink
broadcast, which reaches only udevd's own network namespace — so inside the
container the list is whatever the bus held when usbmuxd started, forever. Only
a fresh process sees the bus as it now is; hotplug appeared to work before only
because usbmuxd ran with -z and exited at the last unplug, which the restart
loop then papered over.

So the watchdog watches sysfs, which is the host's and always current: every
/sys/bus/usb/devices entry with Apple's vendor ID plus its devnum, since
re-enumeration is what a replug looks like. A change restarts usbmuxd. Because
that costs every other iPhone its connection, a change has to survive one more
tick before it counts — a re-enumerating device passes through intermediate
states that would otherwise each be their own restart.

-z goes with the nudge: supervised, usbmuxd should stay up with no devices so
the socket the backend polls is always there. PROVIDER_USBMUXD_RESCAN_SECONDS is
now PROVIDER_USBMUXD_WATCH_SECONDS, which is what it now paces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dinip Dinip closed this Aug 27, 2026
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.

1 participant