fzyzcjy / flutter_rust_bridge

Flutter/Dart <-> Rust binding generator, feature-rich, but seamless and simple.
https://fzyzcjy.github.io/flutter_rust_bridge/
MIT License
4.12k stars 283 forks source link

Request for Reliable App Termination Detection in flutter_rust_bridge #2220

Closed dbsxdbsx closed 1 month ago

dbsxdbsx commented 1 month ago

Is your feature request related to a problem? Please describe.

Currently, flutter_rust_bridge lacks a reliable mechanism to detect when an app is being terminated, especially on different platforms. I've tried implementing process monitoring in Rust and listening for lifecycle events in Flutter, but the results are inconsistent and unreliable.--- I know this may not be a feature required by frb, but since there is really hard to detect the status as described below in both rust and dart/flutter, I hope frb could act as the bridge to fill the gap, which is a practically useful feature.

I implemented the following Rust code to monitor the process in order to detect the APP status change :

pub fn monitor_process(sink: StreamSink<bool>) -> Result<()> {
    let pid = sysinfo::Pid::from(std::process::id() as usize);
    let mut sys = System::new_all();

    loop {
        sys.refresh_process(pid);

        if sys.process(pid).is_none() {
            println!("rust:  TERMINATED");
            sink.add(true).unwrap();
            break;
        } else {
            println!("rust: RUNNING,pid:{}", pid);
        }

        thread::sleep(Duration::from_millis(100));
    }
    println!("rust: TERMINATED");

    Ok(())
}

And in Flutter with GetX:

@override
void onInit() {
  api.monitorProcess().listen((terminated) {
    if (terminated) {
      _handleTermination();
    } 
  });
}

But rust: TERMINATED" is never hitted.

I also tried using Flutter's lifecycle events:

SystemChannels.lifecycle.setMessageHandler((msg) async {
  debugPrint('GLOBAL STATUS: $msg');
  if (msg == AppLifecycleState.hidden.toString()) {
    Get.delete<HomePageController>();
  }
  return null;
});

This way is workable partially. On Windows 10, the 'hidden' state is the last one triggered before app termination, not 'detached' as expected. Moreover, when the app is stopped through debugging, none of these methods effectively detect the termination.

Describe the solution you'd like

I would like flutter_rust_bridge to provide a built-in, cross-platform solution for reliably detecting app termination. This feature should:

  1. Work consistently across different platforms (iOS, Android, Windows, macOS, Linux).
  2. Detect both normal app closure and forced termination (e.g., through task manager or debugging stop).
  3. Provide a way to execute cleanup code or save important data before the app is fully terminated.
  4. Be easy to implement and use within the existing flutter_rust_bridge framework---say, maybe the termination fn API could be tagged as a new macro [frb(onTermination)], which would be revoked once the app is reaching termination and no need user to explicitly use it in dart side.

Additional context

This feature is may be crucial for ensuring data integrity and proper resource cleanup in apps that rely on flutter_rust_bridge. It would greatly enhance the robustness of applications using this framework, especially those dealing with critical data or resources that need proper handling during app termination.

dbsxdbsx commented 1 month ago

maybe it is just too much for frb, I just felt a little frustrated when I found it is hard to doing some job when the app is terminated.

fzyzcjy commented 1 month ago

Good question!

Firstly, I wonder why do you want to detect this, and is there any way to avoid it? You know, it may be theoretically impossibel to know app termination: e.g. the phone can run out of battery and suddenly shutdown!

Secondly, have you tried to search/ask on the rust forums and flutter forums separately? I guess this is not a flutter_rust_bridge specific question. Instead, it is a pure rust or pure flutter question, and I guess some people there may have some ideas.

This feature is may be crucial for ensuring data integrity and proper resource cleanup in apps that rely on flutter_rust_bridge.

I guess for data integrity, for example, you can completely rely on the mechanisms of databases itself. One of the basic things that (most) database must ensure is that, under whatever weird scenarios, it should always protect data integrity. for example, your db transaction should always fully executed or fully not executed.

dbsxdbsx commented 1 month ago

, I wonder why do you want to detect this, and is there any way to avoid it?

The scenario is that the frb app would run anthor terminal app at backstage, like the sing-box app, which would set a net adaptor at backstage, and if the app is shutdown exceptionally, the adaptor would not be removed.

