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.

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