classifyImage, detectObjects and recognizeText. All three were unservable before the capability gate: their Swift arms are conditional on config.enableMLKit and the dispatcher's guard has no else, so a page calling one in an app that left the flag off got a promise that never settled.
The cheapest remaining tier — no permission, no presented UI, no delegate, no subscription. A page hands over a base64 image and gets an array back, which also makes it the one tier a fixture can prove end to end without a human tapping anything.
The headline assertion
The test page draws a word on a <canvas>, sends toDataURL's payload, and asserts the word comes back:
ok: OCR round-tripped a page-drawn word off the main queue (i=26 seen 1x)Nothing is bundled, so the bytes Vision read are bytes the page produced and the expected answer is known exactly. CRAFT has to survive base64 → NSData → UIImage → CGImage → VNRecognizeTextRequest, the background-queue hop, and the ios_async ticket that carries the request id back.
Threading
Swift performs every request on DispatchQueue.global(qos: .userInitiated), and so does this. Inline on the main thread would have been materially less code — no context to hand across, no retain/release pair — and would freeze the UI for as long as recognition takes. The Swift author moved it off the main thread deliberately; a port that quietly moved it back would trade the user's responsiveness for the porter's convenience.
What crosses the hop is a heap-allocated Work passed straight as dispatch_async_f's context, not the index-into-a-pool shape ios_events uses. That pool exists because a location stream can burst faster than the main queue drains; a Vision request is user-initiated and arrives one at a time, so a bounded pool would only add an exhaustion path nothing reaches. ios_async's ticket pool still bounds outstanding replies, which is the resource that needs a ceiling.
The handler is built on the calling thread and retained across the hop: [UIImage imageWithData:] is autoreleased and the main runloop drains its pool at the end of the turn, so a CGImage merely borrowed from it would be freed while the background queue was still reading it.
Two spec quirks carried across, not fixed
detectObjectsbuilds aVNRecognizeAnimalsRequest(CraftApp.swift:4656), whose entire vocabulary is cat and dog. An action named for objects finds only pets. The reply shape, the label strings and the empty result for a photo of a chair are all observable behaviour a page may depend on — renaming the action or swapping the request is the API owner's call, not a translation detail.- The base64 goes to
NSData, not a Zig decoder, so the accepted input is exactly Swift's.Data(base64Encoded:)uses default options, which reject any character outside the alphabet — wrapped newlines, and thedata:image/png;base64,prefix a page might forward from a canvas. A decoder written to be "helpful" about either would accept images the Swift arm refuses.
Two of the three do not work on a simulator
VNClassifyImageRequest and VNRecognizeAnimalsRequest are Core ML backed and cannot build an inference context there:
classifyImage: Failed to create espresso context.
detectObjects: Could not create inference contextVNRecognizeTextRequest uses a different path and works. That is a property of the simulator, not the module. The response is to log the framework's own description and settle with NATIVE_CALL_FAILED — an empty array would have been the tempting answer and the wrong one, since "I found no cats" and "I could not look" are different facts. The fixture asserts both actions answer, by either route, because answering at all is what this phase changes.
A false-positive assertion, found and fixed
While adding markers I reused i=30, which secureSet already posts. The harness's count matches a=<anything> i=<n>, so the detectObjects assertion was passing on secureSet's dispatch — green without detectObjects answering at all.
The marker moved to 32, and the harness now fails if any asserted id is posted by more than one action. Verified by reintroducing a collision:
==> marker ids
FAIL: asserted id 80 is posted by 2 actions: clipboardRead getDeviceInfoAlso made the Work allocator explicit rather than inherited from self.allocator. The two are the same today (ios_dispatch dispatches with c_allocator), which is exactly why the coupling was worth spelling out — the background function is what frees it, so it must not come from anything scoped to the call.
Verification
zig build test green (153/153), zig fmt --check, build-ios and build-ios-simulator clean, pickier . 0 errors. 23 fixture assertions pass on a simulator.