Anyway, I agree that it is question more related to rust or dart but not frb.

dbsxdbsx commented 1 month ago

Let me clarify the case:
I wrote an api fn in rust with this content :

 let result = std::process::Command::new("sing-box")
        .arg("run")
        .arg("-c")
        .arg(&config_path)
        .spawn();

    match result {
        Ok(child) => {
            dbg!("rust连接节点成功");
            let mut process = SING_BOX_PROCESS.lock().unwrap();
            *process = Some(child);
            Ok(())
        }
        Err(e) => Err(anyhow!(e)),
    }

but I finally found that, if the app is additionally terminated, the process would be still running.

fzyzcjy commented 1 month ago

Hmm, what about making it a daemon process?

dbsxdbsx commented 1 month ago

I guess you mean spawning anthor command process which is used to monitor the status of the host rust app and the corresponding spawned original cmd process? If that is the case, I tested it. Since I am on win10, the daemon process code look like this:

use std::env;
use std::process::Command;
use std::thread;
use std::time::Duration;

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 3 {
        eprintln!("守护进程:使用错误,正确用法:daemon <main_pid> <child_pid>");
        return;
    }

    let main_pid: u32 = args[1].parse().expect("守护进程:无效的main_pid");
    let child_pid: u32 = args[2].parse().expect("守护进程:无效的child_pid");

    println!("守护进程已启动:主程序PID={},子程序PID={}", main_pid, child_pid);
    loop {
        // 检查主程序是否仍在运行
        if !is_process_running(main_pid) {
            println!("守护进程:主程序已终止");
            // 终止子进程
            let output = Command::new("taskkill")
                .args(&["/F", "/PID", &child_pid.to_string()])
                .output()
                .expect("守护进程:执行关闭子进程命令失败");

            if output.status.success() {
                println!("守护进程:子进程已被杀死");
                break;
            } else {
                // 检查子进程是否仍在运行
                if !is_process_running(child_pid) {
                    println!("守护进程:子进程未被杀死,但检测到已被终止");
                    break;
                }
                eprintln!("守护进程:无法终止子进程");
            }
        }
        thread::sleep(Duration::from_secs(1));
    }

    println!("守护进程:本进程即将退出");
}

fn is_process_running(pid: u32) -> bool {
    let output = Command::new("tasklist")
        .args(&["/FI", &format!("PID eq {}", pid), "/NH", "/FO", "CSV"])
        .output()
        .expect("守护进程:执行tasklist命令失败");

    if !output.status.success() {
        eprintln!("守护进程:tasklist命令执行失败");
        return false;
    }

    let output_str = String::from_utf8_lossy(&output.stdout);
    let lines: Vec<&str> = output_str.lines().collect();

    if lines.len() != 1 {
        return false; // 如果没有输出或有多行输出,认为进程不存在
    }

    let fields: Vec<&str> = lines[0].split(',').collect();
    if fields.len() < 2 {
        return false; // 如果字段数量不足,认为格式不正确
    }

    // 检查第二个字段(PID)是否与我们要查找的PID匹配
    // 去掉引号并比较
    fields[1].trim_matches('"') == pid.to_string()
}

, and I test it in anthor toy rust project like this:

use std::error::Error;
use std::process::{Child, Command};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

struct ThreadLoop {
    children: Arc<Mutex<Vec<Child>>>,
}

impl ThreadLoop {
    fn new() -> Self {
        let mut children: Arc<Mutex<Vec<Child>>> = Arc::new(Mutex::new(Vec::new()));

        // 启动主进程
        let main_pid = std::process::id();
        // let child_pid = Command::new("./sing-box.exe")
        //     .arg("run")
        //     .arg("-c")
        //     .arg("./config.json")
        //     .spawn()
        //     .expect("无法启动子进程")
        //     .id();
        let child_pid = Command::new("ping")
            .args(&["-t", "127.0.0.1"])
            .spawn()
            .expect("无法启动子进程")
            .id();

        println!("main_pid: {}", main_pid);
        println!("child_pid: {}", child_pid);

        // 启动守护进程
        let daemon_process = Command::new(r"D:\DATA\BaiduSyncdisk\project\personal\daemon_process\target\release\daemon_process.exe")
            .arg(main_pid.to_string())
            .arg(child_pid.to_string())
            .spawn()
            .expect("无法启动守护进程");

        children.lock().unwrap().push(daemon_process);

        ThreadLoop { children }
    }

    fn kill_children(children: &Arc<Mutex<Vec<Child>>>) {
        let mut children = children.lock().unwrap();
        for child in children.iter_mut() {
            if let Err(e) = child.kill() {
                eprintln!("终止子进程时出错: {}", e);
            }
        }
        println!("所有子进程已终止");
    }
}

impl Drop for ThreadLoop {
    fn drop(&mut self) {
        Self::kill_children(&self.children);
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let _t = ThreadLoop::new();
    println!("ThreadLoop已创建");

    // 模拟程序运行一段时间
    thread::sleep(Duration::from_secs(30000));

    println!("程序正常退出");
    Ok(())
}

The result is ok---when the host app is terminated(for example, being terminated by stop debugging when the attached vscode debugger is stopped) with the cmd process and then the daemon process are all exited.

But weid thing is that, when I tested it in frb project with similar code and use it in api module like this:

static COMMAND_RUNNER: Lazy<Mutex<Option<ThreadLoop>>> = Lazy::new(|| Mutex::new(None));

pub async fn start_proxy(node: &ProxyNodeEnum) -> Result<()> {
    // 读取现有的 config.json 文件,如果不存在则创建一个新的
    let mut config_path = std::env::current_exe()?;
    config_path.pop();
    config_path.push("config.json");

    // 写入 sing-box 配置文件
    // TODO:
    let specific_sites = vec![];
    write_sing_box_config_to_disk(&config_path, node, &specific_sites)?;

    let mut command_runner = COMMAND_RUNNER.lock().unwrap();
    if command_runner.is_none() {
        // *command_runner = Some(ThreadLoop::new(config_path.to_str().unwrap())?);
        *command_runner = Some(ThreadLoop::new());
    }

    Ok(())
}

pub async fn close_proxy() -> Result<()> {
    let mut command_runner = COMMAND_RUNNER.lock().unwrap();
    if let Some(mut runner) = command_runner.take() {
        dbg!("关闭代理");
        runner.stop()?;
    }
    Ok(())
}

, I found that the spawned core cmd process is still there while the daemon process is exited along with the termination of the host app.

fzyzcjy commented 1 month ago

Well, I meant to create a subprocess s.t. it auto exits when the parent process exits, and I borrowed the term from python https://docs.python.org/3/library/multiprocessing.html#multiprocessing.Process.daemon. Wondering whether rust has something similar.

And btw, have you tried to listen for signals in rust? e.g. when a process termination signal happens, you can run a function and manually do all cleanup. (dnk whether it works on all 6 platforms and may need experiments)

dbsxdbsx commented 1 month ago

I found no official corresponding daemon concept in Rust, but there are some out-of-date daemon crates.

Besides, since I am working on Windows, I found that the signal mechanism may not be as suitable as it within Posix system, but Job Object may be more suitable, so the final code is like this:

use anyhow::{anyhow, Result};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::process::{Child, Command};
use std::sync::Arc;
use std::{mem, ptr};
use winapi::shared::minwindef::DWORD;
use winapi::um::handleapi::CloseHandle;
use winapi::um::jobapi2::{AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject};
use winapi::um::processthreadsapi::{GetCurrentProcess, OpenProcess};
use winapi::um::winnt::{
    HANDLE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};

pub static COMMAND_RUNNER: Lazy<Mutex<CommandRunner>> =
    Lazy::new(|| Mutex::new(CommandRunner::new().unwrap()));

// 安全的 HANDLE 包装器
struct SafeHandle(HANDLE);

unsafe impl Send for SafeHandle {}
unsafe impl Sync for SafeHandle {}

impl Drop for SafeHandle {
    fn drop(&mut self) {
        unsafe {
            if !self.0.is_null() {
                CloseHandle(self.0);
            }
        }
    }
}

struct JobHandle(Arc<SafeHandle>);

pub struct CommandRunner {
    job: JobHandle,
    tasks: Arc<Mutex<Vec<Child>>>,
}

impl CommandRunner {
    pub fn new() -> Result<Self> {
        let job = Self::create_job_and_assign_current_process()?;
        Ok(CommandRunner {
            job,
            tasks: Arc::new(Mutex::new(Vec::new())),
        })
    }

    fn create_job_and_assign_current_process() -> Result<JobHandle> {
        unsafe {
            let job = CreateJobObjectW(ptr::null_mut(), ptr::null());
            if job.is_null() {
                return Err(anyhow!("创建作业对象失败"));
            }

            let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = mem::zeroed();
            info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;

            if SetInformationJobObject(
                job,
                winapi::um::winnt::JobObjectExtendedLimitInformation,
                &mut info as *mut _ as *mut _,
                mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as DWORD,
            ) == 0
            {
                CloseHandle(job);
                return Err(anyhow!("设置作业对象信息失败"));
            }

            let current_process = GetCurrentProcess();
            if AssignProcessToJobObject(job, current_process) == 0 {
                CloseHandle(job);
                return Err(anyhow!("将当前进程分配到作业失败"));
            }

            Ok(JobHandle(Arc::new(SafeHandle(job))))
        }
    }

    pub fn spawn(&self, command: &str) -> Result<u32> {
        let parts: Vec<&str> = command.split_whitespace().map(|part| part.trim()).collect();
        let (cmd_root, cmd_args) = if parts.len() > 1 {
            (parts[0], &parts[1..])
        } else {
            (parts[0], &[][..])
        };
        let mut command = Command::new(cmd_root);
        command.args(cmd_args);
        let child = command.spawn()?;

        let child_id = child.id();

        unsafe {
            let process = OpenProcess(winapi::um::winnt::PROCESS_ALL_ACCESS, 0, child_id);
            if process.is_null() {
                return Err(anyhow!("Failed to open child process"));
            }
            AssignProcessToJobObject(self.job.0 .0, process);
            CloseHandle(process);
        }

        self.tasks.lock().push(child);

        Ok(child_id)
    }

    fn kill_tasks(&self) {
        let mut tasks = self.tasks.lock();
        for child in tasks.iter_mut() {
            if let Err(e) = child.kill() {
                eprintln!("终止子进程时出错: {}", e);
            }
        }
        tasks.clear();
        println!("所有子进程已终止");
    }

    pub fn stop(&self) -> Result<()> {
        self.kill_tasks();
        Ok(())
    }

    pub fn tasks_number(&self) -> usize {
        self.tasks.lock().len()
    }

    fn check_task_status(&self, pid: u32) -> Option<Result<bool, std::io::Error>> {
        let mut tasks = self.tasks.lock();
        tasks
            .iter_mut()
            .find(|child| child.id() == pid)
            .map(|child| child.try_wait().map(|status| status.is_none()))
    }

    pub fn is_task_running(&self, pid: u32) -> bool {
        match self.check_task_status(pid) {
            Some(Ok(running)) => running,
            _ => false,
        }
    }

    pub fn get_running_tasks_pids(&self) -> Vec<u32> {
        let tasks = self.tasks.lock();
        // NOTE: no need to check the result of `check_task_status` in `map`
        // since all items in `tasks` should be running tasks
        tasks.iter().map(|child| child.id()).collect()
    }
}

impl Drop for CommandRunner {
    fn drop(&mut self) {
        println!("CommandRunner 正在清理资源...");
        self.kill_tasks();
    }
}

pub fn test(app_last_secs: u64) -> Result<()> {
    let command_runner = COMMAND_RUNNER.lock();
    let child_pid = command_runner.spawn("./sing-box.exe run -c ./config.json")?;
    let child_pid2 = command_runner.spawn("ping -t 127.0.0.1")?;

    println!("Child process ID1: {}", child_pid);
    println!("Child process ID2: {}", child_pid2);
    println!("Current running tasks: {}", command_runner.tasks_number());

    // 检查进程状态
    for _ in 0..5 {
        std::thread::sleep(std::time::Duration::from_secs(1));
        println!(
            "Process 1({child_pid}) is running: {}",
            command_runner.is_task_running(child_pid)
        );
        println!(
            "Process 2({child_pid2})  is running: {}",
            command_runner.is_task_running(child_pid2)
        );

        println!(
            "Running processes: {:?}",
            command_runner.get_running_tasks_pids()
        );
    }

    std::thread::sleep(std::time::Duration::from_secs(app_last_secs - 5)); // 减去上面的5秒
    command_runner.stop()?;
    println!("程序正常退出");

    Ok(())
}

By the way, to use signal in unix-like system, I guess nix or signal-hook is needed.

github-actions[bot] commented 3 weeks ago

This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new issue.