Skip to content

fix(ios): prepare shared synthesized input without contacts - #2362

Draft
thiagobrez wants to merge 5 commits into
mainfrom
fix/ios-synthesized-input-cold-start-warmup
Draft

fix(ios): prepare shared synthesized input without contacts#2362
thiagobrez wants to merge 5 commits into
mainfrom
fix/ios-synthesized-input-cold-start-warmup

Conversation

@thiagobrez

@thiagobrez thiagobrez commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Prepare a zero-pointer-path event record at the shared iOS synthesis boundary before constructing the first real timed input record. This adds no app-delivering contact, status-bar tap, frame lookup, accessibility query or screenshot.

Gesture/sequence, drag, controlled scroll, swipe and tap share preparation. Only success sets the process-wide flag; failure allows the real action and a later preparation attempt. Real input keeps its original timings. Six files change, including native regression isolation and nightly result merging/count enforcement.

Validation

At 211f232ee85df5742e3be093a008b46f80a6e1a7:

  • pnpm check:affected --run, CI/coverage, and iOS smoke pass.
  • Hosted nightly passes with all 226 source-derived tests, including the isolated regression.
  • The native regression fails before shared preparation ([1,1,1,1,1,1]) and passes afterward ([0,1,0,1,1,1,1,1]), observing retry and one successful preparation across real mixed bridge routes.
  • Local frozen runtime passed three cold boots, nine gestures and three selector presses. Outcome waits of 110–244 ms measure post-command observation, not physical touch delivery.

Draft: the hosted cold comparison and downstream matrix remain pending. Earlier hosted candidate samples were not usable green evidence. Per-gesture digitizer traces contradict the persistent-digitizer theory; the selector-press probe also uses private synthesis. Preparation's effect on the original symptom remains unproven. Android's independent pre-input smoke positioning failure has a green test-only correction in #2369.

XCTest attaches its HID digitizer lazily, on the first synthesized event a runner
process posts. On a cold or loaded simulator that attach can lag several seconds
behind the synthesizeWithError call that triggers it. When the first synthesized
gesture of a process is timed (a drag with an activation hold, a paced pan), the
touch-down then lands seconds into a window whose later samples were scheduled
relative to the intended touch-down, so the app reconstructs a malformed gesture
even though synthesizeWithError reported success. Once attached, the digitizer
stays attached for the runner process, so every later gesture — including after a
target relaunch — lands on schedule.

Force the one-time attach with a throwaway synthesized contact before the first
real gesture. The gesture's own timings are unchanged; the warm-up only moves the
unavoidable one-time attach cost off the first user gesture, and is a no-op on a
warm host. Runs once per runner process, before the first `gesture` command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.50 MB 4.50 MB +1.7 kB
Package (unpacked) 4.50 MB 4.50 MB +1.7 kB
Package (download) 1.33 MB 1.33 MB +511 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.4 ms 26.6 ms +0.1 ms
CLI --help 78.0 ms 78.1 ms +0.1 ms

@thiagobrez

Copy link
Copy Markdown
Contributor Author

CI validation (draft run)

All code-quality gates pass: Lint & Format, Typecheck & Package, Repo Guards, Coverage, Compatibility & Provenance, CodeQL, Bundle Size, and the iOS/macOS/Linux smoke lanes.

The iOS lane exercised the change end to end on a simulator, all green:

  • Restore and build iOS XCTest runner — the new RunnerTests+SynthesizedInputWarmup.swift compiles.
  • Run targeted iOS runner XCTest regressions — pass.
  • The warm-up ran once, as designed: AGENT_DEVICE_RUNNER_SYNTHESIZED_INPUT_WARMUP outcome=performed elapsedMs=433.
  • gesture-pan-duration.ad synthesized-gesture replay — pass (26.3 s), confirming the warm-up does not perturb normal synthesized-gesture operation.
  • Run fixture-backed iOS simulator E2E smoke — pass.

