fix(apple): session-start log/diagnostic cleanup — probe QoS inversion, widget version, early vend spam
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -975,28 +975,39 @@ public final class Stage2Pipeline {
|
||||
let stash = LatestBox<CAMetalDrawable>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user