Supported way to re-acquire genlock after follow() detaches mid-session?

Summary. iPhone 17 Pro Max + Blackmagic Camera ProDock, genlock BNC in from a generator confirmed at a true 30.00 fps. follow(_:videoFrameDuration:delegate:) reaches .activeSync in ~2 s. On one unit the lock then holds for 10+ minutes. On a second unit, same binary and same reference, it detaches 6 s to ~4 min after lock: .activeSync.ready with input.externalSyncDevice == nil, no runtime error and no delegate error. Calling follow() again on the still-running session is rejected with -11800 every time, while unfollowExternalSyncDevice() plus a stopRunning()/startRunning() bounce recovers reliably.

I am not looking for a fix. I want to know whether my call sequence is wrong, whether this transition is expected, and how a shipping app should be structured around it.

Questions

  1. Is re-following a running session supported? Is the unfollow + bounce the intended reset sequence, or is there a lighter-weight way to clear whatever state the -11800 is keyed on? And once detached, is re-acquisition entirely the app's responsibility, or is the system expected to re-calibrate on its own while the reference is present?

  2. Is a hard detach a legal outcome for an already-calibrated input? The documentation describes .freeRunSync as the hold-over when a locked input loses sync. Is that hold-over guaranteed, or must an app also handle .activeSync.ready with a nil externalSyncDevice?

  3. Is anything in my call sequence wrong (code in a reply below), and is polling input.externalSyncDevice the right signal to key recovery on, or is there a supported notification for detach?


Setup. Video-only AVCaptureSession: one .builtInWideAngleCamera input, one AVCaptureVideoDataOutput. No multi-cam, no depth output, no synchronizer, no audio. Both frame durations set to CMTime(1, 30) before the input is created, never rewritten while a follow is live.

Device A (iOS 26.0.1) holds: 601 s and 956 s runs, zero detaches. Device B (26.3.1, then 26.5.2) has 39 drops across 6 logs. The ProDock, cable and generator were swapped between units; the failure followed the phone. Caveat: n=2, and unit and OS build vary together.

The detach. No AVCaptureSessionRuntimeError and no delegate error (-11892 has never been observed here, so this is not the documented frame-duration-mismatch path). The session keeps running and delivering frames. Status is .ready, not .unavailable — the ProDock stays enumerated, the reference unchanged. No confirmed drop has passed through .freeRunSync.

PTS ground truth, independent of the follow state: while locked, every frame PTS sits exactly on the 1/30 grid, zero drift. At the drop there is exactly one teardown gap, 238–337 ms across 8 runs, after which the clock free-runs at ~30.013 fps (444 ppm) and never returns to the grid.

On -11800. It surfaces through AVCaptureSessionRuntimeErrorNotification. The bounce recovers 3/3, with no activeFormat change. Caveat: -11800 is AVErrorUnknown and I see it in unrelated cases too, so I do not assume it is specific to retained follow state.

Ruled out. Exposure duration — a run at ≤ 16.67 ms, within the recommendation in the follow() documentation, still drops. Reference drift — zero, by microsecond PTS. Accessory chain — full swap; the failure followed the phone. Another client — Final Cut Camera holds genlock on the fragile unit with the same ProDock and reference. Load and resolution — load changes time-to-drop, not whether it drops; with recording and audio off it still drops, and Device B drops at both 12 MP and 1080p.

Code and supporting logs are in replies below; the length limit would not take them inline. Per-frame PTS CSVs, raw status logs, and a minimal Xcode project that still drops are available on request.

Prior art read: forums/thread/799739 and thread/804594 — neither covers post-lock detach or re-acquisition.

Code and the transition log, which would not fit in the original post.

Transition (condensed; LOCKED = .activeSync)

t=0.0 s     .unavailable → .ready → .calibrating → .activeSync   (~2 s after startRunning())
...         locked; every frame PTS exactly on the 1/30 grid
t=T         .activeSync → .ready,  input.externalSyncDevice == nil
t=T+0.30 s  re-sampled, still nil → app declares the lock lost

externalSyncDevice(_:failedWithError:) is implemented and logs domain#code; neither that log line nor the forced .unavailable transition my handler applies ever appears at a drop. That is inferred from absence, not from a positive trace. I did not record the ProDock REF indicator state at the drop instant. A few frames of exposure / white-balance flash are visible at the drop, reproducible with fixed WB — consistent with an input-port or clock reset, but not directly instrumented.

Code. Reduced to the three methods that matter; helper names are sample names, not API. The class is main-actor isolated, and every KVO and delegate callback arrives outside that isolation and hops in through Task { @MainActor in ... }, so callbacks are delivered asynchronously and their relative ordering is not guaranteed. If the mistake is in my sequence, I expect it is here.

private func handleStatus(_ status: AVExternalSyncDevice.Status) {
    if status == .activeSync {
        wasLocked = true; graceTimer?.invalidate(); graceTimer = nil; return
    }
    // .freeRunSync after .activeSync is tolerated as a coast: falls through untouched.
    guard status == .ready || status == .unavailable else { return }
    guard input.externalSyncDevice == nil else { return }  // still attached: flap, ignore
    if wasLocked { scheduleRecheck(after: 0.30) } else { follow() }
}

// follow() with status != .ready or isExternalSyncSupported == false raises an
// NSInvalidArgumentException I cannot catch from Swift, so both are re-checked.
@discardableResult
private func follow() -> Bool {
    guard let dev = syncDevice, dev.status == .ready else { return false }
    guard input.isExternalSyncSupported else { return false }
    input.follow(dev, videoFrameDuration: frameDuration, delegate: self)
    return true
}

// One nil sample is ambiguous, so it is confirmed over a grace window.
private func scheduleRecheck(after seconds: TimeInterval) {
    graceTimer?.invalidate()
    graceTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { [weak self] _ in
        Task { @MainActor in
            guard let self else { return }
            self.graceTimer = nil
            guard self.input.externalSyncDevice == nil else { return }   // self-healed
            await self.recover()
        }
    }
}

// Recovery, deliberately without touching activeFormat.
private func recover() async {
    input.unfollowExternalSyncDevice()
    try? await Task.sleep(nanoseconds: 800_000_000)
    session.stopRunning(); session.startRunning()
    try? await Task.sleep(nanoseconds: 500_000_000)
    follow()
}

Status arrives twice — once through KVO on AVExternalSyncDevice.status, once through externalSyncDeviceStatusDidChange(_:). Both funnel into handleStatus. The app never writes its own status value; every transition reported came from KVO or the delegate.

recover() never changes activeFormat: a format change after any follow() in the same process crashes for me — a separate issue I am not bundling into this thread.

Grace window. 0.30 s, settled on empirically. Widened to 1.0 s it produced 0 self-heals in 5 drops, so I reverted. Once the sample reads nil it stays nil until an unfollow + bounce.

Configuration detail and the measurements behind the numbers in the original post.

Session configuration. One plain AVCaptureSession; AVCaptureMultiCamSession is not used anywhere in the app. One input (.builtInWideAngleCamera, back), one AVCaptureVideoDataOutput, no depth output, no AVCaptureDataOutputSynchronizer, no audio input in this session. activeVideoMinFrameDuration == activeVideoMaxFrameDuration == CMTime(1, 30), set before the AVCaptureDeviceInput is created and never rewritten while a follow is live. preferredVideoStabilizationMode = .off, alwaysDiscardsLateVideoFrames = false.

Preflight, logged immediately before every follow(), verbatim from Device A:

follow preflight: format=4032x3024 extSyncSupported=true minSyncDur=1/60 (valid=true) autoVFR=false
follow preflight: format=1920x1440 extSyncSupported=true minSyncDur=1/60 (valid=true) autoVFR=false

The Device B preflight line was not retained — same binary, same code path.

Reference signal. Two configurations were exercised; all drop data is from (2).

  1. An output labelled "FHD 29.97" that empirically pulses at 59.94 Hz. Followed with CMTime(1001, 60000) against 1920x1080 it locks in ~2 s and the sensor runs at half rate (measured interval 33.330 ms ± 0.786 ms). Followed at a nominal 29.97 it never leaves .calibrating and the delegate reports back .ready, matching the documented "not close enough" behavior.
  2. A generator confirmed to output a true, exact 30.00 fps, followed with CMTime(1, 30).

Single-variable isolation, Device B (26.5.2). All cold .nominal starts, all recording.

4032x3024 + in-session mic + live preview   drop at 6 s     (.nominal / .nominal)
1920x1080 + preview off + in-session mic    drop at 37 s    (.nominal / .fair)
1920x1080 + preview off + mic off           211.9 s / 6358 frames, LOCK HELD, zero drops
4032x3024 + preview off + mic off           drop at 102 s   (.nominal / .nominal)

Removing the mic input took the 1080p case from a 37 s drop to a run held end to end, so session load is a strong lever on how long the lock survives. But with recording off, audio off and all other in-app collection disabled, at 1920x1440, the follow still dropped, and detaches also occur during idle follow with no recording at all.

Device A flaps. No .activeSync.ready + nil detach has ever been recorded on this unit. Its only interruptions are flaps where the follow stays attached and the status returns on its own — 7 in one day's log, all ~1 s except one ~22 s, all while not recording:

[07:12:25] status: LOCKED → FREE-RUN
[07:12:26] status: FREE-RUN → LOCKED
[07:57:22] transient UNAVAILABLE after lock — follow still attached, staying locked
[07:57:22] status: UNAVAILABLE → FREE-RUN
[07:57:44] status: FREE-RUN → LOCKED

Device B takes the coast sometimes too: one cold-start .activeSync.unavailable.freeRunSync.activeSync recovery in ~1 s, follow still attached, no flash. But no confirmed drop has ever gone through .freeRunSync.

Head-to-head. One directly paired run, identical binary and configuration (4032x3024, 30 fps, HEVC 8-bit, Apple Log off), both recording, both at .serious: Device A completed the full 601 s window with zero drops, Device B dropped at 13 s. I do not treat 13 s as a clean number — that Device B process had already churned through a previous drop in the same launch. A freshly launched process on B has held 9–11 minutes.

Wedge state, system-level and cross-app. A crash or force-quit while sync is engaged, or an activeFormat change mid-sync, leaves the device in a state where no app — including Blackmagic Camera — can calibrate afterwards. A ProDock power cycle does not clear it; only rebooting the iPhone does. Every measurement above comes from a session that first demonstrated a clean ~2 s lock, so none was taken inside a wedge window.

Regarding following a session that's already running, I would recommend unfollowing first.

You mentioned the status is arriving twice, once through KVO and again through the externalSyncDeviceStatusDidChange. You don't need to use both of those, just pick one. We provide both so you can pick what's most ergonomic for your use case. That may be inadvertently calling follow on the input more than once. From the sample code, it looks like you're making some of these calls from the main thread. We recommend having a serial queue that you use for all session changes.

Please do send in some sample code with the Feedback Assistant app if at all possible. You certainly shouldn't be getting such a generic error.

Supported way to re-acquire genlock after follow() detaches mid-session?
 
 
Q