On this warm CI simulator the one-time attach is cheap (433 ms), so the warm-up is a near-no-op — which is the intended behavior on a warm host. The cold-boot case it targets is not reproducible on an already-booted smoke simulator; that is what the external reproduction branch demonstrates the defect on, and what a maintainer cold-boot device run (this lane on ready_for_review) would confirm the fix against.

The two red checks are pre-existing flakes unrelated to this iOS-only change and both re-run: Android Smoke (agent-device press on a live emulator, smoke:automation-system) — Android uses the separate helper, untouched by #if os(iOS) Swift; CI Integration (provider-backed suite) — passes 190/190 on a clean local rebuild of this branch.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

Update: CI Integration passed on re-run (it was a flake). Android Smoke is a pre-existing repo-wide failure, not this PR: the same smoke:automation-systempress semantic canary assertion fails on main (e.g. bd08e6e, dcd8b65) and on every current open PR I checked (#2356, #2359, #2360, #2361). It is Android-only and cannot be affected by this #if os(iOS) Swift change. Leaving it rather than re-running into the same base failure.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

The warm-up at 5d4fd44 sends a real tap to the top of the app window. That point is not guaranteed to be inert in a status-bar-hidden or edge-to-edge app, so it can activate content before the requested gesture. Use a non-delivering warm-up and prove that it cannot change app state.

It also runs only for gesture, while scroll and synthesized drag reach the same timed-input pipeline without it. Put the once-only preparation at the shared input boundary and test ordering across those routes. The reported warm CI run does not establish that the cold-start failure is fixed; a cold-start red/green run is still needed.

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

Reviewed 5d4fd44. Agreeing with @thymikee on the two structural points, plus four things in the code itself.

1. The warm-up point is computed in one frame and delivered in another. synthesizedInputWarmupPoint is fed resolvedTouchReferenceFrame (RunnerTests+SynthesizedInputWarmup.swift:43), i.e. the window frame, which can have a non-zero origin. synthesizedTapAt then discards that frame entirely: it re-resolves its own context from XCUIScreen.main.screenshot() (RunnerTests+Interaction.swift:667-679, :895), whose origin is always (0,0), and nativeSynthesizedPoint subtracts that origin. So an absolute point is consumed as a relative one. On a full-screen iPhone the two coincide and it works by accident; under iPad Slide Over / Stage Manager the contact is shifted by the window origin. testSynthesizedInputWarmupPointSitsInTheStatusBarBandOfTheFrame uses a frame at origin (10,20) and asserts frame.contains(point) — precisely the case where the consumer does not honour that contract, so the test passes while the delivered point is wrong.

2. The warm-up adds AX and a screenshot ahead of the gesture it exists to speed up. resolvedTouchReferenceFrame does app.windows.firstMatch.exists/.frame plus visibleKeyboardFrame — AX round-trips — and synthesizedTapAt then takes a full-screen screenshot. Both are serialized in front of the first gesture on a cold or loaded simulator, the exact state this targets. The file's own comment says the digitizer attaches "regardless of where the contact lands", which makes all of this frame resolution unnecessary.

3. Non-delivering warm-up, concretely. The status-bar band is not inert even when the tap does reach the status bar: a status-bar tap triggers scrollsToTop on the app's top scroll view — a real state change, and worst for the list/drag scenarios this PR is about. RunnerSynthesizedGesture.m already has the pieces for a warm-up that delivers nothing: RunnerCreateEventRecord + RunnerSynthesizeEventRecord with no pointer paths. That forces the synthesis call (and so the attach) without a contact, needs no frame at all, and is unit-testable as "the record carries zero paths" — which is the proof that it cannot change app state.

4. Policy mismatch, and the flag is set before the attempt. The warm-up runs under .coordinateTap policy while the gesture that follows runs under .synthesizedDrag. didWarmSynthesizedInput = true is set before synthesizedTapAt, so a context failure specific to .coordinateTap permanently disables the warm-up for the process even though the real gesture path would have synthesized fine. The comment's justification ("a real gesture would hit the same condition") doesn't hold across two different policies. Set the flag on .performed, or resolve under the policy the gesture will actually use.

On the shared boundary (@thymikee's second point) — the three synthesis entry points are synthesizedTapAt, synthesizedDragAt and the sampled synthesizeGesture path. Only the gesture handler warms (RunnerTests+CommandExecution.swift:2272); executeSynthesizedDragGesturesynthesizedDragAt (:2427, :2522, including .controlledScroll) and RunnerTests+SequenceExecution.swift:166 reach the same pipeline cold.

On evidence: the three unit tests are CGRect arithmetic — nothing in the suite fails if ensureSynthesizedInputWarmed is deleted from the gesture handler. Once the preparation sits at the shared boundary, an ordering test (no synthesis on any route before the warm-up, exactly one warm-up across mixed gesture/scroll/drag/sequence traffic) would be cheap and would cover the actual contract. That still leaves the causal claim — a cold-boot red/green run is the only thing that establishes the lazy-attach diagnosis.


Generated by Claude Code

@thiagobrez thiagobrez changed the title fix(ios): warm the synthesized-input digitizer before the first gesture fix(ios): prepare shared synthesized input without contacts Sep 6, 2026
@thiagobrez

Copy link
Copy Markdown
Contributor Author

Addressed the structural feedback in 1cf7e5f:

  • Replaced the real tap with a record containing zero pointer paths. Removed the frame calculation, AX/screenshot work, and .coordinateTap preparation policy.
  • Moved preparation into the shared event-record factory, before any pointer paths are constructed. Gesture/sequence, scroll, synthesized drag, swipe, and synthesized tap all pass through it.
  • The process-wide flag is set only after successful preparation. Failed preparation leaves the requested input available and permits preparation on the next route.
  • Replaced the arithmetic tests with an XCTest spy on the actual bridge entry points. It fails against 5d4fd44 with [1,1,1,1,1,1]; the revised code passes with [0,1,0,1,1,1,1,1], including an intentionally failed first preparation. The test runs separately because the production state is process-scoped.

I also restored Agent Device's iOS 26 pointer route in thiagobrez/react-native-reorderable#101, preserving the simplified candidate pipeline. Its workflows now accept an immutable upstream commit for source-build validation.

Cold-start evidence is still in progress. The first local cold boot with this build passed (empty preparation took 5.2 s; all three drag results were prompt), but the local released baseline did not reproduce a silent drop. I have corrected the PR body to avoid claiming the causal diagnosis is established and am running the hosted baseline/source comparison before claiming resolution.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

The shared contact-free preparation at 444cfeb addresses the earlier structural findings, and the reported red/green bridge test covers ordering and retry. Coverage now fails because nightly skips this regression without running it separately. Add the isolated test invocation to nightly too, preserving the selection guarantees. Comparable cold-start baseline/candidate evidence is still pending, so this is not merge-ready yet.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

One correction to the original diagnosis from the local cold-run trace: the Recap digitizer is attached/detached per real gesture, rather than remaining attached for the whole runner process. In the revised run:

20:54:59.360  empty preparation completed (5213 ms)
20:54:59.365  digitizer attached for the real drag
20:55:09.348  digitizer detached
20:55:09.812  next drag's digitizer attached

There is no attachment during that empty preparation interval. This does not rule out preparation of other shared XCTest state, but it does rule out treating these logs as proof of the original persistent-digitizer explanation. Two valid local cold boots passed, but the source-baseline/fixed hosted comparison is still needed to establish whether this prevents the failure instead of just adding delay. Keeping the causal claim explicitly unproven.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

Addressed the nightly coverage gap in 211f232.

Nightly now runs the preparation regression in its own test invocation, then the remaining suite. It merges both xcresult bundles before the existing source-derived executed-count assertion. The selection guard now unions separate invocations while respecting skips within each invocation; regressions cover removing the isolated run and conflicting flags in one command. No coverage invariant was waived.

The 40 selection/summary tests and pnpm check:affected --run pass. The local remaining native suite passed all 225 tests; the isolated preparation test also passes. I am verifying the merged bundle and waiting on the comparable hosted source-baseline/candidate cold runs. Downstream iOS 27 fallback has passed with the candidate; iOS 26 and the other Apple lane are still running.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The nightly merge was verified locally: the isolated regression plus remaining suite merge to totalTestCount: 226, passedTests: 226, and the unchanged xctest-run-summary.ts liveness assertion passes. The first full-suite run passed its assertions but stalled exporting diagnostics; a local rerun with -collect-test-diagnostics never completed. CI settings were not changed. CI/coverage is now green at 211f232.

The first hosted candidate cold sample is not green: job 101549746436 has no usable first-gesture measurement. Boot 1 hit a 5 s toolchain-query timeout during prepare; boot 2 rejected both gestures before synthesis because viewport was 134 × 291.33 while selector points were in the 402 × 874 coordinate space; boot 3 failed selector resolution. Boot 2's post-relaunch gesture did eventually commit, with a 13.6 s post-command observation wait and a 10.45 s empty preparation.

This does not establish the empty preparation solves cold input. The exact-source baseline and second candidate sample are still running. Downstream iOS 27 fallback passed, while the iOS 26/native contract lanes are still pending. I am examining the pre-synthesis blockers without folding them into the warm-up claim.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

Two further corrections from the recordings/logs:

  • The supposed public-XCTest tap control (press id="engine-fallback") actually uses private synthesis in both 0.20.10 and the candidate: kind=coordinateTap ... fallbackAttempted=false. It cannot discriminate private from public input delivery. I have withdrawn that claim in the downstream report and renamed the current probe fields to selector press.
  • Candidate sample 1 / boot 3 had the URL confirmation sheet covering the app. Setup ignored an alert-handler timeout and matched background text. The repro now treats that timeout as setup failure and stops that attempt immediately; only an explicit absent-alert result is tolerated.

Neither correction turns the hosted candidate red into green. The iOS 27 fallback and native downstream contracts passed; iOS 26 and the remaining cold runs are pending.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

The nightly selection fix looks good at 211f232. It runs the isolated regression and retains the full-suite count check; the reported local merged result contains all 226 passing tests. The remaining blocker is comparable cold-start evidence: the first hosted candidate sample does not establish that preparation fixes the original issue. Keep this draft until that comparison is usable. Android smoke is still red and iOS smoke is pending; the changed nightly workflow also needs a hosted run.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The requested hosted nightly is running at 211f232: 34060558728. The iOS smoke retry has passed fixture compilation and the targeted XCTest regressions and is progressing through replays. Android main is also red (at a different smoke assertion); I started one unchanged Android rerun and am retaining the failures.

All four downstream device lanes, consumer/runtime checks, and parity passed in 34056536337. iOS 26 used its existing one whole-job retry after the first attempt failed the pre-drag scenario wait. The optional final npm dry-run then failed on the already-published 1.0.0 version. A fresh complete matrix is running at the latest source SHA through the existing validation-only channel: 34060592647.

Cold comparison is still not usable as a green claim. Exact base bd08e6 produced two unobserved first drops and one delayed observation. Candidate sample 2 had one prompt first observation, one delayed observation, and one falsely classified “lost”: its recording clearly shows the committed drop, while the observer failed with an invalid viewport. The harness now separates observer errors from established target absence and rejects failed alert setup. Fresh same-harness runs are underway: base, candidate. Keeping the PR draft as requested.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The changed hosted nightly workflow passed at 211f232ee: isolated regression, remaining suite, merged results, and the unchanged source-derived count check (Executed 226 test(s); the source reaches 226 on this lane.). iOS smoke attempt 2 also passed.

Android attempt 2 repeats a pre-input selector failure. Its failed-step-34.png shows the Automation page scrolled beyond automation-press (the top visible input section starts at “Long presses: 0”). The immediately preceding step blindly scrolls down 0.7 after landscape/portrait rotation, then press id="automation-press" fails selector resolution. This points to smoke-test positioning, independently of the iOS bridge change. I am checking a bounded visibility-based setup correction while the fresh cold comparison finishes; no Android runtime changes or extra retries have been added.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The Android smoke positioning correction is isolated in #2369, with required local checks passing at 6211bfc3f. It preserves the input/alert/diff outcome assertions and has no runtime changes. Its hosted Android run is pending. #2362 remains unchanged at 211f232ee for the current cold-start comparison.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The separate Android smoke correction #2369 is now green at 6211bfc3f: Android live smoke, CI/coverage, integration and the remaining checks all pass. The previously failing automation-system scenario retains its input outcome assertions; only test positioning changed. It is ready for review. #2362 remains draft and unchanged at 211f232ee while the corrected hosted cold comparison finishes.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The corrected same-harness comparison finished; neither run passes validation. Downstream harness SHA is afdde2e0867beae448ec9784a641bbdd6cbdbde4.

Source / cold boot First gesture Second After relaunch
base bd08e6 / 1 unobserved, wait_target_absent prompt prompt
base / 2 command error command error unobserved
base / 3 prompt prompt prompt
candidate 211f232 / 1 invalid viewport runner busy observation deadline
candidate / 2 invalid viewport runner busy observed after 5.077 s wait
candidate / 3 prompt prompt prompt

Both candidate first-command rejections use viewport 134 × 291.33 with a selector point at 201,389. They occur before the preparation/real synthesis. Their recordings start with a black app surface even though setup's AX text checks succeeded. The later relaunches render the app. This is another readiness/coordinate-evidence problem, not evidence that the warm-up ran and lost those first two gestures.

Candidate boot 1's post-relaunch gesture is also concerning: empty preparation completes at 21:28:55.269 (elapsedMs=5232), then the real gesture returns ok at 21:29:06.037. The following wait makes 25 readable captures without the expected counter before a final truncated capture (wait_deadline_exceeded), so the conservative harness labels it observation-error. The recording's later visible app frames retain callback count 0 through the selector press; I have not found a committed drop. This must not be presented as a green warm-up result.

All three candidate preparation markers report success (~5.2–5.3 s). One clean candidate boot passes, but these data do not establish that empty preparation fixes the original symptom. Keeping this draft. I am tracing the viewport mismatch and the first actual synthesis after relaunch before another comparison. Is there an existing contact-free XCTest bootstrap that initializes the event-delivery machinery beyond an empty record? The current preparation does not attach a virtual digitizer in the retained traces.

@thiagobrez

Copy link
Copy Markdown
Contributor Author

The retained simulator logs narrow this further: the app does receive UIKit touch events in the unobserved candidate boot-1 relaunch gesture. That contradicts a complete “no touch stream reached the app” explanation for this sample.

Using timestamps from simulator-log.txt (not video PTS):

Gesture Virtual digitizer attached → first app Sending UIEvent type: 0 Logged app window dispatches before detach App outcome
candidate boot 1, relaunch 2,296 ms 4 no recorded drop
candidate boot 2, relaunch 33 ms 72 committed
candidate boot 3, first 32 ms 74 committed
base boot 1, first 1,293 ms 36 unobserved
base boot 2, relaunch 2,438 ms 2 unobserved

These are logged UIKit dispatches, not a count of every hardware sample or an exact touch-down delivery measurement. The delayed first app dispatch and reduced dispatch count are consistent with queued/coalesced delivery disrupting the scripted 650 ms source hold and 1,200 ms movement; that causal link still needs touch-phase/timestamp evidence. In the candidate failure, BackBoard logs attachment at 21:28:55.544, first contact at 21:28:57.028, and the app's first dispatch at 21:28:57.840. Empty preparation had already completed at 21:28:55.269.

This strengthens the reason to keep the PR draft rather than interpreting its successful empty record as demonstrated readiness.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

The completed comparison does not establish the fix at 211f232. As reported, neither baseline nor candidate passed validation, and one candidate gesture still produced no observed drop after successful empty preparation. Keep this draft until a controlled reproduction demonstrates the intended improvement; successful preparation alone is not evidence of input readiness.

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.

2 participants