From 4821ec92c2f950220cb2844995a14b5d305dfd6b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 17:31:59 -0700 Subject: [PATCH] Control: requests are answered at full speed while the window is hidden on macOS Within about a minute of its window no longer being visible, macOS App Nap lowers the app's threads to the lowest priority and coalesces its timers. On a loaded machine, control requests then took seconds to answer, and the 60 s timeout reply arrived up to 26 s late. When the control channel is on (--control or VECTORCRAFT_CONTROL_PORT), the app now holds an NSProcessInfo activity (user initiated, allowing idle system sleep, latency critical) for as long as it runs, so App Nap does not apply. The activity does not keep the Mac awake. docs/control-protocol.md states this in the paragraph on the 60 s timeout. --- apps/vectorcraft/Cargo.toml | 4 +-- apps/vectorcraft/src/control_server.rs | 34 ++++++++++++++++++++++++++ docs/control-protocol.md | 3 ++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 86235afbb..fedc4acbe 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -62,8 +62,8 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_U muda = { version = "=0.21.1", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } -# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd. -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] } +# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] } block2 = "0.6" # The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR # Apache-2.0 OR Zlib, already in the build through eframe and winit. diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index 529100513..e163ffb95 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -67,10 +67,31 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }; eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}"); + #[cfg(target_os = "macos")] + keep_answering(); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } +/// The activity the control channel holds on macOS: work the user asked for, which may let the +/// system sleep when idle, with full timer precision. Without it, App Nap lowers every thread to +/// the lowest priority within about a minute of the window no longer being visible. +#[cfg(target_os = "macos")] +fn activity_options() -> objc2_foundation::NSActivityOptions { + use objc2_foundation::NSActivityOptions; + NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical +} + +/// Begins the control channel's activity. It lasts until the process ends. +#[cfg(target_os = "macos")] +fn keep_answering() { + use objc2_foundation::{NSProcessInfo, NSString}; + let reason = NSString::from_str("Answering control requests"); + let activity = NSProcessInfo::processInfo().beginActivityWithOptions_reason(activity_options(), &reason); + // The token is never released or passed to `endActivity`, so the activity lasts for the process's lifetime. + std::mem::forget(activity); +} + fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming().flatten() { @@ -642,4 +663,17 @@ mod tests { assert!(line.contains("too many control connections"), "{line}"); drop(held); } + + // A test process is not an app that App Nap applies to, so this checks the activity the + // channel asks for; the throttling itself shows only in a running app with its window hidden. + #[cfg(target_os = "macos")] + #[test] + fn the_control_channel_keeps_the_app_at_full_priority_on_macos() { + use objc2_foundation::NSActivityOptions; + let options = activity_options(); + assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20"); + assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep)); + assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake"); + keep_answering(); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0ec4cfd87..3f60aae80 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,8 @@ A request the app hasn't answered after **60 s** gets `{"ok": false, "error": "t app had not started it (it was busy, or its window wasn't being drawn), the request is withdrawn and never runs, and the error says "will not run": retrying is safe. If the app was already running it, the error says "still running": it may yet complete, so check the document before -retrying. +retrying. On macOS the app keeps itself out of App Nap while the control channel is on, so it keeps +answering with its window hidden, minimized or covered, or with the screen locked. ## Confining file access