Skip to content

feat(ios): add dartVmServicePort capability for deterministic Dart VM binding + log-filter fallback#870

Merged
KazuCocoa merged 4 commits into
appium:mainfrom
AsijitM:add-dart-vm-service-port-capability
May 28, 2026
Merged

feat(ios): add dartVmServicePort capability for deterministic Dart VM binding + log-filter fallback#870
KazuCocoa merged 4 commits into
appium:mainfrom
AsijitM:add-dart-vm-service-port-capability

Conversation

@AsijitM

@AsijitM AsijitM commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an appium:dartVmServicePort capability (iOS only, Flutter ≥3.10) that pins the Dart VM service to a known port at app launch and provides a discovery fallback for the case where the syslog scan exhausts without finding the URL.

Problem

The current driver discovers the Dart VM port by tailing iOS syslog for:

flutter: The Dart VM service is listening on http://127.0.0.1:<port>/

If that line doesn't deliver within ~30 s, the session dies with No observatory URL matching .... Two real-world conditions make this fail:

1. iOS unified-logging filters the discovery line non-deterministically

Apple's unified-log privacy/redaction filters silently drop the flutter: … line on a significant fraction of launches. Empirical data on iPhone 15 Pro / iOS 17.1.1 across one day of production traffic on a single device: 25 Runner(Flutter) PIDs launched, only 10 flutter: … lines reached syslog — a ~60% filter rate. Same pattern observed on iOS 18.5 and iOS 26.4.1. Per-launch non-deterministic, OS-level, outside the driver's control.

2. The port is non-deterministic when the engine doesn't honor the legacy flag

Engine commit 396c7fd0bd (Jan 2023, first in Flutter 3.10) renamed --observatory-port--vm-service-port. Apps built against Flutter ≥3.10 silently ignore --observatory-port and bind to an OS-assigned ephemeral port. Without the syslog line, the driver has no way to discover what that random port is.

What this PR adds

A single new capability: appium:dartVmServicePort: <number> (iOS only).

When set, the driver does two things — the user doesn't need to touch processArguments directly:

1. At session start (startIOSSession): inject required engine flags

A new injectDartVmServicePortFlags helper mutates caps.processArguments.args to inject:

  • --vm-service-port=<port> — tells the Flutter engine which port to bind. Requires Flutter ≥3.10.
  • --disable-service-auth-codes — drops the random auth-code path component from the service URL so the fallback URL (constructed without observing the actual URL) is correct.

Any pre-existing --vm-service-port=* entries in processArguments.args are stripped first so this cap is the authoritative source for the port. Other entries are left untouched.

2. In discovery (getObservatoryWsUri): fall back when scan exhausts

When LogMonitor.waitForLastMatchExist returns no match AND dartVmServicePort is set, the driver dials ws://127.0.0.1:<dartVmServicePort>/ws instead of throwing. The engine was instructed to bind to this port with no auth codes — so this is the correct URL even when the discovery line was filtered.

When dartVmServicePort is NOT set, behaviour is identical to upstream — driver throws the same No observatory URL matching error.

How it works — scenarios

✅ Passing scenarios

Scenario What happens Why it works
Flutter ≥3.10 app, syslog leaks the line Driver matches the line via LogMonitor, extracts URL with whatever port the engine bound to (which is the cap value since --vm-service-port was honored), dials it. Cap injection forced deterministic binding; syslog gave ground truth confirmation. Cap value and observed URL match.
Flutter ≥3.10 app, iOS filters the line LogMonitor exhausts, fallback constructs ws://127.0.0.1:<port>/ws from cap value, dials it. Engine is reachable there because we sent both --vm-service-port=<port> and --disable-service-auth-codes. Both halves of the injection are required — the port flag for engine binding, the auth-codes flag so the fallback URL doesn't need an auth path component.
Cap is unset (existing user behaviour) Discovery and connection follow the original upstream path. Cap is fully opt-in; no code path changes when it's unset.
Both dartVmServicePort and observatoryWsUri set observatoryWsUri takes precedence for the connection target; dartVmServicePort still injects flags so the engine binds where requested. Existing escape-hatch behaviour for observatoryWsUri is preserved unchanged.

❌ Failing scenarios + how to avoid

