Skip to content

iOS: the wrong device's log is streamed when several real devices are attached #3245

Description

@xShuusui

Steps to reproduce

patrol_cli spawns idevicesyslog without a UDID for real iOS devices. On a host
with more than one device attached, every concurrent patrol test run reads the same
device's syslog — so the Flutter output, the step progress and the Test summary in a
run can all belong to a device that run is not testing.

--device is honoured everywhere else: xcodebuild gets the right
-destination id=… and the right -only-testing, and the correct test executes on
the correct device. Only the log stream is unscoped, so the failure is silent — a run
prints a plausible, complete, wrong log.

Version

Reproduced with patrol_cli 4.7.0. The same code is present in 4.4.0 through 4.7.0,
so this is long-standing rather than a regression — it only becomes observable once
two real devices are attached to one host and two runs overlap.

Root cause

packages/patrol_cli/lib/src/ios/ios_test_backend.dart:

final patrolLogCommand = device.real
    ? ['idevicesyslog']            // ← no --udid: attaches to the first device found
    : [
        'xcrun', 'simctl', 'spawn',
        device.id,                 // ← the simulator branch does scope it
        'log', 'stream', '--type', 'log', '--color', 'none',
      ];

final processLogs = await _processManager.start(patrolLogCommand, runInShell: true);

device.id is available at that point and is used two lines below for the simulator.
libimobiledevice falls back to whichever device it enumerates first, which is not
stable across reboots or reconnects.

Two of the three log paths in the same codebase already scope correctly, which is what
makes this look like an oversight:

backend log command scoped to the device?
iOS, real device idevicesyslog no
iOS, simulator xcrun simctl spawn <device.id> log stream yes
Android _adb.logcat(device: device.id, …) yes

Impact

The pass/fail verdict is unaffected — execute() takes it from xcodebuild's exit
code, and the log stream is killed immediately afterwards:

final exitCode = await process.exitCode;   // ← the only thing that decides the result
patrolLogReader.stopTimer();
processLogs.kill();
...
_logger.info(patrolLogReader.summary);     // ← the stream is only ever printed

Everything derived from the stream is wrong, and nothing marks it as wrong:

  • the / step progress lines
  • --show-flutter-logs output, including app-side exceptions and stack traces
  • the final Test summary, including which .dart target is named as failed

A run therefore reports another device's exception under its own target name. In CI
that is worse than a missing log, because the output looks complete and internally
consistent.

Reproduction

Two real iOS devices attached to one macOS host. Build once with two targets, then
start two runs concurrently, each pinned to a different device and a different suite,
with distinct server ports so nothing else is shared:

patrol build ios --target integration_test/test_a.dart \
                 --target integration_test/test_b.dart --release

# terminal 1
patrol test-without-building --device <UDID-1> \
  --test-server-port 8081 --app-server-port 8082 \
  --release --show-flutter-logs --only "test_a Suite A"

# terminal 2
patrol test-without-building --device <UDID-2> \
  --test-server-port 8091 --app-server-port 8092 \
  --release --show-flutter-logs --only "test_b Suite B"

Observed:

  • The two runs' Flutter-log sections are byte-identical — same line count, empty
    diff, identical millisecond timestamps, and the same CoreBluetooth peripheral
    UUID. That identifier is assigned per host-device pair, so two different phones
    cannot produce it.
  • The run that requested Suite B, and whose xcodebuild invocation correctly ran
    Suite B's generated test method, printed Failed: Suite A (/test_a.dart) in its
    summary.
  • When the two runs differ in duration, the shorter run's stream is an exact
    prefix of the longer run's — one stream, two readers, one stopping earlier.

The tab-indented xcodebuild output in each run is unaffected and shows each device
running the test it was asked for. That contrast is the quickest way to confirm the
diagnosis: the native half is right and the Dart-side log is another device's.

Suggested fix

Scope the real-device branch the way the simulator branch already is:

final patrolLogCommand = device.real
    ? ['idevicesyslog', '-u', device.id]
    : [ /* unchanged */ ];

idevicesyslog has accepted -u, --udid UDID for as long as it has existed, so this
needs no version gate, and a single-device host behaves exactly as before.

Actual results

.

Logs

Logs
<!-- Replace this line with your logs. Do not remove the backticks! -->

Patrol version

patrol_cli: ^4.7.0

Patrol Doctor output

Patrol Doctor output
<!-- Replace this line with your logs. Do not remove the backticks! -->

Flutter Doctor output

Flutter Doctor output
<!-- Replace this line with your logs. Do not remove the backticks! -->

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority issues at the top of the work listbugSomething isn't workingpackage: patrol_cliRelated to the patrol_cli packageplatform: iosiOS is affected

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions