Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions idevice/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ xctest = [
"dvt",
"installation_proxy",
"afc",
"house_arrest",
"dep:uuid",
"dep:ns-keyed-archive",
"tunnel_tcp_stack",
Expand Down
9 changes: 9 additions & 0 deletions idevice/src/services/dvt/xctest/dtx_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ pub const XCTEST_PROXY_IDE_TO_DAEMON: &str =
pub const XCTEST_PROXY_IDE_TO_DRIVER: &str =
"dtxproxy:XCTestManager_IDEInterface:XCTestDriverInterface";

/// iOS < 17 proxy channel: XCTestDriverInterface ↔ XCTestManager_IDEInterface
/// (reverse channel from the runner, opened by testmanagerd on legacy
/// transports). Format matches pymobiledevice3's legacy implementation
/// (`serve_channel("dtxproxy:XCTestDriverInterface:XCTestManager_IDEInterface", …)`)
/// and the identifier observed on iOS 15.x devices. Note the sub-service order
/// is the reverse of the iOS 17+ form above.
pub const XCTEST_PROXY_DRIVER_TO_IDE: &str =
"dtxproxy:XCTestDriverInterface:XCTestManager_IDEInterface";

// ---------------------------------------------------------------------------
// Xcode version reported to testmanagerd
// ---------------------------------------------------------------------------
Expand Down
250 changes: 228 additions & 22 deletions idevice/src/services/dvt/xctest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ use dtx_services::{
XCT_LOG_MESSAGE, XCT_METHOD_DID_MEASURE_METRIC, XCT_RUNNER_READY_WITH_CAPABILITIES,
XCT_SUITE_DID_FINISH, XCT_SUITE_DID_FINISH_ID, XCT_SUITE_DID_START, XCT_SUITE_DID_START_ID,
XCT_UI_INIT_DID_FAIL, XCTEST_DRIVER_INTERFACE, XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE,
XCTEST_MANAGER_IDE_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER,
XCTEST_MANAGER_IDE_INTERFACE, XCTEST_PROXY_DRIVER_TO_IDE, XCTEST_PROXY_IDE_TO_DRIVER,
};
use listener::{XCTestCaseResult, XCUITestListener};
use types::{
Expand Down Expand Up @@ -905,16 +905,33 @@ struct DriverProxy {
impl DriverProxy {
async fn wait(
client: &mut RemoteServerClient<Box<dyn ReadWrite>>,
ios_major_version: u8,
timeout_secs: f64,
) -> Result<Self, IdeviceError> {
Ok(Self {
channel: wait_for_driver_channel(client, timeout_secs).await?,
channel: wait_for_driver_channel(client, ios_major_version, timeout_secs).await?,
})
}

async fn start_executing_test_plan(&mut self) -> Result<(), IdeviceError> {
start_executing_test_plan(&mut self.channel).await
}

/// iOS 14-16 (serialized transport): actively start the test plan on the
/// bridge channel requested by testmanagerd. Mirrors the go-ios xcode12
/// path (xcuitestrunner_12.go): after ForChannelRequest returns the bridge
/// channel (-1), call `_IDE_startExecutingTestPlanWithProtocolVersion:` with
/// the NSKeyedArchived protocol version 36, MethodCallAsync (no reply wait).
async fn start_executing_test_plan_legacy(&mut self) -> Result<(), IdeviceError> {
let version_bytes = AuxValue::archived_value(Value::Integer(36i64.into()));
self.channel
.call_method(
Some(IDE_START_EXECUTING_TEST_PLAN),
Some(vec![version_bytes]),
false,
)
.await
}
}

struct XCTestProcessControlChannel<'a, R: ReadWrite> {
Expand Down Expand Up @@ -953,30 +970,47 @@ impl<'a, R: ReadWrite + 'static> XCTestProcessControlChannel<'a, R> {
/// replies with an empty acknowledgement, registers the channel, and returns a
/// `Channel` handle to it.
fn testmanager_uses_proxy(ios_major_version: u8) -> bool {
ios_major_version >= 17
// All supported iOS versions (11+) use the
// dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface
// proxy channel — matches tidevice (unconditional) and pymobiledevice3
// (proxy channel form is version-independent; only the lockdown service
// name changes below iOS 14). The previous >=14 threshold left iOS 11-13
// on the plain IDEInterface channel, which testmanagerd cancels
// ("No channel handler specified"); iOS 11-13 with the plain channel is
// unverified on-device, but the reference implementations never use it.
ios_major_version >= 11
}

async fn wait_for_xctest_service_channel(
main_client: &mut RemoteServerClient<Box<dyn ReadWrite>>,
plain_identifiers: &[&str],
proxy_remote_identifiers: &[&str],
ios_major_version: u8,
timeout_secs: f64,
) -> Result<OwnedChannel<Box<dyn ReadWrite>>, IdeviceError> {
let timeout = Some(std::time::Duration::from_secs_f64(timeout_secs));

let code = match main_client
.wait_for_proxied_service_channel_code(proxy_remote_identifiers, true, Some(true), timeout)
.await
{
Ok(code) => code,
Err(IdeviceError::XcTestTimeout(_)) => match main_client
// Wait for exactly the channel form this iOS version uses, under a single
// deadline (pymobiledevice3 parity). A sequential proxied-then-plain
// fallback would double the effective timeout and would only ever select
// the plain channel after the proxied wait expired.
let code = if testmanager_uses_proxy(ios_major_version) {
main_client
.wait_for_proxied_service_channel_code(
proxy_remote_identifiers,
true,
Some(true),
timeout,
)
.await
} else {
main_client
.wait_for_service_channel_code(plain_identifiers, Some(true), timeout)
.await
{
Ok(code) => code,
Err(IdeviceError::XcTestTimeout(_)) => return Err(IdeviceError::TestRunnerTimeout),
Err(error) => return Err(error),
},
};
let code = match code {
Ok(code) => code,
Err(IdeviceError::XcTestTimeout(_)) => return Err(IdeviceError::TestRunnerTimeout),
Err(error) => return Err(error),
};

Expand All @@ -990,7 +1024,12 @@ async fn register_early_driver_channel_handler(
let xctest_config = xctest_config.clone();
main_client
.register_incoming_channel_initializer(
&[XCTEST_DRIVER_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER],
&[
XCTEST_DRIVER_INTERFACE,
XCTEST_PROXY_IDE_TO_DRIVER,
XCTEST_PROXY_DRIVER_TO_IDE, // legacy iOS < 17 bridge channel form
XCTEST_MANAGER_IDE_INTERFACE, // legacy iOS 15 driver channel name
],
move |mut channel, _identifier| {
let xctest_config = xctest_config.clone();

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.

I think we still need to implement --env feature that already been merged in iOS17+ implemention

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@truebit Good point — rebased onto latest master, which includes #128's runner environment overrides (USE_PORT / MJPEG_SERVER_PORT). The legacy iOS 14-16 transport now goes through the same run_until_wda_ready path, so the env overrides apply there too.

Box::pin(async move {
Expand Down Expand Up @@ -1056,22 +1095,38 @@ async fn launch_and_authorize_test_runner(
async fn start_test_plan_session(
main_client: &mut RemoteServerClient<Box<dyn ReadWrite>>,
_main_proxy: &mut TestManagerProxy<Box<dyn ReadWrite>>,
ios_major_version: u8,
) -> Result<OwnedChannel<Box<dyn ReadWrite>>, IdeviceError> {
let mut driver_proxy = DriverProxy::wait(main_client, 30.0).await?;
driver_proxy.start_executing_test_plan().await?;
driver_proxy.channel.clear_incoming_handler().await;
Ok(driver_proxy.channel)
if ios_major_version < 17 {

@truebit truebit Aug 10, 2026

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.

Why are iOS 11–13 still using the plain daemon channel?

The XCTest module explicitly documents support for iOS 11+, but this threshold leaves iOS 11–13 on the plain XCTestManager_IDEInterface channel.

Both comparison implementations select the old lockdown service below iOS 14 while still opening the IDE-to-daemon channel as dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface:

Should this be ios_major_version >= 11, or is there device evidence that iOS 11–13 specifically require the plain channel? If the latter is intentional, it would be useful to document that divergence from both reference implementations.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@truebit You're right, and I confirmed both references: tidevice opens the dtxproxy:...DaemonConnectionInterface channel unconditionally, and pymobiledevice3 only switches the lockdown service name below iOS 14 while keeping the proxy channel form. Since the plain channel is what testmanagerd cancels on iOS 14–16, keeping iOS 11–13 on plain had no supporting evidence. Changed the threshold to >= 11, aligning with both reference implementations.

Note: iOS 11–13 remains unverified on-device (no device available), but the divergence from both references is gone.

// iOS 14-16: no XCTestDriverInterface channel (serialized transport).
// Order matches go-ios: wait for the testmanagerd bridge channel request
// (ForChannelRequest), then actively startExecutingTestPlan(36) on it.
let mut driver_proxy = DriverProxy::wait(main_client, ios_major_version, 30.0).await?;
driver_proxy.start_executing_test_plan_legacy().await?;
// early handler has served the capabilities exchange; clear it so the
// dispatch loop owns channel messages from here on
driver_proxy.channel.clear_incoming_handler().await;
Ok(driver_proxy.channel)
} else {
let mut driver_proxy = DriverProxy::wait(main_client, ios_major_version, 30.0).await?;
driver_proxy.start_executing_test_plan().await?;
driver_proxy.channel.clear_incoming_handler().await;
Ok(driver_proxy.channel)
}
}

pub(super) async fn wait_for_driver_channel(
main_client: &mut RemoteServerClient<Box<dyn ReadWrite>>,
ios_major_version: u8,
timeout_secs: f64,
) -> Result<OwnedChannel<Box<dyn ReadWrite>>, IdeviceError> {
const DRIVER_SERVICE_IDENTIFIERS: &[&str] = &[XCTEST_DRIVER_INTERFACE];
const DRIVER_SERVICE_IDENTIFIERS: &[&str] =
&[XCTEST_DRIVER_INTERFACE, XCTEST_MANAGER_IDE_INTERFACE]; // the latter for iOS 15
wait_for_xctest_service_channel(
main_client,
DRIVER_SERVICE_IDENTIFIERS,
DRIVER_SERVICE_IDENTIFIERS,
ios_major_version,
timeout_secs,
)
.await
Expand Down Expand Up @@ -2005,15 +2060,60 @@ impl XCUITestService {
// 3. Build XCTestConfiguration
let xctest_config = cfg.build_xctest_configuration(session_id, ios_major_version)?;

// 3.5 iOS < 17: write the xctestconfiguration into the app container's
// tmp/ dir (launch env XCTestConfigurationFilePath points at it; iOS 17+
// passes config via the capabilities reply instead). Mirrors go-ios
// createTestConfigOnDevice (house_arrest VendContainer + write
// tmp/<session>.xctestconfiguration).
if ios_major_version < 17 {
use crate::services::afc::opcode::AfcFopenMode;
use crate::services::house_arrest::HouseArrestClient;
let house = HouseArrestClient::connect(&*self.provider).await?;
let mut afc = house.vend_container(&cfg.runner_bundle_id).await?;
if let Err(e) = afc.mk_dir("/tmp").await {
debug!("mk_dir /tmp: {e} (likely already exists)");
}
let relative = xctest_path.clone(); // already /tmp/<session>.xctestconfiguration

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.

Clean up stale XCTest configuration files

Each run writes a uniquely named .xctestconfiguration file, but neither the success nor error path removes it. Repeated WDA launches will therefore leave an increasing number of stale files in the runner container.

Both legacy reference implementations remove old configuration files before uploading the new one:

Could we similarly remove stale *.xctestconfiguration files before writing, or use a cleanup guard that removes the current file when the run finishes?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@truebit Agreed — each run left a uniquely named .xctestconfiguration behind. Fixed by cleaning before writing, matching tidevice: list /tmp and remove any *.xctestconfiguration prior to writing the new file, so stale files from crashed runs get cleaned up on the next launch too. Removal failures are logged at debug level and don't fail the run.

// Remove stale configs from previous runs before writing (tidevice
// parity): each run writes a uniquely named file and neither the
// success nor the error path removes it, so repeated WDA launches
// would accumulate files in the runner container.
if let Ok(files) = afc.list_dir("/tmp").await {
for fname in files {
if fname.ends_with(".xctestconfiguration")
&& let Err(e) = afc.remove(format!("/tmp/{fname}")).await
{
debug!("remove stale config {fname}: {e}");
}
}
}
let bytes = xctest_config.to_archive_bytes()?;
let mut fd = match afc.open(&relative, AfcFopenMode::WrOnly).await {
Ok(fd) => fd,
Err(e) => {
warn!("write xctestconfig failed: path={relative} err={e}");
return Err(e);
}
};
fd.write_entire(&bytes).await?;
fd.close().await?;
debug!("wrote xctestconfiguration to device: {}", relative);
}

// 4. Connect to testmanagerd (ctrl + main) and DVT
let mut conns = connect_testmanagerd(&*self.provider, ios_major_version).await?;
// Register the incoming channel initializer BEFORE opening the main
// channel: testmanagerd immediately opens the driver bridge channel
// (named XCTestManager_IDEInterface on iOS 15) in response to our open;
// a late registration gets the request canceled ("No channel handler
// specified") and the runner transport times out.
register_early_driver_channel_handler(&mut conns.main, &xctest_config).await;
let mut ctrl_proxy = TestManagerProxy::open(&mut conns.ctrl, ios_major_version).await?;
let mut main_proxy = TestManagerProxy::open(&mut conns.main, ios_major_version).await?;
let mut process_control = XCTestProcessControlChannel::open(&mut conns.dvt).await?;

let config_name = cfg.config_name().to_owned();
initialize_testmanager_sessions(&mut ctrl_proxy, &mut main_proxy, &xctest_config).await?;
register_early_driver_channel_handler(&mut conns.main, &xctest_config).await;
initialize_testmanager_daemon_sessions(
&mut ctrl_proxy,
&mut main_proxy,
Expand Down Expand Up @@ -2047,7 +2147,8 @@ impl XCUITestService {
.await?;

// 6-7. Wait for driver channel and start the test plan.
let driver_channel = start_test_plan_session(&mut conns.main, &mut main_proxy).await?;
let driver_channel =
start_test_plan_session(&mut conns.main, &mut main_proxy, ios_major_version).await?;

// 8. Dispatch loop, raced against the runner connection dropping.
run_dispatch_loop_until_done_or_disconnect(
Expand Down Expand Up @@ -2149,6 +2250,111 @@ mod tests {
use super::wda_port_overrides_from_runner_environment;
use crate::services::wda::WdaPorts;

#[test]
fn testmanager_uses_proxy_for_all_supported_ios_versions() {
// The proxied daemon channel is used on every supported iOS version,
// matching tidevice (unconditional) and pymobiledevice3 (proxy form is
// version-independent). Lock this in so a version threshold cannot
// silently regress iOS 11-13 back onto the plain channel.
for version in 11..=18u8 {
assert!(
super::testmanager_uses_proxy(version),
"iOS {version} should use the proxied daemon channel"
);
}
}

#[test]
fn legacy_launch_env_points_at_config_file() {
let session = uuid::Uuid::new_v4();
let config_path = format!(
"/tmp/{}.xctestconfiguration",
session.to_string().to_uppercase()
);
let (_, env, _) = super::build_launch_env(
15,
&session,
"/app/Runner.app",
"/var/mobile/Containers/Data/Application/ABC",
"WebDriverAgentRunner",
&config_path,
None,
None,
);

// iOS < 17: the runner reads the config from the path we write into
// the app container's tmp/ dir.
let on_device = env
.get("XCTestConfigurationFilePath")
.and_then(|v| v.as_string())
.expect("XCTestConfigurationFilePath set");
assert!(on_device.starts_with("/var/mobile/Containers/Data/Application/ABC/tmp/"));
assert!(on_device.ends_with(".xctestconfiguration"));
// iOS 17+ only marker must not leak into the legacy launch env.
assert!(env.get("XCTestManagerVariant").is_none());
}

#[test]
fn ios17_launch_env_clears_config_file_and_sets_variant() {
let session = uuid::Uuid::new_v4();
let (_, env, _) = super::build_launch_env(
17,
&session,
"/app/Runner.app",
"/var/mobile/Containers/Data/Application/ABC",
"WebDriverAgentRunner",
"/tmp/x.xctestconfiguration",
None,
None,
);

// iOS 17+: config travels via the capabilities reply, so the env path
// is cleared and the DDI variant marker is set.
assert_eq!(
env.get("XCTestConfigurationFilePath")
.and_then(|v| v.as_string()),
Some("")
);
assert_eq!(
env.get("XCTestManagerVariant").and_then(|v| v.as_string()),
Some("DDI")
);
let dyld_fw = env
.get("DYLD_FRAMEWORK_PATH")
.and_then(|v| v.as_string())
.expect("DYLD_FRAMEWORK_PATH set");
assert!(dyld_fw.starts_with('$'));
}

#[test]
fn launch_env_merges_runner_overrides() {
let session = uuid::Uuid::new_v4();
let extra = crate::plist!(dict {
"USE_PORT": "8200",
"NSUnbufferedIO": "NO",
});
let (_, env, _) = super::build_launch_env(
15,
&session,
"/app/Runner.app",
"/var/mobile/Containers/Data/Application/ABC",
"WebDriverAgentRunner",
"/tmp/x.xctestconfiguration",
Some(&extra),
None,
);

assert_eq!(
env.get("USE_PORT").and_then(|v| v.as_string()),
Some("8200")
);
// Caller-provided env overrides the base value.
assert_eq!(
env.get("NSUnbufferedIO").and_then(|v| v.as_string()),
Some("NO")
);
}

#[test]
fn wda_ports_follow_runner_environment() {
let runner_env = crate::plist!(dict {
Expand Down