Scenario What goes wrong How to avoid
App built with Flutter <3.10 (engine recognises only --observatory-port, not --vm-service-port) Engine ignores our injected --vm-service-port=N, binds to a random ephemeral port. Syslog leak path: driver reads the random port and connects (✅ — cap was effectively a no-op but session succeeds). Syslog filter path: fallback dials cap value, engine is on a random port, socket hang up (❌). Use Flutter ≥3.10 (the canonical flag was added then), or fall back to passing --observatory-port=N via processArguments.args directly and rely on syslog scan only. Future Android-extension or backward-compat addition could mitigate this.
App's AppDelegate explicitly calls FlutterDartProject(arguments: ["--vm-service-port=X"]) with a hardcoded X different from the cap value App-code arguments take precedence over CLI args (flutter#124049). Engine binds to X, not the cap value. Syslog leak: driver reads X and connects (✅). Syslog filter: fallback dials cap value (not X), socket hang up (❌). Either set the cap to match the hardcoded value (dartVmServicePort: X), or remove the hardcoded arguments from the AppDelegate so the CLI flag wins.
App uses Flutter add-to-app integration with a custom AppDelegate that doesn't forward NSProcessInfo.arguments to the engine (flutter#34731) Engine never sees the cap's injected flags. Binds to a random port. Syslog leak: works (cap was no-op). Syslog filter: fallback dials cap value, engine elsewhere, socket hang up. App-side fix required — the AppDelegate must forward NSProcessInfo.arguments to FlutterDartProject. Not addressable from this cap.
Cap value is a port already in use on the device Engine fails to bind, no syslog listening line ever emitted, session can't proceed. Pick a port the test infrastructure isn't otherwise using.
Customer test code separately sets --vm-service-port=Y in processArguments AND also sets the cap to a different value Z The cap's dedup filter strips the user's --vm-service-port=Y before injecting --vm-service-port=Z. Cap wins; user's value is silently overridden. Documented behaviour — cap is authoritative for the port. If the user wants a different value, they should change the cap, not both.

Properties

  • Opt-in. No behaviour change when the cap is omitted.
  • Self-contained. User sets the cap; driver handles flag injection + fallback. User doesn't have to know about processArguments, auth codes, or the Flutter flag rename.
  • Precedence-safe. Honours the existing observatoryWsUri escape hatch (that takes precedence for connection).
  • Backward-compatible. Users currently passing flags via processArguments.args continue to work. Cap is independent.
  • Zero new dependencies.

Scope

iOS only. Android Flutter uses optionalIntentArguments with --ei observatory-port N (a different injection shape), and the flag-rename surface there has not been verified equivalently. Happy to follow up with an Android counterpart in a separate PR if there's interest.

