Skip to content

fix(ios): prepare snapshot helpers within a shared readiness budget - #4181

Draft
andrewfree wants to merge 4 commits into
callstack:mainfrom
andrewfree:fix/ios-detached-bridge-readiness-upstream
Draft

andrewfree wants to merge 4 commits into
callstack:mainfrom
andrewfree:fix/ios-detached-bridge-readiness-upstream

Conversation

@andrewfree

@andrewfree andrewfree commented Nov 3, 2026 •

Copy link
Copy Markdown

Summary

Cold Simulator AX initialization can consume a capture deadline. Prepare the helper under source ownership, sharing the existing two-second readiness allowance with binary preparation. Pending startup returns typed preparing; its ten-second ceiling never extends a capture deadline.

Initialize the native runtime before listener readiness. Close aborts and awaits owned work, including concurrent callers. Failed helper startup is retried instead of replaying a stale failure that disables a new app generation. Target, foreground and capture safeguards remain intact.

Ten files change, including native/lifecycle fixtures. Adjacent #4172 touches the same lifecycle/native files. No public API change or linked issue.

Validation

Head 312cdcfba1574f15db99bbb8cc3e15ca42fb144f, base cf9153e1973bd17ea034423cf092b2f971fb363e.

  • pnpm check:affected --run --head 312cdcfba: PASS, exact commit, 41 runnable checks; 25 skipped lanes are not passes.
  • Related 302/302; broad unit: 1,915 files, 17,107 passes, one skip. Provider integration and compatibility checks pass.
  • New-generation regression fails at 34236db and passes after correction; focused lifecycle/adapter/preparation 45/45. Native Darwin 51/51 includes listener readiness.
  • Tests changed: added coverage, socket-dispatch synchronization, and corrected stale-failure replay expectation while retaining expiry/cleanup assertions. No deadline widened.
  • Live evidence demonstrates primary AX recovery/navigation and pending-startup cleanup. The exact cold pending/fallback trajectory and wider runtime matrix remain unproven; fallback is a workaround. Vercel authorization is unrelated.

Codex authored under human direction; independent Codex source reviews. Human review pending. Draft retained for incomplete cold qualification.

@vercel

vercel Bot commented Nov 3, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@thymikee

thymikee commented Nov 3, 2026

Copy link
Copy Markdown
Member

Thanks for the change. The code at 34236db looks sound, but it is device-facing and has no live run yet, so it is not ready to merge.

