winshift 0.0.6

A cross-platform window change hook library
#[cfg(not(target_os = "macos"))]
fn main() {
    eprintln!("macos_memory_diagnostics is intended for macOS only");
    std::process::exit(1);
}

#[cfg(target_os = "macos")]
mod macos_repro {
    use clap::{Parser, ValueEnum};
    use std::hint::black_box;
    use std::process;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
    use std::thread;
    use std::time::{Duration, Instant};
    use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
    use winshift::{
        ActiveWindowInfo, FocusChangeHandler, MonitoringMode, WindowFocusHook, WindowHookConfig,
        get_active_window_info,
    };

    #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
    enum Mode {
        HookCycle,
        QueryLoop,
    }

    #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
    enum Monitor {
        Combined,
        AppOnly,
        WindowOnly,
    }

    impl Monitor {
        fn into_winshift(self) -> MonitoringMode {
            match self {
                Self::Combined => MonitoringMode::Combined,
                Self::AppOnly => MonitoringMode::AppOnly,
                Self::WindowOnly => MonitoringMode::WindowOnly,
            }
        }
    }

    #[derive(Parser, Debug)]
    #[command(name = "macos_memory_diagnostics")]
    #[command(about = "Manual macOS memory regression diagnostics for winshift")]
    struct Args {
        #[arg(long, value_enum, default_value_t = Mode::HookCycle)]
        mode: Mode,

        #[arg(long, default_value_t = 200)]
        iterations: u64,

        #[arg(long, default_value_t = 10)]
        sample_every: u64,

        #[arg(long, value_enum, default_value_t = Monitor::Combined)]
        monitor: Monitor,

        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
        embed_active_info: bool,

        #[arg(long, default_value_t = 250)]
        hook_run_ms: u64,

        #[arg(long, default_value_t = 50)]
        cooldown_ms: u64,

        #[arg(long, default_value_t = 10)]
        query_sleep_ms: u64,
    }

    #[derive(Default)]
    struct CallbackStats {
        app_changes: AtomicU64,
        window_changes: AtomicU64,
        app_info_changes: AtomicU64,
        window_info_changes: AtomicU64,
    }

    struct CountingHandler {
        stats: Arc<CallbackStats>,
    }

    impl CountingHandler {
        fn new(stats: Arc<CallbackStats>) -> Self {
            Self { stats }
        }
    }

    impl FocusChangeHandler for CountingHandler {
        fn on_app_change(&self, _pid: i32, _app_name: String) {
            self.stats.app_changes.fetch_add(1, Ordering::Relaxed);
        }

        fn on_window_change(&self, _window_title: String) {
            self.stats.window_changes.fetch_add(1, Ordering::Relaxed);
        }

        fn on_app_change_info(&self, _info: ActiveWindowInfo) {
            self.stats.app_info_changes.fetch_add(1, Ordering::Relaxed);
        }

        fn on_window_change_info(&self, _info: ActiveWindowInfo) {
            self.stats
                .window_info_changes
                .fetch_add(1, Ordering::Relaxed);
        }
    }

    struct RunStats {
        started_at: Instant,
        baseline_rss_bytes: Option<u64>,
        query_ok: u64,
        query_err: u64,
        hook_ok: u64,
        hook_err: u64,
        stop_attempts: u64,
        stop_retries: u64,
    }

    impl RunStats {
        fn new() -> Self {
            Self {
                started_at: Instant::now(),
                baseline_rss_bytes: current_rss_bytes(),
                query_ok: 0,
                query_err: 0,
                hook_ok: 0,
                hook_err: 0,
                stop_attempts: 0,
                stop_retries: 0,
            }
        }
    }

    struct StopReport {
        attempts: u64,
        retries: u64,
    }

    pub fn main() {
        let args = Args::parse();
        println!(
            "starting mode={:?} pid={} monitor={:?} embed_active_info={} iterations={} sample_every={}",
            args.mode,
            process::id(),
            args.monitor,
            args.embed_active_info,
            args.iterations,
            args.sample_every
        );
        println!(
            "tip: compare before/after fix with the same command and watch rss_mb / rss_delta_mb"
        );

        let stats = Arc::new(CallbackStats::default());
        let mut run_stats = RunStats::new();

        match args.mode {
            Mode::HookCycle => run_hook_cycles(&args, &stats, &mut run_stats),
            Mode::QueryLoop => run_query_loop(&args, &stats, &mut run_stats),
        }
    }

