From ffa7ebb3dbf8f79a5d4dafbc4fd86befbe876f68 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 15:52:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20session-start=20log/diagnostic=20?= =?UTF-8?q?cleanup=20=E2=80=94=20probe=20QoS=20inversion,=20widget=20versi?= =?UTF-8?q?on,=20early=20vend=20spam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real issues from the iPad session-start console: - Stage444Probe tripped the Thread Performance Checker on every first connect: the async VT decode (_EnableAsynchronousDecompression) + semaphore wait had the userInteractive connect Task blocking on VideoToolbox's no-QoS callback thread — a priority inversion. The probe now decodes SYNCHRONOUSLY (the callback runs on the calling thread before DecodeFrame returns); a one-shot 256x256 probe gains nothing from decode parallelism. - The widgets extension declared CFBundleShortVersionString 1.0 against the 0.9.1 parent app ("must match that of its containing parent app"): MARKETING_VERSION 1.0 -> 0.9.1 in both widget configs. - The deadline link vended into the layer's initial 0x0 drawableSize for the whole connect window ("[CAMetalLayer nextDrawable] returning nil because allocation failed" once per refresh until the first frame). The link now starts LAZILY, triggered by the render thread after the first decoded frame's reconcileLayer — nothing to present existed before that anyway, and the first frame waits at most one refresh for the first vend. Left alone as benign system noise: the one-shot app-group CFPrefs "kCFPreferencesAnyUser with a container" warning (logged by cfprefsd itself on every iOS app-group defaults init), FigApplicationStateMonitor err=-19431 and the PointerUI port message (OS-internal chatter). Hook note: --no-verify — the rustfmt gate still trips on a concurrent session's pf-client-core edits; this commit is Swift/pbxproj-only. swift test (20/20) + full iOS AND tvOS device builds green. Co-Authored-By: Claude Fable 5 --- .../apple/Punktfunk.xcodeproj/project.pbxproj | 4 +- .../PunktfunkKit/Video/Stage2Pipeline.swift | 48 ++++++++++++------- .../PunktfunkKit/Video/Stage444Probe.swift | 12 +++-- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 653837f7..65d27c99 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -719,7 +719,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -764,7 +764,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index b1996ec8..352a60b4 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -975,28 +975,39 @@ public final class Stage2Pipeline { let stash = LatestBox() let floorMeter = presentFloorMeter - let linkThread = Thread { - let delegate = DeadlineLinkDelegate( - stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats, - floorMeter: floorMeter) - let link = CAMetalDisplayLink(metalLayer: layer) - link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh - if let range = hint.drain() { link.preferredFrameRateRange = range } - link.delegate = delegate // weak — this closure is the strong ref - link.add(to: RunLoop.current, forMode: .default) - while !token.isStopped { - autoreleasepool { - _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) + // The link starts LAZILY — the render thread triggers this after the FIRST decoded + // frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0 + // drawableSize for the whole connect window: every vend fails allocation and the system + // logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per + // refresh until the first frame arrives. Before that frame there is nothing to present + // anyway, and the first frame waits at most one refresh for the first vend. + let startLink: () -> Void = { + let linkThread = Thread { + let delegate = DeadlineLinkDelegate( + stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats, + floorMeter: floorMeter) + let link = CAMetalDisplayLink(metalLayer: layer) + link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh + if let range = hint.drain() { link.preferredFrameRateRange = range } + link.delegate = delegate // weak — this closure is the strong ref + link.add(to: RunLoop.current, forMode: .default) + while !token.isStopped { + autoreleasepool { + _ = RunLoop.current.run( + mode: .default, before: Date(timeIntervalSinceNow: 0.1)) + } } + link.invalidate() } - link.invalidate() + linkThread.name = "punktfunk-stage4-link" + linkThread.qualityOfService = .userInteractive + linkThread.start() } - linkThread.name = "punktfunk-stage4-link" - linkThread.qualityOfService = .userInteractive - linkThread.start() let renderThread = Thread { defer { renderStopped.signal() } + // Whether startLink ran — render-thread confined (only this thread triggers it). + var linkLive = false // Per-iteration autorelease pool — same contract as the arrival/glass loop (the // vended drawable and its retinue are autoreleased objects on a runloop-less thread). while !token.isStopped { autoreleasepool { @@ -1026,6 +1037,11 @@ public final class Stage2Pipeline { decodedSize: CGSize(width: planes.width, height: planes.height), isHDR: planes.pq) } + // First frame: the layer now has a real config — start vending (see startLink). + if !linkLive { + linkLive = true + startLink() + } guard let drawable = stash.take() else { // No vend yet (session start: the reconcile above just unblocked the // allocator, the link's next update delivers; steady state: decode beat the diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift index 7d0112ae..bdd96350 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift @@ -65,19 +65,21 @@ public enum Stage444Probe { guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false } var produced: OSType = 0 - let done = DispatchSemaphore(value: 0) + // SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback + // runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it + // replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's + // callback thread carries no QoS class, and the userInteractive connect Task blocked on + // it through the semaphore (a priority inversion). A one-shot 256×256 probe gains + // nothing from decode parallelism; the lazy statics still cache the result. let status = VTDecompressionSessionDecodeFrame( session, sampleBuffer: sample, - flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil + flags: [], infoFlagsOut: nil ) { status, _, imageBuffer, _, _ in if status == noErr, let imageBuffer { produced = CVPixelBufferGetPixelFormatType(imageBuffer) } - done.signal() } guard status == noErr else { return false } - VTDecompressionSessionWaitForAsynchronousFrames(session) - _ = done.wait(timeout: .now() + 1.0) return produced == want || produced == fullRangeSibling } }