This change reorders native helper startup (runtime init before bind) and moves helper creation out of the capture into a detached 10 s attempt (lifecycle.ts#L231). The PR body says live qualification on the current head is pending, and unit fakes cannot show the main risk. If the cold AX cost sits in the app's first snapshotForProcess and not in sharedRuntime, the fix only moves the timeout. The first bridge capture would still burn its 5 s deadline and retire the generation, and earlier captures would pay the 2 s wait and fall back to XCTest. Please run on the PR head with a just-booted iOS Simulator (shutdown, boot, launch an app) and a prebuilt bridge binary. The run should show: (1) the first snapshot falls back to XCTest with warning code bridge-readiness-pending, and its total latency stays within the capture budget; (2) within 10 s a later snapshot is served by simulator-ax-bridge, with the same helper pid and inside its 5 s deadline; (3) the generation is not disabled, so the snapshot after that is also bridge-served; (4) closing the session or daemon while startup is pending leaves no SnapshotBridge process in the simulator and no socket file. Please also run cache-native-runtime.test.ts on darwin and report the native readiness pass.

Not blocking, and fine to take or leave: (a) with retryAfterMs: 60000 (lifecycle.ts#L91) a failed startup is replayed to every capture for 60 s, so after an app relaunch the new generation inherits the stored failure and is disabled although no helper ever ran for it; the rule should be that a startup failure retires only the generation whose capture saw the attempt fail, so either omit retryAfterMs or classify a replayed stored failure as capture-scoped; (b) the 2 s readiness budget is written as separate literals in adapter.ts:67, lifecycle.ts:88, lifecycle.ts:228 and preparation.ts, retryAfterMs duplicates the exported SNAPSHOT_BRIDGE_PREPARATION_RETRY_AFTER_MS, and the 10000 ceiling appears twice, so one readiness-allowance constant, one startup-ceiling constant and a required readinessDeadline on SnapshotBridgeRequest (dropping the manager fallback) would leave the adapter as the only constructor; (c) lifecycle.test.ts builds readinessDeadline by hand, so passing deadline instead of readinessDeadline in adapter.ts still passes, and a test through createSimulatorSnapshotSource with about 1.5 s of preparation plus about 1 s of connect that returns bridge-readiness-pending at about 2 s would cover it; (d) the running Set in preparation.ts#L71 re-tracks what DetachedAttempts already holds, so making DetachedAttempts.close() abort and then await each attempt's settled would remove it; (e) sharedRuntime memoizes with dispatch_once and serve() now exits before bind, so the per-request unsupported runtime-unavailable branch in SnapshotBridge.m:95 can no longer fire and a runtime failure surfaces only as bridge-exited, so please delete it or keep the typed code, and coordinate with #4172; (f) the new snapshot-source/README.md restates the code and is the only README under a packages/*/src directory, so deleting it or moving the two native-ordering sentences into apple/snapshot-bridge/README.md would fit better.

The only failing check is Vercel, which needs a team member to authorize the fork deploy. The diff touches no website or docs-site files, so this looks unrelated. No GitHub Actions job ran on this fork head, so there is no unit or gate evidence yet. Before merge, the live cold-Simulator run above is needed, followed by a full check:affected pass, and the PR is still a draft.

@andrewfree

Copy link
Copy Markdown
Author

Initial simulator evidence for 34236dba9f1f119ce5375cfac7ef4d3d63eaab80, built and run through the source CLI with a fresh private daemon/state directory. Device: M5 arm64, iOS 27.0 simulator, Xcode 27.2 (27B52). This is separate from the installed v0.21.31 fleet tooling.

Commands used node bin/agent-device.mjs, --platform ios, the assigned simulator UDID, session pr4181-primary-ax, --state-dir "$STATE", --debug --json: open settings --relaunch, snapshot -i, another snapshot -i, click @e6, snapshot -i, and screenshot "$PNG" --pixel-density 3.

Observed phase evidence:

  • Initial open's background acquisition returned typed bridge-preparation-pending after 2,003 ms.
  • First snapshot returned typed bridge-readiness-pending after 2,001 ms, used XCTest preparation, then recovered within the same command to simulator-ax-bridge: acquisition 1,588 ms, presentation 9 ms. Total command time was 76.172 seconds, including runner preparation. This proves bounded preparation and recovery, not fast or fallback-free cold capture.
  • The following Settings snapshot used simulator-ax-bridge, acquisition 1,336 ms and presentation 53 ms, with no warnings.
  • Fresh ref e6 opened General. Its snapshot also used simulator-ax-bridge, acquisition 2,571 ms and presentation 8 ms. Both source and T3 PNGs were saved and inspected.

The native cache manifest matches the six current source inputs: source fingerprint 609d845f857129fe3e2abba609c1ba177bcbbdef0e842c2e84f6b4dbde0c223e. The actual running helper executable matched that cache entry and binary SHA256 ff2576721c8944d2ac0e400ffc57e59530ac14451cc6d7f80331b646ba7b19aa. Source close completed, daemon stop was graceful/clean with no pending or orphaned claims, and the observed helper PID exited.

The full affected gate passes all 41 runnable checks at this SHA; 25 skipped lanes are not passes. Draft retained: exclusively primary cold acceptance, the earlier separate application-server/foreground failure, and the wider runtime matrix remain unproven. The fallback remains a workaround. No capture deadline or behavioral assertion was weakened.

Verification: OpenAI Codex, parent gpt-6-astra; independent source review gpt-6.1-sol.

Follow-up at 312cdcfba1574f15db99bbb8cc3e15ca42fb144f: accepted review item (a). Removed the helper-startup failure cooldown so a stale failure cannot disable a new app generation. Binary preparation retains its separate cooldown, and pending helper creation remains shared. The new recovery regression fails before this correction and passes afterward. The existing expiry test encoded stale-failure replay and was corrected to preserve finite expiry/SIGTERM/no-socket cleanup, then require a fresh-generation attempt. Focused lifecycle/adapter/preparation: 45/45. No timeout was widened.

On the preceding head with a prebuilt bridge and a freshly rebooted simulator, one helper PID served all captures. Preparation took 145 ms. Open encountered foreground-owner-unverified, then three subsequent snapshot commands used primary AX (acquisitions 2,697/109 ms within the first command, then 203 ms and 88 ms). That run did not reproduce bridge-readiness-pending; it does not establish the exact requested pending/fallback trajectory. The Darwin native runtime file passed 51/51, including both native listener-readiness cases.

Nonblocking refactors (shared constants, required readiness deadline, shared cleanup ownership, README placement, and typed native startup-protocol cleanup) are deferred. The suggested adapter-boundary timing test is a coverage gap; current source already passes the same readiness deadline to preparation and manager. The unreachable runtime-unavailable response is a diagnostic cleanup, not a newly observed capture defect. No unrelated refactor was included.

Final correction head 312cdcfba1574f15db99bbb8cc3e15ca42fb144f is now pushed. A fresh complete check:affected --run --head 312cdcfba passes all 41 runnable checks, exact commit; 25 lanes remain skipped. Broad unit: 1,915 files, 17,107 passed, one skipped.

The current-head cleanup probe observed the real prebuilt helper with no socket yet, then requested daemon stop --clean on its private state. Open returned the expected intentional request_canceled; source acquisition recorded cancelled: abort-signal. Stop was graceful/clean, and both the observed helper PID and its socket were absent afterward. No synthetic startup delay was inserted.

A subsequent fresh current-head daemon captured Settings through primary AX (754 ms and a 188 ms stabilization acquisition), navigated by General's fresh ref, then captured General through primary AX in 719 ms. Source and T3 General PNGs were saved and inspected. The 8.235-second click included 6.458 seconds of XCTest runner readiness preflight; it was not a fast-click benchmark. Open still reported the transient foreground-owner refusal before primary recovery. Final source session/daemon cleanup passed, with owned helper/daemon PIDs and socket absent. This closes the pending-startup cleanup evidence gap, while the exact requested cold pending/fallback trajectory and runtime matrix remain unqualified.

@thymikee

thymikee commented Nov 3, 2026

Copy link
Copy Markdown
Member

I reviewed 312cdcf. The live evidence I asked for at 34236db is now in, so that part is resolved. One defect remains in the new readiness path.

With retryAfterMs removed, a failed readiness attempt is deleted when it settles (detached-attempt.ts:141-142). Only a caller still inside its wait at that moment sees the failure. With the first-caller grant that is at most the first capture, for at most 2 s. If the helper crashes or hits the 10 s bridge-startup deadline after that, no capture gets the error. The first capture already returned preparing, which is capture-scoped (snapshot-route-failure-policy.ts:46) and opens no circuit. The next capture starts a fresh attempt. So a helper that keeps failing never opens the generation circuit and leaves no route diagnostic. Each capture, or one per 10 s in the timeout case, pays the 2 s wait again, spawns and kills another helper, and falls back to XCTest. At 34236db and on main the next capture saw the failure and retired the generation, so this weakens the persistent-failure route the first-caller grant was meant to bound (#2491). The corrected expiry test at lifecycle.test.ts:743 encodes this: the timeout is never surfaced.

The rule the code must satisfy: every helper startup failure is delivered to exactly one capture, and it retires a generation only when that capture belongs to the generation whose capture started the attempt. A capture of any other generation starts a fresh attempt. SnapshotBridgeManager (lifecycle.ts:88) should own this, because only it knows the starting generation. One way: when createSession fails, record { generation: input.target.generation, failure } for the udid. Clear the record when a readiness waiter observes the rejection. In ensureSession, before readiness.value, throw and clear a record whose generation matches input.target.generation, and clear a non-matching one. To validate, add a lifecycle test where the helper exits after the 2 s wait. The next same-generation capture must reject with process-crash (or timeout with bridge-startup-deadline), and a generation-2 capture must start a fresh helper.

Not blocking, take or leave: the new regression at lifecycle.test.ts:83 throws a hand-built process-crash synchronously from host.start, while production reports a crash through process exit and bridgeExitOrClose, and it asserts "does not retire" as a second manager attempt rather than through the route's disabledGenerations. Driving the failure through the fixture's process exit, and folding the late-failure case into the same test, would cover both.

There are no conflicts. The only failing check is Vercel, which waits for a team member to authorize the fork deploy. The diff touches only packages/platform-apple/src/snapshot-source and apple/snapshot-bridge, so it is unrelated. No GitHub Actions job ran on this fork head, and I did not run tests locally, so the 45/45 focused, 41-check check:affected and native 51/51 results are the author's. The live pending to bridge-served run happened at 34236db with a bridge build in progress, not a cold-AX pending. A rebooted-simulator run did not reproduce pending. The delta does not change those paths, so I accepted that evidence, but the cold-AX pending case is still unobserved. Before merge, fix the late-failure delivery with the lifecycle test above, then run a full check:affected on the new head and take the PR out of draft.

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.

2 participants