refactor(ios): drive device tunnel via io.Closer seam

Replace the tunnelChild *exec.Cmd and spawnTunnel seam with a tunnel
io.Closer and startTunnel seam backed by the in-process usbmux forwarder.
This commit is contained in:
pj committed 2026-06-09 16:50:57 +05:30
1 parent 956b114cba
commit 033b17a102
2 files changed
+28 -24

No files matched your search

+14 -12
View File
@@ -23,7 +23,7 @@ import (
// carried here: realSpawnDeviceRunner reads them from the environment at the // carried here: realSpawnDeviceRunner reads them from the environment at the
// point of use so secrets never reach the Options struct or run artifacts. // point of use so secrets never reach the Options struct or run artifacts.
type DeviceOptions struct { type DeviceOptions struct {
// HardwareUDID feeds xcodebuild -destination and iproxy -u. // HardwareUDID feeds xcodebuild -destination and the usbmux device match.
HardwareUDID string HardwareUDID string
// CoreDeviceID feeds devicectl install/uninstall. // CoreDeviceID feeds devicectl install/uninstall.
CoreDeviceID string CoreDeviceID string
@@ -39,7 +39,7 @@ type DeviceOptions struct {
// Test seams. Production leaves them nil and NewDevice wires the real // Test seams. Production leaves them nil and NewDevice wires the real
// build/spawn/tunnel/dial. // build/spawn/tunnel/dial.
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error) spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
spawnTunnel func(ctx context.Context, hardwareUDID, localPort, devicePort string) (*exec.Cmd, error) startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
dialRunner func(address string) (transport.Companion, error) dialRunner func(address string) (transport.Companion, error)
pickAddress func() (string, error) pickAddress func() (string, error)
} }
@@ -80,13 +80,13 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
deviceMode: true, deviceMode: true,
hybrid: false, hybrid: false,
spawnRunner: options.spawnRunner, spawnRunner: options.spawnRunner,
spawnTunnel: options.spawnTunnel, startTunnel: options.startTunnel,
} }
if d.spawnRunner == nil { if d.spawnRunner == nil {
d.spawnRunner = d.realSpawnDeviceRunner d.spawnRunner = d.realSpawnDeviceRunner
} }
if d.spawnTunnel == nil { if d.startTunnel == nil {
d.spawnTunnel = spawnTunnel d.startTunnel = startUsbmuxTunnel
} }
d.dialRunner = options.dialRunner d.dialRunner = options.dialRunner
if d.dialRunner == nil { if d.dialRunner == nil {
@@ -147,25 +147,27 @@ func (d *Driver) bringUpDevice(ctx context.Context) error {
} }
d.runnerChild = runnerChild d.runnerChild = runnerChild
tunnelChild, err := d.spawnTunnel(d.processContext, d.udid, port, port) // The forwarder listens on the host loopback port and bridges to the same
// port number on the device, where the runner listens.
tunnel, err := d.startTunnel(d.processContext, d.udid, address, port)
if err != nil { if err != nil {
d.stopRunnerChild() d.stopRunnerChild()
return fmt.Errorf("spawn tunnel: %w", err) return fmt.Errorf("start tunnel: %w", err)
} }
d.tunnelChild = tunnelChild d.tunnel = tunnel
startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout) startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout)
defer cancel() defer cancel()
if err := waitForListener(startupCtx, address); err != nil { if err := waitForListener(startupCtx, address); err != nil {
d.stopTunnelChild() d.stopTunnel()
d.stopRunnerChild() d.stopRunnerChild()
return fmt.Errorf("device runner listener: %w", err) return fmt.Errorf("device runner listener: %w", err)
} }
companion, err := d.dialRunner(address) companion, err := d.dialRunner(address)
if err != nil { if err != nil {
d.stopTunnelChild() d.stopTunnel()
d.stopRunnerChild() d.stopRunnerChild()
return fmt.Errorf("dial device runner: %w", err) return fmt.Errorf("dial device runner: %w", err)
} }
@@ -173,7 +175,7 @@ func (d *Driver) bringUpDevice(ctx context.Context) error {
if err := d.waitForHealth(startupCtx); err != nil { if err := d.waitForHealth(startupCtx); err != nil {
_ = companion.Close() _ = companion.Close()
d.stopTunnelChild() d.stopTunnel()
d.stopRunnerChild() d.stopRunnerChild()
return fmt.Errorf("device runner health: %w", err) return fmt.Errorf("device runner health: %w", err)
} }
@@ -189,7 +191,7 @@ func (d *Driver) respawnDevice(ctx context.Context) error {
_ = d.companion.Close() _ = d.companion.Close()
} }
d.stopRunnerChild() d.stopRunnerChild()
d.stopTunnelChild() d.stopTunnel()
return d.bringUpDevice(ctx) return d.bringUpDevice(ctx)
} }
+14 -12
View File
@@ -127,12 +127,12 @@ type Driver struct {
// Device-mode fields. On the physical-device path d.companion is the runner // Device-mode fields. On the physical-device path d.companion is the runner
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil. // dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
// coreDeviceID feeds devicectl; tunnelChild is the iproxy process forwarding // coreDeviceID feeds devicectl; tunnel is the in-process usbmux forwarder
// the host loopback port to the runner's device-side port. // bridging the host loopback port to the runner's device-side port.
deviceMode bool deviceMode bool
coreDeviceID string coreDeviceID string
tunnelChild *exec.Cmd tunnel io.Closer
spawnTunnel func(ctx context.Context, hardwareUDID, localPort, devicePort string) (*exec.Cmd, error) startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
pickDeviceAddress func() (string, error) pickDeviceAddress func() (string, error)
// processContext owns the companion child's lifetime: it is derived from // processContext owns the companion child's lifetime: it is derived from
@@ -995,19 +995,21 @@ func (d *Driver) Close() {
d.runnerClient = nil d.runnerClient = nil
} }
d.stopRunnerChild() d.stopRunnerChild()
d.stopTunnelChild() d.stopTunnel()
if d.processCancel != nil { if d.processCancel != nil {
d.processCancel() d.processCancel()
} }
} }
// stopTunnelChild terminates the iproxy usbmux tunnel on the device path. // stopTunnel closes the in-process usbmux forwarder on the device path. Closing
// SIGTERM lets it close its forwarded sockets before exit; a nil child (the // its listener ends the accept loop and lets the open bridges drain; a nil
// simulator path) is a no-op. // tunnel (the simulator path) is a no-op.
func (d *Driver) stopTunnelChild() { func (d *Driver) stopTunnel() {
child := d.tunnelChild tunnel := d.tunnel
d.tunnelChild = nil d.tunnel = nil
stopProcess(child) if tunnel != nil {
_ = tunnel.Close()
}
} }
// stopChild terminates the companion child gracefully (SIGTERM, grace window, // stopChild terminates the companion child gracefully (SIGTERM, grace window,