From 383a1ea467979700d570205f3d510b7224064665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 14:57:30 -0400 Subject: [PATCH] One copy per app: a second is refused and names the running process (X-03) --- agents.md | 2 + crates/ely-platform/src/instance.rs | 118 ++++++++++++++++++++++++++++ crates/ely-platform/src/lib.rs | 1 + crates/ely-ui/src/run.rs | 4 + tasks/BUGS.md | 2 +- tasks/reviews/X-03-r1.md | 3 + tasks/reviews/X-03-r2.md | 5 ++ tasks/reviews/X-03-r3.md | 3 + tasks/reviews/X-03-r4.md | 1 + tests/scripts/one-instance.sh | 35 +++++++++ 10 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 crates/ely-platform/src/instance.rs create mode 100644 tasks/reviews/X-03-r1.md create mode 100644 tasks/reviews/X-03-r2.md create mode 100644 tasks/reviews/X-03-r3.md create mode 100644 tasks/reviews/X-03-r4.md create mode 100755 tests/scripts/one-instance.sh diff --git a/agents.md b/agents.md index 1ce9232..066e7de 100644 --- a/agents.md +++ b/agents.md @@ -159,6 +159,7 @@ tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删 tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴) tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 +tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动 tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限) tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限) tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限) @@ -274,3 +275,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。 - 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。 - 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。 +- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。 diff --git a/crates/ely-platform/src/instance.rs b/crates/ely-platform/src/instance.rs new file mode 100644 index 0000000..dfbc87e --- /dev/null +++ b/crates/ely-platform/src/instance.rs @@ -0,0 +1,118 @@ +//! One running copy per app; two would wipe shared data. + +use std::{fs::File, io, path::Path, sync::OnceLock}; + +use crate::AppId; + +/// The lock this process holds while it runs. +static HELD: OnceLock = OnceLock::new(); + +/// Takes `app`'s instance lock; a running copy refuses this one. +pub fn hold(app: AppId) -> io::Result<()> { + let dir = crate::data_dir(app).map_err(io::Error::other)?; + std::fs::create_dir_all(&dir)?; + take(app, &dir) +} + +fn take(app: AppId, dir: &Path) -> io::Result<()> { + let file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(dir.join("instance.lock"))?; + match lock(&file)? { + None => HELD.set(file).map_err(|_| io::Error::other("this process took its instance lock twice")), + Some(holder) => Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} is already running ({holder}); open files in its window", app.name()), + )), + } +} + +/// Locks `file`, or names its holder as the OS reports. +#[cfg(unix)] +fn lock(file: &File) -> io::Result> { + use std::os::unix::io::AsRawFd; + let fd = file.as_raw_fd(); + loop { + let mut want: libc::flock = unsafe { std::mem::zeroed() }; + want.l_type = libc::F_WRLCK as _; + want.l_whence = libc::SEEK_SET as _; + if unsafe { libc::fcntl(fd, libc::F_SETLK, &want) } == 0 { + return Ok(None); + } + let error = io::Error::last_os_error(); + if !matches!(error.raw_os_error(), Some(libc::EACCES | libc::EAGAIN)) { + return Err(error); + } + let mut held = want; + if unsafe { libc::fcntl(fd, libc::F_GETLK, &mut held) } != 0 { + return Err(io::Error::last_os_error()); + } + // Let go between the two calls: try again. + if i64::from(held.l_type) != i64::from(libc::F_UNLCK) { + return Ok(Some(format!("process {}", held.l_pid))); + } + } +} + +/// Windows names no holder; the lock alone refuses. +#[cfg(windows)] +fn lock(file: &File) -> io::Result> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx}; + const ERROR_LOCK_VIOLATION: i32 = 33; + let flags = LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY; + let mut overlapped = unsafe { std::mem::zeroed() }; + match unsafe { LockFileEx(file.as_raw_handle(), flags, 0, u32::MAX, u32::MAX, &mut overlapped) } { + 0 if io::Error::last_os_error().raw_os_error() == Some(ERROR_LOCK_VIOLATION) => Ok(Some("another copy holds its lock".into())), + 0 => Err(io::Error::last_os_error()), + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copies_started_together_name_the_one_holder() { + let dir = tempfile::tempdir().expect("a temp dir"); + let exe = std::env::current_exe().expect("this test"); + let copies: Vec<_> = (0..6) + .map(|_| { + std::process::Command::new(&exe) + .args(["--exact", "instance::tests::one_copy", "--nocapture"]) + .env("ELY_INSTANCE_DIR", dir.path()) + .stdout(std::process::Stdio::piped()) + .spawn() + .expect("a copy") + }) + .collect(); + let said: Vec = copies + .into_iter() + .map(|copy| String::from_utf8_lossy(©.wait_with_output().expect("ends").stdout).into_owned()) + .collect(); + let holders: Vec<&String> = said.iter().filter(|out| out.contains("HELD")).collect(); + assert_eq!(holders.len(), 1, "{said:?}"); + let holder = holders[0].split("HELD ").nth(1).and_then(|rest| rest.split_whitespace().next()).expect("its pid"); + let refused: Vec<&String> = said.iter().filter(|out| out.contains("REFUSED")).collect(); + assert_eq!(refused.len(), 5, "{said:?}"); + // Unix names the holder; Windows cannot ask. + let named = if cfg!(unix) { format!("(process {holder})") } else { "(another copy holds its lock)".into() }; + for out in refused { + assert!(out.contains(&named), "{out}"); + } + } + + /// One copy, in a process of its own. + #[test] + fn one_copy() { + let Ok(dir) = std::env::var("ELY_INSTANCE_DIR") else { + return; + }; + match take(AppId::Doc, Path::new(&dir)) { + Ok(()) => { + println!("HELD {} ", std::process::id()); + std::thread::sleep(std::time::Duration::from_millis(1500)); + } + Err(error) => println!("REFUSED {error}"), + } + } +} diff --git a/crates/ely-platform/src/lib.rs b/crates/ely-platform/src/lib.rs index ed83d9c..a19b5e6 100644 --- a/crates/ely-platform/src/lib.rs +++ b/crates/ely-platform/src/lib.rs @@ -7,6 +7,7 @@ mod dirs; pub mod env; pub mod git; pub mod graphics; +pub mod instance; pub mod locale; pub mod lock; pub mod logging; diff --git a/crates/ely-ui/src/run.rs b/crates/ely-ui/src/run.rs index 7672b7a..4144956 100644 --- a/crates/ely-ui/src/run.rs +++ b/crates/ely-ui/src/run.rs @@ -15,6 +15,10 @@ pub fn run( app: AppId, root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity + 'static, ) { + // Before the log: a second copy must not trim it. + if let Err(error) = ely_platform::instance::hold(app) { + startup::fail(app, &format!("{error}")); + } if let Err(error) = logging::init(app) { startup::fail(app, &format!("logging did not start: {error:#}")); } diff --git a/tasks/BUGS.md b/tasks/BUGS.md index ff6169c..77a5484 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -89,7 +89,7 @@ - [ ] K-04 `Sheets.current` 用 `as u64` 转有符号的 part - [x] X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点 — 退出一律经主窗口宿主的流程(route_quit),延后到动作结束、先把宿主窗口提到前面;R1 主窗口 Cmd+Q 不问;R2 询问被设置窗口挡住;R3 PASS - [x] X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来 — 作者名取启动时读入的设置;R1 重启失败丢作者、脚本退出码、client.rs 超行;R2 作者更新的先后与停机期间的更新;R3 PASS -- [ ] X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点 +- [x] X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点 — 每个应用一个实例锁,第二份被拒并报出持锁进程(Unix fcntl F_GETLK);R1–R3 进程号竞争、Windows 断言;R4 PASS - [ ] X-04 属性栏字号菜单的 `expect("sizes are numbers")` 是脆弱点,但当前不可达 - [ ] X-05 恢复点写不成时每 10 秒弹一次提示 - [ ] X-06 `session.lock` 空或损坏即永久关闭恢复功能 diff --git a/tasks/reviews/X-03-r1.md b/tasks/reviews/X-03-r1.md new file mode 100644 index 0000000..ee8d612 --- /dev/null +++ b/tasks/reviews/X-03-r1.md @@ -0,0 +1,3 @@ +FAIL + +crates/ely-platform/src/instance.rs:19 — 拒绝路径读取的 PID 与锁持有者存在竞争,违反 X-03“报出运行中进程号”的要求。首份在第 18 行取得锁后才在第 26 行写入 instance.pid;同时启动的第二份可以在这两步之间读到缺失文件、上次运行的 PID,或 write 截断后的空文件。使用当前构建的 ely-platform::instance::hold,以同一 HOME、文件屏障同时释放两个独立进程,首份持锁 100 ms;全新目录与遗留 PID 的目录交替测 80 轮,58 轮的拒绝提示出现 `process unknown: No such file or directory (os error 2)` 或 `process `。另一次 16 进程并发探针实际持锁者为 62564,拒绝提示却报 `process 999999`(预置的上次 PID)。证据保留在 /tmp/ely-x03-two-process.log 和 /tmp/ely-x03-race.log。现有单测预先写好 PID,脚本等首份画出图块后才启动第二份,均覆盖不到此竞争。修复方向:同步持有者信息的发布与拒绝路径的读取,原子写入 PID,并确认提示中的 PID 属于当前锁持有者;增加同时启动的真实双进程回归测试。 diff --git a/tasks/reviews/X-03-r2.md b/tasks/reviews/X-03-r2.md new file mode 100644 index 0000000..56931fa --- /dev/null +++ b/tasks/reviews/X-03-r2.md @@ -0,0 +1,5 @@ +FAIL + +crates/ely-platform/src/instance.rs:38 — 上一轮的 PID 归属竞争仍有残留:`alive(pid)` 只能证明进程存活,`owner` 随即把它当作当前持锁者。原子发布、死 PID 延迟发布单测和四进程并发单测均通过;但上次实例退出后,其遗留 PID 被其他进程复用,新实例取得锁、尚未 rename 时,第二份会报出那个无关进程。使用本轮构建的测试二进制调用实际 `take`:进程 64851 持有 `instance.lock`,预置存活的无关进程 64852,200 ms 后原子发布实际持锁者 64851;拒绝路径在 12 ms 即返回 `process 64852`,连续三轮均复现(另两轮为 64854、64856)。证据:`/tmp/ely-x03-r2-live-stale.log`。现有延迟发布单测本身也由测试进程持锁、另一个从未持锁的 `sleep` 提供期望 PID,覆盖不到归属校验。修复方向:让拒绝路径验证 PID 与本次锁持有者的关联,并以独立进程真实持锁、遗留 PID 仍存活的场景补回归测试。 + +crates/ely-platform/src/instance.rs:55 — Windows 的 `alive` 仅判断 PID 与自身不同,已退出的旧实例同样被判为存活,破坏本轮“等待存活的持锁者 PID”要求。具体场景:目录遗留已退出实例的 PID,新实例刚取得锁、尚未发布 PID,第二份会立即读取并报告旧 PID,跳过等待新 PID 的过程。将该函数原样提取编译,在 macOS 上执行其平台无关谓词:启动、终止并回收进程 65062 后,`alive(65062)` 仍返回 `true`,存活断言失败,退出码 101;证据:`/tmp/ely-x03-r2-windows-predicate.log`(此探针验证函数逻辑,未运行 Windows 原生环境)。修复方向:通过 Windows 进程 API 核验存活状态,并覆盖死 PID 延迟替换的 Windows 回归场景。 diff --git a/tasks/reviews/X-03-r3.md b/tasks/reviews/X-03-r3.md new file mode 100644 index 0000000..34642f2 --- /dev/null +++ b/tasks/reviews/X-03-r3.md @@ -0,0 +1,3 @@ +FAIL + +crates/ely-platform/src/instance.rs:98 — 新增并发测试在 Windows 上也强制断言拒绝提示含 `(process {holder})`,与第 64 行 Windows 分支返回的 `another copy holds its lock` 冲突。具体场景:Windows 上六份正常取得一份成功、五份拒绝后,`copies_started_together_name_the_one_holder` 仍会在此断言失败,使 `cargo test -p ely-platform --lib` 失败。本机以原测试及 Unix 原生锁执行独立探针,仅把 Unix 拒绝文字替换为 Windows 分支现有文字,已得到一份持锁、五份拒绝,并在第 98 行复现失败,退出码 101;证据:`/tmp/ely-x03-r3-windows-contract.log`。此探针验证测试断言与返回值的冲突;Windows 原生构建与 LockFileEx 运行仍未验证。修复方向:所有平台保留一份持锁、五份拒绝的断言,按平台核对拒绝内容,Unix 核对实际持锁 PID,Windows 核对其无 PID 的拒绝提示。 diff --git a/tasks/reviews/X-03-r4.md b/tasks/reviews/X-03-r4.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/X-03-r4.md @@ -0,0 +1 @@ +PASS diff --git a/tests/scripts/one-instance.sh b/tests/scripts/one-instance.sh new file mode 100755 index 0000000..75a7696 --- /dev/null +++ b/tests/scripts/one-instance.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# A second ElyDoc is refused; the first keeps its data. +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } +work=$(mktemp -d) +cleanup() { + if [ -n "${pid:-}" ]; then + # Its worker writes into the folder too. + pkill -9 -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } + +cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml" +run() { HOME="$work/home" ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_SMOKE=hold exec "$root/target/debug/ely-doc"; } +run >"$work/first" 2>&1 & +pid=$! +for _ in $(seq 90); do grep -q "lok: first tile drawn" "$work/first" && break; sleep 1; done +grep -q "lok: first tile drawn" "$work/first" || fail "the first copy did not start: $(tail -5 "$work/first")" +data="$work/home/Library/Application Support/ElyOffice/ely-doc" +[ -d "$data/work" ] || fail "no work folder" +touch "$data/work/kept" +status=0 +(run) >"$work/second" 2>&1 || status=$? +[ "$status" != 0 ] || fail "the second copy started" +grep -q "already running (process $pid)" "$work/second" || fail "the refusal: $(tail -3 "$work/second")" +[ -e "$data/work/kept" ] || fail "the second copy wiped the first's work folder" +kill -0 "$pid" || fail "the first copy died" +echo "ok: a second ElyDoc is refused and the first keeps its data"