feat(iris): deliver QR/face metadata objects to AVCaptureMetadataOutput - #19
Conversation
Apps that scan codes rarely touch pixels themselves — they attach an AVCaptureMetadataOutput and wait for AVMetadataMachineReadableCodeObjects. Nothing in the injected session produces those, so QR scanners saw the injected feed render correctly and then never fire. This runs detection over the frame the injector already delivered and calls the app's metadata delegate directly. Hooks AVCaptureMetadataOutput's setMetadataObjectsDelegate:queue:, availableMetadataObjectTypes, and the metadataObjectTypes pair. setMetadataObjectTypes: is recorded rather than forwarded — the real setter validates against its own (empty) available list and raises NSInvalidArgumentException. Three things this ran into, all documented in SIM_CAM_ARCHITECTURE.md: - Vision does not work in the Simulator. Every VNImageRequestHandler fails with "Could not create inference context", so detection uses CIDetector, which covers QR codes and faces. availableMetadataObjectTypes advertises exactly those two rather than the full symbology set, so apps don't wait on callbacks that can never come. - AVMetadataObject's -dealloc segfaults on an instance built with class_createInstance — reproducibly, even with nothing assigned. The synthesised objects are therefore pooled: allocated once, recycled, never released, which keeps memory bounded and that dealloc unreached. - Detection is throttled to 200ms and single-flight on its own queue. Also fixes a crash independent of this feature: -[AVCaptureDeviceInput portsWithMediaType:sourceDeviceType:sourceDevicePosition:] on the synthesised input fell through to AVFoundation's implementation, which walks state class_createInstance never set up and segfaults in -[AVCaptureDeviceInput multiCamPorts]. Any app calling ports(for:) hits it. MSCFakeCaptureInput now answers typed port lookups itself. Verified end to end against a real Flutter app (multicamera plugin) in the Simulator: `sim cam start --image qr.png` and the app's barcode callback fires with the decoded payload.
annurdien
left a comment
There was a problem hiding this comment.
Code Review
Author: meatpaste · Diff: +460 / −0 across 7 files · Single commit
Overall Verdict
Strong PR. Well-researched, well-documented, solves a real gap in the injection layer. The code is careful around AVFoundation's sharp edges (dealloc segfaults, missing ports, Vision unavailability) and the workarounds are sound. The architecture doc and commit message are unusually thorough — every non-obvious decision is explained with root causes.
A few issues worth discussing before merge — nothing blocking, but some have correctness implications under concurrency.
Issues
🔴 High — Race on pooled metadata objects
FakeCaptureObjects.mm — MSCPooledMetadataObject
The pool uses a ring buffer of size 16. An object is mutated (ivars overwritten) in objectWithType: on the recognition queue, then read by the delegate on delegateQueue. With a pool size of 16 and recognition throttled to 200ms, the window is narrow — but the problem is structural, not probabilistic:
MSCPooledMetadataObjectreturns an object from the pool (under lock).- The caller writes to its ivars after the lock is released.
- Meanwhile the delegate from a previous detection may still be reading the same object's ivars if the delegate callback was slow.
The 200ms throttle makes collision unlikely in practice, but if delegateQueue is congested (e.g. main queue under load), a callback from frame N could overlap with ivar mutation for frame N+16. The pool size being 16 gives ~3.2 seconds of runway at 200ms cadence, which is generous — but the invariant is fragile.
Suggestion: Either:
- Double the pool to 32 (buys 6.4s of delegate lag — effectively "never happens" territory), or
- Copy the object ivars under the pool lock (move mutation inside
MSCPooledMetadataObjectby passing the values), or - Add a lightweight flag/generation counter so the delegate can detect staleness.
🟡 Medium — recognitionInFlight flag spans two lock acquisitions (TOCTOU)
CaptureHooks.mm — runRecognition
// First lock region: read inFlight
os_unfair_lock_lock(&gState.lock);
bool inFlight = gState.recognitionInFlight;
// ...
os_unfair_lock_unlock(&gState.lock);
// Gap — another thread could read inFlight=false here too
// Second lock region: set inFlight = true
os_unfair_lock_lock(&gState.lock);
gState.recognitionInFlight = true;
os_unfair_lock_unlock(&gState.lock);If deliverFrame fires twice in quick succession (e.g. timer coalescing), both could read inFlight = false before either sets it to true, launching two concurrent detection passes. Mitigated by the serial recognitionQueue (work is serialised even if dispatched twice), but "single-flight" isn't strictly enforced — two blocks can be enqueued.
Suggestion: Merge the two lock regions into one:
os_unfair_lock_lock(&gState.lock);
// ... read all state ...
bool shouldRun = !inFlight && (now - last >= IRIS_RECOGNITION_INTERVAL);
if (shouldRun) {
gState.recognitionInFlight = true;
gState.lastRecognition = now;
}
os_unfair_lock_unlock(&gState.lock);
if (!shouldRun) return;🟡 Medium — Superfluous __bridge roundtrip
FakeCaptureObjects.mm — pool allocation:
id instance = (__bridge id)(__bridge void *)class_createInstance(cls, 0);class_createInstance already returns id. The double-bridge (__bridge id)(__bridge void *) is a no-op — harmless but confusing. Likely survived from an earlier version that used __bridge_transfer.
Suggestion: Simplify to id instance = class_createInstance(cls, 0);
🟡 Medium — Missing comment on iris_availableMetadataObjectTypes
iris_setMetadataObjectsDelegate:queue: chains to the original (correct). iris_setMetadataObjectTypes: deliberately does NOT chain (correct, with a clear comment). But iris_availableMetadataObjectTypes also doesn't chain and has no comment explaining why. The asymmetry looks like a bug to a reader who doesn't have the PR description in front of them.
🟢 Low — Face detection faceIDs are sequential per-frame, not stable across frames
NSInteger faceID = 1;
for (CIFeature *feature in [detector featuresInImage:image]) {
MSCFakeFaceObject *object = [MSCFakeFaceObject objectWithFaceID:faceID++ ...];Real AVMetadataFaceObject.faceID is a tracking ID that persists across frames. Here, face 1 in frame N and face 1 in frame N+1 may not be the same person. Fine for v1 (CIDetector doesn't provide tracking IDs), but worth noting as a known limitation in the arch docs.
🟢 Low — IRIS_RECOGNITION_INTERVAL comment says "Vision"
IrisConstants.h:
// Vision runs over the injected frames to synthesise AVCaptureMetadataOutputThe implementation uses CIDetector (Core Image), not Vision. The PR description itself explains why Vision doesn't work — the comment should match.
🟢 Low — kCMTimeZero is deprecated
Both fake objects return kCMTimeZero from -duration. Deprecated since iOS 12. Use CMTimeMake(0, 1). No functional impact.
Things Done Well
| Area | Detail |
|---|---|
| Crash workarounds | The -dealloc segfault workaround (pooling) and the portsWithMediaType: fix are both well-researched. The pool approach is clean — bounded memory, no lifecycle surprises. |
| Throttling | 200ms cadence + single-flight + serial queue is the right design. Not over-engineered. |
| Swizzle hygiene | setMetadataObjectsDelegate:queue: correctly chains to the original. setMetadataObjectTypes: correctly doesn't (with a clear comment). The getter returns recorded state, not the real value — correct. |
| Documentation | The architecture doc addition is excellent — the mermaid diagram, the three constraints section, and the note about setMetadataObjectTypes: being intercepted. Commit message is thorough. |
| Bonus crash fix | The portsWithMediaType: fix is the right scope for this PR — it's closely related (same fake-object subsystem) and would block testing this feature. |
| Defensive coding | Nil checks on delegate/queue/output, respondsToSelector: before calling, fallback to dispatch_get_main_queue() when queue is nil. |
Recommendation
Approve with requests:
- Merge the two lock regions in
runRecognitionto actually enforce single-flight (🟡). - Fix the "Vision" comment in
IrisConstants.h(🟢 — trivial). - Consider the pool race (🔴) — either increase pool size or restructure mutation. The current window is generous but the invariant is brittle.
The rest (bridge no-op, kCMTimeZero deprecation, faceID instability) can be follow-up work.
Review feedback on annurdien#19. The pooled metadata objects were handed round a ring, so a slot could in principle be rewritten by the recognition queue while a slow delegate was still reading it. Pool size bounded the window rather than closing it, so slots are now checked out and back in: acquisition marks a slot in use and MSCReleasePooledMetadataObjects returns it once the delegate callback that carried it has returned — exactly the lifetime the real API promises. The pool grows to a cap under pressure and logs rather than silently dropping when a delegate stops returning. runRecognition read recognitionInFlight in one lock region and set it in another, so two frames arriving together could both claim a pass. Every precondition and the claim now share a single region. Also from review: - availableMetadataObjectTypes explains why it doesn't chain, as the setMetadataObjectTypes: hook next to it already did. - Drop the no-op __bridge roundtrip around class_createInstance. - kCMTimeZero (deprecated since iOS 12) -> CMTimeMake(0, 1). - IrisConstants.h said Vision where the implementation uses CIDetector. - Note that a synthesised faceID is an index within one detection pass, not a tracking id stable across frames as it is on real hardware. Re-verified end to end: 138 detections over a live QR feed with zero pool exhaustion, the app's barcode callback firing throughout, and its returning -visitor lookup answering 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — genuinely useful review, and you were right on every point. All seven addressed in 🔴 Pool raceYou called this structural, and it is, so I've fixed the structure rather than widened the window. Slots are now checked out and back in instead of handed round a ring:
So a slot is never handed out again while anything could still be reading it — that's the invariant, not a probability bound. It also happens to be exactly the lifetime the real API promises ("valid for the duration of the callback"), so the semantics line up rather than being an approximation of them. I skipped the three suggestions deliberately, and it's worth saying why: doubling the pool only buys more runway, and copying ivars under the pool lock doesn't actually help — the delegate doesn't take that lock, so the write and the read still aren't ordered against each other. The generation counter would work but puts the burden on the reader. Under pressure the pool grows to a cap (16 → 64) and then logs and drops rather than failing quietly, so a delegate that stops returning costs detections instead of memory or correctness. 🟡
|
|
@meatpaste , could you attach your manual test? A screen recording would be appreciated. |
|
Sure, i've got a Claude skill to record demo videos 🤣 What it shows, filmed against the actual PR branch (feat/metadata-output-barcode-scanning, built via Iris/Scripts/build.sh):
The narration explicitly separates "the feed rendered" from "the delegate fired", since that's the distinction a reviewer needs to see. iris-qr-metadata-demo.mp4 |
|
LGTM! thank you @meatpaste |
The gap
Apps that scan codes rarely touch pixels themselves — they attach an
AVCaptureMetadataOutputand wait forAVMetadataMachineReadableCodeObjects. Nothing in the injected session produces those, since there's no real capture connection behind it. The practical result today: pointsim camat a QR code and the app renders the injected feed perfectly, then its scanner never fires. No error, no timeout, just a dead-end.This runs detection over the frame the injector has already delivered and calls the app's metadata delegate directly.
sim cam start --image qr.png # the app's QR scanner now fires with the decoded payloadWhat's hooked
setMetadataObjectsDelegate:queue:,availableMetadataObjectTypes, and themetadataObjectTypespair.setMetadataObjectTypes:is recorded rather than forwarded — the real setter validates against its own (empty) available list and raisesNSInvalidArgumentException.Three things worth knowing
Vision doesn't work in the Simulator. Every
VNImageRequestHandlerfails with "Could not create inference context" — barcodes and faces alike. Detection therefore usesCIDetector, which covers QR codes and faces only.availableMetadataObjectTypesadvertises exactly those two rather than the full symbology set, because apps intersect their requested types against that list; claiming EAN or PDF417 would just make them wait on callbacks that can never arrive.AVMetadataObject's-deallocsegfaults on an instance built withclass_createInstance— reproducibly, even on one with nothing assigned. The synthesised objects are therefore pooled: a fixed ring allocated once and recycled, never released, so thatdeallocis never reached and memory stays bounded. An object is valid for the callback it arrives in, which is the lifetime the real API promises anyway.Detection is throttled to 200ms (
IRIS_RECOGNITION_INTERVAL) and single-flight on its own serial queue — frames arrive at up to 120fps and scanning every one costs milliseconds for nothing.Also fixes a crash unrelated to this feature
-[AVCaptureDeviceInput portsWithMediaType:sourceDeviceType:sourceDevicePosition:]fell through to AVFoundation's implementation on the synthesised input, which walks stateclass_createInstancenever set up and segfaults inside-[AVCaptureDeviceInput multiCamPorts]. Any app callinginput.ports(for:)hits this — it's how I found it.MSCFakeCaptureInputnow answers typed port lookups itself, returning the video port for video requests and an empty array otherwise (the documented "no such port" answer, which callers already handle).Verification
End to end against a real Flutter app — the multicamera plugin driving an iPad Pro simulator on Xcode 26.6 / macOS 26.5:
One caveat on that app: like the libraries in
patches/, it hard-codes#if targetEnvironment(simulator)and returns before touching AVFoundation, so it needed the same one-line guard relaxation to see the injected camera at all. Worth apatches/entry if that's useful — happy to add one.Docs updated: a new Metadata objects section in
docs/SIM_CAM_ARCHITECTURE.mdand a short note in the README.