Skip to content

feat(network): capture in-app HTTP traffic for React Native sessions - #3930

Open
thymikee wants to merge 13 commits into
mainfrom
feat/rn-network-capture
Open

thymikee wants to merge 13 commits into
mainfrom
feat/rn-network-capture

Conversation

@thymikee

@thymikee thymikee commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Summary

network dump on a React Native session with a Metro dev-server binding now installs a JS fetch/XMLHttpRequest interceptor into the app runtime over the Metro inspector (CDP Runtime.evaluate) and reports real traffic: method, full URL, status, duration, headers, and bounded bodies. Previously it only parsed app-log lines, which on RN name almost nothing.

The first dump arms the hook and answers that window from the app log (source=app-log plus an arming note); later dumps answer from the in-app ring buffer (source=provider, drain-once, newest-first, 200 entries). No Metro binding, an unreachable dev server, several connected apps, or a session device the host cannot name uniquely keeps the old log path with a typed note. The provider injects only into the runtime whose reported device equals the platform-composed Metro device identity — a name that marks exactly one device this host can see (Android reads every adb devices serial in any state and withholds the name when any read fails; Apple checks booted simulators in every simulator set the host can name plus CoreDevice's connected devices, and ADR 0038 records the one residue it cannot see: a set directory the host was never told about); anything weaker fails closed with metro-device-mismatch before any injection. The identity probe resolves lazily inside a dump, so only a session whose transport declared a provider dump pays for it. A session without an app never reaches the provider at all. Bodies/headers are capped and redacted with the shared kernel rules. Provider preference lives inside the Apple/Android runtimes via the existing NetworkTransport slot, so network dump stays always-available. docs/adr/0038 records the source owner, the probe | provider | app-log enum, and the lazy-arming opt-in policy shared with #3928.

agent-device network dump                # arms the capture (answers from app log)
agent-device network dump --include all  # method, URL, status, duration, headers, bodies

Rejected alternatives

  • RN DevTools' built-in Network CDP domain instead of injected code: no app-side injection, but it needs a daemon-held CDP connection for the session's lifetime and drops RN versions without the domain. The injected runtime (~320 lines) is the version-agnostic core; which floor we commit to belongs in the source-selection ADR alongside feat(ios): capture every URLSession request with open --capture-network #3928.
  • Arming at app open when Metro is bound: rejected for now. Injection stays coupled to the first explicit network dump, so nothing is installed in the app under test until a command asks for network data. Open-path arming needs an attach-retry policy for cold-start races and pre-empts the feat(ios): capture every URLSession request with open --capture-network #3928/ADR injection-policy decision.

Validation

  • pnpm check:affected --run at head 3271f8738e: all runnable checks passed (exactCommit=true, base c48d27a819, receipt .tmp/validation/run-DWkJRo/receipt.json); fallow audit --base c48d27a819 passes on changed files.
  • Review round 3 (fail-closed identity): Apple checks every simulator set the host can name (own set, default set, operator set) plus CoreDevice-connected devices and fails closed on any unreadable answer; Android reads every listed serial in any state with bounded timeouts and withholds the identity on any unreadable serial; the probe resolves lazily inside the dump. Live iOS at the final head: arming dump, source=provider capture, drain-once, then a second booted same-named simulator flips the dump to source=app-log with the mismatch note, and shutting the twin restores it. Verbatim outputs in the round-3 reply.
  • Review round 2 (device identity): metroDeviceIdentity(device) is platform-owned — Android composes <model> - <release> - API <level> only when unique across adb devices, Apple passes the simulator name only when unique among booted simulators; anything weaker fails closed with metro-device-mismatch before any injection. Live on two same-image emulators (both sdk_gphone64_arm64): shared model fails closed with no injection on either device; one attached device arms and captures the full sequence. Verbatim outputs in the round-2 reply.
  • Live iOS: simulator rn-net2 ios, final head — arming dump (source=app-log + arming note), then source=provider dumps with full URL/status/durationMs/headers/bodies for the network lab's fetch/POST/XHR (httpbin), x-api-key shown as [REDACTED]; drain-once verified; cold restart re-arms; a failed fetch followed by an XHR to the same URL reports both entries.
  • Live Android: emulator ReactNative_API_35 — same sequence; entries carry the okhttp/4.12.0 User-Agent.
  • Live two-device Metro (one Metro, iOS sim + Android emulator, two sessions): each session's dump returned only its own runtime's entries; draining one left the other at matchedLines=0.
  • Full CLI outputs and per-finding disposition in the review-round reply.

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3930/

Built to branch gh-pages at 2026-10-23 17:19 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.58 MB 5.60 MB +22.1 kB
Package (unpacked) 5.58 MB 5.60 MB +22.1 kB
Package (download) 1.69 MB 1.70 MB +7.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 40.9 ms 40.3 ms -0.7 ms
CLI --help 79.7 ms 80.7 ms +1.0 ms

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for this. At e16c7c9 I found three code defects and no live evidence at head, so this is not ready to merge yet. All 22 checks pass, but CI cannot reach the Hermes/Metro route, and the unit fixture runs the injected source in node:vm without XMLHttpRequest.

A failed fetch hides every later XHR to the same URL. The fetch wrapper takes a URL-keyed claim at line 135 and releases it only on a synchronous throw and on fulfilment. The rejection handler at line 188 never releases it, so that URL stays claimed and each later XHR to it records nothing. The claim is also held for the whole request, so a direct XHR concurrent with an in-flight fetch to the same URL is dropped. I reproduced this in node:vm with a hand-written fake XMLHttpRequest and a whatwg-fetch-style fetch: a failed fetch to /down, then an XHR to /down, gave no xhr entry. On an app that mixes fetch with axios, one network error or abort makes later axios calls to that URL invisible, and the dump looks clean. The rule should be: an XHR is attributed to fetch only when its send() runs synchronously inside a wrapped fetch call. Releasing the claim right after fetch.apply returns satisfies that. Since the PR body says live runs showed one entry per request without the guard, deleting the guard is also fine if a fresh run at head shows no double entries. A fixture test with fetch built over the fixture's XMLHttpRequest should cover a rejected fetch followed by an XHR to the same URL. It fails at this head.

The dump may read and inject into another device's app. network-capture-dump.ts:95 calls selectReactNativeTarget with only the optional appId and no device filter. If one Metro serves several devices and the session's own app is not connected at that moment (closed, crashed, reloading), the same app id on another device is the only candidate. The dump then hooks that runtime and returns its traffic as this session's provider entries. That is wrong attribution, and it changes an app on a device this session does not own. The provider should read and inject only the runtime of the session's own app on the session's own device. Is passing the session device through NetworkTransportScope into RuntimeTargetFilter.device the right route, or should the selected target be checked against the session device and fail closed with a typed reason? Please also skip the provider when the session has no appBundleId, and add a two-device fixture where only the other device is connected and no entries come back. I did not run a two-device Metro, and I am not sure the Metro deviceName maps reliably to DeviceInfo.name on Android.

Provider notes now suppress the inactive-log guidance. Both runtimes seed the fallback notes with provider.notes (Apple runtime.ts:30, Android runtime.ts:33) before appendLifecycleNote runs. That function only adds the 'Session app log stream is inactive…' note when the notes list is empty, so an arming note or a provider-unavailable note now hides it. On a React Native session with an inactive log stream, the arming dump and every dump with Metro unreachable lose the hint to run logs clear --restart. The new runtime tests use an active snapshot, so they cannot see this. Lifecycle notes should depend only on the app-log scan and its recovery. Build them first and add the provider notes after, or gate the inactive note on a recovered flag, in both runtimes. One Apple and one Android test with an inactive snapshot and an armed provider should expect both notes.

This change installs code in a live app runtime over CDP, so it needs live proof at head. The live claims in the PR body have no output and no SHA, and they predate 922d57f, which rewrote the injected Hermes source (claim guard, recording after clone.text(), header caps, eviction accounting). After the fixes above, please run the examples/test-app network lab at the new head on an iOS simulator and an Android emulator, and paste the CLI output: (1) the first network dump shows source: app-log with the arming note; (2) after network-fetch, network-post and network-xhr, network dump --include all shows source: provider with exactly three entries (transport fetch, fetch, xhr), status 200, a [REDACTED] sensitive header and bounded bodies, and network-lab-status shows the app's own requests settled; (3) an immediate second dump repeats no entry and does not re-arm; (4) after a Metro reload the next dump re-arms (source: app-log plus the arming note) and a later dump still shows one entry per request; (5) a failed fetch followed by an XHR to the same URL reports the XHR.

Not blocking, take or leave: providerFailureNote in capture-kit turns every provider failure into prose, so a typed outcome such as provider: { status: 'armed' | 'unavailable', reason? } on the app-log result would be easier to act on; redactNetworkHeaders repeats redactDiagnosticData's object path, so calling redactDiagnosticData(headers) in network-capture-entries.ts:20 would let you delete it; the early return in the SENSITIVE_ASSIGNMENT_RE replacer in redaction.ts cannot fire (I checked it in node against {"token":"abc"} and similar inputs), so it and its comment can go, and the new JSON rule also widens redaction for every redactDiagnosticData caller, which is worth a docs line; the fetch wrapper returns only after a second full body read, which is cheap with whatwg-fetch but would delay a streaming global fetch, so returning the response at once and recording in a detached chain is safer (I did not check whether current Expo SDKs replace globalThis.fetch); the fixture app defines no XMLHttpRequest, so instrumentXHR and the claim guard never run in any test, and the unreachable-server case asserts a message regex instead of details.reason; the docs in commands.md say 'every request' and 'names every request it observed', but capture covers only fetch and XMLHttpRequest after arming, the code accepts any apple/android device kind while debugging-profiling.md says simulator or emulator, and URL query strings are not redacted, which the docs should say; and the abort signal is checked once but not passed to the target list or the InspectorSession, so the CDP session can outlive the 12 s budget, and closing it on abort would fix that.

Could this be smaller and better owned? Shipped code is about 880 lines, and the PR body names no rejected alternative. One shape: a single capture-kit owner, dumpSessionNetwork, walks one ordered source list (an armed in-app source, then the app log with platform recovery), with Apple and Android calling it and web as the provider-only case. Then the #3928 probe becomes another NetworkTransport resolved at the composition root from sessionId, so neither PR adds a NetworkDumpInput field or host slot. stateDir could also leave NetworkDumpInput if the composition root resolves it the way readMetroSessionRuntime does (I did not verify that the daemon's resolveDaemonStateDir agrees). Dropping the fetch-claim guard and redactNetworkHeaders would remove roughly 100 to 150 lines and one seam. The 320-line injected runtime looks like the needed core for React Native versions without a Network CDP domain. Subscribing to RN DevTools' built-in Network domain would need no injected code but would need a daemon-held CDP connection and drop older RN (from memory, not checked against RN source), and the PR body should state that trade-off. Before either PR lands, #3928 and this PR need a decision on which goes first, and ADR 0037 (or a new ADR) should name the source-selection owner, the source enum ('probe' | 'provider' | 'app-log'), and one opt-in policy, since today they differ on injecting into the app under test: explicit at open versus implicit on the first dump.

Before merge, please fix the three defects above, post the live iOS and Android run at the new head, and settle source-selection ownership with #3928.

@thymikee

Copy link
Copy Markdown
Member Author

All findings addressed at 31344be1cc..a03ce9ca9b, with the live run at the final head on both platforms. Item by item:

1. A failed fetch hides every later XHR to the same URL (blocker). Fixed the way you sketched: the claim is released synchronously right after fetch.apply returns, so the window covers only a whatwg-style fetch that builds its XHR synchronously. The rejection path no longer holds anything. The fixture fetch is now a whatwg-style polyfill over the fixture's real-shape XMLHttpRequest, so instrumentXHR and the guard actually execute in unit tests, and a rejected fetch followed by an XHR to the same URL is covered (it fails at e16c7c93 as you said). Live at the final head on both platforms, network-failed-fetch-xhr (fetch first, then a direct XHR to the same URL) reports both entries — the fetch error (status=0) and the XHR:

# Android (emulator-5554, head 0933621fc2), also on iOS sim below
source=provider active=true state=active backend=react-native-app include=all scannedLines=2 matchedLines=2
2026-10-23T12:42:18.592Z GET https://invalid.agent-device.test/after-failed-fetch status=0 durationMs=2
  response: Unable to resolve host "invalid.agent-device.test": No address associated with hostname
2026-10-23T12:42:18.581Z GET https://invalid.agent-device.test/after-failed-fetch durationMs=11
# iOS simulator "rn-net2 ios" (head 0933621fc2)
source=provider active=true state=active backend=react-native-app include=all scannedLines=2 matchedLines=2
2026-10-23T12:47:18.533Z GET https://invalid.agent-device.test/after-failed-fetch status=0 durationMs=4
  response: A server with the specified hostname could not be found.
2026-10-23T12:47:18.513Z GET https://invalid.agent-device.test/after-failed-fetch durationMs=20

The second line in each pair is the XHR (no status field, error XHRs report 0; durationMs=11/20 versus the fetch's 2/4). The fixture gained that button (network-failed-fetch-xhr) in 5b435d1295.

2. The dump may read and inject into another device's app (blocker). Both routes you asked about, threaded through the existing seam: NetworkTransportScope gains deviceAliases + requireDeviceMatch; capture-kit fills them from the session DeviceInfo (name, id, plus Android's probed device.model); RuntimeTargetFilter narrows candidates to runtimes whose reported device positively matches an alias, and when candidates report device names but none matches, selection fails closed with details.reason: 'metro-device-mismatch' instead of hooking a foreign runtime. Targets that report no device name at all fall back to the existing several-devices guard. The provider is also skipped entirely when the session has no appBundleId (capture-kit returns unavailable with no notes). Your two-device fixture exists as a unit test (only the other device connected → the typed mismatch, no entries). I also ran a real two-device Metro at the final head: iOS sim + Android emulator on one Metro, two sessions. Each session's dump returned only its own runtime's entries (Android entries show User-Agent: okhttp/4.12.0, the iOS dump shows its own fetch), and neither session read the other's buffer — draining one leaves the other at matchedLines=0.

On your doubt about the name mapping: it is reliable enough to enforce. iOS Metro reports the simulator name verbatim (rn-net2 ios); Android reports <model> - <release> - API <level> (live: sdk_gphone64_arm64 - 15 - API 35). So deviceNameMatchesAlias accepts an exact alias or a <alias> - prefix, and Android contributes device.model as an alias (0933621fc2, with unit coverage). Before the prefix rule the Android provider failed closed with the typed mismatch note — the fail-closed path proven live before the mapping fix, graceful fallback to app-log with the note.

3. Provider notes suppress the inactive-log guidance (blocker). Both runtimes now build scan/recovery/lifecycle notes first and append provider notes last, so appendLifecycleNote's empty-list check sees only scan state. One Apple and one Android test use an inactive snapshot with an armed provider and expect both notes. Live, the arming dump shows the inactive-stream guidance and the arming note together (output (1) in item 4).

4. Live proof at head (blocker). Full sequence on iOS simulator rn-net2 ios and Android emulator ReactNative_API_35 (sdk_gphone64_arm64, API 35), Metro on 8081, final head a03ce9ca9b (live runs done at 0933621fc2; the delta is docs-only prose). Examples/test-app network lab.

(1) First dump arms, answers from app log, arming note last:

source=app-log active=false state=inactive backend=android include=summary scannedLines=0 matchedLines=0
Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.
No HTTP(s) entries were found in recent session app logs.
In-app network capture was installed by this dump, so it holds no earlier traffic. Reproduce the requests now, then dump again for method, full URL, status, duration, headers, and bodies.

(2) After network-fetch, network-post, network-xhr (Android, --include all, bodies elided here):

source=provider active=true state=active backend=react-native-app include=all scannedLines=3 matchedLines=3
2026-10-23T12:41:29.470Z GET https://httpbin.org/get?xhr=1 status=200 durationMs=739
2026-10-23T12:41:26.181Z POST https://httpbin.org/post status=200 durationMs=144
  request headers: {"content-type":"application/json"}
  request: {"lab":"network","seq":1}
  response: { "args": {}, "data": "{\"lab\":\"network\",\"seq\":1}", ... "User-Agent": "okhttp/4.12.0" ... }
2026-10-23T12:41:22.850Z GET https://httpbin.org/json status=200 durationMs=587
  request headers: {"x-agent-device-lab":"network","x-api-key":"[REDACTED]"}
  response: { "slideshow": { ... } }

Exactly three entries, one per request, x-api-key → [REDACTED], bodies bounded by NETWORK_MAX_PAYLOAD_CHARS. iOS shows the same shapes (x-api-key":"[REDACTED]" on the fetch entry above).

(3) Immediate second dump: matchedLines=0, no re-arm note, source=provider.

(4) Cold app restart (am force-stop + launch / simctl terminate + launch): the next dump is source=app-log plus the arming note on both platforms, and later dumps again show one entry per request. One measured correction to the previous docs wording: a Metro reload does not wipe the JS global — the hook survives it and the buffer keeps draining; only an app process restart resets it. a03ce9ca9b fixes both docs pages to say that.

(5) Above in item 1, on both platforms.

Non-blocking items — took almost all:

  • Fetch response returns immediately; the entry joins the buffer via a detached clone.text() chain instead of delaying the app's own await.
  • redactNetworkHeaders deleted; network-capture-entries.ts calls redactDiagnosticData(headers).
  • Dead JSON-form guard in the SENSITIVE_ASSIGNMENT_RE replacer deleted (your probe matches mine: the separator [=:] can never match after a quote). The widened rule is covered by the docs note about redaction.
  • Fixture now defines a real-shape XMLHttpRequest (whatwg-style fetch over it), so instrumentXHR and the claim guard run in unit tests.
  • Unreachable dev server asserts details.reason: 'metro-server-unreachable', not a message regex. Typed reasons also for metro-device-mismatch.
  • Abort signal is passed to listMetroInspectorTargets and InspectorSession.connect; abort closes the CDP socket, so a session cannot outlive the 12 s budget.
  • Docs corrected: capture covers fetch/XHR after arming only, other transports (WebSockets, native HTTP) are out of scope, URL query strings appear verbatim, and "simulator or emulator" wording matches what the code accepts.
  • Took-or-left one I declined: typed provider: { status, reason? } on the app-log result instead of prose notes. The notes are already the response's user-facing contract for every fallback reason; adding a parallel machine field for just the provider would be a second source of truth. If you still want it, I'd do it as a follow-up across all note producers.

Arm at app open when Metro is bound: no, deliberately. Injecting on the first explicit network dump keeps the opt-in honest: nothing is installed in the app under test until a command asks for network data, and the arming note then explains why that window reads the app log. Arming at open would need a retry/wait policy for runtimes that attach after launch (cold start vs Metro attach races are real — I hit them while collecting this evidence) and would pre-empt the #3928/ADR question of who owns injection policy. I'd settle that in the ADR you propose, with this PR's explicit-first-dump rule as the starting point.

On making the change smaller: I did not restructure to the single dumpSessionNetwork owner in this round. The current shape exists because the platform runtimes must build their own fallback (Apple's log show recovery, Android's adb logcat recovery and PID handling) around the provider answer; capture-kit already owns everything shareable (the provider probe, the scan merge, the entry/redaction builders). The rejected alternative to the injected runtime is RN DevTools' built-in Network CDP domain: no injected code, but it needs a daemon-held CDP connection and drops RN versions without the domain — stated in the PR body now, and the right ADR question is whether we commit to that floor.

Gates at a03ce9ca9b: pnpm check:affected --run --head a03ce9ca9b all runnable checks passed (exactCommit=true, base b08b5e796a); unit suites green (1,268 tests in the touched packages, incl. the new device-attribution and note-ordering coverage); check:fallow clean on changed files; format/typecheck/lint clean.

@thymikee

Copy link
Copy Markdown
Member Author

The earlier findings from e16c7c9 are fixed at a03ce9c, except for one that remains: device matching in the Metro runtime selection. CI is green (22 checks, none failing), and there are no conflicts.

The device match treats a shared name as an identity (metro-inspector.ts#L180). runtime.ts#L34 adds device.model as an alias, and Metro names an Android runtime <Build.MODEL> - <release> - API <level>, which deviceNameMatchesAlias accepts by prefix. Two emulators on one system image (both sdk_gphone64_arm64 - 15 - API 35), or two physical devices of one model, match each other's runtimes. Say session A's app is closed or restarting while the same app id runs on emulator B on the same Metro. selectReactNativeTarget (line 204) finds B's target, owned=[B] passes, and A's dump injects into B's runtime. A then drains B's buffer and reports B's traffic as its own, so B's next dump misses those requests. The same applies on Apple when two booted simulators share a display name, and kernel/device.ts:58-59 documents model as never part of device identity or selection. When both apps are connected, the several-devices guard fails closed, so capture does not work for two same-image emulators. That part is a safe degrade. The rule to enforce: a runtime may be selected only by an identity that names exactly one device attached to this host, and anything weaker fails closed with metro-device-mismatch. Please make the platform the single owner of that identity, for example a platform-owned metroDeviceIdentity(device) in place of the generic alias list and prefix rule. Android would contribute the model alias only when no other attached adb device reports the same ro.product.model, and Apple would contribute the simulator name only when no other booted simulator shares it. Otherwise it passes no alias. Please add a unit case with two same-model targets where only the other device's app is connected, expecting metro-device-mismatch. Please also add a live run with two same-image Android emulators on one Metro: stop the app on A and leave it running on B. A's dump must report source app-log with the mismatch note, and B's next dump must still return its own requests.

Not blocking, take or leave: the docs sentence at debugging-profiling.md#L220 lists axios as a native HTTP stack outside the capture, but axios uses the XMLHttpRequest adapter that instrumentXHR patches, so name a real native stack such as an OkHttp or URLSession module, or none. The orphaned JSDoc for the deleted redactNetworkHeaders at redaction.ts#L37 can go (lines 37-41). requireDeviceMatch (network-runtime.ts#L91) is always sent as true by its only production producer, so the non-strict branch is unreachable and the contract comment describes behavior the code does not have. Deleting it everywhere is one less seam field and one less branch. The test at network-capture-dump.test.ts#L223 runs on the default one-device fake server, so it passes without alias narrowing. Open fakeMetroDevServer({ devices: 2 }) there and assert which target was hooked, and delete the unused deviceNameFor, which is also off by one. The comment at network-capture-runtime.ts#L177 should say that a drain in that window reports the request on a later dump, because record(entry) runs only after the body read settles.

Could one exact device identity per platform, passed as the existing RuntimeTargetFilter.device and failing closed when ambiguous on the host, replace deviceAliases, requireDeviceMatch, extraDeviceAliases and the prefix matcher? That would remove two contract fields, one capture-kit option bag and the non-strict branch, and the identity rule would live in the platform package that knows its own inventory. The earlier source-selection question stays open. Before this and #3928 both land, please name one owner and one opt-in policy (implicit on the first dump here, explicit at open there). That needs an ADR, either amending 0037 or a new one, naming the network source-selection owner, the source enum and the injection opt-in policy. Please also agree the merge order with #3928.

Evidence limits on my side: I read the code and did not run a multi-device Metro. The device-name finding relies on the author's live Android name, on ro.product.model being shared across same-image emulators, and on simctl allowing duplicate simulator names. I did not check whether RN's logicalDeviceId can be computed from the host; if it can, matching deviceKey exactly would be an alternative. The iOS outputs for (1)-(4), Android (3)-(4) and the two-session run are prose, not pasted output, while the pasted output proves the injected source at head on both platforms. I could not verify that a Metro reload keeps the injected hook, and in bridgeless RN a full reload may recreate the JS runtime, which would make the new docs sentence wrong. I did not rerun the gates or tests locally.

The device-selection change with its live run is what must happen before merge, and then the ADR settles source-selection ownership with #3928.

thymikee added a commit that referenced this pull request Oct 23, 2026
… device

Review round on #3928 (thymikee, second pass). A refused open still changed the
device first: the owner refusal ran inside invokeApplicationOpen, after the
relaunch close (closeBeforeRelaunch on Limrun/webdriver), the runner stop, and
the runtime-hint write. `open <app> --relaunch --capture-network` on a
provider iOS simulator killed the running app and then failed.

- One admission step: contracts `assertOpenAdmitted(binding, execution)` is the
  whole rule for `--launch-console` and `--capture-network`. It is the first call
  in openDirectApplication, openAndroidApplication and openAppleApplication, so
  an owner that cannot honor the option is refused with UNSUPPORTED_OPERATION
  before any device action. It replaces the nine
  `supportsLocalSimulatorLaunch` pass-through sites and the four stacked
  refusals in the canonical open; `invokeApplicationOpen` keeps only its
  positional, URL, and argument-shape checks.
- Both refusals carry a typed reason (`launch-console-owner-unadmitted`,
  `network-capture-owner-unadmitted`), matching the sibling
  `provider-runtime-interactor-missing` posture. Their messages stay module
  constants of the module that raises them, clear of any function's JSDoc (the
  shared message home's facade must stay exhaustive, and a re-export no
  consumer imports is dead code).
- The provider-shaped contract test now opens with `relaunch: true` against a
  `closeBeforeRelaunch: true` owner and asserts the typed code and reason and
  that neither open nor close ran. A second case pins the same typed refusal for
  a local binding on a non-simulator device.
- A probe file at its 32 MB cap no longer reads as current traffic: the capture
  read reports `atSizeCap` (the reader owns the cap constant mirroring the
  probe's AdMaxFileBytes) and the probe-mode dump says the window stopped
  recording. The cap check reads the handle offset through `getOffset:error:`
  instead of the raising `offsetInFile`, per ADR 0037 rule 6.
- ADR 0037 rule 1 now states admission-before-device-action, rule 6 the cap
  note and the offset API, and rule 5 fixes Apple dump precedence — probe, then
  provider, then app-log — as the answer to the #3930 Metro question.

Live evidence re-run at this head on ios-sim-net: one open with
--capture-network --launch-console produced source=probe with five distinct
httpbin URLs, a 10-line console log, masked Authorization/Cookie with X-Trace-Id
kept, zero secret occurrences in network.jsonl; `open --relaunch
--capture-network` on the local simulator stays admitted (relaunch close, then
capture, dump still probe); a non-admitted open refuses with
UNSUPPORTED_OPERATION and reason network-capture-owner-unadmitted.
thymikee added a commit that referenced this pull request Oct 23, 2026
…licy

Settles the source-selection question raised against #3930 and #3928 in one
record: the platform runtime owns the order, the source enum is
probe/provider/app-log, no capture installs unless an explicit operator
command arms it, and the React Native provider injects only on the runtime
whose device the platform named uniquely among attached devices.
@thymikee

Copy link
Copy Markdown
Member Author

Round 2 addressed at b8ec3210ef..d70bf844a4, with the live same-image two-emulator run. Item by item:

Device matching treats a shared name as an identity (blocker). Fixed with your rule and your ownership split. The alias list, requireDeviceMatch, extraDeviceAliases, and the model-prefix matcher are deleted (grep-clean). Each platform now owns one metroDeviceIdentity(device) that returns a string only when it names exactly one device attached to this host:

  • Android (platform-android/src/network/runtime.ts): reads ro.product.model, ro.build.version.release, ro.build.version.sdk for every serial in adb devices and composes the exact <model> - <release> - API <level> name only when no other attached device shares it; any collision, unreadable property, or failing listing returns no identity.
  • Apple (platform-apple/src/network/runtime.ts): passes device.name only when simctl list devices booted -j (scoped to the device's simulator set) shows exactly one booted simulator with that name. Physical devices get no identity — no host-side listing proves uniqueness — so the provider stays off there and the dump answers from the app log.

The fail-closed rule sits at the two owners that matter: selectReactNativeTarget matches deviceName against the identity verbatim only (prefix logic gone; Pixel or a mis-composed Pixel 9 - 15 - API 35 now throws metro-device-mismatch), and the dump itself refuses to run app-backed at all without an identity, before it even lists runtimes — so the reviewer's exact scenario (session A's app closed, same app id running on B, shared name) cannot reach selection with a shared name: the platform passed none, and A's dump fails closed with the typed note and reads its own app log. Capture-kit and the contract now carry one optional metroDeviceIdentity string and nothing else.

Unit cases as requested: two same-model targets in metro-inspector.test.ts (both matching a shared name reach the several-devices guard; selection never silently picks one), plus a dump-level case where only the other device's identically-named runtime is connected and the hooked-target list stays empty (fakeMetroDevServer now records hookedPages, and deviceNameFor is deleted with its off-by-one). Per your question on logicalDeviceId: its derivation is a device-side UUID, not computable from the host, so deviceKey cannot be an identity source; the composed-name route stands.

Live run, two same-image Android emulators, one Metro, head b8ec3210ef/d70bf844a4 build: ReactNative_API_35 (emulator-5554) and a clone AVD (emulator-5556); both verified reporting ro.product.model=sdk_gphone64_arm64, release 15, API 35 — identical Metro names.

(1) Both emulators attached, session A on 5554, its app closed/restarting state — A's dump fails closed and B is untouched:

source=app-log active=false state=inactive backend=android include=summary scannedLines=0 matchedLines=0
Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.
No HTTP(s) entries were found in recent session app logs.
In-app network capture could not answer this dump: This session device cannot be named unambiguously among the devices attached to this host, so the in-app network capture stays off for it. This dump reads the session app log instead.

No injection happened on either device (the failure is before target listing; nothing was drained from B). Bonus corroboration from this run: Metro itself listed only one target for two attached same-image emulators — the shared device name collides in Metro's own device registry too, which is exactly why a shared name cannot be an identity.

(2) Positive branch after shutting emulator-5554 down (adb emu kill, one attached device left): the identity becomes unique and the full sequence passes on 5556 at this head —

source=app-log ... In-app network capture was installed by this dump, so it holds no earlier traffic. ...
(3 presses)
source=provider active=true state=active backend=react-native-app include=all scannedLines=3 matchedLines=3
2026-10-23T15:10:05.834Z GET https://httpbin.org/get?xhr=1 status=200 durationMs=1023
  request headers: {"x-agent-device-lab":"network","x-api-key":"[REDACTED]"}
  (+ POST /post 200, GET /json 200 — full bodies shown)
(second dump) source=provider ... matchedLines=0 / captured no request since the last dump
(failed fetch + XHR to same URL)
source=provider ... scannedLines=2 matchedLines=2
2026-10-23T15:10:29.258Z GET https://invalid.agent-device.test/after-failed-fetch status=0 durationMs=2
  response: Unable to resolve host "invalid.agent-device.test": No address associated with hostname
2026-10-23T15:10:29.002Z GET https://invalid.agent-device.test/after-failed-fetch durationMs=256

On your evidence limits: agreed on all three, and the clone-AVD run above is now pasted output at this head. On the Metro-reload docs sentence — fair doubt. My earlier claim was measured on this app only: after a /reload, the next dump answered source=provider with pre-reload traffic, i.e. the JS global survived on this SDK/bridgeless combo. That is not a guarantee for all RN versions — a bridgeless full reload could rebuild the JS runtime on another SDK. The docs sentence ("A Metro reload keeps the capture armed; restarting the app process resets it and the next dump arms again") states only what was measured on this test app; if you'd rather the docs not claim the reload half at all, say so and I'll cut it to the restart rule, which every implementation must satisfy anyway since the buffer lives in the JS global.

Non-blocking items — all taken:

  • debugging-profiling.md no longer names axios (it does ride the XHR adapter); it now says "a native HTTP module such as OkHttp or URLSession".
  • The orphaned redactNetworkHeaders JSDoc block in redaction.ts is deleted.
  • requireDeviceMatch is deleted everywhere — contract field, capture-kit option, dump input, and the unreachable non-strict branch.
  • The two-device dump test now runs fakeMetroDevServer({ devices: 2 }) and asserts which page was hooked (hookedPages); deviceNameFor is deleted.
  • The detached-body comment now says a drain in that window reports the request on a later dump, because record(entry) runs after the body read settles.

ADR (source-selection ownership with #3928): added docs/adr/0038-network-dump-source-selection.md, because #3928 already reserves 0037 for the probe. It names one owner (the platform runtime orders sources), one enum (probe | provider | app-log) with the probe-first ordering when both injections are armed, and one opt-in policy: no capture installs unless an explicit operator command arms it (open --capture-network for the probe, first network dump for the JS capture). Since #3928 is not merged, the 0038 Status says plainly that its probe-row/ordering is a proposal to 0037 and that whichever PR lands second amends its own record — merge order is yours and @thymikee's to pick; nothing here blocks on it. If you'd rather fold this into 0037 when the PRs converge, the record is one file to move.

Arm-at-open policy for this PR stays as stated in the round-1 reply, now also recorded in 0038.

Gates at d70bf844a4: pnpm check:affected --run --head d70bf844a4 all runnable checks passed (exactCommit=true, receipt .tmp/validation/run-c5G7Jc/receipt.json); the provider-integration suite needed one fake-simctl addition (list devices booted -j now answered, d70bf844a4); 17,213 tests green; fallow audit --base b08b5e796a clean on changed files (two warn-level clone groups, both composition-root wiring); format/typecheck/lint clean.

network dump answers a React Native app session from an in-app
fetch/XMLHttpRequest capture installed over the session's dev-server
debugger connection, instead of only parsing what the app wrote to the
platform log.

The capture is an ES5 runtime source installed into the app's JavaScript
runtime on the first dump. It records method, full URL, status, duration,
headers, and text bodies into a bounded ring buffer, drains newest-first,
and survives repeated dumps without repeating entries. The host projects
untrusted entries, redacts headers and bodies with the shared kernel
rules, and adds JSON secret masking so a payload secret no longer passes
through. When no dev-server binding exists or the provider cannot answer,
the dump falls back to the app-log path and its notes say why.

- contracts: NetworkTransportScope carries the session facts a provider
  transport needs; NetworkDumpResult reports its source
- capture-kit: shared non-throwing provider prelude with a 12s budget
- react-native: runtime source, CDP drain/stats orchestration, projection
- root composition gates the transport on the session's Metro binding
- CLI help and website docs describe arming, draining, and fallback
The route issues one plain fetch, one JSON POST, and one XMLHttpRequest
per press, so a network dump can be verified against requests the harness
just made. Live RN verification used it on an iOS simulator and an
Android emulator.
Address review findings on the React Native network capture:
- Eviction counts ride the drain result so buffered and dropped notes can
  never disagree with what a dump returned.
- The in-app body cap takes the dump's payload budget; header values are
  capped at 512 characters across 64 entries at every capture seam.
- JSON-quoted secrets with escaped quotes mask whole; the unquoted rule
  keeps masking 'api_key=value'; truncation markers count against the
  payload budget.
- Drain JSON parsing fails with a typed reason instead of throwing raw.
- whatwg-fetch-on-XHR double capture is suppressed by a synchronous URL
  claim held across the fetch() call.
- The 2048 payload budget is one contract constant, shared by the daemon
  and the RN transport.
- The test-app network lab is a registered route with README coverage.
The Apple and Android dumps read their fallback window with one capture-kit
call instead of cloned lines, the projection splits its include branches into
named helpers, and the capture backend name stops exporting what no consumer
consumes.
- Release the fetch URL claim when fetch() returns: the claim marks only the
  synchronous window a whatwg-fetch XHR is built in, so a failed or in-flight
  fetch no longer hides later XHRs to the same URL.
- Record fetch entries in a detached chain: the app gets its response at once
  even behind a streaming global fetch.
- Hook only a runtime that reports the session's own device: the transport
  scope carries device aliases, selection narrows to positively-matching
  runtimes, and a mismatch or a session without an app id fails the provider
  closed with a typed reason instead of injecting into a stranger's app.
- Build lifecycle and scan notes from the app-log source alone and append
  provider notes last, so an arming or unavailable note never hides the
  inactive-stream guidance.
- Close the CDP socket on abort and thread the signal through the target list.
- The fixture runs a real XMLHttpRequest shape with fetch polyfilled over it;
  new tests cover direct XHR capture, a failed fetch followed by an XHR to
  the same URL, and two-device attribution.
- Drop redactNetworkHeaders for redactDiagnosticData and the dead JSON-form
  guard; type the unreachable-dev-server failure; state the capture's real
  scope, device kinds, and verbatim query strings in the docs.
…Android name

An Android runtime reports its device as '<model> - <release> - API <level>',
while the session knows the model from the inventory probe. Match the model as
a prefix so a session's own Android runtime survives device attribution.
Identification now requires a reported device name; a synthetic logical device
id alone cannot match a session alias.
Live verification showed a reload does not wipe the JS global, so only an app
process restart resets the capture and re-arms it.
A shared model or simulator name is not an identity: two emulators of one
system image (or two same-name simulators) could match each other's runtimes,
so one session could hook, drain, and report another device's traffic. The
platform that owns the device inventory now composes the dev-server identity
only when exactly one attached device answers to it — Android the composed
'<model> - <release> - API <level>' after checking every attached serial,
Apple the simulator display name after checking booted simulators — and an
app-backed dump without one fails closed with metro-device-mismatch instead
of guessing. This replaces the alias list, the model-prefix rule, and the
requireDeviceMatch seam.
…licy

Settles the source-selection question raised against #3930 and #3928 in one
record: the platform runtime owns the order, the source enum is
probe/provider/app-log, no capture installs unless an explicit operator
command arms it, and the React Native provider injects only on the runtime
whose device the platform named uniquely among attached devices.
…s listing

The network provider's device-identity probe lists booted simulators before
resolving the transport; the shared listing helper now recognizes that argv.
@thymikee
thymikee force-pushed the feat/rn-network-capture branch from d70bf84 to d0eda05 Compare October 23, 2026 15:51
@thymikee

Copy link
Copy Markdown
Member Author

The earlier findings from a03ce9c are mostly addressed at d0eda05, but two identity checks still fail open and the iOS path has no live run.

Apple: the identity probe in runtime.ts:33 lists booted simulators only in the session's own simulator set. Take session A on a default-set "iPhone 17 Pro" and session B on a tenant-set simulator with the same name, both using one Metro. Each sees one such simulator in its own set, so each gets the identity. When A's app is closed, A's dump can select, inject into and drain B's runtime. This is the same cross-device drain as before, and it contradicts the "exactly one device attached to this host" wording in ADR 0038. Rule: grant the identity only when no other booted simulator on the host carries the name, in any set, and a physical device that could report the same name also counts. If the platform cannot check every set, fail closed. If a host-wide check is not feasible, record the per-set limit as an accepted risk in the ADR and docs. Please add a unit case with a same-named booted simulator in another set that expects no identity.

Android: the collision loop in runtime.ts:99 treats an unknown device as unique. If getprop fails, times out or prints nothing on another serial, readDeviceNameParts returns undefined and that serial is skipped. listAttachedSerials also drops serials that are offline or unauthorized. An emulator reaches Metro over 10.0.2.2 without adb, so its runtime can stay connected while adb shows it offline. Two same-image emulators then give A the identity, and A's dump can drain B's runtime. This contradicts the claim that any unreadable property returns no identity. Rule: no identity unless every serial adb lists, in any state, was read and differs. Please add a test where the second serial's getprop fails and expect metroDeviceIdentity to be undefined.

Evidence: the Apple route changed from device.name plus requireDeviceMatch to a simctl-listing-gated identity at runtime.ts:24, and the pasted live run is Android only. Any listing or parse problem now turns the iOS provider off, so a mismatch with the real simctl list devices booted -j output would silently drop iOS RN capture to app-log. Please run one iOS simulator RN session at this head with Metro bound. The first network dump should report the arming note, and the second, after reproducing requests, should report source=provider with entries. Once the Apple fix lands, also run two booted simulators with the same name and show source=app-log with the metro-device-mismatch note.

The Android two-emulator and single-emulator runs cover the Android route. I read the code only and did not run Metro, simulators or emulators. The Apple finding assumes RN's iOS inspector reports the simulator display name as deviceName and that two simulator sets can share one Metro server. The Apple "Smoke Tests" check is still running, and I cannot yet tell whether it overlaps with the network dump change. Before merge, both probes must fail closed on anything unknown and the iOS provider run must be posted.

Not blocking, and fine to take or leave: the identity probe runs before the transport is resolved, so native non-RN sessions pay 1 + N adb calls without a timeout, or a 4 s simctl listing, outside the capture budget, and it could be resolved lazily after the transport declares a dump; and the new adb and simctl parsing duplicates parseAndroidDeviceEntries and readSimctlDevicesByRuntime, which could be reused.

…device

The identity probe granted a name while devices it could not see remained
possible. Android now lists every serial adb reports, in any state, and
withholds the identity when any serial's properties cannot be read. Apple
now lists booted simulators in every set the host can name and the connected
physical devices devicectl reports, and fails closed when either answer is
unreadable or the daemon's configured set contradicts the device. The probe
runs lazily inside a dump, so a session whose transport declares no provider
dump never pays for it.
…the Apple identity

devicectl registers the host's booted simulators as connected devices under
their own udids, so the identity probe that lists connected devices to rule a
simulator name in among physical devices sees the session simulator itself.
The live run caught it: the first dump failed closed on this host because the
session's own booted simulator answered as a connected other device. The probe
now compares the device identity across CoreDevice's registrations and skips
only the session's own udid, so a same-named booted twin is still counted.
@thymikee

Copy link
Copy Markdown
Member Author

Round 3 (at d0eda05) addressed at 91a1e90a5f + 3271f8738e, with a live iOS run at the final head.

Blocker 1 — Apple: the name must be unique across every set and against physical devices

packages/platform-apple/src/network/runtime.ts now proves the identity in three steps and fails closed on each unknown:

  1. Every set the host can name. The probe lists simctl list devices booted -j for the device's own set and, for a tenant-set device, the default set too (simctlListBootedArgsForSet in core/simctl.ts, parsed through readSimctlBootedNames → the reused readSimctlDevicesByRuntime). The daemon operator's set (AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET) is included; a device outside the operator's set is a host state the probe cannot reconcile, so it gets no identity. Any unreadable listing (exit ≠ 0, non-JSON) withholds the identity.
  2. Physical devices count. devicectl list devices --json-output (temp file via host.temporaryFiles, same owner shape as physical-inventory.ts); every connected entry under a udid other than the session's counts against the name. devicectl failing, timing out, or answering unreadably withholds the identity.
  3. Accepted risk, recorded. A true host-wide check is not feasible with any tool: simctl list sets does not exist (verified on this host — simctl list sets -j prints the list usage), simulator-set paths are arbitrary caller-supplied directories, there is no sets root on the host, and the platform runtime host exposes no filesystem-enumeration slot. A simulator booted in a set directory this host was never told about is invisible to every available command. Per your fallback, ADR 0038 now records this residue as an accepted risk ("Operators who share one dev server across simulator sets must keep simulator display names unique across those sets"), and debugging-profiling.md states the same to users.

The live run found a collision this rule exposes that the old probe could not see: CoreDevice registers booted simulators themselves as connected devices (reality: simulated, tunnelState: connected). The first live dump failed closed because devicectl answered with the session's own booted simulator. connectedOtherDeviceNames therefore skips only the session's own udid — a same-named booted twin is now caught twice, by both the set listings and devicectl. 3271f8738e covers it with a harness entry that registers the session's own simulator, so the exclusion itself is pinned.

Unit cases (network/runtime.test.ts): same-named booted simulator in another reachable set → no identity (your requested case, tenant-device shape); connected physical device sharing the name → no identity; devicectl failing → no identity; operator-set contradicting the device → no identity; single unique simulator → identity (unchanged).

Blocker 2 — Android: every listed serial, any state, must be read

packages/platform-android/src/network/runtime.ts:

  • The listing is adb devices parsed by the new parseAndroidListedDeviceEntries in inventory-parsers.ts, which keeps every serial in any state (offline, unauthorized, …) and skips the header and * daemon chatter.
  • Every serial other than the session's must answer the getprop read; a read that fails or returns empty takes the whole identity away (other === undefined → return undefined) — an unreadable serial is an unknown device that could share the name.
  • Fail-closed on anything unknown: listing fails, listing does not contain the session's own serial (an untrustworthy view), or any read errors.
  • All identity adb calls now carry timeoutMs: 4000 (the review noted unbounded adb calls); a timeout is an unreadable read → no identity.

Test: second serial ZY22 offline whose getprop exits 1 → resolveMetroDeviceIdentity answers undefined, dump stays app-log.

Live iOS run at the final head

Environment: simulator rn-net2 ios (iPhone 17 Pro, iOS 27.0), Metro on 8081, session opened open com.callstack.agentdevicelab --platform ios --device "rn-net2 ios" --metro-port 8081.

Dump 1 — arming note (source=app-log, provider note verbatim):

In-app network capture was installed by this dump, so it holds no earlier traffic. Reproduce the requests now, then dump again for method, full URL, status, duration, headers, and bodies.

Dump 2 — source=provider with entries (after tapping Run fetch / Run post / Run XHR in the network lab; --include all):

source=provider active=true state=active backend=react-native-app include=all scannedLines=1 matchedLines=1
2026-10-23T16:56:25.026Z GET https://httpbin.org/json status=200 durationMs=482
  request headers: {"x-agent-device-lab":"network","x-api-key":"[REDACTED]"}
  response headers: {"server":"gunicorn/19.9.0","content-type":"application/json",...}
  response: { "slideshow": { ... } }

Drain-once, immediately after: The app runtime captured no request since the last dump. Then the failed-fetch→XHR pair, verbatim:

source=provider active=true state=active backend=react-native-app include=summary scannedLines=2 matchedLines=2
2026-10-23T16:57:10.974Z GET https://invalid.agent-device.test/after-failed-fetch status=0 durationMs=5
2026-10-23T16:57:10.959Z GET https://invalid.agent-device.test/after-failed-fetch durationMs=15

Two booted simulators sharing the name → fail closed. Created a second simulator named rn-net2 ios in the same (default) set and booted it while the session's twin kept running; the very next dump, no other change:

source=app-log active=false state=inactive backend=ios-simulator include=summary scannedLines=0 matchedLines=0
In-app network capture could not answer this dump: This session device cannot be named unambiguously among the devices attached to this host, so the in-app network capture stays off for it. This dump reads the session app log instead.

Shutting the twin down and re-running returned source=provider immediately (GET https://httpbin.org/json status=200 durationMs=443) — the gate degrades and restores with the device picture, as ADR 0038 records.

Non-blocking notes — both taken

  • Lazy identity. NetworkTransportScope now carries resolveMetroDeviceIdentity(signal) instead of a precomputed string. capture-kit passes the resolver through untouched; only src/platform-runtime-network-react-native-transport.ts calls it, inside the dump closure — after the Metro binding has already declared the dump. A web session, a non-Metro session, or an app session whose transport declares no dump never triggers one adb/simctl/devicectl call; pinned by tests in both network-provider-capture.test.ts ("a transport that declares no dump never calls the identity resolver") and the transport test ("the identity resolver runs only inside the dump", "a resolver without a Metro binding is never called"). The probe also inherits the dump's bounded budget signal.
  • Parser reuse. Apple: readSimctlBootedNames is built on the existing readSimctlDevicesByRuntime (its entry type gained name), and set scoping goes through simctlListBootedArgsForSet in core/simctl.ts — the network runtime no longer parses simctl JSON inline. Android: the parse owner gained parseAndroidListedDeviceEntries in inventory-parsers.ts and the runtime's inline regex is gone. I did not reuse parseAndroidDeviceEntries itself: it filters to state === 'device' by contract, which is exactly what blocker 2 forbids — changing that contract would silently widen the inventory's device set, so the shared owner gained the all-states sibling instead.

Validation at 3271f8738e: pnpm check:affected --run all runnable checks passed, exactCommit=true, receipt .tmp/validation/run-DWkJRo/receipt.json; unit suites for capture-kit, contracts seam, both platform runtimes, react-native dump/inspector and the provider-scenario integrations green locally.

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. The earlier findings from d0eda05 are addressed in 3271f87, but one cross-device case is still open. All 22 checks pass and there are no conflicts.

A simulator session can still take the identity when a same-named physical iPhone is paired but idle. connectedOtherDeviceNames keeps only devicectl entries with tunnelState === 'connected'. A phone that devicectl lists as paired with a disconnected tunnel is dropped. That is the usual state when no Xcode or devicectl session is active. A React Native dev build on that phone reaches Metro over the LAN, not the CoreDevice tunnel, so it can attach to the same dev server and report the same deviceName. The simulator dump can then hook and drain the phone's runtime, which is the cross-device case the last round blocked. It also breaks the stated rule that a physical device that could report the same name counts, and it differs from the Android rule in this change, which counts serials in any state. The rule should be: every device devicectl lists, in any connection state, counts against the name, except the session's own udid. A tunnel or boot filter may apply only to simulated entries, if devicectl also lists shutdown simulators. Please add a unit case where a same-named paired entry with tunnelState: 'disconnected' gives no identity. Then re-run one iOS simulator dump at the new head and show that source=provider still answers on a normal host.

Not blocking, and you can take or leave these: (1) identitySetPaths returns [undefined] for a default-set device, so a default-set session never lists a tenant set that another session named with --ios-simulator-device-set. Only the reverse direction is tested, so please state this in ADR 0038 and debugging-profiling.md, or feed the daemon's live session sets into the probe. (2) connectedOtherDeviceNames re-implements listPhysicalDevicesFromDevicectl and the mapDevicectlAppleDevice mapping from physical-inventory.ts. One shared devicectl reader that returns undefined on failure would remove it, and nameless entries should not map to '' as distinct names. (3) IDENTITY_SETS_ROOT_ENV re-declares the kernel's DAEMON_IOS_SIMULATOR_DEVICE_SET_ENV, and the operatorSet !== deviceSet refusal cannot fire in production because resolveRequestAppleSimulatorSetPath forces simulator requests into the operator set. Import the kernel constant or drop the branch. (4) The probe runs up to two simctl listings and devicectl one after another with a 4 s timeout each. That worst case of 12 s equals PROVIDER_CAPTURE_BUDGET_MS, and a devicectl timeout is reported as 'cannot be named unambiguously' instead of a probe timeout. Running them in parallel, reporting a timed-out probe separately, and making the transport's signal required would fix this, since the AbortSignal.timeout(30_000) fallback is unreachable.

Could each platform's inventory owner provide one fail-closed census of the host's devices, so that the network identity reduces to "the census names device.name exactly once and every entry was read"? On Apple that is a shared devicectl reader plus simctlListBootedArgsForSet per set, with the set taken from the kernel owner. On Android it is parseAndroidListedDeviceEntries plus the existing description probe. The operator-set refusal, the re-declared env constant and the second devicectl block would go away. The first step would be to extract the devicectl reader from physical-inventory.ts with a fail-closed return, and no ADR change is needed.

I did not run devicectl, simctl, Metro, a simulator or the unit tests. The paired-but-idle finding assumes devicectl reports a tunnelState other than connected for such a phone and that an RN dev build on it reaches Metro over the LAN, and I did not confirm either against live output. I also did not check whether CoreDevice registers booted tenant-set simulators, which would cover the first note through devicectl. The live iOS outputs in the PR are as pasted, consistent with the code, and not reproduced by me.

Before merge, count every devicectl-listed physical device regardless of tunnel state, add the disconnected-but-paired unit case, and post one iOS simulator dump at the new head showing source=provider still answers.

This branch has not been deployed

No deployments
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