    fn run_hook_cycles(args: &Args, stats: &Arc<CallbackStats>, run_stats: &mut RunStats) {
        let config = WindowHookConfig {
            monitoring_mode: args.monitor.into_winshift(),
            embed_active_info: args.embed_active_info,
        };
        let mut cycle = 0_u64;

        while keep_running(cycle, args.iterations) {
            cycle += 1;
            let hook = Arc::new(WindowFocusHook::with_config(
                CountingHandler::new(Arc::clone(stats)),
                config.clone(),
            ));
            let run_done = Arc::new(AtomicBool::new(false));
            let stopper = spawn_stopper(
                Arc::clone(&hook),
                Arc::clone(&run_done),
                Duration::from_millis(args.hook_run_ms),
            );

            let run_result = hook.run();
            run_done.store(true, Ordering::Relaxed);
            let stop_report = stopper.join().unwrap_or(StopReport {
                attempts: 0,
                retries: 0,
            });

            run_stats.stop_attempts = run_stats.stop_attempts.saturating_add(stop_report.attempts);
            run_stats.stop_retries = run_stats.stop_retries.saturating_add(stop_report.retries);

            match run_result {
                Ok(()) => run_stats.hook_ok = run_stats.hook_ok.saturating_add(1),
                Err(err) => {
                    run_stats.hook_err = run_stats.hook_err.saturating_add(1);
                    eprintln!("hook_cycle_error cycle={} error={}", cycle, err);
                    print_sample("hook-cycle", cycle, stats, run_stats);
                    break;
                }
            }

            if should_sample(cycle, args.sample_every) {
                print_sample("hook-cycle", cycle, stats, run_stats);
            }

            if args.cooldown_ms > 0 {
                thread::sleep(Duration::from_millis(args.cooldown_ms));
            }
        }

        print_sample("hook-cycle", cycle, stats, run_stats);
    }

    fn run_query_loop(args: &Args, stats: &Arc<CallbackStats>, run_stats: &mut RunStats) {
        let mut iteration = 0_u64;

        while keep_running(iteration, args.iterations) {
            iteration += 1;
            match get_active_window_info() {
                Ok(info) => {
                    run_stats.query_ok = run_stats.query_ok.saturating_add(1);
                    black_box((
                        info.process_id,
                        info.window_id,
                        info.title.len(),
                        info.app_name.len(),
                    ));
                }
                Err(err) => {
                    run_stats.query_err = run_stats.query_err.saturating_add(1);
                    if should_sample(iteration, args.sample_every) {
                        eprintln!("query_loop_error iteration={} error={}", iteration, err);
                    }
                }
            }

            if should_sample(iteration, args.sample_every) {
                print_sample("query-loop", iteration, stats, run_stats);
            }

            if args.query_sleep_ms > 0 {
                thread::sleep(Duration::from_millis(args.query_sleep_ms));
            }
        }

        print_sample("query-loop", iteration, stats, run_stats);
    }

    fn spawn_stopper(
        hook: Arc<WindowFocusHook>,
        run_done: Arc<AtomicBool>,
        run_for: Duration,
    ) -> thread::JoinHandle<StopReport> {
        thread::spawn(move || {
            thread::sleep(run_for);
            let mut attempts = 0_u64;
            let mut retries = 0_u64;

            loop {
                if run_done.load(Ordering::Relaxed) {
                    return StopReport { attempts, retries };
                }

                attempts = attempts.saturating_add(1);
                match hook.stop() {
                    Ok(()) => return StopReport { attempts, retries },
                    Err(_) => {
                        retries = retries.saturating_add(1);
                        thread::sleep(Duration::from_millis(10));
                    }
                }
            }
        })
    }

    fn keep_running(iteration: u64, limit: u64) -> bool {
        limit == 0 || iteration < limit
    }

    fn should_sample(iteration: u64, sample_every: u64) -> bool {
        sample_every > 0 && iteration % sample_every == 0
    }

    fn current_rss_bytes() -> Option<u64> {
        let pid = Pid::from(process::id() as usize);
        let mut system = System::new();
        system.refresh_processes_specifics(
            ProcessesToUpdate::Some(&[pid]),
            false,
            ProcessRefreshKind::nothing().with_memory(),
        );
        system.process(pid).map(|process| process.memory())
    }

    fn print_sample(
        label: &str,
        iteration: u64,
        stats: &Arc<CallbackStats>,
        run_stats: &RunStats,
    ) {
        let rss_bytes = current_rss_bytes();
        let rss_mb = rss_bytes.map(bytes_to_mb).unwrap_or(-1.0);
        let rss_delta_mb = match (run_stats.baseline_rss_bytes, rss_bytes) {
            (Some(base), Some(now)) => bytes_to_mb(now.saturating_sub(base)),
            _ => -1.0,
        };
        let elapsed = run_stats.started_at.elapsed().as_secs_f64();

        println!(
            "sample label={} iteration={} elapsed_s={:.3} rss_mb={:.3} rss_delta_mb={:.3} query_ok={} query_err={} hook_ok={} hook_err={} stop_attempts={} stop_retries={} app_changes={} window_changes={} app_info={} window_info={}",
            label,
            iteration,
            elapsed,
            rss_mb,
            rss_delta_mb,
            run_stats.query_ok,
            run_stats.query_err,
            run_stats.hook_ok,
            run_stats.hook_err,
            run_stats.stop_attempts,
            run_stats.stop_retries,
            stats.app_changes.load(Ordering::Relaxed),
            stats.window_changes.load(Ordering::Relaxed),
            stats.app_info_changes.load(Ordering::Relaxed),
            stats.window_info_changes.load(Ordering::Relaxed),
        );
    }

    fn bytes_to_mb(bytes: u64) -> f64 {
        bytes as f64 / (1024.0 * 1024.0)
    }
}

#[cfg(target_os = "macos")]
fn main() {
    macos_repro::main();
}