Flutter ≥3.10 only (engine commit 396c7fd0bd is when --vm-service-port was added; older engines don't recognise it). Earlier scope iteration injected --observatory-port as a legacy alias but was dropped in 5df06c3 based on review feedback — cap is now narrower and cleaner.

Files

File Change
driver/lib/desired-caps.ts Register dartVmServicePort: { isNumber: true }
driver/lib/sessions/ios.ts Add injectDartVmServicePortFlags helper; call from startIOSSession; add fallback branch in getObservatoryWsUri
README.md Document the new cap in the capabilities table

Diff: small (~+73 / -6).

Commit history

Commit Purpose
6070ab2 Initial implementation. Injected --vm-service-port + --observatory-port + --disable-service-auth-codes.
5df06c3 ← HEAD Scope narrowed: drop --observatory-port (Flutter <3.10 cohort is out-of-scope). Inject only --vm-service-port + --disable-service-auth-codes. Dedup filter only strips pre-existing --vm-service-port=*.

Validation

  • npm run format:check — clean on modified files.
  • npx eslint lib/ — 0 errors. 5 warnings reported on lib/sessions/ios.ts are all pre-existing on main (identical line numbers shifted by the new function).
  • TypeScript build errors observed during npm install (in IsolateSocket-related files) are pre-existing on main from an rpc-websockets v10 type drift — out of scope for this PR.

Example usage

capabilities:
  platformName: iOS
  appium:automationName: flutter
  appium:bundleId: com.example.myapp
  appium:dartVmServicePort: 8283   # driver handles the rest

vs. without the cap, where users have to know both flag-name mechanics and disable-service-auth-codes implications:

capabilities:
  platformName: iOS
  appium:automationName: flutter
  appium:bundleId: com.example.myapp
  appium:processArguments:
    args:
      - "--vm-service-port=8283"
      - "--disable-service-auth-codes"
  # Plus user has to know that syslog scan might fail and have a Plan B

References

Happy to iterate on naming, scope, or implementation based on review feedback.

Adds an `appium:dartVmServicePort` capability (iOS only) that pins the
Dart VM service to a known port at app launch and provides a discovery
fallback when the syslog scan exhausts.

Background

Flutter renamed the engine flag `--observatory-port` to
`--vm-service-port` in Flutter 3.10 (engine commit 396c7fd0bd, Jan 2023).
Customer app binaries built against Flutter >=3.10 no longer recognise
the legacy alias — passing `--observatory-port` via processArguments
is silently ignored and the Dart VM binds to an OS-assigned ephemeral
port instead of the requested one. Conversely, Flutter <3.10 only
recognises the legacy name. Without knowing the customer's bundled
Flutter version at session creation time, there is no single flag name
that works across the field.

Separately, iOS unified-logging privacy filters can silently drop the
`flutter: The Dart VM service is listening on http://127.0.0.1:<port>/`
syslog line on a significant fraction of launches, causing the existing
LogMonitor-based discovery to time out even on apps that bind correctly.

What this adds

`appium:dartVmServicePort: <number>` (iOS only). When set, the driver:

1. In `startIOSSession`, injects both `--vm-service-port=<port>` and
   `--observatory-port=<port>` (plus `--disable-service-auth-codes` if
   absent) into `caps.processArguments.args`. Any pre-existing entries
   for either flag are stripped first so the cap is authoritative.
   The Flutter engine silently ignores unknown flags, so sending both
   names is safe across all Flutter versions — each engine picks up
   whichever it knows.

2. In `getObservatoryWsUri`, when the syslog scan exhausts without
   finding the Dart VM service URL, fall back to
   `ws://127.0.0.1:<dartVmServicePort>/ws` instead of throwing. The
   engine was instructed to bind to this port at launch, so this is
   the correct connect target even when the discovery line was
   filtered.

Pure opt-in. When the cap is not set, behaviour is identical to before.
Honours the existing `observatoryWsUri` escape hatch (that takes
precedence for connection).

Scope

iOS only. Android Flutter uses `optionalIntentArguments` with a
different injection shape, and the flag-rename surface there has not
been verified — left as a follow-up.

Files

* `lib/desired-caps.ts` — register the cap with `isNumber: true`.
* `lib/sessions/ios.ts` — add `injectDartVmServicePortFlags` helper,
  call from `startIOSSession`, add fallback branch in
  `getObservatoryWsUri`.
* `README.md` — document the new cap in the capabilities table.

References

* Flutter engine rename commit:
  https://chromium.googlesource.com/external/github.com/flutter/engine/+/396c7fd0bd324c74f1027b3a961e9269ed5ab63c%5E!/
* Dart 3.0 Observatory removal:
  dart-lang/sdk#50233
* Flutter 3.10 release notes:
  https://docs.flutter.dev/release/release-notes/release-notes-3.10.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@linux-foundation-easycla

linux-foundation-easycla Bot commented May 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: AsijitM / name: Asijit Manna (6070ab2)

Simplify the injection to only the flags strictly required for the cap's
self-contained behaviour on modern Flutter:

* `--vm-service-port=<port>` — engine binding (Flutter >=3.10)
* `--disable-service-auth-codes` — required so the fallback URL has no
  random auth-code path component

`--observatory-port=<port>` (the legacy alias for Flutter <3.10) was
dropped. Customer apps on Flutter <3.10 are an increasingly rare cohort,
and users targeting them can continue to use `processArguments.args`
directly or `observatoryWsUri`. Scope the cap as "Flutter >=3.10" in the
JSDoc and README.

The dedup filter now only strips pre-existing `--vm-service-port=*`
entries (the cap is authoritative for the port). Pre-existing
`--observatory-port=*` entries are left untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread driver/lib/sessions/ios.ts Outdated
if (typeof port !== 'number') {
return;
}
caps.processArguments = caps.processArguments ?? {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
caps.processArguments = caps.processArguments ?? {};
caps.processArguments ??= {};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated this now!

Comment thread driver/lib/sessions/ios.ts Outdated
Comment on lines +175 to +177
urlObject = new URL(`http://${LOCALHOST}:${caps.dartVmServicePort}/`);
urlObject.protocol = `ws`;
urlObject.pathname += `ws`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any reason to not set:

new URL(`ws://${LOCALHOST}:${caps.dartVmServicePort}/ws`);

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There’s no particular reason for that. The three-line http to ws conversion was reused from the surrounding implementation, where extractObservatoryUrl parses an http:// URL from the syslog and then converts it afterward. In this fallback flow, we’re constructing the URL directly, so using ws:// from the start is simpler and cleaner.

* Use logical-nullish-assignment for caps.processArguments default
  (`??=` instead of `... = ... ?? {}`).
* Construct fallback URL directly as `ws://...` instead of building an
  http URL and mutating protocol/pathname.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AsijitM AsijitM closed this May 27, 2026
@AsijitM AsijitM deleted the add-dart-vm-service-port-capability branch May 27, 2026 09:50
@AsijitM AsijitM restored the add-dart-vm-service-port-capability branch May 27, 2026 09:51
@AsijitM AsijitM reopened this May 27, 2026
@AsijitM AsijitM requested a review from KazuCocoa May 27, 2026 10:02
@KazuCocoa KazuCocoa merged commit 17f07e3 into appium:main May 28, 2026
4 of 6 checks passed
@KazuCocoa

Copy link
Copy Markdown
Member

3.7.0 has this

@KazuCocoa KazuCocoa added the size:S contribution size: S label Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:S contribution size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants