diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 7b35176d..588cd118 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,9 +1,11 @@ { - "api_version": 51, + "api_version": 52, "clients": 4, "config_path": "", + "draining": 0, "gateway_addr": null, "gateway_reachable": [], + "handover": "idle", "in_flight": 0, "pid": 0, "providers": 7, @@ -14,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.79.0" + "version": "0.82.0" } diff --git a/scripts/shots/core/oracle.rs b/scripts/shots/core/oracle.rs index 0b3c549d..746b4ed1 100644 --- a/scripts/shots/core/oracle.rs +++ b/scripts/shots/core/oracle.rs @@ -56,6 +56,7 @@ async fn dump() { price_updater: Default::default(), chatgpt: Default::default(), zai: Default::default(), + handover: Default::default(), }); let write = |name: &str, v: &serde_json::Value| { diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 7b35176d..588cd118 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,9 +1,11 @@ { - "api_version": 51, + "api_version": 52, "clients": 4, "config_path": "", + "draining": 0, "gateway_addr": null, "gateway_reachable": [], + "handover": "idle", "in_flight": 0, "pid": 0, "providers": 7, @@ -14,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.79.0" + "version": "0.82.0" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 135edc35..b2a256a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5338,8 +5338,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "serde", "serde_json", @@ -5350,8 +5350,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "memchr", "serde", @@ -5360,8 +5360,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5379,8 +5379,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "serde", "serde_json", @@ -5406,8 +5406,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "serde", "ts-rs", @@ -5415,8 +5415,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "libc", "notify", @@ -5426,8 +5426,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 37011f06..f39d405e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index eadfd3c4..c319dcaa 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -224,6 +224,8 @@ mod tests { "Diagnostics", "Fixture", "Status", + // 请 core 等接替者:只在更新、重启网关时由 Rust 这边发(`handover`) + "HandoverArm", "StartChatgptLogin", "CancelChatgptLogin", "StartZaiLogin", diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs index 3ef304a9..36515fa5 100644 --- a/src-tauri/src/connection/connector.rs +++ b/src-tauri/src/connection/connector.rs @@ -262,7 +262,7 @@ mod tests { serde_json::json!({ "api_version": tw_api::CONTROL_API_VERSION, "version": "x", "pid": 1, "gateway_addr": gateway, "config_path": "/x", "clients": 0, "providers": 0, - "uptime_secs": 0, "in_flight": 0, + "uptime_secs": 0, "in_flight": 0, "handover": "idle", "draining": 0, "remote_control": { "enabled": true, "addr": null, "allow_from": [], "reachable": [] }, "gateway_reachable": reachable, }) diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index d65990c3..51e64df7 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -100,6 +100,18 @@ pub(crate) fn bundled_core(product: &str) -> Option { /// 让环境变量替换掉网关本体,在开发机上是便利,在用户机器上是一个口子。 pub fn locate_core(app: &tauri::AppHandle) -> anyhow::Result { if let Some(inside) = bundled_core(&app.package_info().name) { + // **Linux AppImage 从数据目录里的按版本缓存运行**:挂载点随这个进程退出而卸载, + // 交接时旧 core 还要接着跑(见 `handover::core_cache`) + #[cfg(target_os = "linux")] + if inside.exists() + && tauri::utils::platform::bundle_type() + == Some(tauri::utils::config::BundleType::AppImage) + { + return Ok(crate::handover::core_cache::from_cache( + inside, + &app.package_info().version.to_string(), + )); + } if inside.exists() { return Ok(inside); } @@ -238,12 +250,27 @@ pub(crate) async fn restart_gateway(app: &tauri::AppHandle) -> Out<()> { return Err(why.clone().into()); } if state.supervising.swap(true, Ordering::SeqCst) { - return state.supervisor.request_restart().await.map_err(text); + return restart_running(&state).await; } spawn_supervise(app, state.supervisor.clone(), state.supervising.clone()); Ok(()) } +/// 守护在跑时的「重新启动」:**同版本交接**(方案 R5)。请正在跑的 core 等接替者,守护起一个 +/// 新的接过监听,旧的做完手上的请求再走 —— 和换版本时一样,网关不断、在跑的流不被截断。 +/// +/// arm 不上(安全模式里的、还在启动的、登录进行中、core 太旧)就照以前停了再起。安全模式之后 +/// 配置修好了的那次自动重启也走这里:安全模式的 core 不交接,照旧停了再起 +async fn restart_running(state: &AppState) -> Out<()> { + if matches!(state.supervisor.state(), CoreState::Running { .. }) { + match crate::handover::arm(state.supervisor.control(), None).await { + Ok(_) => return state.supervisor.hand_over().await.map_err(text), + Err(e) => tracing::info!("重新启动网关:core 没能等接替者,停了再起({e})"), + } + } + state.supervisor.request_restart().await.map_err(text) +} + /// 守护没在跑就接回来;在跑就什么都不做。切回本机时用:远程模式下它停了 pub(crate) fn ensure_supervising(app: &tauri::AppHandle) { use std::sync::atomic::Ordering; diff --git a/src-tauri/src/handover/brew.rs b/src-tauri/src/handover/brew.rs new file mode 100644 index 00000000..72b6d933 --- /dev/null +++ b/src-tauri/src/handover/brew.rs @@ -0,0 +1,464 @@ +//! `brew upgrade` 退出应用时认出它来(方案 8.3.2)。 +//! +//! Homebrew 升级一个 cask 的顺序是:下好新版 → 执行旧版 cask 的 `uninstall quit:`(由 +//! `osascript -l JavaScript` 向应用发一个退出 Apple Event,10 秒内没退出就告警继续)→ 换上 +//! 新的 `.app` → `open -b` 重新打开。换上之后的重开和自动更新的「重启为新版本」是一回事, +//! 所以这次退出只要像自动更新一样 arm、不停 core,网关就不断。 +//! +//! **难在分清这次退出是谁发起的。**同一个退出事件,用户在活动监视器里点「退出」、`brew +//! uninstall`、用户自己的脚本也会发;那些时候用户要的是网关跟着停。所以只认三项**同时**成立的: +//! +//! 1. 事件的发送进程(`keySenderPIDAttr`)是 `/usr/bin/osascript`; +//! 2. 它的父进程的可执行文件在**装这个应用的那份 Homebrew** 的前缀之下(Homebrew 的 +//! `system_command` 经 `/usr/bin/env` 起 osascript,env 是 exec 过去的,父进程就是跑 +//! `brew.rb` 的那个 Ruby;Ruby 是 Homebrew 自带的,在前缀里); +//! 3. 父进程参数里 `brew.rb` 之后的子命令是 `upgrade` 或 `reinstall`(`brew.sh` 最后 +//! `exec ruby … brew.rb <命令> <参数>`,命令紧跟在 `brew.rb` 后面)。 +//! +//! 系统注销、关机、重启发的退出事件带 `kAEQuitReason`,一律不算。**任何一项取不到就当普通 +//! 退出**:判定只会漏判(漏了就是今天的行为,停掉 core),不会把普通退出当成升级。 + +use std::path::{Path, PathBuf}; + +/// 退出事件的发送方,判定要用到的几样。取不到的是 `None` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Sender { + /// 发送进程的可执行文件 + pub exe: Option, + /// 它的父进程的可执行文件 + pub parent_exe: Option, + /// 父进程的参数(`argv`,含 `argv[0]`) + pub parent_args: Option>, +} + +/// Homebrew 发退出事件用的程序 +const OSASCRIPT: &str = "/usr/bin/osascript"; + +/// 算升级的子命令。`reinstall` 同样是先退出、换上、再打开 +const UPGRADES: [&str; 2] = ["upgrade", "reinstall"]; + +/// 这次退出是不是 `brew upgrade`(或 `brew reinstall`)发起的。 +/// +/// `system_quit`:事件带着 `kAEQuitReason`(注销、关机、重启)。`prefix`:装这个应用的那份 +/// Homebrew 的前缀,不是 Homebrew 装的就是 `None` —— 那就不可能是它的升级 +pub fn is_upgrade(sender: &Sender, prefix: Option<&Path>, system_quit: bool) -> bool { + if system_quit { + return false; + } + let Some(prefix) = prefix else { + return false; + }; + if sender.exe.as_deref() != Some(Path::new(OSASCRIPT)) { + return false; + } + if !sender + .parent_exe + .as_deref() + .is_some_and(|p| p.starts_with(prefix)) + { + return false; + } + sender + .parent_args + .as_deref() + .and_then(brew_command) + .is_some_and(|c| UPGRADES.contains(&c)) +} + +/// 参数里 `brew.rb` 后面紧跟的那个子命令。没有 `brew.rb`(不是 Homebrew 的 Ruby)就是 `None` +fn brew_command(args: &[String]) -> Option<&str> { + let at = args + .iter() + .position(|a| Path::new(a).file_name().is_some_and(|n| n == "brew.rb"))?; + args.get(at + 1).map(String::as_str) +} + +/// 解开 `sysctl(KERN_PROCARGS2)` 给的那一块:开头是 `argc`(本机字节序的 32 位整数),然后是 +/// 可执行文件的路径和若干个补齐用的 NUL,再往后是 `argc` 个以 NUL 结尾的参数(之后是环境 +/// 变量,不要)。 +/// +/// 形状不对(太短、参数个数对不上)就是 `None`:取不到,按普通退出算 +pub fn parse_procargs2(buf: &[u8]) -> Option> { + let argc = i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?); + let argc = usize::try_from(argc).ok()?; + let rest = &buf[4..]; + // 跳过可执行文件路径 + let mut at = rest.iter().position(|b| *b == 0)?; + // 跳过补齐的 NUL + while rest.get(at) == Some(&0) { + at += 1; + } + let mut args = Vec::with_capacity(argc); + for _ in 0..argc { + let tail = rest.get(at..)?; + let end = tail.iter().position(|b| *b == 0)?; + args.push(String::from_utf8_lossy(&tail[..end]).into_owned()); + at += end + 1; + } + Some(args) +} + +/// 刚收到的退出事件判成了升级。**只在主线程上写和读**:退出事件的处理函数和退出回调都在 +/// 主线程的事件循环上 +static UPGRADE_QUIT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// 退出回调问:这次退出是不是 `brew upgrade` 发起的(见 [`super::arm_on_quit`]) +pub(crate) fn quit_was_upgrade() -> bool { + UPGRADE_QUIT.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(target_os = "macos")] +pub use macos::install_quit_handler; + +#[cfg(target_os = "macos")] +mod macos { + use std::path::PathBuf; + + use objc2::rc::Retained; + use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol}; + use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; + use objc2_app_kit::NSApplication; + use objc2_foundation::{NSAppleEventDescriptor, NSAppleEventManager}; + + use super::{Sender, UPGRADE_QUIT, is_upgrade}; + + /// 四个字符的 Apple Event 代码 + const fn four(c: &[u8; 4]) -> u32 { + u32::from_be_bytes(*c) + } + const K_CORE_EVENT_CLASS: u32 = four(b"aevt"); + const K_AE_QUIT_APPLICATION: u32 = four(b"quit"); + /// 发送进程的 pid(`keySenderPIDAttr`) + const KEY_SENDER_PID: u32 = four(b"spid"); + /// 注销、关机、重启时退出事件带着的理由(`kAEQuitReason`) + const K_AE_QUIT_REASON: u32 = four(b"why?"); + + define_class!( + /// 退出事件的处理者:先判定来源、记下,再走原来的退出(`terminate:`) + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "ThinkWatchQuitHandler"] + struct QuitHandler; + + impl QuitHandler { + #[unsafe(method(handleQuit:withReplyEvent:))] + fn handle_quit(&self, event: &NSAppleEventDescriptor, _reply: &NSAppleEventDescriptor) { + let upgrade = from_brew_upgrade(event); + if upgrade { + tracing::info!("退出事件来自 brew upgrade"); + } + UPGRADE_QUIT.store(upgrade, std::sync::atomic::Ordering::SeqCst); + // **交给原来的退出流程**:系统默认的处理函数做的也就是这一下 + let mtm = MainThreadMarker::from(self); + NSApplication::sharedApplication(mtm).terminate(None); + } + } + + unsafe impl NSObjectProtocol for QuitHandler {} + ); + + thread_local! { + /// Apple Event 管理器不持有处理者,这里拿着它活到进程结束 + static HANDLER: std::cell::OnceCell> = const { std::cell::OnceCell::new() }; + } + + /// 换上自己的退出事件处理函数。**在应用起来之后调**(setup 里):系统在 + /// `finishLaunching` 时装默认的那一个,早于它装的会被盖掉 + pub fn install_quit_handler(mtm: MainThreadMarker) { + let handler: Retained = unsafe { msg_send![QuitHandler::alloc(mtm), init] }; + let manager = NSAppleEventManager::sharedAppleEventManager(); + let target: &AnyObject = &handler; + // SAFETY: 选择子和上面声明的方法同名同签名;类和事件号是 32 位的四字符代码 + unsafe { + let _: () = msg_send![ + &*manager, + setEventHandler: target, + andSelector: sel!(handleQuit:withReplyEvent:), + forEventClass: K_CORE_EVENT_CLASS, + andEventID: K_AE_QUIT_APPLICATION + ]; + } + HANDLER.with(|h| { + let _ = h.set(handler); + }); + } + + /// 这个退出事件是不是 `brew upgrade` 发的 + fn from_brew_upgrade(event: &NSAppleEventDescriptor) -> bool { + let system_quit = keyword(event, K_AE_QUIT_REASON, true).is_some() + || keyword(event, K_AE_QUIT_REASON, false).is_some(); + if system_quit { + return false; + } + let Some(prefix) = crate::update::homebrew_prefix() else { + return false; + }; + let pid = keyword(event, KEY_SENDER_PID, true).map(|d| d.int32Value()); + let sender = match pid { + Some(pid) if pid > 0 => sender_of(pid), + _ => Sender::default(), + }; + is_upgrade(&sender, Some(&prefix), false) + } + + /// 事件上的一项:`attribute` 为真取属性,否则取参数 + fn keyword( + event: &NSAppleEventDescriptor, + key: u32, + attribute: bool, + ) -> Option> { + // SAFETY: 两个方法都只读这个描述符,关键字是四字符代码;没有这一项时返回空 + unsafe { + if attribute { + msg_send![event, attributeDescriptorForKeyword: key] + } else { + msg_send![event, paramDescriptorForKeyword: key] + } + } + } + + /// 发送进程、它的父进程、父进程的参数。都是同一个用户读得到的信息 + fn sender_of(pid: i32) -> Sender { + let parent = parent_of(pid); + Sender { + exe: path_of(pid), + parent_exe: parent.and_then(path_of), + parent_args: parent.and_then(args_of), + } + } + + /// 可执行文件的路径(`proc_pidpath`) + fn path_of(pid: i32) -> Option { + let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: 缓冲区的大小如实交出去,写进来的是以 NUL 结尾的路径,返回写了多少字节 + let n = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr().cast(), buf.len() as u32) }; + if n <= 0 { + return None; + } + buf.truncate(n as usize); + Some(PathBuf::from(String::from_utf8(buf).ok()?)) + } + + /// 父进程的 pid(`proc_pidinfo` 的 BSD 信息) + fn parent_of(pid: i32) -> Option { + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as i32; + // SAFETY: 结构体的大小如实交出去;成功时返回写进来的字节数 + let n = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + (n == size && info.pbi_ppid > 0).then_some(info.pbi_ppid as i32) + } + + /// 进程的参数(`sysctl(KERN_PROCARGS2)`) + fn args_of(pid: i32) -> Option> { + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; + let mut size: libc::size_t = 0; + // SAFETY: 先问要多大(缓冲区为空),再按问到的大小取 + unsafe { + if libc::sysctl( + mib.as_mut_ptr(), + 3, + std::ptr::null_mut(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + { + return None; + } + } + let mut buf = vec![0u8; size]; + unsafe { + if libc::sysctl( + mib.as_mut_ptr(), + 3, + buf.as_mut_ptr().cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + { + return None; + } + } + buf.truncate(size); + super::parse_procargs2(&buf) + } + + #[cfg(test)] + mod tests { + /// 本进程的参数读得出来,和标准库看到的一样(`KERN_PROCARGS2` 的解析在真机上对得上) + #[test] + fn our_own_arguments_read_back() { + let pid = std::process::id() as i32; + let args = super::args_of(pid).expect("读不到自己的参数"); + let std_args: Vec = std::env::args().collect(); + assert_eq!(args, std_args); + assert!(super::path_of(pid).is_some()); + assert!(super::parent_of(pid).is_some()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PREFIX: &str = "/opt/homebrew"; + const RUBY: &str = "/opt/homebrew/Library/Homebrew/vendor/portable-ruby/3.4.7/bin/ruby"; + const BREW_RB: &str = "/opt/homebrew/Library/Homebrew/brew.rb"; + + fn ruby_args(command: &[&str]) -> Vec { + let mut a: Vec = [RUBY, "-W1", "--disable=gems,rubyopt", BREW_RB] + .iter() + .map(|s| s.to_string()) + .collect(); + a.extend(command.iter().map(|s| s.to_string())); + a + } + + fn from_brew(command: &[&str]) -> Sender { + Sender { + exe: Some(OSASCRIPT.into()), + parent_exe: Some(RUBY.into()), + parent_args: Some(ruby_args(command)), + } + } + + fn judge(s: &Sender) -> bool { + is_upgrade(s, Some(Path::new(PREFIX)), false) + } + + /// 判定表:三项都成立的 `upgrade`、`reinstall` 才算升级 + #[test] + fn only_an_upgrade_or_reinstall_from_this_homebrew_counts() { + assert!(judge(&from_brew(&["upgrade", "--cask", "thinkwatch-lite"]))); + // `brew upgrade` 不带参数,一次升级所有过期的 + assert!(judge(&from_brew(&["upgrade"]))); + assert!(judge(&from_brew(&["reinstall", "thinkwatch-lite"]))); + // 卸载、别的子命令:网关跟着停 + assert!(!judge(&from_brew(&["uninstall", "thinkwatch-lite"]))); + assert!(!judge(&from_brew(&["install", "thinkwatch-lite"]))); + assert!(!judge(&from_brew(&[]))); + } + + /// 不是 osascript 发的(活动监视器、用户自己的程序)、父进程不在这份 Homebrew 的前缀里 + /// (用户自己的脚本里写了 brew.rb upgrade):不算 + #[test] + fn other_senders_are_ordinary_quits() { + let mut s = from_brew(&["upgrade"]); + s.exe = Some( + "/System/Applications/Utilities/Activity Monitor.app/Contents/MacOS/Activity Monitor" + .into(), + ); + assert!(!judge(&s)); + + let mut s = from_brew(&["upgrade"]); + s.parent_exe = Some("/usr/bin/ruby".into()); + assert!(!judge(&s)); + + // 另一份 Homebrew(Intel 的前缀)发的,而这一份是 /opt/homebrew 装的 + let mut s = from_brew(&["upgrade"]); + s.parent_exe = Some( + "/usr/local/Homebrew/Library/Homebrew/vendor/portable-ruby/current/bin/ruby".into(), + ); + assert!(!judge(&s)); + + // 父进程是 shell(用户在终端里直接跑 osascript) + let s = Sender { + exe: Some(OSASCRIPT.into()), + parent_exe: Some("/bin/zsh".into()), + parent_args: Some(vec!["-zsh".into()]), + }; + assert!(!judge(&s)); + } + + /// **取不到任何一项都当普通退出**:只会漏判,不会误判 + #[test] + fn anything_missing_is_an_ordinary_quit() { + let full = from_brew(&["upgrade"]); + for s in [ + Sender { + exe: None, + ..full.clone() + }, + Sender { + parent_exe: None, + ..full.clone() + }, + Sender { + parent_args: None, + ..full.clone() + }, + Sender::default(), + ] { + assert!(!judge(&s), "{s:?}"); + } + // 不是 Homebrew 装的这一份 + assert!(!is_upgrade(&full, None, false)); + // 参数里没有 brew.rb(父进程不是 brew 的 Ruby) + let mut s = full.clone(); + s.parent_args = Some(vec![RUBY.into(), "-e".into(), "upgrade".into()]); + assert!(!judge(&s)); + } + + /// 注销、关机、重启(带 `kAEQuitReason`):哪怕别的都对上了也不算 + #[test] + fn a_system_quit_is_never_an_upgrade() { + assert!(!is_upgrade( + &from_brew(&["upgrade"]), + Some(Path::new(PREFIX)), + true + )); + } + + fn procargs(argc: i32, exe: &str, pad: usize, args: &[&str], env: &[&str]) -> Vec { + let mut b = argc.to_ne_bytes().to_vec(); + b.extend_from_slice(exe.as_bytes()); + b.extend(std::iter::repeat_n(0u8, pad)); + for a in args.iter().chain(env) { + b.extend_from_slice(a.as_bytes()); + b.push(0); + } + b + } + + #[test] + fn procargs2_yields_the_arguments_and_not_the_environment() { + let buf = procargs( + 3, + RUBY, + 5, + &[RUBY, BREW_RB, "upgrade"], + &["HOME=/Users/x", "PATH=/usr/bin"], + ); + assert_eq!( + parse_procargs2(&buf), + Some(vec![ + RUBY.to_string(), + BREW_RB.to_string(), + "upgrade".to_string() + ]) + ); + } + + /// 截断的、个数对不上的、负数的:取不到 + #[test] + fn a_malformed_procargs2_is_unreadable() { + assert_eq!(parse_procargs2(&[1, 0]), None); + let buf = procargs(4, RUBY, 1, &[RUBY, BREW_RB], &[]); + assert_eq!(parse_procargs2(&buf), None); + let buf = procargs(-1, RUBY, 1, &[RUBY], &[]); + assert_eq!(parse_procargs2(&buf), None); + let mut buf = procargs(1, RUBY, 1, &["abc"], &[]); + buf.pop(); // 最后一个参数没有结尾的 NUL + assert_eq!(parse_procargs2(&buf), None); + } +} diff --git a/src-tauri/src/handover/bundle_watch.rs b/src-tauri/src/handover/bundle_watch.rs new file mode 100644 index 00000000..482ef85d --- /dev/null +++ b/src-tauri/src/handover/bundle_watch.rs @@ -0,0 +1,141 @@ +//! Homebrew 没退出应用就把它换掉了:自己 arm 并重启(方案 8.3.3、F10)。 +//! +//! `brew upgrade` 通常先让应用退出(见 [`super::brew`]),但有几种情况它直接换:`--no-quit`、 +//! 在 SSH 里执行(没登录图形界面)、自动化权限被拒、10 秒内没退出。这时跑着的还是旧版,磁盘上 +//! 已经是新版 —— 守护哪天重起 core,起来的就是一个和界面不同版本的网关;Homebrew 也不会再把 +//! 它重新打开。 +//! +//! 所以盯着自己应用包里的 `Info.plist`:磁盘上的版本和正在跑的不一样了,就照自动更新的样子 +//! 走一遍(arm、记下退出时的样子、重启),重启之后照退出时的窗口状态恢复。 +//! +//! **只看 Homebrew 装的那一份**:自己更新的那一份是更新流程自己在换包,换完自己重启;别的 +//! 方式换掉的不归这里管。 + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// 多久看一次。**轮询一个小文件**:读的是修改时间,变了才读内容 +const EVERY: Duration = Duration::from_secs(3); + +/// 看到版本变了之后再等多久、确认它不再变才动手:Homebrew 换好 `.app` 之后还要清掉隔离 +/// 属性 —— 带着隔离属性重启,未签名的应用会被系统拦下来 +const SETTLE: Duration = Duration::from_secs(5); + +/// 起一个后台任务盯着。不是 Homebrew 装的、找不到自己的应用包就不盯 +pub(crate) fn spawn(app: &tauri::AppHandle) { + if crate::update::kind() != crate::update::Install::Homebrew { + return; + } + let Some(bundle) = crate::update::own_bundle() else { + return; + }; + let plist = plist_of(&bundle); + let running = app.package_info().version.to_string(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(found) = until_replaced(&plist, &running).await else { + return; + }; + tracing::info!(running, found, "应用包已被换成另一版,交接并重启"); + crate::updater::restart_into_replaced(&app).await; + }); +} + +/// 等到磁盘上的版本和 `running` 不同、并且稳定下来,返回那个版本 +async fn until_replaced(plist: &Path, running: &str) -> Option { + let mut seen: Option = None; + loop { + tokio::time::sleep(EVERY).await; + let modified = std::fs::metadata(plist).and_then(|m| m.modified()).ok(); + if modified.is_none() || modified == seen { + continue; + } + seen = modified; + let Some(disk) = read_version(plist) else { + continue; + }; + if disk == running { + continue; + } + tokio::time::sleep(SETTLE).await; + if read_version(plist).as_deref() == Some(disk.as_str()) { + return Some(disk); + } + // 还在变:下一轮重新看 + seen = None; + } +} + +fn read_version(plist: &Path) -> Option { + plist_version(&std::fs::read_to_string(plist).ok()?) +} + +/// 从 `Info.plist`(XML)里读出 `CFBundleShortVersionString`。打包器写的就是 XML;读不懂的 +/// (二进制 plist、没有这一项)是 `None`,当没变 +pub fn plist_version(text: &str) -> Option { + let key = "CFBundleShortVersionString"; + let rest = &text[text.find(key)? + key.len()..]; + let rest = rest.trim_start().strip_prefix("")?; + let v = rest[..rest.find("")?].trim(); + (!v.is_empty()).then(|| v.to_string()) +} + +/// 应用包里的 `Info.plist` +fn plist_of(bundle: &Path) -> PathBuf { + bundle.join("Contents").join("Info.plist") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plist(version: &str) -> String { + format!( + r#" + + + + CFBundleExecutable + thinkwatch-lite + CFBundleShortVersionString + {version} + CFBundleVersion + {version} + +"# + ) + } + + #[test] + fn the_version_is_read_from_the_plist() { + assert_eq!( + plist_version(&plist("2026.10.29")).as_deref(), + Some("2026.10.29") + ); + assert_eq!(plist_version(""), None); + assert_eq!(plist_version(&plist("")), None); + assert_eq!(plist_version("bplist00\u{1}\u{2}"), None); + } + + /// 换成另一版、稳定下来才报;没变一直等 + #[tokio::test(start_paused = true)] + async fn a_replaced_bundle_is_noticed_once_it_settles() { + let d = tempfile::tempdir().unwrap(); + let bundle = d.path().join("ThinkWatch Lite.app"); + std::fs::create_dir_all(bundle.join("Contents")).unwrap(); + let p = plist_of(&bundle); + std::fs::write(&p, plist("2026.10.28")).unwrap(); + let watch = tokio::spawn({ + let p = p.clone(); + async move { until_replaced(&p, "2026.10.28").await } + }); + tokio::time::sleep(EVERY * 3).await; + assert!(!watch.is_finished(), "版本没变不该报"); + std::fs::write(&p, plist("2026.10.29")).unwrap(); + let found = tokio::time::timeout(EVERY * 10, watch) + .await + .unwrap() + .unwrap(); + assert_eq!(found.as_deref(), Some("2026.10.29")); + } +} diff --git a/src-tauri/src/handover/core_cache.rs b/src-tauri/src/handover/core_cache.rs new file mode 100644 index 00000000..55f5e11f --- /dev/null +++ b/src-tauri/src/handover/core_cache.rs @@ -0,0 +1,204 @@ +//! Linux AppImage:core 从数据目录里的按版本缓存运行(方案 5.3、R7)。 +//! +//! AppImage 跑起来时,包里的文件在一个挂载点下,**挂载随主进程退出而卸载**。交接时旧 Lite +//! 先退出、旧 core 还要接着把手上的请求做完 —— 它要是从挂载点里跑的,可执行文件就在它脚下 +//! 消失了(已经映射进内存的部分还在,没读进来的页再要就是总线错误)。所以起 core 之前把包里 +//! 的 `twcore` 复制到 `<数据目录>/core-cache/<版本>/twcore`,从那里运行。 +//! +//! 只留当前和上一个版本:上一个正是交接时还在排空的那一个。哪个是「上一个」看目录的修改 +//! 时间 —— 每次用到一个版本都碰一下它的目录([`stage`]),最近碰过的就是最近在用的。 +//! +//! 复制不成(盘满、数据目录挂在 `noexec` 上)就照旧从挂载点运行:网关照常起来,只是这一份 +//! 交接时旧 core 没有退路,和今天一样。 +//! +//! 逻辑本身不分平台(测试在哪都跑);只有 Linux AppImage 真的用它 +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::path::{Path, PathBuf}; + +/// 数据目录下缓存放在哪 +pub const CACHE_DIR: &str = "core-cache"; + +/// 把 `src`(挂载点里的 `twcore`)放进缓存里 `version` 那一格,返回该从哪里运行。 +/// +/// 那一格里已经有、而且和 `src` 一样大的,不再复制(版本号相同的两次构建不会是两个 +/// 文件 —— 发出去的每一版号都不同)。**写到临时文件再改名**:中途断电不会留下半个 +/// 可执行文件被当成完整的 +pub fn stage(src: &Path, data: &Path, version: &str) -> std::io::Result { + let file = src + .file_name() + .ok_or_else(|| std::io::Error::other("the core has no file name"))?; + let cache = data.join(CACHE_DIR); + let slot = cache.join(version); + let target = slot.join(file); + std::fs::create_dir_all(&slot)?; + if !executable_here(&slot) { + return Err(std::io::Error::other( + "the data directory does not allow running programs (noexec)", + )); + } + let want = std::fs::metadata(src)?.len(); + let have = std::fs::metadata(&target).map(|m| m.len()).ok(); + if have != Some(want) { + let tmp = slot.join(format!(".{}.partial", file.to_string_lossy())); + // `copy` 连权限一起带过去:包里的 twcore 本来就是可执行的 + std::fs::copy(src, &tmp)?; + // **可写地打开**:Windows 上刷盘(FlushFileBuffers)要写权限,只读打开会被拒 + std::fs::OpenOptions::new() + .write(true) + .open(&tmp)? + .sync_all()?; + std::fs::rename(&tmp, &target)?; + } + touch(&slot); + prune(&cache, version); + Ok(target) +} + +/// 碰一下目录:把它的修改时间拨到现在。**用一个记号文件**:改目录里的东西就是改目录 +fn touch(slot: &Path) { + let mark = slot.join(".used"); + let _ = std::fs::remove_file(&mark); + let _ = std::fs::write(&mark, b""); +} + +/// 只留 `current` 和除它之外最近用过的那一格。删不掉的跳过 +fn prune(cache: &Path, current: &str) { + let Ok(entries) = std::fs::read_dir(cache) else { + return; + }; + let mut others: Vec<(std::time::SystemTime, PathBuf)> = entries + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.file_name() != current) + .map(|e| { + let at = e + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + (at, e.path()) + }) + .collect(); + // 最近用过的在前,留下第一个 + others.sort_by_key(|a| std::cmp::Reverse(a.0)); + for (_, dir) in others.into_iter().skip(1) { + if let Err(e) = std::fs::remove_dir_all(&dir) { + tracing::info!("旧的 core 缓存 {} 没删掉:{e}", dir.display()); + } + } +} + +/// 这个目录所在的文件系统允许运行程序吗(不是 `noexec` 挂载的)。问不出来就当允许 +#[cfg(target_os = "linux")] +fn executable_here(dir: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(c) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else { + return true; + }; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: 路径以 NUL 结尾,结构体由调用方提供,只读 + if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { + return true; + } + (st.f_flag & libc::ST_NOEXEC) == 0 +} + +#[cfg(not(target_os = "linux"))] +fn executable_here(_dir: &Path) -> bool { + true +} + +/// 包里的 core 换成缓存里的那一份;放不进去就还用包里的 +#[cfg(target_os = "linux")] +pub fn from_cache(inside: PathBuf, version: &str) -> PathBuf { + match stage(&inside, &crate::data_dir(), version) { + Ok(p) => p, + Err(e) => { + tracing::warn!("core 放不进数据目录里的缓存,从挂载点运行:{e}"); + inside + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn core_in(dir: &Path, bytes: &[u8]) -> PathBuf { + let p = dir.join("twcore"); + std::fs::write(&p, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + p + } + + fn slots(data: &Path) -> Vec { + let mut v: Vec = std::fs::read_dir(data.join(CACHE_DIR)) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + v.sort(); + v + } + + #[test] + fn the_core_runs_from_a_copy_in_the_data_directory() { + let mount = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let src = core_in(mount.path(), b"core 1"); + let at = stage(&src, data.path(), "2026.10.29").unwrap(); + assert_eq!( + at, + data.path() + .join(CACHE_DIR) + .join("2026.10.29") + .join("twcore") + ); + assert_eq!(std::fs::read(&at).unwrap(), b"core 1"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&at).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "复制出来的要能运行"); + } + // 挂载点卸载之后,缓存里那一份还在 + drop(mount); + assert!(at.exists()); + } + + /// 已经放好的不再复制;大小对不上的(上次复制到一半)重来 + #[test] + fn an_existing_copy_is_kept_and_a_broken_one_replaced() { + let mount = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let src = core_in(mount.path(), b"core 1"); + let at = stage(&src, data.path(), "v1").unwrap(); + let before = std::fs::metadata(&at).unwrap().modified().unwrap(); + assert_eq!(stage(&src, data.path(), "v1").unwrap(), at); + assert_eq!(std::fs::metadata(&at).unwrap().modified().unwrap(), before); + std::fs::write(&at, b"core").unwrap(); + stage(&src, data.path(), "v1").unwrap(); + assert_eq!(std::fs::read(&at).unwrap(), b"core 1"); + } + + /// 只留当前和上一个(最近用过的) + #[test] + fn only_the_current_and_the_previous_version_are_kept() { + let mount = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let src = core_in(mount.path(), b"core"); + for v in ["v1", "v2", "v3"] { + stage(&src, data.path(), v).unwrap(); + // 修改时间的精度:让三格分得出先后 + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(slots(data.path()), ["v2", "v3"]); + // 回到 v2(比如换回旧版):v3 成了上一个 + stage(&src, data.path(), "v2").unwrap(); + assert_eq!(slots(data.path()), ["v2", "v3"]); + } +} diff --git a/src-tauri/src/handover/mod.rs b/src-tauri/src/handover/mod.rs new file mode 100644 index 00000000..76eef758 --- /dev/null +++ b/src-tauri/src/handover/mod.rs @@ -0,0 +1,205 @@ +//! 升级不断线:桌面端这一侧的几件事。 +//! +//! 换版本时**不再停掉网关再起**:旧 Lite 装好更新之后请本机的 core 等接替者(arm, +//! `POST /handover/arm`),然后立刻重启自己;新 Lite 照常起新 core,新 core 发现数据目录 +//! 被一个等接替者的旧 core 占着,就从它手里接过监听和状态,旧 core 做完手上的请求再走。 +//! 网关端口从头到尾都开着,在跑的流也不被截断。交接本身是 core 的事(见 tw-handover), +//! 这里只管: +//! +//! - 什么时候 arm、arm 不上时退回「等请求结束、停掉、重启」([`arm`],调用方在 `updater`); +//! - 已 arm 的这一次退出不停 core([`armed`],退出回调看它); +//! - 等不到接替者时 core 怎么把桌面端重新打开([`relaunch_for`]); +//! - 运行中的 core 的可执行文件在替换应用之后仍然在(Windows 改名 [`retire`]、Linux +//! AppImage 的按版本缓存 [`core_cache`]); +//! - `brew upgrade` 退出应用时认出它来([`brew`]),以及 Homebrew 没退出应用就换掉了它时 +//! 自己重启([`bundle_watch`])。 +//! +//! **界面上不提交接。**这是正常运转的事,用户不需要知道(只有新 core 的配置读不进来、网关 +//! 暂按旧配置运行的那一种在安全模式页上说一句)。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crate::control::ControlClient; + +pub mod brew; +#[cfg(target_os = "macos")] +pub mod bundle_watch; +pub mod core_cache; +pub mod retire; + +/// 这一次退出已经请 core 等接替者了。 +/// +/// **退出回调看它决定停不停 core**(见 `lib.rs` 的 `RunEvent::Exit`),退出记录里也记上 +/// 它(`updater::record_exit`):重新打开时据此照退出时的样子恢复窗口。 +static ARMED: AtomicBool = AtomicBool::new(false); + +/// 这一次退出已经 arm 了吗 +pub(crate) fn armed() -> bool { + ARMED.load(Ordering::SeqCst) +} + +/// arm 上了记一笔;替换没成、把 core 接回来之后撤掉 +pub(crate) fn set_armed(on: bool) { + ARMED.store(on, Ordering::SeqCst); +} + +/// 请 core 等接替者,最多等多久。**arm 本身很快**(core 只是脱离父进程、开一个端点), +/// 等不到多半是 core 卡住了:那就当 arm 不上,退回现行流程 +const ARM_WITHIN: Duration = Duration::from_secs(5); + +/// 请本机的 core 等接替者。成了返回交接连接的版本号。 +/// +/// **任何一种不成都只是退回现行流程**,调用方不区分原因:core 太旧(协议 52 之前没有这个 +/// 端点,答的是 404;连 `Status` 都解不出来)、登录进行中(`control.handover.busy`)、安全 +/// 模式(`control.handover.safe_mode`)、core 没在跑、[`ARM_WITHIN`] 内没回话。原因进日志 +/// +/// `relaunch` 是等不到接替者时 core 重新打开桌面端的方式(见 [`relaunch_for`]);同一个 +/// 桌面端接着起新 core 的(「重新启动网关」)不需要它 +pub(crate) async fn arm( + control: &ControlClient, + relaunch: Option, +) -> Result { + let req = tw_api::HandoverArm { + wait_secs: None, + relaunch, + }; + match tokio::time::timeout( + ARM_WITHIN, + control.call::(&[], &req), + ) + .await + { + Ok(Ok(armed)) => { + tracing::info!(version = armed.version, "core 已在等接替者"); + Ok(armed.version) + } + Ok(Err(e)) => Err(format!("{e:#}")), + Err(_) => Err(format!("core {ARM_WITHIN:?} 内没回话")), + } +} + +/// arm 的结果出来之后:成了就什么都不用等,返回 `true`;不成就照以前的做法(方案 F2), +/// 先等手上的请求结束(`wait`)、再停掉 core(`stop`),返回 `false`。 +/// +/// **两步都只在退回时才跑**:future 不被 await 就什么都不做 +pub(crate) async fn or_fall_back( + armed: &Result, + wait: impl std::future::Future, + stop: impl std::future::Future, +) -> bool { + match armed { + Ok(_) => true, + Err(e) => { + tracing::info!("core 没能等接替者,退回等请求结束再重启:{e}"); + wait.await; + stop.await; + false + } + } +} + +/// 等不到接替者时,core 怎么把桌面端重新打开(方案 F1:新 Lite 没起来)。 +/// +/// - macOS:按 bundle id(`open -b`)。打开的是磁盘上现在的那一份:换好了是新版,Homebrew +/// 升级失败回滚了就是旧版 —— 两种都能接手; +/// - Linux AppImage:`$APPIMAGE` 那个文件,**不是挂载点里的可执行文件**:挂载点随这个 +/// 进程退出而卸载,那个路径之后就不在了; +/// - Windows(安装版、绿色版)和别的:自己这个可执行文件。重启之后的路径不变(安装程序 +/// 覆盖原处,绿色版换的是同一个文件名)。 +/// +/// 答不出来就不给:core 不重新打开,只是等满之后排空退出 +pub(crate) fn relaunch_for(app: &tauri::AppHandle) -> Option { + #[cfg(target_os = "macos")] + { + Some(tw_api::Relaunch::BundleId { + id: app.config().identifier.clone(), + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + #[cfg(target_os = "linux")] + let path = crate::update::mounted_appimage( + tauri::utils::platform::bundle_type(), + std::env::var_os("APPIMAGE").as_deref(), + ) + .or_else(|| std::env::current_exe().ok()); + #[cfg(not(target_os = "linux"))] + let path = std::env::current_exe().ok(); + path.map(|p| tw_api::Relaunch::Exe { + path: p.display().to_string(), + args: Vec::new(), + }) + } +} + +/// 退出回调里:这次退出是 `brew upgrade` 发起的,就 arm(不停 core)。见 [`brew`]。 +/// +/// 跑在主线程的事件循环上(`RunEvent::Exit`),不在运行时里 —— 和 `stop_core_on_exit` +/// 一样用 `block_on` 等这一下;万一在运行时里就不等(`block_on` 会 panic),当普通退出。 +/// arm 不上也当普通退出:调用方接着停 core,和今天一样 +pub(crate) fn arm_on_quit(app: &tauri::AppHandle) { + if armed() || !brew::quit_was_upgrade() { + return; + } + let Some(st) = tauri::Manager::try_state::(app) else { + return; + }; + if tokio::runtime::Handle::try_current().is_ok() { + return; + } + let control = st.supervisor.control().clone(); + let relaunch = relaunch_for(app); + let armed = tauri::async_runtime::block_on(async move { arm(&control, relaunch).await }); + match armed { + Ok(_) => { + tracing::info!("Homebrew 升级退出应用:core 留着等新版接手"); + set_armed(true); + } + Err(e) => tracing::info!("Homebrew 升级退出应用,core 没能 arm,照常停下({e})"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + /// 本机没有 core 在跑(连着远程、core 停着、没装好):arm 不上,**马上**说不上,不挂住 + #[tokio::test] + async fn arming_without_a_core_fails_at_once() { + let dir = tempfile::tempdir().unwrap(); + let control = ControlClient::new( + tw_api::control::Address::in_dir(dir.path()), + dir.path().join("config.yaml"), + ); + let got = tokio::time::timeout(Duration::from_secs(10), arm(&control, None)) + .await + .expect("arm 挂住了"); + assert!(got.is_err()); + } + + /// 退回的那一支:先等请求结束、再停 core;arm 上了的两步都不跑 + #[tokio::test] + async fn falling_back_waits_then_stops_and_arming_does_neither() { + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let step = |name: &'static str| { + let order = order.clone(); + async move { order.lock().unwrap().push(name) } + }; + assert!(!or_fall_back(&Err("refused".into()), step("wait"), step("stop")).await); + assert_eq!(*order.lock().unwrap(), ["wait", "stop"]); + + let ran = Arc::new(AtomicUsize::new(0)); + let count = || { + let ran = ran.clone(); + async move { + ran.fetch_add(1, Ordering::SeqCst); + } + }; + assert!(or_fall_back(&Ok(1), count(), count()).await); + assert_eq!(ran.load(Ordering::SeqCst), 0); + } +} diff --git a/src-tauri/src/handover/retire.rs b/src-tauri/src/handover/retire.rs new file mode 100644 index 00000000..c8be3277 --- /dev/null +++ b/src-tauri/src/handover/retire.rs @@ -0,0 +1,147 @@ +//! Windows 安装版:把运行中的 `twcore.exe` 让出来给安装程序(方案 5.3)。 +//! +//! 安装程序要覆盖 `twcore.exe`,而交接时旧 core 还要接着跑完手上的请求 —— 以前的做法是先 +//! 停掉它。**NTFS 允许给运行中的可执行文件改名**:调安装程序之前把它改成 +//! `twcore.old-.exe`,它照常跑到排空退出,安装程序在腾出来的名字上写新版本。改出来 +//! 的文件下次启动时清掉([`sweep`]),删不掉(那个 core 还在排空)就留到下一次。 +//! +//! 绿色版不走这里:它换文件本来就是改名替换(`portable::swap_in`)。 +//! +//! 判断和改名本身不分平台(测试在哪都跑);只有 Windows 安装版真的用它 +#![cfg_attr(not(windows), allow(dead_code))] + +use std::path::{Path, PathBuf}; + +use crate::gateway::CORE_EXE; + +/// 改名之后叫什么:`twcore.old-.exe`。**带 pid**:上一个还在排空时又更新了一次,两个 +/// 旧的不会撞名 +pub fn retired_name(pid: u32) -> String { + format!("twcore.old-{pid}.exe") +} + +/// 这是不是改名改出来的那种文件。**只认这一种形状**:安装目录是我们的,但别的 `.exe` +/// 不是这里改出来的,不碰 +pub fn is_retired(name: &str) -> bool { + name.strip_prefix("twcore.old-") + .and_then(|r| r.strip_suffix(".exe")) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) +} + +/// 把 `dir` 里运行中的 `twcore.exe` 改名让出来,返回改成的路径 +pub fn retire(dir: &Path, pid: u32) -> std::io::Result { + let to = dir.join(retired_name(pid)); + std::fs::rename(dir.join(CORE_EXE), &to)?; + Ok(to) +} + +/// 安装没成:改回去。那个 core 还在跑,改名照样可以 +pub fn restore(dir: &Path, retired: &Path) -> std::io::Result<()> { + std::fs::rename(retired, dir.join(CORE_EXE)) +} + +/// 删掉 `dir` 里上次改出来的文件。删不掉的跳过(那个 core 还在排空),返回删掉了几个 +pub fn sweep(dir: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(is_retired)) + .filter(|e| match std::fs::remove_file(e.path()) { + Ok(()) => true, + Err(err) => { + tracing::info!("上次更新改名留下的 {:?} 这次没删掉:{err}", e.file_name()); + false + } + }) + .count() +} + +/// 启动时在后台清一次。只在 Windows 安装版上做:别处不会有这种文件 +#[cfg(windows)] +pub fn sweep_in_background() { + if crate::update::kind() != crate::update::Install::Standalone { + return; + } + let Some(dir) = std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(Path::to_path_buf)) + else { + return; + }; + std::thread::spawn(move || { + let n = sweep(&dir); + if n > 0 { + tracing::info!("清掉了 {n} 个上次更新改名留下的 core"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_names_we_make_are_recognised() { + assert_eq!(retired_name(4242), "twcore.old-4242.exe"); + assert!(is_retired(&retired_name(4242))); + for other in [ + "twcore.exe", + "twcore.old-.exe", + "twcore.old-12a.exe", + "twcore.old-12.exe.bak", + "twcore.exe.old", + "ThinkWatch Lite.exe", + "xtwcore.old-12.exe", + ] { + assert!(!is_retired(other), "{other}"); + } + } + + #[test] + fn the_core_is_renamed_aside_and_back() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(CORE_EXE), b"MZ old").unwrap(); + let aside = retire(d.path(), 77).unwrap(); + assert_eq!(aside, d.path().join("twcore.old-77.exe")); + assert!(!d.path().join(CORE_EXE).exists()); + // 安装程序在腾出来的名字上写新的 + std::fs::write(d.path().join(CORE_EXE), b"MZ new").unwrap(); + std::fs::remove_file(d.path().join(CORE_EXE)).unwrap(); + // 没装成:改回去 + restore(d.path(), &aside).unwrap(); + assert_eq!(std::fs::read(d.path().join(CORE_EXE)).unwrap(), b"MZ old"); + } + + /// 没有 `twcore.exe` 可让(被删了、目录不对):报错,调用方退回现行流程 + #[test] + fn retiring_a_missing_core_fails() { + let d = tempfile::tempdir().unwrap(); + assert!(retire(d.path(), 1).is_err()); + } + + /// 清理只删改名改出来的,别的文件都不碰 + #[test] + fn sweeping_removes_only_retired_cores() { + let d = tempfile::tempdir().unwrap(); + for f in [ + "twcore.old-1.exe", + "twcore.old-2.exe", + "twcore.exe", + "uninstall.exe", + "twcore.exe.old", + ] { + std::fs::write(d.path().join(f), b"x").unwrap(); + } + assert_eq!(sweep(d.path()), 2); + let mut left: Vec = std::fs::read_dir(d.path()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + left.sort(); + assert_eq!(left, ["twcore.exe", "twcore.exe.old", "uninstall.exe"]); + assert_eq!(sweep(&d.path().join("nope")), 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc0bc8a8..d9046e10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,8 @@ pub mod error; /// 导出文件时系统的「存储」对话框和写盘(安全日志的导出),见模块头上 pub mod export; pub mod gateway; +/// 升级不断线:请 core 等接替者、已 arm 的退出不停 core、`brew upgrade` 的识别,见模块头上 +pub mod handover; pub mod import_link; pub mod keys; /// 一个在跑的请求的实时内容,搬给请求详情的「内容」页 @@ -332,17 +334,23 @@ pub fn run() { // 只读)。**两样都只在这里定一次**,守护和客户端用的是同一对 let at = control_address(); let key_file = tw_api::control::config_file(&data_dir()); - // 起好了没有,问控制面:`/status` 答得上来才算。半秒答不上这一次就 - // 算没答应,守护隔一会儿再问 + // 起好了没有,问控制面:`/status` 答得上来、**答话的就是刚起的那一个**才算 + // (升级不断线时,先答话的可能是等着被接替的旧 core,见 `supervisor::readiness`)。 + // 半秒答不上这一次就算没答应,守护隔一会儿再问 let ready = { let (at, key_file) = (at.clone(), key_file.clone()); - supervisor::probe(move || { + supervisor::probe_pid(move |pid| { let control = ControlClient::new(at.clone(), key_file.clone()); async move { - control - .ping(std::time::Duration::from_millis(500)) - .await - .is_ok() + match tokio::time::timeout( + std::time::Duration::from_millis(500), + control.call::(&[], &()), + ) + .await + { + Ok(Ok(s)) => supervisor::readiness(pid, s.pid, s.handover), + _ => supervisor::Answer::NotYet, + } } }) }; @@ -591,6 +599,19 @@ pub fn run() { update_loop(h).await; }); + // `brew upgrade` 退出应用时认出它来(arm 之后再退,见 `handover::brew`);它没退出 + // 应用就把包换掉了的话自己重启(`handover::bundle_watch`) + #[cfg(target_os = "macos")] + { + if let Some(mtm) = objc2::MainThreadMarker::new() { + handover::brew::install_quit_handler(mtm); + } + handover::bundle_watch::spawn(&handle); + } + // 上次更新时改名让出来的 core,这次清掉(见 `handover::retire`) + #[cfg(windows)] + handover::retire::sweep_in_background(); + Ok(()) }) .on_window_event(|window, event| { @@ -656,11 +677,16 @@ pub fn run() { api.prevent_exit(); } } - // 真要退了:先记下这个实例的样子(更新之后重新打开时照它恢复窗口), - // 再让 core 跟着走 —— 「一个程序」原则的另一半 + // 真要退了:`brew upgrade` 发起的先请 core 等接替者(见 `handover::brew`), + // 再记下这个实例的样子(重新打开时照它恢复窗口),最后让 core 跟着走 —— + // 「一个程序」原则的另一半。**arm 过的不停**:自动更新、`brew upgrade` 换版本时, + // 旧 core 接着服务,等新版起来的 core 接手 tauri::RunEvent::Exit => { + handover::arm_on_quit(app); updater::record_exit(app); - stop_core_on_exit(app); + if !handover::armed() { + stop_core_on_exit(app); + } } _ => {} } diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 90d72e6e..e1cff23e 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -193,8 +193,12 @@ async fn run(app: tauri::AppHandle) { /// 该不该每秒走一次:菜单开着;或者有请求在跑、菜单栏上挂着速度那一行(只有标识的、 /// 不显示的没有)。**什么都没在跑、菜单关着时不走**:菜单栏上的破折号不会自己变 +/// +/// **升级之后旧 core 还在排空时也走**:它手上的请求结束了不会有事件过来,不走的话进行中的 +/// 个数(和菜单栏上的忙碌标记)就一直停在交接那一刻 fn ticks(open: bool, snap: &Snapshot, style: Style) -> bool { - open || (!snap.live.is_empty() && matches!(style, Style::Full | Style::Numbers)) + open || snap.draining > 0 + || (!snap.live.is_empty() && matches!(style, Style::Full | Style::Numbers)) } /// 叫醒菜单栏的几种理由(见 [`wait`]):有事件(攒一攒再收)、要立刻收、有请求开始了 @@ -268,6 +272,12 @@ async fn refresh_live(state: &AppState, snap: &mut Snapshot) { if let Ok(live) = ask(ASK, state.control.call::(&[], &())).await { apply_live(snap, live); } + // 排空中的旧 core 结束一个请求,这边没有事件:在排空的时候每一拍顺带问一下还剩几个 + if snap.draining > 0 + && let Ok(s) = ask(ASK, state.control.status()).await + { + snap.draining = s.draining; + } } /// 问 core 一句,最多等 `within`。**等不到就当没问到**,和问了被拒一样处理 @@ -476,6 +486,7 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits } if let Ok(s) = &status { snap.listen_error = s.listen_error.clone().map(|e| crate::core_text::text(&e)); + snap.draining = s.draining; } // 右边写的是「复制网关地址」复制的那一个:主机按连接(本机是 127.0.0.1),端口按配置。 // 网关没在监听时不写 diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index 103e8716..2d73a8f6 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -58,6 +58,9 @@ pub struct Snapshot { pub quotas: Vec, /// 进行中的请求,开始得早的在前 pub live: Vec, + /// 升级之后还在排空的旧 core 手上的请求数(`/status` 的 `draining`)。**算进进行中的个数**, + /// 不单独提示;它们不在 `live` 里(旧 core 的请求没有实时行) + pub draining: usize, /// 此刻每秒生成多少 token:在跑的请求各自的速度加起来(core 的 `/live`)。**没有在跑的 /// 请求是 None**;有请求在跑、却都还没开始生成(在等第一个 token,或者是非流式的)是 0 pub rate: Option, @@ -76,6 +79,13 @@ pub struct Snapshot { pub now_ms: u64, } +impl Snapshot { + /// 进行中的个数:这个 core 手上的,加上升级之后还在排空的旧 core 手上的 + pub fn in_progress(&self) -> usize { + self.live.len() + self.draining + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Connection { pub id: String, @@ -580,7 +590,7 @@ fn bar(s: &Snapshot, style: Style) -> Bar { None }, dim: !running, - busy: running && !s.live.is_empty(), + busy: running && s.in_progress() > 0, alert: !running && !matches!(s.gateway, Gateway::Starting), tooltip: tooltip(s, tight), } @@ -663,8 +673,8 @@ fn tooltip(s: &Snapshot, tight: Option<(&str, &Window)>) -> String { ) )); } - if !s.live.is_empty() { - parts.push(live_count(s.live.len())); + if s.in_progress() > 0 { + parts.push(live_count(s.in_progress())); } } parts.join(tr!(",", ", ")) @@ -798,10 +808,10 @@ fn rows(s: &Snapshot) -> Vec { } out.push(Row::Separator); - if !s.live.is_empty() { + if s.in_progress() > 0 { out.push(section( tr!("进行中", "In Progress"), - Some(s.live.len().to_string()), + Some(s.in_progress().to_string()), )); for l in s.live.iter().take(MAX_LIVE) { out.push(Row::Live { @@ -817,8 +827,10 @@ fn rows(s: &Snapshot) -> Vec { action: Action::OpenRequest(l.id), }); } - if s.live.len() > MAX_LIVE { - let more = s.live.len() - MAX_LIVE; + // 列不出来的那几个:超出行数的,加上排空中的旧 core 手上的(它们没有实时行) + let shown = s.live.len().min(MAX_LIVE); + if s.in_progress() > shown { + let more = s.in_progress() - shown; out.push(Row::Item(Item { icon: None, ..Item::new( diff --git a/src-tauri/src/menubar/model/tests.rs b/src-tauri/src/menubar/model/tests.rs index 0cc9c21a..e2c49263 100644 --- a/src-tauri/src/menubar/model/tests.rs +++ b/src-tauri/src/menubar/model/tests.rs @@ -83,6 +83,39 @@ fn windows(rows: &[Row]) -> Vec<(&str, &WindowRow)> { .collect() } +/// 升级之后旧 core 还在排空:**它手上的算进进行中的个数**,不单独提示;它们没有实时行, +/// 落在「另有 N 个」里。菜单栏照样标成忙 +#[test] +fn requests_left_on_the_draining_core_count_as_in_progress() { + with_lang(Lang::Zh, || { + let mut s = running(); + s.live = vec![Live { + id: 1, + started_ms: NOW - 1_000, + ..Default::default() + }]; + s.draining = 2; + let (bar, rows) = build(&s, Style::Full); + assert!(bar.busy); + assert!(bar.tooltip.contains("3 个请求进行中"), "{}", bar.tooltip); + let count = rows.iter().find_map(|r| match r { + Row::Section { title, right } if title == "进行中" => right.clone(), + _ => None, + }); + assert_eq!(count.as_deref(), Some("3")); + assert_eq!( + item(&rows, "live-more").map(|i| i.title.as_str()), + Some("另有 2 个") + ); + + // 只剩旧 core 手上的 + s.live.clear(); + let (bar, rows) = build(&s, Style::Icon); + assert!(bar.busy); + assert!(outline(&rows).contains(&"[进行中]".to_string())); + }); +} + /// 网关在运行、什么都有:打开主界面在最上面;提醒;今日、额度连在一起;进行中;策略组和 /// 两个复制;有新版本时「安装新版本」在设置前面;退出 #[test] diff --git a/src-tauri/src/notices/fixtures/snapshot.json b/src-tauri/src/notices/fixtures/snapshot.json index e85ff69f..f3cd0568 100644 --- a/src-tauri/src/notices/fixtures/snapshot.json +++ b/src-tauri/src/notices/fixtures/snapshot.json @@ -22,6 +22,8 @@ "providers": 1, "uptime_secs": 19, "in_flight": 0, + "handover": "idle", + "draining": 0, "remote_control": { "enabled": false, "addr": null, "allow_from": [], "reachable": [] }, "gateway_reachable": [] }, diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index f729bbd0..279f8de2 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -27,19 +27,76 @@ const HEALTHY_AFTER: Duration = Duration::from_secs(30); /// 进程起来之后,控制面多久还不答应就算这次没起来:换掉它,按一次失败算。 const READY_WITHIN: Duration = Duration::from_secs(15); -/// 问一次控制面答不答应。 +/// 新起来的 core 接管一个等接替者的旧 core,最多给它多久(方案 9.4 的接管超时,从连上旧 core +/// 到提交)。这段里控制面还是旧 core 在答([`Answer::HandingOver`]),**算启动中,不算没答应** +const TAKEOVER_WITHIN: Duration = Duration::from_secs(30); + +/// 问一次控制面,答成什么样(见 [`Probe`]) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Answer { + /// 这一个 core 答应了 + Ready, + /// 还没人答,或者答不上来 + NotYet, + /// 答话的是另一个**正在交接**的 core(等接替者的旧 core、接管还没交出控制面):这一个 + /// 正在接管它,接着等 + HandingOver, +} + +/// 问一次控制面答不答应。参数是刚起来的这个 core 的 pid。 /// /// **守护不认识控制面**,由外面给(lib.rs 里是一次带超时的 `/status`)。这样 /// 测试可以换成一个假的,不用真起一个会说 HTTP 的 core。 -pub type Probe = Arc Pin + Send>> + Send + Sync>; +/// +/// **要带 pid**:升级不断线以后,新 core 刚起来时控制面那个 socket 上答话的可能还是等着 +/// 被接替的旧 core(见 [`readiness`])。只问「有没有人答」的话,旧 core 一答就报了「运行中」, +/// 界面去连的是旧的那个 +pub type Probe = Arc Pin + Send>> + Send + Sync>; -/// 把一个返回 future 的闭包包成 [`Probe`]。 +/// 把一个只回答「答没答应」的闭包包成 [`Probe`](测试里用:不看是谁答的)。 pub fn probe(f: F) -> Probe where F: Fn() -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static, { - Arc::new(move || -> Pin + Send>> { Box::pin(f()) }) + let f = Arc::new(f); + probe_pid(move |_| { + let f = f.clone(); + async move { + if f().await { + Answer::Ready + } else { + Answer::NotYet + } + } + }) +} + +/// 把一个按 pid 回答的闭包包成 [`Probe`]。 +pub fn probe_pid(f: F) -> Probe +where + F: Fn(u32) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + Arc::new(move |pid| -> Pin + Send>> { Box::pin(f(pid)) }) +} + +/// 控制面答了话(`/status`),对刚起来的 `child` 来说算什么。 +/// +/// - 答话的就是它:答应了; +/// - 答话的是另一个 core、它说自己在等接替者或者正被接管:`child` 正在接手它,接着等; +/// - 答话的是另一个 core、它不在交接(多出来的一个、上一次没退干净的):**和以前一样算答应**。 +/// 那种时候 `child` 取不到锁,马上会以「已有实例在运行」退出,守护照崩溃处理 —— 不在这里 +/// 另立一套判断;pid 对不上也可能只是开发时套了一层不 `exec` 的启动脚本 +pub fn readiness(child: u32, answered_by: u32, handover: tw_api::HandoverState) -> Answer { + use tw_api::HandoverState; + if answered_by == child { + return Answer::Ready; + } + match handover { + HandoverState::Armed | HandoverState::TakingOver => Answer::HandingOver, + HandoverState::Idle | HandoverState::Draining => Answer::Ready, + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -220,6 +277,11 @@ pub struct Supervisor { /// 叫醒。只是一个标志的话,那两段里没人看它 —— 以前切到远程时,一个还在启动的 core /// 照样起来、照样占着网关端口,一个在退避里的睡醒了照样被拉起来 stopping: watch::Sender, + /// 手上这个 core 已经 arm 了,要起一个新的来接替它(见 [`Supervisor::hand_over`])。 + /// + /// **和 `intentional` 的区别在于旧的那个不等它退出**:它还要接着把手上的请求做完,可能 + /// 要几十分钟;守护循环放开它(不再等、不杀),当场起下一个 + handing_over: watch::Sender, /// 起 core 之前补上用户配的环境变量。见 [`user_env`]。 /// /// 测试里不开:那要真去跑一次用户的登录 shell。 @@ -274,6 +336,7 @@ impl Supervisor { state: watch::channel(CoreState::Stopped).0, intentional: Arc::new(AtomicBool::new(false)), stopping: watch::channel(false).0, + handing_over: watch::channel(false).0, user_env: false, } } @@ -430,6 +493,41 @@ impl Supervisor { Ok(()) } + /// 手上这个 core 已经 arm 了(在等接替者):放开它,当场起一个新的来接替。 + /// + /// 「重新启动网关」(方案 R5)和更新没装成、要把 arm 过的 core 接回来时用。**旧的那个不停**: + /// 守护循环不再等它、不杀它,它接着把手上的请求做完,新起来的 core 连上它、接过监听,它 + /// 排空了自己退出。新的那个起来之前、接管的那几秒里网关照常由旧的服务。 + /// + /// 不计入退避(和 `request_restart` 一样是我们要求的)。新的那个接管失败、退出了,守护按 + /// 崩溃处理:再起一个,再接管一次(旧的那个这时回到等接替者,方案 F4) + pub async fn hand_over(&self) -> anyhow::Result<()> { + let pid = match self.state() { + CoreState::Running { pid } => pid, + CoreState::Starting | CoreState::Restarting { .. } | CoreState::SafeMode { .. } => { + anyhow::bail!(tr!( + "core 正在启动,请稍后再试", + "Core is starting; try again in a moment" + )) + } + CoreState::Stopped | CoreState::Exited { .. } | CoreState::Failed { .. } => { + anyhow::bail!(tr!( + "core 未运行,无法重启", + "Core is not running and cannot be restarted" + )) + } + }; + if self.user_env { + tokio::spawn(user_env::read_ahead()); + } + self.handing_over.send_replace(true); + // 守护循环收到就换状态(放开它、报「重启中」),几乎是当场的 + if !self.wait_gone(pid, Duration::from_secs(3)).await { + tracing::warn!(pid, "守护循环没有按时放开要交出去的 core"); + } + Ok(()) + } + /// 停掉 core、守护不再拉它,**并且等它真的退出**。 /// /// 应用重启(装完更新)之前要用它。只是自己退出、让 core 靠 `--parent` @@ -502,6 +600,27 @@ impl Supervisor { pub fn resume(&self) { self.stopping.send_replace(false); self.intentional.store(false, Ordering::SeqCst); + self.handing_over.send_replace(false); + } + + /// 放开一个已经 arm 的 core:不再等它、不杀它,报「重启中」,守护循环当场起下一个来接替。 + /// + /// **子进程交给一个后台任务去等**:丢掉句柄会杀掉它(`kill_on_drop`),而它要接着把手上 + /// 的请求做完;等到它退出也顺带把它收走,不留僵尸。它的 stderr 照旧有人读([`Tail`] 的 + /// 读取任务不随句柄结束),管道不会写满 + fn let_go(&self, mut child: tokio::process::Child, last_words: Option) -> Next { + let pid = child.id(); + tracing::info!(?pid, "交出 core:它做完手上的请求再退出,起一个新的接替它"); + tokio::spawn(async move { + let status = child.wait().await; + tracing::info!(?pid, ?status, "交出去的 core 已退出"); + drop(last_words); + }); + self.set(CoreState::Restarting { + attempt: 0, + in_ms: 0, + }); + Next::Again } /// 按要求停下:报「已停止」,守护循环不再起它。 @@ -557,17 +676,28 @@ impl Supervisor { &self.binary } - /// 等控制面答应,最多 [`READY_WITHIN`]。答应了是 true。 + /// 等 `pid` 这个 core 的控制面答应,最多 [`READY_WITHIN`]。答应了是 true。 /// /// 先密后疏:通常一两百毫秒就好了,那时候隔半秒才问一次,启动画面就平白 /// 多停半秒。 - async fn until_ready(&self) -> bool { + /// + /// **接管进行中算启动中**:答话的是一个正在交接的旧 core([`Answer::HandingOver`])时, + /// 期限放宽到再给它一个接管超时([`TAKEOVER_WITHIN`])—— 旧 core 要先做完手上的令牌续期、 + /// 上传没传的配置改动,才把控制面交过来 + async fn until_ready(&self, pid: u32) -> bool { // tokio 的时钟:测试里暂停时钟就能跑过这 15 秒 - let deadline = tokio::time::Instant::now() + READY_WITHIN; + let mut deadline = tokio::time::Instant::now() + READY_WITHIN; + let mut extended = false; let mut gap = Duration::from_millis(25); loop { - if (self.ready)().await { - return true; + match (self.ready)(pid).await { + Answer::Ready => return true, + Answer::HandingOver if !extended => { + extended = true; + deadline = deadline.max(tokio::time::Instant::now() + TAKEOVER_WITHIN); + tracing::info!(pid, "新起的 core 正在接管等接替者的那一个"); + } + Answer::HandingOver | Answer::NotYet => {} } if tokio::time::Instant::now() >= deadline { return false; @@ -585,6 +715,8 @@ impl Supervisor { if *stop.borrow_and_update() { return Ok(self.stopped()); } + // 「交出去」只对上一轮那一个有意义:它没等到就自己退出了的话,记号不能落到这一个身上 + self.handing_over.send_replace(false); self.set(if safe { CoreState::SafeMode { pid: None } } else { @@ -663,9 +795,11 @@ impl Supervisor { Ready(bool), Stop, } + // 已经退出的话没有 pid:那一支马上就会先到 + let pid = child.id().unwrap_or(0); let waited = tokio::select! { status = child.wait() => Waited::Exited(status), - ready = self.until_ready() => Waited::Ready(ready), + ready = self.until_ready(pid) => Waited::Ready(ready), _ = until_stopped(&mut stop) => Waited::Stop, }; // 信号一:子进程退出事件。**最快最准**,但只覆盖我们自己 spawn @@ -675,6 +809,7 @@ impl Supervisor { // 答应的那一刻「要停」也到了:不报「运行中」,和下面一样停掉 Waited::Ready(true) if *self.stopping.borrow() => stop_starting(&mut child).await?, Waited::Ready(ready) => { + let mut running = false; match (ready, child.id()) { (true, Some(pid)) => { self.set(if safe { @@ -682,6 +817,7 @@ impl Supervisor { } else { CoreState::Running { pid } }); + running = !safe; tracing::info!(pid, safe, elapsed = ?started.elapsed(), "core 已就绪"); } (true, None) => {} @@ -690,7 +826,17 @@ impl Supervisor { let _ = child.start_kill(); } } - child.wait().await? + // **跑着的时候也听着「交出去」**([`Supervisor::hand_over`])。只有正常模式里 + // 答应了的才会被交出去:`hand_over` 只认「运行中」 + let mut over = self.handing_over.subscribe(); + tokio::select! { + status = child.wait() => status?, + // 拿到的那个借用当场放掉:它握着 watch 的读锁,下面要写 + _ = async { let _ = over.wait_for(|h| *h).await; }, if running => { + self.handing_over.send_replace(false); + return Ok(self.let_go(child, last_words)); + } + } } Waited::Stop => stop_starting(&mut child).await?, }; diff --git a/src-tauri/src/supervisor/tests.rs b/src-tauri/src/supervisor/tests.rs index e78d2beb..b5c0cc3d 100644 --- a/src-tauri/src/supervisor/tests.rs +++ b/src-tauri/src/supervisor/tests.rs @@ -881,3 +881,109 @@ async fn asking_a_core_whose_control_plane_never_answers_gives_up() { .expect("请它退出挂住了"); assert!(!asked, "控制面没答、信号也没处送,却说请到了"); } + +/// 控制面答话的是谁:刚起的那一个才算答应;等接替者、正被接管的旧 core 答的算「接管中」; +/// 不在交接的另一个 core 答的照以前算答应(刚起的那个取不到锁,马上会以崩溃退出) +#[test] +fn who_answered_decides_whether_the_new_core_is_ready() { + use tw_api::HandoverState as H; + for h in [H::Idle, H::Armed, H::TakingOver, H::Draining] { + assert_eq!(readiness(7, 7, h), Answer::Ready, "{h:?}"); + } + assert_eq!(readiness(7, 3, H::Armed), Answer::HandingOver); + assert_eq!(readiness(7, 3, H::TakingOver), Answer::HandingOver); + assert_eq!(readiness(7, 3, H::Idle), Answer::Ready); + assert_eq!(readiness(7, 3, H::Draining), Answer::Ready); +} + +/// 一直是旧 core 在答(接管中):**期限放宽一个接管超时**,过了这个也还没交过来才换掉它 +#[tokio::test(start_paused = true)] +async fn a_takeover_in_progress_counts_as_starting() { + let (_dir, bin) = long_runner("takeover"); + let s = Supervisor::new( + bin, + None, + probe_pid(|_| async { Answer::HandingOver }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + let t0 = tokio::time::Instant::now(); + assert!(!s.until_ready(1).await); + assert!( + t0.elapsed() >= TAKEOVER_WITHIN, + "接管中只等了 {:?}", + t0.elapsed() + ); + // 没人答的:照旧 READY_WITHIN + let s = Supervisor::new( + PathBuf::from("/x"), + None, + probe_pid(|_| async { Answer::NotYet }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + let t0 = tokio::time::Instant::now(); + assert!(!s.until_ready(1).await); + assert!(t0.elapsed() < TAKEOVER_WITHIN); +} + +/// 「重新启动网关」走交接:**旧的那个不停**(它还要把手上的请求做完),守护放开它、当场起 +/// 一个新的,不计入退避 +#[cfg(unix)] +#[tokio::test] +async fn handing_over_lets_the_old_core_run_and_starts_a_new_one() { + let (_dir, bin) = long_runner("handover"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + let old = until_running(&s).await; + s.hand_over().await.unwrap(); + assert_eq!( + tokio::time::timeout(PATIENCE, looped) + .await + .unwrap() + .unwrap(), + Next::Again + ); + assert_eq!( + s.state(), + CoreState::Restarting { + attempt: 0, + in_ms: 0 + } + ); + // SAFETY: 信号 0 只问这个 pid 还在不在,什么都不送 + assert_eq!( + unsafe { libc::kill(old as i32, 0) }, + 0, + "交出去的 core 被停了" + ); + + // 下一轮照常起一个新的 + let again = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + let new = until_running(&s).await; + assert_ne!(new, old); + // 交出去的那一个还在跑,停新的不碰它 + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(again.await.unwrap(), Next::Stop); + assert_eq!(unsafe { libc::kill(old as i32, 0) }, 0); + // 收拾:测试自己停掉它 + unsafe { libc::kill(old as i32, libc::SIGKILL) }; +} + +/// 没在运行的(启动中、安全模式、停着)不交接:调用方退回停了再起 +#[tokio::test] +async fn only_a_running_core_is_handed_over() { + assert!(sup().hand_over().await.is_err()); +} diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index c2d4a8f8..3246fd30 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -104,12 +104,19 @@ fn bundle_of(exe: &Path) -> Option<&Path> { /// **不去 exec `brew`。**Finder 拉起来的进程 PATH 里没有它;而且为了回答 /// 一个布尔值去跑一个 Ruby 程序,代价和失败面都不对。 fn from_homebrew(bundle: &Path, roots: &[PathBuf]) -> bool { + homebrew_root(bundle, roots).is_some() +} + +/// 放这个 `.app` 的那一份 Homebrew 的前缀([`from_homebrew`] 比对上的那一个)。 +/// +/// `brew upgrade` 退出应用时要认出发起的是不是这一份 Homebrew(见 `handover::brew`), +/// 前缀就取这里认出来的,不另猜、不调用 brew。 +#[cfg(target_os = "macos")] +fn homebrew_root<'a>(bundle: &Path, roots: &'a [PathBuf]) -> Option<&'a PathBuf> { // 两边都解引用之后再比。`/Applications` 本身可能是链接,Caskroom 里 // 那一项一定是链接 —— 比路径字符串会两边都判错。 - let Ok(me) = bundle.canonicalize() else { - return false; - }; - roots.iter().any(|root| { + let me = bundle.canonicalize().ok()?; + roots.iter().find(|root| { let Ok(versions) = std::fs::read_dir(root.join("Caskroom").join(CASK)) else { return false; }; @@ -122,6 +129,20 @@ fn from_homebrew(bundle: &Path, roots: &[PathBuf]) -> bool { }) } +/// 自己所在的 `.app`(见 [`bundle_of`])。不在一个 `.app` 里是 `None` +#[cfg(target_os = "macos")] +pub(crate) fn own_bundle() -> Option { + let exe = std::env::current_exe().ok()?; + bundle_of(&exe).map(Path::to_path_buf) +} + +/// 这一份是 Homebrew 装的话,那份 Homebrew 的前缀(`/opt/homebrew` 或 `/usr/local`) +#[cfg(target_os = "macos")] +pub(crate) fn homebrew_prefix() -> Option { + let bundle = own_bundle()?; + homebrew_root(&bundle, &BREW_PREFIXES.map(PathBuf::from)).cloned() +} + /// 给定可执行文件和 brew 的前缀,判断这是哪一种安装。 #[cfg(target_os = "macos")] pub fn kind_at(exe: &Path, roots: &[PathBuf]) -> Install { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 98b9c4d0..474a7699 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -8,7 +8,9 @@ use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::dmg; #[cfg(windows)] use crate::gateway::restart_gateway; -use crate::{AppState, control::ControlClient, data_dir, error::Out, i18n, notices, prefs, update}; +use crate::{ + AppState, control::ControlClient, data_dir, error::Out, handover, i18n, notices, prefs, update, +}; /// 更新这件事的当前状态,一次说完。 /// @@ -456,15 +458,20 @@ pub(crate) async fn wait_for_quiet(control: &ControlClient, on_wait: impl Fn(usi } } -/// 下载、等手上的请求结束、替换、重启。**按一次之后不再问任何问题。** +/// 下载、替换、请 core 等接替者(arm)、重启。**按一次之后不再问任何问题。** /// /// **Homebrew 装的一律拒绝。**理由在 `update` 模块开头。这里再挡一次,是 /// 因为这是一条策略 —— 只靠界面上那个按钮不显示来维持,等于让一次渲染的 /// 疏忽去降级用户的安装。 /// -/// **先下载,再等,最后才替换。**反过来的话,磁盘上的包在等待的那几分钟 -/// 里已经是新版本了 —— 这期间 core 万一退出,守护会从磁盘上拉起一个和 -/// 正在运行的界面不是同一版的网关。 +/// **不再等手上的请求结束**:旧 core arm 之后脱离这个进程接着服务,新版起来的 core 接过 +/// 监听,旧的做完手上的请求再走(见 `handover`)。arm 不上(core 太旧、登录进行中、安全 +/// 模式)才退回以前的做法:等请求结束、停掉 core、重启([`hand_over_or_stop`])。 +/// +/// **先替换,再 arm。**arm 之后旧 core 只等接替者一段时间(缺省 120 秒),替换要是在那之后 +/// 才完成(macOS 上要等用户输管理员密码),它就当新版没起来、自己排空退出了。代价是退回 +/// 现行流程时,等请求结束的那几分钟里磁盘上已经是新版:这期间 core 万一崩溃,守护拉起的是 +/// 新版的 core,界面要到重启之后才对得上 —— 只在 arm 不上又恰好崩溃时才会遇到。 #[tauri::command] pub async fn update_install( app: tauri::AppHandle, @@ -528,8 +535,7 @@ pub async fn update_install( if install == update::Install::Portable { return install_portable(&app, &state, bytes).await; } - // 发布页上只有 DMG,插件只装 `.app.tar.gz` —— 见 `dmg`。放在等请求之前: - // 包打不开的话,不该先让用户白等几分钟 + // 发布页上只有 DMG,插件只装 `.app.tar.gz` —— 见 `dmg` #[cfg(target_os = "macos")] let bytes = tauri::async_runtime::spawn_blocking(move || dmg::app_archive(&bytes)) .await @@ -541,49 +547,22 @@ pub async fn update_install( ) })?; - // 问的是本机的 core:要重启的是它。连着远程时它本来就停着,不用等 - wait_for_quiet(state.supervisor.control(), |in_flight| { - let _ = app.emit("update-step", Step::Waiting { in_flight }); - }) - .await; - let _ = app.emit("update-step", Step::Installing); - // **Windows 上 `install` 不回来。**插件拉起新版本的安装程序之后当场 - // `exit(0)`,排在它后面的每一步都轮不到,退出时的那一套(记下窗口开没开、 - // 停 core)也轮不到 —— 所以都得挪到它前面。停 core 在那边还是硬要求: - // `twcore.exe` 还在跑的话,安装程序覆盖不了一个被占用的文件。装不成的话这条 - // 记录留着也不碍事:版本没变,下次启动不会把它当成更新之后重开 #[cfg(windows)] { - record_exit(&app); - let _ = app.emit("update-step", Step::Restarting); - state - .supervisor - .stop_and_wait(std::time::Duration::from_secs(5)) - .await; - let installed = install_blocking(up, bytes) - .await - .and_then(|r| r.map_err(|e| e.to_string())); - if let Err(e) = installed { - // 最常见的是 UAC 那一下点了「否」。**把停掉的网关接回来**:不接的话, - // 它就这么停着 - resume_after_failed_update(&app).await; - return Err(tr!( - format!("安装失败:{e}"), - format!("Installation failed: {e}") - ) - .into()); - } + install_windows(&app, &state, up, bytes).await?; // 走到这里说明插件没有照它自己说的那样退出。那就和其他平台一样,自己重启 } #[cfg(not(windows))] { - // Linux 上走到这里的只有 AppImage:插件原地换掉 `$APPIMAGE` 那个文件 + // Linux 上走到这里的只有 AppImage:插件原地换掉 `$APPIMAGE` 那个文件。正在跑的 + // core 不在那个文件里,在数据目录的缓存里(见 `handover::core_cache`) #[cfg(target_os = "linux")] install_blocking(up, bytes) .await? .map_err(|e| update::appimage_failure(&e))?; + // macOS 上整个 `.app` 被换掉;正在跑的 core 已经把旧文件映射进来了,不受影响 #[cfg(target_os = "macos")] install_blocking(up, bytes).await?.map_err(|e| { tr!( @@ -591,26 +570,144 @@ pub async fn update_install( format!("Installation failed: {e}") ) })?; + hand_over_or_stop(&app, &state).await; + } + let _ = app.emit("update-step", Step::Restarting); + app.restart() +} + +/// 换好了新版、重启之前:请本机的 core 等接替者。arm 上了返回 `true`,这一次退出不停它 +/// (退出回调看 [`handover::armed`]),退出记录里也记上([`record_exit`])。 +/// +/// **arm 不上就照以前的做法**(方案 F2):等手上的请求结束(更新窗口里显示「等待 N 个请求 +/// 结束」)、停掉 core、等它真的退出 —— 否则新起来的应用会先撞上旧 core 手里的锁(见 +/// `Supervisor::stop_and_wait`)。问的是本机的 core:要重启的是它,连着远程时它本来就停着, +/// arm 不上、也不用等 +async fn hand_over_or_stop(app: &tauri::AppHandle, state: &AppState) -> bool { + let armed = handover::arm(state.supervisor.control(), handover::relaunch_for(app)).await; + let armed = handover::or_fall_back(&armed, wait_out(app, state), async { let _ = app.emit("update-step", Step::Restarting); - // 先停掉 core、等它真的退出,再重启应用 —— 否则新起来的应用会先撞上 - // 旧 core 手里的锁。见 `Supervisor::stop_and_wait`。 state .supervisor .stop_and_wait(std::time::Duration::from_secs(5)) .await; - } + }) + .await; + handover::set_armed(armed); + armed +} + +/// 退回现行流程时的「等请求结束」:更新窗口里显示还剩几个 +async fn wait_out(app: &tauri::AppHandle, state: &AppState) { + wait_for_quiet(state.supervisor.control(), |in_flight| { + let _ = app.emit("update-step", Step::Waiting { in_flight }); + }) + .await; +} + +/// Homebrew 没退出应用就把它换掉了(见 `handover::bundle_watch`):照自动更新的样子交接、 +/// 重启。重启之后照退出时的窗口状态恢复(退出回调记下的) +#[cfg(target_os = "macos")] +pub(crate) async fn restart_into_replaced(app: &tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + // 连着远程时本机的 core 停着:arm 不上,等也是立刻回来,照样重启 + hand_over_or_stop(app, &state).await; app.restart() } -/// 绿色版的安装:拆包、等请求结束、停网关、换掉文件夹里的两个 exe、重启。**不弹 -/// UAC**:文件夹是用户自己解压的,启动时已经确认过能写(`portable::prepare`)。 +/// Windows 安装版:arm、把运行中的 `twcore.exe` 改名让出来、跑安装程序。 +/// +/// **`install` 不回来。**插件拉起新版本的安装程序之后当场 `exit(0)`,排在它后面的每一步都 +/// 轮不到,退出时的那一套(记下窗口开没开、停不停 core)也轮不到 —— 所以都得挪到它前面。 +/// +/// **安装程序要覆盖 `twcore.exe`**:以前是先停掉它;现在 arm 上了就把它改名成 +/// `twcore.old-.exe`(`handover::retire`),它照常跑到排空,安装程序在腾出来的名字上 +/// 写新版本。改不了名(被别的程序占着、杀毒软件拦了)就和 arm 不上一样,退回停掉它。 +/// +/// 装不成(最常见的是 UAC 那一下点了「否」):arm 过的,名字改回来、起一个同版本的 core +/// 接替它(交接,网关不断);停过的,把停掉的网关接回来 +#[cfg(windows)] +async fn install_windows( + app: &tauri::AppHandle, + state: &AppState, + up: tauri_plugin_updater::Update, + bytes: Vec, +) -> Out<()> { + use crate::supervisor::CoreState; + let dir = state + .supervisor + .binary() + .parent() + .map(std::path::Path::to_path_buf); + let pid = match state.supervisor.state() { + CoreState::Running { pid } => Some(pid), + _ => None, + }; + let mut retired = None; + if let (Some(dir), Some(pid)) = (&dir, pid) + && handover::arm(state.supervisor.control(), handover::relaunch_for(app)) + .await + .is_ok() + { + match handover::retire::retire(dir, pid) { + Ok(to) => retired = Some(to), + Err(e) => tracing::warn!("运行中的 twcore.exe 改不了名,退回停掉它:{e}"), + } + } + // arm 上了、也让出了文件才算交接;改不了名的和 arm 不上的一样退回停掉它 + let handed = match &retired { + Some(_) => Ok(0), + None => Err("没有 arm,或者 twcore.exe 改不了名".to_string()), + }; + let armed = handover::or_fall_back(&handed, wait_out(app, state), async { + let _ = app.emit("update-step", Step::Restarting); + state + .supervisor + .stop_and_wait(std::time::Duration::from_secs(5)) + .await; + }) + .await; + handover::set_armed(armed); + record_exit(app); + let _ = app.emit("update-step", Step::Restarting); + let installed = install_blocking(up, bytes) + .await + .and_then(|r| r.map_err(|e| e.to_string())); + let Err(e) = installed else { + return Ok(()); + }; + // 没装成:这次不算退出,记录删掉 + forget_exit(); + match (retired, dir) { + (Some(aside), Some(dir)) => { + handover::set_armed(false); + if let Err(r) = handover::retire::restore(&dir, &aside) { + tracing::error!("改了名的 twcore.exe 改不回来:{r}"); + } + adopt_armed_core(app).await; + } + _ => resume_after_failed_update(app).await, + } + Err(tr!( + format!("安装失败:{e}"), + format!("Installation failed: {e}") + ) + .into()) +} + +/// 绿色版的安装:拆包、arm、换掉文件夹里的两个 exe、重启。**不弹 UAC**:文件夹是用户自己 +/// 解压的,启动时已经确认过能写(`portable::prepare`)。 +/// +/// **包先拆开看。**形状不对(见 `portable::unpack`)不该先 arm、也不该让用户白等。 /// -/// **包先拆开看。**形状不对(见 `portable::unpack`)不该先让用户白等几分钟,网关 -/// 也还没停。 +/// 换文件本来就是改名替换(`portable::swap_in`):正在跑的 `twcore.exe` 改名成 `.old` 照常 +/// 跑到排空,所以 arm 上了就不停它。arm 不上才照以前的做法等请求结束、停掉它。 /// -/// 换文件之前先记下退出时的样子([`record_exit`]),和其他平台一样:重启之后照它 -/// 恢复窗口。换不成就把改过名的文件改回来(`portable::swap_in` 自己做)、把停掉的 -/// 网关接回来,说清楚去哪下载。 +/// 换文件之前先记下退出时的样子([`record_exit`]),和其他平台一样:重启之后照它恢复窗口。 +/// 换不成就把改过名的文件改回来(`swap_in` 自己做):arm 过的起一个同版本的 core 接替它, +/// 停过的把网关接回来;说清楚去哪下载。 #[cfg(windows)] async fn install_portable(app: &tauri::AppHandle, state: &AppState, zip: Vec) -> Out<()> { use crate::portable; @@ -619,31 +716,37 @@ async fn install_portable(app: &tauri::AppHandle, state: &AppState, zip: Vec .map_err(|e| e.to_string())? .map_err(|e| update::portable_failure(&e))?; - // 问的是本机的 core:要重启的是它。连着远程时它本来就停着,不用等 - wait_for_quiet(state.supervisor.control(), |in_flight| { - let _ = app.emit("update-step", Step::Waiting { in_flight }); - }) - .await; - let _ = app.emit("update-step", Step::Installing); + let armed = hand_over_or_stop(app, state).await; record_exit(app); - // `twcore.exe` 也要换:先停下,等它真的退出 - state - .supervisor - .stop_and_wait(std::time::Duration::from_secs(5)) - .await; let swapped = tauri::async_runtime::spawn_blocking(move || portable::install(package)) .await .map_err(|e| e.to_string()) .and_then(|r| r); if let Err(e) = swapped { - resume_after_failed_update(app).await; + forget_exit(); + if armed { + handover::set_armed(false); + adopt_armed_core(app).await; + } else { + resume_after_failed_update(app).await; + } return Err(update::portable_failure(&e).into()); } let _ = app.emit("update-step", Step::Restarting); app.restart() } +/// 更新没装成,core 却已经 arm 了(它脱离了这个进程,在等接替者):起一个同版本的 core +/// 接替它。**交接,不是停掉再起**:网关不断,和「重新启动网关」走的是同一条路 +#[cfg(windows)] +async fn adopt_armed_core(app: &tauri::AppHandle) { + let state = app.state::(); + if let Err(e) = state.supervisor.hand_over().await { + tracing::error!("更新没装成,也没能起一个 core 接替已经 arm 的那一个:{e:#}"); + } +} + /// 装下载好的那一份,**在阻塞线程上**。 /// /// `install` 从头到尾都是阻塞的活:macOS 上要替换的位置写不进去时,插件把管理员密码 @@ -699,9 +802,17 @@ struct LastExit { at_ms: u64, /// 主窗口开着吗 window: bool, + /// 这次退出前请 core 等接替者了(见 `handover`):接下来要么是新版被打开(自动更新的 + /// 重启、Homebrew 的 `open -b`、看到应用包被换掉之后的自己重启),要么是等不到接替者的 + /// core 把桌面端重新打开 —— 后者版本没变,**只有这个记号说得出它是被重新打开的**。 + /// + /// 缺省 `false`:上一版写的记录没有这一项(第一次升级到带交接的版本,走的还是现行流程, + /// 退出时写的是旧格式),读不出来就丢了那一次的窗口状态 + #[serde(default)] + armed: bool, } -/// 被更新换下来之后重新打开:换之前是哪一版,那时主窗口开着没有 +/// 被重新打开(更新换下来之后、core 等不到接替者之后):之前是哪一版,那时主窗口开着没有 #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Relaunch { pub from: String, @@ -722,7 +833,8 @@ pub(crate) fn quitting_by_user() { /// /// **用户自己点的「退出」不记**(旧的记录也删掉):之后再打开是用户要打开,照常开窗口。 /// 别的退出都记 —— Homebrew 升级时用 Apple Event 让应用退出,和在 Dock 上点「退出」、注销时 -/// 系统让它退出是同一条路;应用自己装完更新是先正常退出再重启 +/// 系统让它退出是同一条路;应用自己装完更新是先正常退出再重启。**这次退出 arm 过就记上** +/// ([`handover::armed`]):重新打开时凭它照样恢复窗口,版本变没变都一样 pub(crate) fn record_exit(app: &tauri::AppHandle) { let path = data_dir().join(LAST_EXIT); if QUIT_BY_USER.load(std::sync::atomic::Ordering::SeqCst) { @@ -735,6 +847,7 @@ pub(crate) fn record_exit(app: &tauri::AppHandle) { window: app .get_webview_window("main") .is_some_and(|w| w.is_visible().unwrap_or(false)), + armed: handover::armed(), }; let written = serde_json::to_vec(&exit) .map_err(std::io::Error::other) @@ -744,11 +857,23 @@ pub(crate) fn record_exit(app: &tauri::AppHandle) { } } -/// 这次启动是不是更新之后被重新打开的。启动时调一次,**读完就删**。 +/// 更新没装成、这次不退出了:删掉刚才记下的那一条(它标着「已 arm」,留着的话用户过一会儿 +/// 自己退出又打开,会被当成更新之后重新打开) +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn forget_exit() { + let _ = std::fs::remove_file(data_dir().join(LAST_EXIT)); +} + +/// 这次启动是不是被重新打开的(更新之后、core 等不到接替者之后)。启动时调一次,**读完就删**。 /// -/// 三条都要满足:上一个实例不是用户自己退出的([`record_exit`] 记下了它)、那之后版本变了、 -/// 离它退出没多久([`RELAUNCH_WITHIN_MS`])。之前没在跑的,Homebrew 不会重新打开它, -/// 之后是用户自己点开的 +/// 三条都要满足:上一个实例不是用户自己退出的([`record_exit`] 记下了它)、那次退出 arm 过 +/// 或者那之后版本变了、离它退出没多久([`RELAUNCH_WITHIN_MS`])。之前没在跑的,Homebrew 不会 +/// 重新打开它,之后是用户自己点开的。 +/// +/// **arm 过的不看版本**:core 等不到接替者时会把桌面端重新打开(Homebrew 升级失败回滚了, +/// 打开的还是这一版),那也是「之前什么样、现在什么样」。**版本变了而没 arm 的也算**:arm 不上 +/// 退回现行流程的那次更新、没认出是 `brew upgrade` 的那次退出、上一版写的记录 —— 都是被更新 +/// 换下来的 pub(crate) fn relaunched(dir: &std::path::Path, version: &str, now_ms: u64) -> Option { let path = dir.join(LAST_EXIT); let raw = std::fs::read(&path).ok(); @@ -759,7 +884,7 @@ pub(crate) fn relaunched(dir: &std::path::Path, version: &str, now_ms: u64) -> O fn after_update(exit: LastExit, version: &str, now_ms: u64) -> Option { let recent = now_ms.saturating_sub(exit.at_ms) <= RELAUNCH_WITHIN_MS; - (exit.version != version && recent).then_some(Relaunch { + ((exit.armed || exit.version != version) && recent).then_some(Relaunch { from: exit.version, window: exit.window, }) @@ -870,7 +995,61 @@ mod tests { version: version.into(), at_ms: EXITED, window, + armed: false, + } + } + + fn armed_exit(version: &str, window: bool) -> LastExit { + LastExit { + armed: true, + ..exit(version, window) + } + } + + /// 退出前 arm 过的(自动更新、`brew upgrade`、看到应用包被换掉之后的自己重启):照退出 + /// 时的样子恢复。**版本没变也算** —— core 等不到接替者时把桌面端重新打开(Homebrew 升级 + /// 失败回滚了,打开的还是这一版) + #[test] + fn an_armed_exit_restores_the_window_even_without_a_new_version() { + let soon = EXITED + 8_000; + for (window, version) in [ + (false, "2026.9.23"), + (true, "2026.9.23"), + (false, "2026.9.22"), + ] { + assert_eq!( + after_update(armed_exit("2026.9.22", window), version, soon), + Some(Relaunch { + from: "2026.9.22".into(), + window + }), + "{window} {version}" + ); } + // 太久之后才打开的不算,arm 过也一样 + assert_eq!( + after_update( + armed_exit("2026.9.22", false), + "2026.9.22", + EXITED + RELAUNCH_WITHIN_MS + 1 + ), + None + ); + } + + /// 上一版写的记录没有 `armed`:读得出来,当没 arm(版本变了照样恢复) + #[test] + fn a_record_from_the_previous_version_still_reads() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(LAST_EXIT), + format!(r#"{{"version":"2026.9.22","at_ms":{EXITED},"window":false}}"#), + ) + .unwrap(); + assert_eq!( + relaunched(dir.path(), "2026.9.23", EXITED + 8_000).map(|r| r.window), + Some(false) + ); } /// Homebrew 退出应用、换上新版、几秒后重新打开:照退出时的样子,窗口关着的还关着,开着 diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx index 77bb0568..aa2eabe9 100644 --- a/src/connection/Unlinked.tsx +++ b/src/connection/Unlinked.tsx @@ -188,6 +188,7 @@ export function Mismatch({ view, ours, theirs }: { view: ConnView; ours: string; export function ConfigBroken({ rejection, path, + held = false, fixes, repairing, onRepair, @@ -196,6 +197,11 @@ export function ConfigBroken({ }: { rejection: ConfigRejection; path: string; + /** + * 升级之后的新 core 读不了配置、只接过了控制面(`handover` 为 `taking_over`):网关还在 + * 旧 core 上按更新前的配置转发,配置修好后新 core 再接过网关 + */ + held?: boolean; /** 一键修复要改的几处(见 `useRepair`)。空的就是修不了,只剩另外两条路 */ fixes: ConfigFix[]; repairing: boolean; @@ -210,8 +216,13 @@ export function ConfigBroken({ const [restarting, run] = usePending(); const stage = stageLabel(rejection.stage); return ( - } tone="error" title={t.configBroken} status={t.safeMode}> -

{t.configBrokenLead}

+ } + tone={held ? "warn" : "error"} + title={held ? t.configBrokenHeldTitle : t.configBroken} + status={t.safeMode} + > +

{held ? t.configBrokenHeld : t.configBrokenLead}

-

{t.configBrokenResume}

+

{held ? t.configBrokenHeldResume : t.configBrokenResume}

); } diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx index aeb8e70f..4d2415c5 100644 --- a/src/connection/connection.i18n.tsx +++ b/src/connection/connection.i18n.tsx @@ -173,6 +173,11 @@ export const connText = messages( openConfigFile: "打开配置文件", restoreFromBackup: "从备份恢复…", configBrokenResume: "保存修正后的配置后,网关自动恢复。", + /** 升级之后新 core 读不了配置:网关还在旧 core 上按更新前的配置转发 */ + configBrokenHeldTitle: "更新后的配置文件有误", + configBrokenHeld: + "新版本无法读取配置文件。网关暂按更新前的配置运行,客户端的请求照常转发;修正配置文件或从备份恢复可用的版本后,自动切换到新版本。", + configBrokenHeldResume: "保存修正后的配置后,自动切换到新版本。", // 断线横幅 lostBanner: (name: string, attempt: number) => @@ -328,6 +333,10 @@ export const connText = messages( openConfigFile: "Open configuration file", restoreFromBackup: "Restore from backup…", configBrokenResume: "Once a corrected configuration is saved, the gateway resumes on its own.", + configBrokenHeldTitle: "The configuration file has an error after the update", + configBrokenHeld: + "The new version cannot read the configuration file. The gateway runs with the configuration from before the update and keeps forwarding client requests; fix the file or restore a working version from the backups to switch to the new version.", + configBrokenHeldResume: "Once a corrected configuration is saved, the new version takes over on its own.", lostBanner: (name: string, attempt: number) => attempt > 0 diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 3666424b..03314f23 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 51; +export const CONTROL_API_VERSION = 52; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -1959,6 +1959,34 @@ rules: Array, }; */ export type GuardMode = "off" | "observe" | "enforce"; +/** + * `POST /handover/arm` 的请求体。 + */ +export type HandoverArm = { +/** + * 等接替者多久,秒。缺省 120 + */ +wait_secs: number | null, +/** + * 等不到时怎么重新打开桌面端(只试一次,之后再等一个 `wait_secs`,仍等不到就排空退出)。 + * 空 = 不重新打开 + */ +relaunch: Relaunch | null, }; + +/** + * `POST /handover/arm` 的应答。 + */ +export type HandoverArmed = { +/** + * 交接连接的版本号(`tw_handover::VERSION`)。接替者的不同就不交接,退回现行流程 + */ +version: number, }; + +/** + * 交接走到了哪一步([`Status::handover`])。 + */ +export type HandoverState = "idle" | "armed" | "taking_over" | "draining"; + /** * 一个报文头:请求行或状态行,和头([`RequestDetail::heads`],也是实时内容里的 * `head`,见 [`LiveContent`])。 @@ -4169,6 +4197,11 @@ median: number, samples: number, }; */ export type ReferenceView = { "kind": "rule_target", route: string, rule: string, } | { "kind": "rule_condition", route: string, rule: string, } | { "kind": "group", group: string, }; +/** + * 重新打开桌面端的方式。 + */ +export type Relaunch = { "kind": "bundle_id", id: string, } | { "kind": "exe", path: string, args: Array, }; + /** * 远程控制端口(`listen.control.remote`)。 */ @@ -5467,6 +5500,15 @@ config_rejected?: ConfigRejection | null, config_path: string, clients: number, * 重启网关之前要看它 —— 重启会掐断所有还没结束的流。 */ in_flight: number, +/** + * 交接走到了哪一步。见 [`HandoverState`] + */ +handover: HandoverState, +/** + * 排空中的旧 core 手上还有几个请求(它每结束一个就报一次)。**只有接管了它的新 core + * 报**,平时是 0。界面把它加进在途个数,不单独提示 + */ +draining: number, /** * 远程控制端口此刻的样子。 */ @@ -6252,6 +6294,7 @@ export type ErrorBody = Msg; export const ENDPOINTS = { Status: { method: "GET", path: "/status", params: [], format: "json" }, Shutdown: { method: "POST", path: "/shutdown", params: [], format: "json" }, + HandoverArm: { method: "POST", path: "/handover/arm", params: [], format: "json" }, Interfaces: { method: "GET", path: "/interfaces", params: [], format: "json" }, Events: { method: "GET", path: "/events", params: [], format: "events" }, InFlight: { method: "GET", path: "/in-flight", params: [], format: "json" }, @@ -6403,6 +6446,7 @@ export const ENDPOINTS = { export type Endpoints = { Status: { req: null; res: Status }; Shutdown: { req: null; res: Msg }; + HandoverArm: { req: HandoverArm; res: HandoverArmed }; Interfaces: { req: null; res: Array }; Events: { req: null; res: Event }; InFlight: { req: null; res: InFlight }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 51e0f101..4fb7d4c2 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -427,6 +427,7 @@ "gw.oauth.status": "令牌端点返回 {status}:{body}", "gw.oauth.no_token": "令牌端点的响应中没有 access_token。", "gw.oauth.expired": "OAuth 凭据已过期,请重新登录或更换 refresh token。令牌端点返回 {status}:{body}", + "gw.oauth.held": "网关正在更新,登录令牌暂不续期。请稍后重试该请求。", "gw.oauth.backoff.unreachable": "上一次刷新令牌失败,{secs} 秒后重试:无法连接令牌端点 {endpoint}:{detail}", "gw.oauth.backoff.status": "上一次刷新令牌失败,{secs} 秒后重试:令牌端点返回 {status}:{body}", "gw.oauth.backoff.no_token": "上一次刷新令牌失败,{secs} 秒后重试:令牌端点的响应中没有 access_token。", @@ -578,6 +579,11 @@ "control.name_reserved": "{kind:kind}名称不能以 {prefix} 开头,该前缀保留给内置项。", "control.name_is_builtin": "「{name}」是内置选项的名称,请使用其他名称。", "control.shutdown": "网关正在关闭。", + "control.handover.busy": "账号登录或另一次交接正在进行,完成后再试。", + "control.handover.endpoint_failed": "无法打开交接端点:{detail}", + "control.handover.login_refused": "网关即将更新,请在更新完成后重新登录。", + "control.handover.remote": "不能经远程连接准备交接。", + "control.handover.safe_mode": "网关处于安全模式、没有在转发,无需交接。", "control.no_such_endpoint": "控制面没有 {method} {path} 端点,桌面应用与网关的版本可能不一致。", "control.method_not_allowed": "控制面不接受对 {path} 的 {method} 请求,桌面应用与网关的版本可能不一致。", "control.request_rejected": "控制面无法读取该请求:{detail}", diff --git a/src/overview/useLive.ts b/src/overview/useLive.ts index 34226a99..b35a97ce 100644 --- a/src/overview/useLive.ts +++ b/src/overview/useLive.ts @@ -1,11 +1,18 @@ import { useEffect, useReducer, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { call } from "@/control"; import { subscribe } from "@/lib/tauriEvent"; import { isRunning, parseCoreState } from "@/coreState"; import { textOf } from "@/i18n"; -import type { CoreEvent, HistoryRow } from "@/types"; +import type { CoreEvent, CoreStatus, HistoryRow } from "@/types"; import { liveText } from "./useLive.i18n"; +/** + * 升级之后旧 core 还在排空时,隔多久问一次它还剩几个请求(见 `useInFlight`)。旧 core 结束 + * 一个请求,这边没有事件;排空通常几十秒到几分钟,两秒一问,数字跟得上又不密 + */ +export const DRAINING_POLL_MS = 2_000; + /** * 实时档一格多宽。十秒:十分钟铺六十格。 * @@ -198,14 +205,38 @@ function useFrame(): [() => void, () => void] { * * **别拿 `Status::in_flight`**:那个从连接进数据面就算,鉴权失败、排队的都在 * 里面。 + * + * **升级之后还在排空的旧 core 手上的也算进来**(`Status::draining`),不单独提示。它们 + * 不在事件流上(旧 core 的事件不到这边),所以对账时问一次 `/status`,还有剩的就每隔 + * {@link DRAINING_POLL_MS} 再问,直到归零。 */ export function useInFlight(): number { const flying = useRef(new Set()); + const draining = useRef(0); const [, soon] = useFrame(); useEffect(() => { let alive = true; let since: { started: Set; ended: Set } | null = null; + let drainTimer: ReturnType | null = null; + const stopDraining = () => { + if (drainTimer) clearTimeout(drainTimer); + drainTimer = null; + }; + const askDraining = async () => { + stopDraining(); + try { + const s = await invoke("core_status"); + if (!alive) return; + if (s.draining !== draining.current) { + draining.current = s.draining; + soon(); + } + if (s.draining > 0) drainTimer = setTimeout(() => void askDraining(), DRAINING_POLL_MS); + } catch { + // 问不到就当没有:core 回来的时候(`running:`)还会再问 + } + }; const start = (id: number) => { flying.current.add(id); since?.started.add(id); @@ -250,6 +281,7 @@ export function useInFlight(): number { } finally { if (since === mark) since = null; } + void askDraining(); }; void resync(); /* @@ -262,17 +294,20 @@ export function useInFlight(): number { } else { since = null; flying.current.clear(); + stopDraining(); + draining.current = 0; soon(); } }); return () => { alive = false; + stopDraining(); un(); unState(); }; }, [soon]); - return flying.current.size; + return flying.current.size + draining.current; } /** diff --git a/src/shell/Pages.tsx b/src/shell/Pages.tsx index c689f539..d4ddbd6a 100644 --- a/src/shell/Pages.tsx +++ b/src/shell/Pages.tsx @@ -282,6 +282,7 @@ export function Pages({