Skip to content

feat(sim): P1 private-API framebuffer streaming + HID injection, crash-isolated (CODE-396)#255

Open
AprilNEA wants to merge 6 commits into
xuan/code-395from
xuan/code-396
Open

feat(sim): P1 private-API framebuffer streaming + HID injection, crash-isolated (CODE-396)#255
AprilNEA wants to merge 6 commits into
xuan/code-395from
xuan/code-396

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Summary

Stacked on #254. The P1 private-API layer for crates/linkcode-sim: live framebuffer streaming and touch/button injection on iOS 26, ported from baguette (Apache-2.0; NOTICE added). Rust FFI via objc2 (dynamic ObjC messaging) + libloading/dlsym (private C symbols) — no Swift.

What works, verified on real hardware (iPhone 17 Pro / iOS 26.5):

  • HID injectiontap/swipe/button, stable (10 taps + 5 swipes + 3 buttons, no crashes). Uses the iOS 26 IOHIDEvent digitizer recipe (the 9-arg mouse fn is misread as a Home gesture on 26). Runs in the sidecar main process; wired into the RPC (probe now reports interactive: true).
  • Framebuffer streaming — SimulatorKit descriptor enumeration → screen-callback registration → IOSurface → JPEG. Delivered live 1206×2622 frames (clock advancing across captures). Key recipe details cracked: XPC-proxy objects need raw objc_msgSend (not objc2's verified macro); the callback block needs BLOCK_HAS_SIGNATURE; surfaces are recycled buffers so the callback only memcpies pixels (the reader thread encodes); a registration-race gate stops callbacks touching a half-registered descriptor.

The hard part — crash isolation (engineering best-practice):
The private framebuffer path intermittently aborts hard (SIGABRT "unknown class" inside CoreSimulator's XPC machinery) — uncatchable in-process, and worse on the Xcode 26 SimStreamProcessor era. So capture runs in a disposable worker subprocess: the sidecar spawns capture-worker, reads length-prefixed JPEG frames, and respawns it with backoff on crashes; a crash-loop gives up gracefully. Input and the simctl lifecycle RPCs stay in the parent and are never taken down by a capture crash. When the private worker gives up, the stream degrades to simctl io screenshot — slower, but frames never stop.

New sidecar frames/ops (PROTOCOL.md): STREAM_FRAME + tap/swipe/button/streamStart/streamStop.

Verification

  • cargo fmt/clippy/test clean.
  • Live: input ops over the RPC all succeed; streamStart delivers frames (private fast-path when the sim state cooperates — 41 frames/5s observed earlier; simctl fallback when the worker gives up — verified frames keep flowing through a crash-loop while the sidecar stays up).

Follow-up

The private framebuffer path is fragile on the current Xcode 26 / iOS 26 SimStreamProcessor simulators (timing-sensitive hard crashes). It is fully contained by the isolation + graceful-degradation architecture, so it never threatens the sidecar; hardening the private path to reliably hit ≥10fps headless on this Xcode is a contained follow-up (the worker + supervisor are the stable foundation, and baguette upstream is the signal source when Xcode shifts these APIs).

@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

CODE-396

@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: 00815b349c

ℹ️ 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".

pub const RESULT: u8 = 0x81;
pub const SCREENSHOT: u8 = 0x82;
/// A streamed framebuffer frame: `[u16 LE udid_len][udid][jpeg]` (unsolicited, while a stream runs).
pub const STREAM_FRAME: u8 = 0x83;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wire the interactive protocol into the daemon client

The new frame type and P1 operations have no peer in the shipped daemon: in the inspected packages/host/sim, client.ts exposes only the P0 methods through screenshot (lines 69-105), codec.ts defines only RESULT and SCREENSHOT (lines 12-13), and handleFrame silently discards 0x83 via its default branch (lines 186-187). Because call is private, no product path can send tap or streamStart, and even an externally initiated stream would have every frame dropped; extend the TypeScript codec/client/backend and frame routing with this protocol change.

AGENTS.md reference: AGENTS.md:L27-L27

Useful? React with 👍 / 👎.

ptr::null(),
ptr::null(),
);
CFRelease(number);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain the JPEG quality value while the dictionary uses it

CFDictionaryCreate receives null key/value callbacks, so the dictionary does not retain number; releasing it here leaves options containing a dangling CFNumber before encode_cgimage_jpeg passes the dictionary to CGImageDestinationCreateWithData. Every private JPEG capture therefore risks a use-after-free, which can crash-loop the worker or lose the requested quality; use Core Foundation retaining callbacks or keep the number alive until the dictionary consumer is finished.

Useful? React with 👍 / 👎.

Comment on lines +79 to +81
self.stopped.store(true, Ordering::Relaxed);
if let Some(manager) = self.manager.take() {
let _ = manager.join();

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 Terminate the capture worker before joining its manager

If the worker opens a Screen but no framebuffer callback arrives, such as during a boot or shutdown transition, it remains alive without writing stdout and pump_worker blocks in read_exact. Setting this atomic does not wake that read or terminate the child, so dropping the stream from streamStop can block forever in manager.join() and never send its RESULT; close or kill the worker before joining, or make the read interruptible.

Useful? React with 👍 / 👎.

Comment on lines +254 to +257
if let Some(frame) = stream.latest() {
let ptr = Arc::as_ptr(&frame);
if last != Some(ptr) {
last = Some(ptr);

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 Keep the previous frame alive when deduplicating

When the pusher misses an intermediate worker frame, the prior Arc can be freed and its allocation address reused by a later frame, so persisting only this raw pointer creates an ABA comparison that treats a new JPEG as already sent. With producer and poller rates aligned, allocator address alternation can repeatedly suppress visible updates; retain the previous Arc and compare it with Arc::ptr_eq, or deduplicate with a generation counter.

Useful? React with 👍 / 👎.

Comment on lines +177 to +178
if reg.streams.contains_key(udid) {
return Ok(json!({ "alreadyStreaming": true }));

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 Preserve the stream-start response shape on retries

When streamStart is called for an already-running UDID, this branch returns only { alreadyStreaming: true }, whereas both the first-call implementation and PROTOCOL.md promise { streaming, fps }. A caller validating the documented result will fail specifically on an idempotent retry; return the normal fields as well, with alreadyStreaming only as an optional addition if needed.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds interactive iOS Simulator control through private macOS APIs. The main changes are:

  • Crash-isolated framebuffer capture with worker restart and screenshot fallback.
  • JPEG stream frames with configurable frame rate, quality, and scale.
  • Touch, swipe, Home, and Lock-button injection.
  • New RPC operations, capability reporting, diagnostics, and protocol documentation.

Confidence Score: 5/5

No additional blocking issue qualifies for this follow-up review.

The remaining stream-delivery and lifecycle failures are already covered by existing review findings.

No separate production failure requiring an independent fix was identified.

T-Rex T-Rex Logs

What T-Rex did

  • The before-log records platform and tool checks and a failed simctl list devices booted command (exit 127), establishing the initial environment state.
  • The after-log shows a single build attempt and the exact Objective-C compiler failure (exit 101), documenting the immediate build outcome.
  • The process complied with the stop condition and did not perform repeated large builds, package installations, mock frames, or direct worker execution.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
crates/linkcode-sim/src/capture.rs Adds the supervised capture worker, frame transport, restart policy, and pacing.
crates/linkcode-sim/src/interactive.rs Adds HID operations, per-device stream management, frame forwarding, and screenshot fallback.
crates/linkcode-sim/src/main.rs Routes the new operations and adds worker, diagnostic, and benchmark entry points.
crates/linkcode-sim/src/private/screen.rs Implements framebuffer callbacks, surface copying, image scaling, and JPEG encoding.
crates/linkcode-sim/src/proto.rs Extends the sidecar protocol with unsolicited stream frames.

Reviews (2): Last reviewed commit: "feat(sim): configurable capture scale (d..." | Re-trigger Greptile

Comment thread crates/linkcode-sim/src/interactive.rs
Comment thread crates/linkcode-sim/src/capture.rs
Comment on lines +232 to +233
let fallback_interval = Duration::from_millis(500);
let mut last: Option<*const Vec<u8>> = None;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Silent Worker Never Triggers Fallback

Fallback starts only after is_dead() becomes true. If the worker stays alive but framebuffer registration yields no callbacks, it emits no JPEGs and is never marked dead, so this loop sends no frames indefinitely instead of switching to simctl.

Artifacts

Repro: runnable extraction, compilation, and controlled stream harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: generated Rust harness containing the extracted push_frames implementation and controlled doubles

  • Contains supporting evidence from the run (text/x-rust; charset=utf-8).

Repro: successful runtime trace contrasting the silent-alive stream with the dead-stream control

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

AprilNEA added 5 commits July 22, 2026 22:40
Reach the frame sink through a process global instead of a block-captured
pointer (ROCK's XPC delivery zeroes the capture), consume the screen
callbacks' delivered surface instead of polling framebufferSurface on the
XPC proxy every frame, and build the ImageIO options dict with the standard
CFType callbacks. Sustained 15fps headless with no crashes.
thread::sleep coalesces timers on macOS and overshoots by several ms, so a
plain deadline loop drifts below target (60fps landed ~52-56). Pace on the
mach absolute clock instead: locks lower rates exactly (30->29.5) and drives
60fps to the full-res pipeline ceiling (~58.5), with no busy-spin.
Downscale each frame before JPEG encode via a high-quality CoreGraphics
resample. Native-resolution encode caps the stream near 55fps; scale below
1.0 lifts it to the display's 60Hz and cuts bandwidth (0.5 -> ~1/3 the bytes,
603x1311 locks 60fps). Threaded from streamStart through StreamParams to the
worker; tap/swipe stay normalized so no coordinate change is needed.
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