fix(macos): keep the headless server out of App Nap - #735
Conversation
wagenet
left a comment
There was a problem hiding this comment.
Makes sense to me.
68e28f9 to
ecd5b19
Compare
Headless Strom registers with LaunchServices as a Foreground app but never opens a window, and macOS App-Naps that shape ~32 s after launch: every thread drops to scheduling priority 4, which on Apple Silicon means efficiency cores at a throttled clock. Pipelines then run ~7.4x slower for the rest of the process's life, and a restart resets it for another 32 s -- which is why this reads as the server degrading as flows are cycled, though nothing accumulates. gst-launch-1.0 is exempt because it registers as UIElement; what makes Strom Foreground is not known, but the assertion opts out regardless of activation policy. beginActivityWithOptions:reason: is the documented opt-out. Take a user-initiated activity for the life of the process. The two failure modes cost three orders of magnitude apart, so they are guarded separately. Losing the call from run_headless_entry is the realistic regression and is caught in under a second by watching for the line it logs. macOS ceasing to honour the assertion is only observable by waiting out the demotion window and reading scheduling priority, so that check is #[ignore]d and run by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ecd5b19 to
b3833fd
Compare
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment — right mechanism and correctly scoped to the headless path, but every
macOS-only line this PR adds has never been compiled by CI.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
objc2-foundation adds no new crate to the tree |
CONFIRMED |
Cargo.lock diff adds only a dependency edge under the strom package; no new [[package]] block — eframe already resolves objc2-foundation 0.3.2 |
| macOS-specific code compiled by CI on this PR | CONTRADICTED |
gh pr checks 735: Build (macOS) is skipping |
| The assertion is taken on every path into headless mode | CONFIRMED |
backend/src/main.rs:347 and :359 both call run_headless_entry, which calls hold_activity_for_process_lifetime() unconditionally at backend/src/main.rs:571 before the #[cfg(target_os = "macos")] block |
| GUI mode never takes the assertion | CONFIRMED |
Only call site is inside run_headless_entry; run_with_gui (backend/src/main.rs:369) has no such call |
Diagnosis — Cause is specific and measured (LaunchServices Foreground registration vs.
gst-launch-1.0's UIElement, ~32 s demotion window). beginActivityWithOptions:reason: is
Apple's documented App Nap opt-out; I cannot verify the objc2-foundation binding surface
(method name, NSActivityOptions variants) from this repo — no cached crate source and no
build available here, so treat that call as EXTERNAL (assumed correct per Apple's documented API). Coverage is ABSOLUTE for headless mode: one entry point, both call sites route through
it. GUI mode is left untouched on the claim that an open window already keeps the process out
of App Nap — that's an OS-behaviour assumption (EXTERNAL), not verified by any test here, but
plausible and not blocking.
Radius — LOCAL. New module, no existing call sites redirected, #[cfg(not(target_os = "macos"))] no-op elsewhere (backend/src/macos_app_nap.rs:40), and the new dependency edge
resolves to a crate version already in the tree.
Tests & CI — Check (Linux), Build (Linux x86_64/ARM64), Check & Build (WASM), API Contract Check green. But Build (macOS) and Build (Windows) both skipping — this repo's
CI does not build macOS on push/PR, so the #[cfg(target_os = "macos")] body in
backend/src/macos_app_nap.rs:20-37, its in-module unit test (:43-53), and the whole of
backend/tests/macos_app_nap_test.rs (#![cfg(target_os = "macos")] at line 13) have never
been compiled by this repo. UNVERIFIED. Dispatch before merge:
gh workflow run ci.yml --ref wagenet/flow-cycle-degradation -f platforms=macos. The
#[ignore]d behavioural test (server_is_not_demoted_to_background_qos) is correctly excluded
from CI per its own comment (unknown whether the GitHub macOS runner App-Naps at all) — that
one has to stay a manual/opt-in check, but the wiring test does not, and a platforms=macos
compile is enough to catch a wrong selector or missing feature flag before merge.
Repo rules — No BUFFER probe, no pipeline/element closures, no new shared type, no new
endpoint. The cross-platform no-op correctly uses #[cfg(not(target_os = "macos"))] rather
than #[allow(dead_code)] (backend/src/macos_app_nap.rs:40-41), matching CLAUDE.md's
target-specific dead-code rule directly.
Confidence: HIGH
|
Dispatched https://github.com/wagenet/strom/actions/runs/33835592320
I will report the result here when it finishes. -- Claude |
|
The two tests that had never been compiled both ran: So the selector and the -- Claude |
Problem
Once a headless Strom process is ~32 s old, macOS moves the whole task into the
background QoS band and leaves it there: every thread drops to scheduling priority
4, which on Apple Silicon means efficiency cores at a throttled clock. CPU-bound
work slows down accordingly — a 1080p x264 encode takes 7.4x longer, measured
below.
This tracks process age, not work done. A backend that sat idle and then ran its
first cycle is already slow if it is old enough, and a restart resets it for
another 32 s.
Cause
Headless Strom registers with LaunchServices as a
Foregroundapp and never opensa window.
gst-launch-1.0has the same runtime shape —gst_macos_main, amain-thread CFRunLoop, an
NSEventThread, no window — but registers asUIElementand is never demoted. Read it withlsappinfo info -pid <pid>.gst-launch-1.0 udpsrc port=N ! fakesinkUIElementgst-launch-1.0 videotestsrc ! fakesinkUIElementForegroundWorkload does not discriminate: the
udpsrcpipeline waits on packets that neverarrive at zero CPU and keeps priority 46. Activation policy does. Forcing the
unpatched server to the accessory policy with
setActivationPolicy:, while takingno activity assertion, held it at 46 for 162 s against a
Foregroundcontroldemoted on schedule.
What registers Strom as
Foregroundis unknown. Ruled out by measurement:gst_macos_mainandgst_macos_main_simple(C probes of both registerUIElement), load-time AppKit linkage, creatingNSApplicationbeforegst_macos_main, winit/eframe (a--features no-guibuild still demotes), andCEF (
cefsrcis not installed on the measuring machine). The assertion opts outwhichever it turns out to be.
Fix
Hold a user-initiated activity for the life of the process.
UserInitiatedAllowingIdleSystemSleeprather thanUserInitiated, because theplain variant also blocks system idle sleep;
LatencyCriticalalso opts out oftimer coalescing.
objc2-foundationalready ships via eframe, so no new crate.Only
run_headless_entrytakes the assertion, so GUI mode is untouched.Verification
12 paired reps: counterbalanced randomised order, a fresh server per rep, a 50 s
settle so both arms age equally, and one binary with an environment variable
gating only the assertion call. Each rep records its own
ps -Mpriority and hostload. Workload is 600 frames of 1080p
videotestsrc ! capsfilter ! x264enc ! fakesink, timed fromPaused -> Playingto end-of-stream in the server log.Geometric mean 7.41x, 95% CI 7.16-7.67, 12/12 pairs, t = +114.4, p = 2.9e-18.
Order effect is 7.36x patched-first against 7.53x unpatched-first (5 and 7 pairs).
A/A control, both arms patched, same schedule and analysis: 0.994x, CI
0.987-1.002, p = 0.15. Per-pair spread is 1.25% there against 6.06% in the A/B,
the difference being variability in the demoted arm.
Demotion threshold, six servers sampled once a second: 31.7-32.5 s, median 32.1 s.
M-series laptop, AC power, host load 1.9-5.8 across the reps. Battery is untested.
Tests
headless_entry_takes_the_activity_assertioncatches the call going missingfrom
run_headless_entry, by watching for the line it logs. 0.5 s.server_is_not_demoted_to_background_qoschecks that macOS still honours theassertion, which needs the full demotion window, so it is
#[ignore]d:STROM_TEST_APP_NAP=1 cargo test --test macos_app_nap_test -- --ignored. Notwired into CI, where the runner is a VM that may not App-Nap at all.
A unit test in
macos_app_nap.rscatches a wrong selector on every macOScargo test.🤖 Generated with Claude Code