Skip to content

fix(macos): keep the headless server out of App Nap - #735

Open
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:wagenet/flow-cycle-degradation
Open

fix(macos): keep the headless server out of App Nap#735
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:wagenet/flow-cycle-degradation

Conversation

@wagenet

@wagenet wagenet commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 Foreground app and never opens
a window. gst-launch-1.0 has the same runtime shape — gst_macos_main, a
main-thread CFRunLoop, an NSEventThread, no window — but registers as
UIElement and is never demoted. Read it with lsappinfo info -pid <pid>.

process type priority while idle
gst-launch-1.0 udpsrc port=N ! fakesink UIElement 46, held 200 s
gst-launch-1.0 videotestsrc ! fakesink UIElement 46, held 200 s
headless Strom, unpatched Foreground 4, from ~32 s

Workload does not discriminate: the udpsrc pipeline waits on packets that never
arrive at zero CPU and keeps priority 46. Activation policy does. Forcing the
unpatched server to the accessory policy with setActivationPolicy:, while taking
no activity assertion, held it at 46 for 162 s against a Foreground control
demoted on schedule.

What registers Strom as Foreground is unknown. Ruled out by measurement:
gst_macos_main and gst_macos_main_simple (C probes of both register
UIElement), load-time AppKit linkage, creating NSApplication before
gst_macos_main, winit/eframe (a --features no-gui build still demotes), and
CEF (cefsrc is not installed on the measuring machine). The assertion opts out
whichever it turns out to be.

Fix

Hold a user-initiated activity for the life of the process.
UserInitiatedAllowingIdleSystemSleep rather than UserInitiated, because the
plain variant also blocks system idle sleep; LatencyCritical also opts out of
timer coalescing. objc2-foundation already ships via eframe, so no new crate.
Only run_headless_entry takes 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 -M priority and host
load. Workload is 600 frames of 1080p videotestsrc ! capsfilter ! x264enc ! fakesink, timed from Paused -> Playing to end-of-stream in the server log.

patched unpatched
median 3.62 s 26.68 s
range 3.52-3.66 s 23.0-29.7 s
priority 50 / 56 4

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_assertion catches the call going missing
    from run_headless_entry, by watching for the line it logs. 0.5 s.
  • server_is_not_demoted_to_background_qos checks that macOS still honours the
    assertion, which needs the full demotion window, so it is #[ignore]d:
    STROM_TEST_APP_NAP=1 cargo test --test macos_app_nap_test -- --ignored. Not
    wired into CI, where the runner is a VM that may not App-Nap at all.

A unit test in macos_app_nap.rs catches a wrong selector on every macOS
cargo test.

🤖 Generated with Claude Code

@wagenet wagenet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me.

@wagenet
wagenet force-pushed the wagenet/flow-cycle-degradation branch 2 times, most recently from 68e28f9 to ecd5b19 Compare September 1, 2026 04:22
@srperens srperens mentioned this pull request Sep 1, 2026
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>
@wagenet
wagenet force-pushed the wagenet/flow-cycle-degradation branch from ecd5b19 to b3833fd Compare September 3, 2026 01:27
@wagenet
wagenet marked this pull request as ready for review September 3, 2026 01:41

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

RadiusLOCAL. 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 & CICheck (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

@wagenet

wagenet commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Dispatched ci.yml with platforms=macos so the macOS-only code gets compiled. The branch lives on the fork, so the run is there:

https://github.com/wagenet/strom/actions/runs/33835592320

Build (macOS) runs clippy with -D warnings, cargo test --features efp,nvidia under STROM_REQUIRE_GST_PLUGINS=1, and the release build — so it covers the #[cfg(target_os = "macos")] body in macos_app_nap.rs, its selector unit test, and headless_entry_takes_the_activity_assertion. server_is_not_demoted_to_background_qos stays #[ignore]d and does not run.

I will report the result here when it finishes.

-- Claude

@wagenet

wagenet commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Build (macOS) is green — 27m14s, clippy + tests + release build: https://github.com/wagenet/strom/actions/runs/33835592320/job/100907688074

The two tests that had never been compiled both ran:

test macos_app_nap::tests::taking_the_activity_assertion_does_not_abort ... ok
test headless_entry_takes_the_activity_assertion ... ok
test server_is_not_demoted_to_background_qos ... ignored, waits ~50 s for the App Nap window; set STROM_TEST_APP_NAP=1

So the selector and the objc2-foundation binding surface are confirmed against a real macOS toolchain, and the wiring test holds. The demotion test stayed #[ignore]d as designed.

-- Claude

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants