Skip to content

feat(protocol): version the runner /command wire protocol + move runner state out of /tmp (#383)#398

Merged
Lykhoyda merged 15 commits into
mainfrom
feat/383-runner-protocol-versioning
Jul 2, 2026
Merged

feat(protocol): version the runner /command wire protocol + move runner state out of /tmp (#383)#398
Lykhoyda merged 15 commits into
mainfrom
feat/383-runner-protocol-versioning

Conversation

@Lykhoyda

@Lykhoyda Lykhoyda commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Closes #383 (Story 02 of the Maestro-adoption epic — spec: docs/stories/02-runner-protocol-versioning.md, PR #381).

Why

The native runner HTTP contract was unversioned — bridge/runner skew had no guard and fired live on 2026-07-01 (0.57.1 dist vs 0.57.3 supervisor, undetected). Runner state also sat at fixed shared /tmp paths, which the session file had already been hardened off for a symlink CVE.

What

Protocol handshake

  • RUNNER_PROTOCOL_VERSION = 1 in three synced files (TS protocol.ts, Swift RunnerProtocol.swift, Kotlin RunnerProtocol.kt) — tri-file grep test enforces agreement.
  • Both runners' GET /health now report {v, protocolVersion, runnerVersion, capabilities: []}; every response carries a "v" stamp (defense-in-depth, checked only when present → rollout order-independent).
  • runnerVersion is a launch-time env echo (iOS TEST_RUNNER_RN_PLUGIN_VERSION — xcodebuild only forwards TEST_RUNNER_-prefixed vars, device-caught; Android -e RN_PLUGIN_VERSION), so a runner launched by an older bridge is detectable without compile-time version baking and without rebuild loops.
  • Bridge gates: a reachable runner whose protocol is missing (legacy) / older / newer, or whose runnerVersion skews from the plugin version (fail-open when either side unknown), classifies stale → existing reap+restart path upgrades it transparently; the triggering call carries meta.note: "runner upgraded (protocol/version mismatch)". Only a mismatch that survives reinstall surfaces the new typed error RUNNER_PROTOCOL_MISMATCH with exact rebuild commands. Android reaps via the bug: agent-device holds Android instrumentation slot, blocking maestro_run UIAutomator2 #237 releaseAndroidInteractionSlot (both owned packages) + forced APK reinstall.

State relocation

  • Shared util/secure-state-file.ts (extracted from the CDP-015 session-file hardening: symlink-refusing reads, atomic 0600 writes) now also backs runner state at ~/Library/Application Support/rn-dev-agent/runner-state/ios-<udid>.json / android-<serial>.json (Android persists only under a resolved serial — two projects can never share a state file).
  • A live pre-upgrade runner pointed at by the legacy /tmp file is adopted once (lenient), reaped, relaunched/tmp files are deleted only after the relaunch persists new state. Grep-enforced test keeps /tmp literals out of both clients.
  • Teardown is adoption-aware: stopFastRunner(deviceId?)/stopAndroidRunner(deviceId?) + deviceId threaded through every call site (session close, restart, recovery, maestro park), so a post-worker-respawn stop can't leak a live runner.

Surfacing: cdp_statusdeviceSession.runnerProtocol {expected, runner, runnerVersion, pluginVersion, compatible}; doctor guidance for RUNNER_PROTOCOL_MISMATCH.

Process

Plan reviewed pre-code by multi-LLM (Codex + Claude coordinator) — 4 blockers fixed in the plan (legacy-adopt-before-delete, adoption-aware teardown, #237-grade Android reap, sanitize-test bug). Executed via subagent-driven TDD (8 tasks, per-task spec+quality reviews, 3 fix waves). Final whole-branch review: ready to merge.

Verification

  • Unit: 2594/2594 green; oxlint + oxfmt clean.
  • iOS device pass 15/15 (iPhone 16 Pro): live legacy runner → adopted → reaped → real runner relaunched in ~18s → meta.note ✓; /health {ok, v:1, protocolVersion:1, capabilities:[], runnerVersion:"0.57.3"} ✓; per-UDID state file mode 0600 ✓; /tmp cleaned post-relaunch ✓; warm reuse 3ms, no restart ✓; teardown clears state ✓. Run 1 device-caught the xcodebuild env-forwarding bug (fixed via TEST_RUNNER_ prefix).
  • Android: new runner's /health proven live on emulator-5554 ({ok, protocolVersion:1, capabilities:[], runnerVersion:"0.57.3", v:1}-e arg echo works). Scripted e2e exercised adopt→classify→reap→force-reinstall; the final readiness wait exceeded the 30s budget purely from host-disk-full emulator degradation (bug: rn-android-runner foreground() pre-flight stalls ~10s (relaunch intent + By.pkg wait) around IME state → client RUNNER_TIMEOUT on guarded verbs #378-class; the emulator now refuses to boot: "not enough disk space" — 891MB free). Bridge-side Android gate fully covered by the unit matrix. Proof artifacts: rn-dev-agent-workspace/docs/proof/383-runner-protocol-versioning/.

Rollout note

The first device tool call after upgrading from a pre-protocol plugin pays one transparent runner restart (changeset documents it). Old bridges ignore the new /health fields and "v" stamps.

🤖 Generated with Claude Code

Lykhoyda and others added 15 commits July 2, 2026 11:13
…sioning + /tmp state relocation

Amendments applied from the multi-LLM plan review (Codex + Claude coordinator;
Gemini failed on interactive OAuth):
- BLOCKER: legacy /tmp state is now adopted once (lenient, protocolVersion 0)
  so a live pre-upgrade runner is discovered -> classified legacy -> reaped ->
  relaunched; /tmp files deleted only after the relaunch persists new state.
- BLOCKER: stopFastRunner/stopAndroidRunner + device-interact
  isFastRunnerAvailable fast-paths are adoption-aware (deviceId-scoped), so a
  post-respawn close/restart/park never leaks a live runner.
- BLOCKER: Android mismatch reap reuses releaseAndroidInteractionSlot() (#237,
  force-stops BOTH owned packages) via dynamic import.
- BLOCKER: fixed self-contradictory sanitize-test assertion (no '/' survives).
- IMPORTANT: no android-default state key - persist only under a resolved
  serial; acceptance criterion holds unconditionally.
- IMPORTANT: added missed fixture test/unit/runners/rn-android-runner-client
  .test.js (catch-all fetch mock must branch /health vs /command).
- MINOR: pendingUpgradeNote cleared on the mismatch-reject path.
#383)

Relocate iOS rn-fast-runner state from the shared /tmp/rn-fast-runner-state.json
singleton to a per-UDID hardened file under <stateDir>/runner-state/ios-<udid>.json
(schema v1, protocolVersion stamp). Replace the import-time /tmp load with lazy
per-device adoption (adoptPersistedFastRunnerState) so a respawned worker
rediscovers a live runner. Adoption-aware teardown: stopFastRunner(deviceId?)
adopts before reaping; deviceId threaded through every stop call site. Legacy
/tmp state is read once (protocolVersion 0) so a live pre-upgrade runner is
adopted not orphaned, and deleted only after a fresh relaunch persists the new
per-device file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes in device-session.ts (#383)

stopFastRunner(deviceId?) now adopts persisted per-device state before
reaping; a bare call after a bridge-worker respawn no-ops and leaks the
runner. Both remaining call sites had a deviceId in scope (session and
a function parameter, respectively) but weren't passing it.
Remove an unused readFileSync import in gh-383-secure-state-file.test.js
(oxlint no-unused-vars) and re-run oxfmt on secure-state-file.ts and
gh-383-protocol-sync.test.js to clear formatter drift from earlier
commits on this branch. dist rebuilt for the util source formatting
change.
…NER_PROTOCOL_MISMATCH after failed reinstall (#383)
…nstall on mismatch (#383)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ocs + changeset (#383)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…device-session open paths (#383)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…en catch; type runnerProtocol on StatusResult (#383)
…; tighten open-catch wiring test (#383)

Fix 1 (critical): runFlowParked's iOS branch called stopFastRunner()
bare, so after a bridge-worker respawn wiped in-memory state, parking
the L2 runner ahead of a Maestro flow silently no-op'd instead of
adopting the persisted per-device state and killing it — a
flow-exclusivity regression. Pass opts.deviceId through and widen the
FlowParkOpts.stopFastRunner type to accept it.

Fix 2 (important): the two Android close paths (device-session.ts's
runner-leak-recovery closeSession, and device-session-close.ts's two
teardown branches) called stopAndroidRunner() with no deviceId,
missing the same adoption-aware teardown the iOS twins already got.
Widened CloseDeviceSessionDeps.stopAndroidRunner to
(deviceId?: string) => Promise<void> and threaded the in-scope
deviceId through all three call sites.

Fix 3 (test): the gh-383 open-catch wiring test sliced from
'// Ensure runner + launch.', which includes the try body's own
consumePendingAndroidUpgradeNote() call on the success path — so
indexOf() matched that call instead of the catch-path discard,
making the assertion pass even with the catch-path discard removed.
Re-anchored the slice to start at 'catch (err)'. Verified locally
(then reverted) that removing the catch-path discard now fails the
test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d8624271f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +659 to +663
if (!compat.compatible) {
resolved = true;
pendingUpgradeNote = undefined; // review amendment: never report an upgrade that failed
child.kill('SIGTERM');
reject(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retry reinstall before rejecting stale Android APKs

When upgrading with no live runner state but an older rn-android-runner APK still installed, forceReinstall stays false, so ensureAndroidRunnerInstalled() treats the registered instrumentation as reusable and launches the stale APK. This post-start mismatch branch then immediately returns RUNNER_PROTOCOL_MISMATCH instead of doing the intended self-heal; retrying the install/build path with forceReinstall here would let normal plugin upgrades recover without requiring a manual Gradle rebuild/install.

Useful? React with 👍 / 👎.

forceReinstall = true;
await reapMismatchedAndroidRunner(deviceId);
}
// unreachable/unhealthy: fall through — the fresh start below supersedes it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reap unhealthy Android runner before respawning

If the saved Android runner PID is still alive but /health is unreachable or returns ok:false, this fallthrough starts a new instrumentation without stopping the old adb shell am instrument process or removing its forward. In that stale-listener scenario the old instrumentation can still own the UiAutomation slot, so the fresh runner is likely to fail before readiness; release/stop the existing runner before spawning the replacement.

Useful? React with 👍 / 👎.

@Lykhoyda Lykhoyda merged commit 694a57d into main Jul 2, 2026
11 checks passed
@Lykhoyda Lykhoyda deleted the feat/383-runner-protocol-versioning branch July 2, 2026 14:39
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.

Story 02 — Version the runner /command wire protocol + move runner state out of /tmp

1 participant