R4.12: answer the token before paths, one deadline per caller, hand-overs via the main window
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5bf2056281
commit
78a285d124
8 files changed
+363
-124
No files matched your search
@@ -265,7 +265,7 @@ 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,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后发令牌与各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上或令牌不符(旧的门文件)时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。收到的文件进 `ely_ui::on_open` 的同一队列(各应用不改),随后把最先开的窗口(主窗口)带到前台;不带文件的再次启动只带到前台。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后先发令牌,门答“开”或“拒”之后才发各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上、令牌被拒(旧的门文件)或答“开”之前断开时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。门为每个来者各开一个线程,一个来者从连上到答完共 5 秒(零星补字不续期),令牌前的帧至多 64 字节、路径帧至多 128 KiB(`ely_ipc::read_capped`,超出即在分配前拒绝)。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。每个应用的启动(`ely_ui::run`,`blank` 的 ElyMail、ElyAgent 同)在主窗口开出后装上交接的接收者(`opening::home`):先把主窗口带到前台(最小化的先复原),再把文件放进 `ely_ui::on_open` 的同一队列(各应用不改),然后才回执;主窗口已不在即不回执,第二份照报未收下。不带文件的再次启动只带到前台。主窗口关闭即退出应用(只剩设置窗口时进程原会一直持锁,却开不了文件)。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。
|
||||
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
|
||||
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
|
||||
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
|
||||
|
||||
@@ -53,6 +53,11 @@ pub fn check_reply(asked: u64, got: u64) -> io::Result<()> {
|
||||
|
||||
/// Reads (tag, id, payload); None at a clean stream end.
|
||||
pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
|
||||
read_capped(input, MAX_FRAME)
|
||||
}
|
||||
|
||||
/// As `read_frame`, refusing payloads over `cap` before allocating.
|
||||
pub fn read_capped(input: &mut impl Read, cap: usize) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
|
||||
let mut len = [0u8; 4];
|
||||
// Only a stream ending between frames ends cleanly.
|
||||
let mut got = 0;
|
||||
@@ -66,10 +71,10 @@ pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)
|
||||
}
|
||||
}
|
||||
let len = u32::from_le_bytes(len) as usize;
|
||||
if len > MAX_FRAME {
|
||||
if len > cap {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"frame over the cap",
|
||||
format!("a {len} byte frame is over the {cap} byte cap"),
|
||||
));
|
||||
}
|
||||
let mut tag = [0u8; 1];
|
||||
|
||||
@@ -161,7 +161,8 @@ mod tests {
|
||||
panic!("the first copy did not hold");
|
||||
};
|
||||
let (sender, heard) = std::sync::mpsc::channel();
|
||||
door.serve(move |files| sender.send(files).expect("heard")).expect("served");
|
||||
let sender = std::sync::Mutex::new(sender);
|
||||
door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok()).expect("served");
|
||||
println!("HELD");
|
||||
let files = heard.recv_timeout(std::time::Duration::from_secs(20)).expect("a hand-over");
|
||||
println!("GOT {}", files[0].display());
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs,
|
||||
io::{self, Write},
|
||||
io::{self, Read, Write},
|
||||
net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -15,8 +16,13 @@ const TOKEN: u8 = 1;
|
||||
const PATH: u8 = 2;
|
||||
const DONE: u8 = 3;
|
||||
const REFUSED: u8 = 4;
|
||||
/// How long a stalled caller may hold the door.
|
||||
const OPEN: u8 = 5;
|
||||
/// How long one caller may take, start to end.
|
||||
const PATIENCE: Duration = Duration::from_secs(5);
|
||||
/// The largest frame before the token is shown.
|
||||
const SHOWN: usize = 64;
|
||||
/// The largest path frame: a whole Windows path.
|
||||
const LONGEST: usize = 128 * 1024;
|
||||
|
||||
/// The running copy's listener; its token keeps others out.
|
||||
pub struct Door {
|
||||
@@ -39,46 +45,93 @@ impl Door {
|
||||
Ok(Door { listener, token })
|
||||
}
|
||||
|
||||
/// Hands each caller's files to `deliver`, on its own thread.
|
||||
pub fn serve(self, deliver: impl Fn(Vec<PathBuf>) + Send + 'static) -> io::Result<()> {
|
||||
/// Hands each caller's files to `deliver`; false means not taken.
|
||||
pub fn serve(
|
||||
self,
|
||||
deliver: impl Fn(Vec<PathBuf>) -> bool + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let deliver = Arc::new(deliver);
|
||||
thread::Builder::new()
|
||||
.name("instance door".into())
|
||||
.spawn(move || {
|
||||
for stream in self.listener.incoming() {
|
||||
if let Err(error) = stream.and_then(|stream| self.answer(stream, &deliver)) {
|
||||
log::warn!("instance: a hand-over failed: {error}");
|
||||
let (token, deliver) = (self.token, deliver.clone());
|
||||
// A stalled caller must not hold up the next.
|
||||
let answered = stream.and_then(|stream| {
|
||||
thread::Builder::new()
|
||||
.name("instance caller".into())
|
||||
.spawn(move || {
|
||||
if let Err(error) = answer(&stream, &token, &*deliver) {
|
||||
log::warn!("instance: a hand-over failed: {error}");
|
||||
}
|
||||
})
|
||||
.map(drop)
|
||||
});
|
||||
if let Err(error) = answered {
|
||||
log::warn!("instance: a caller was not answered: {error}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(&self, mut stream: TcpStream, deliver: &impl Fn(Vec<PathBuf>)) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(PATIENCE))?;
|
||||
let (tag, token) = next(&mut stream)?;
|
||||
if tag != TOKEN || !same(&token, &self.token) {
|
||||
ely_ipc::write_frame(&mut stream, REFUSED, 0, &[])?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"a caller without the token",
|
||||
));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
loop {
|
||||
match next(&mut stream)? {
|
||||
(PATH, bytes) => files.push(path(bytes)?),
|
||||
(DONE, _) => break,
|
||||
(tag, _) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("frame {tag} is not a hand-over's"),
|
||||
));
|
||||
}
|
||||
fn answer(
|
||||
stream: &TcpStream,
|
||||
token: &[u8],
|
||||
deliver: &dyn Fn(Vec<PathBuf>) -> bool,
|
||||
) -> io::Result<()> {
|
||||
let mut timed = Timed {
|
||||
stream,
|
||||
until: Instant::now() + PATIENCE,
|
||||
};
|
||||
let (tag, shown) = next(&mut timed, SHOWN)?;
|
||||
if tag != TOKEN || !same(&shown, token) {
|
||||
ely_ipc::write_frame(&mut &*stream, REFUSED, 0, &[])?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"a caller without the token",
|
||||
));
|
||||
}
|
||||
ely_ipc::write_frame(&mut &*stream, OPEN, 0, &[])?;
|
||||
let mut files = Vec::new();
|
||||
loop {
|
||||
match next(&mut timed, LONGEST)? {
|
||||
(PATH, bytes) => files.push(path(bytes)?),
|
||||
(DONE, _) => break,
|
||||
(tag, _) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("frame {tag} is not a hand-over's"),
|
||||
));
|
||||
}
|
||||
}
|
||||
log::info!("instance: another copy handed over {} files", files.len());
|
||||
deliver(files);
|
||||
ely_ipc::write_frame(&mut stream, DONE, 0, &[])
|
||||
}
|
||||
log::info!("instance: another copy handed over {} files", files.len());
|
||||
// No receipt: the caller reports the files untaken.
|
||||
if !deliver(files) {
|
||||
return Err(io::Error::other("the app is quitting and took no files"));
|
||||
}
|
||||
ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])
|
||||
}
|
||||
|
||||
/// A stream whose reads all end by one deadline.
|
||||
struct Timed<'a> {
|
||||
stream: &'a TcpStream,
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
impl Read for Timed<'_> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let left = self.until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"a caller held the door too long",
|
||||
));
|
||||
}
|
||||
self.stream.set_read_timeout(Some(left))?;
|
||||
(&mut &*self.stream).read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,18 +139,17 @@ impl Door {
|
||||
pub(super) fn hand(dir: &Path, files: &[PathBuf], wait: Duration) -> io::Result<()> {
|
||||
let deadline = Instant::now() + wait;
|
||||
loop {
|
||||
match knock(dir) {
|
||||
Ok((stream, token)) => match give(stream, &token, files)? {
|
||||
true => return Ok(()),
|
||||
// An old door file; the new one is coming.
|
||||
false if Instant::now() < deadline => {}
|
||||
false => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"it refused the door's token",
|
||||
));
|
||||
}
|
||||
},
|
||||
// Nothing handed yet, so every failure here may retry.
|
||||
match knock(dir).and_then(|(stream, token)| Ok((admit(&stream, &token)?, stream))) {
|
||||
Ok((true, stream)) => return give(&stream, files),
|
||||
// An old door file; the new one is coming.
|
||||
Ok((false, _)) if Instant::now() < deadline => {}
|
||||
Ok((false, _)) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"it refused the door's token",
|
||||
));
|
||||
}
|
||||
Err(_) if Instant::now() < deadline => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
@@ -121,30 +173,44 @@ fn knock(dir: &Path) -> io::Result<(TcpStream, Vec<u8>)> {
|
||||
let token = words.next().and_then(unhex).ok_or_else(bad)?;
|
||||
let stream =
|
||||
TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), PATIENCE)?;
|
||||
stream.set_read_timeout(Some(PATIENCE))?;
|
||||
lend_foreground(pid);
|
||||
Ok((stream, token))
|
||||
}
|
||||
|
||||
/// Sends the files; false when the door refuses the token.
|
||||
fn give(mut stream: TcpStream, token: &[u8], files: &[PathBuf]) -> io::Result<bool> {
|
||||
stream.set_read_timeout(Some(PATIENCE))?;
|
||||
ely_ipc::write_frame(&mut stream, TOKEN, 0, token)?;
|
||||
for file in files {
|
||||
ely_ipc::write_frame(&mut stream, PATH, 0, &bytes(file))?;
|
||||
}
|
||||
ely_ipc::write_frame(&mut stream, DONE, 0, &[])?;
|
||||
match next(&mut stream)? {
|
||||
(DONE, _) => Ok(true),
|
||||
/// Shows the token; false when the door refuses it.
|
||||
fn admit(stream: &TcpStream, token: &[u8]) -> io::Result<bool> {
|
||||
ely_ipc::write_frame(&mut &*stream, TOKEN, 0, token)?;
|
||||
match next(&mut &*stream, SHOWN)? {
|
||||
(OPEN, _) => Ok(true),
|
||||
(REFUSED, _) => Ok(false),
|
||||
(tag, _) => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("it answered frame {tag}"),
|
||||
)),
|
||||
(tag, _) => Err(unexpected(tag)),
|
||||
}
|
||||
}
|
||||
|
||||
fn next(stream: &mut TcpStream) -> io::Result<(u8, Vec<u8>)> {
|
||||
match ely_ipc::read_frame(stream)? {
|
||||
/// Sends the files once admitted; never retried.
|
||||
fn give(stream: &TcpStream, files: &[PathBuf]) -> io::Result<()> {
|
||||
for file in files {
|
||||
ely_ipc::write_frame(&mut &*stream, PATH, 0, &bytes(file))?;
|
||||
}
|
||||
ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])?;
|
||||
// The app may answer after the door's own wait.
|
||||
stream.set_read_timeout(Some(PATIENCE * 2))?;
|
||||
match next(&mut &*stream, SHOWN)? {
|
||||
(DONE, _) => Ok(()),
|
||||
(tag, _) => Err(unexpected(tag)),
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected(tag: u8) -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("it answered frame {tag}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn next(stream: &mut impl Read, cap: usize) -> io::Result<(u8, Vec<u8>)> {
|
||||
match ely_ipc::read_capped(stream, cap)? {
|
||||
Some((tag, _, payload)) => Ok((tag, payload)),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
@@ -226,12 +292,22 @@ mod tests {
|
||||
|
||||
fn opened(dir: &Path) -> std::sync::mpsc::Receiver<Vec<PathBuf>> {
|
||||
let (sender, heard) = std::sync::mpsc::channel();
|
||||
let sender = std::sync::Mutex::new(sender);
|
||||
let door = Door::open(dir).expect("a door");
|
||||
door.serve(move |files| sender.send(files).expect("heard"))
|
||||
door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok())
|
||||
.expect("served");
|
||||
heard
|
||||
}
|
||||
|
||||
/// Writes a door file whose token is wrong.
|
||||
fn forge(dir: &Path) -> String {
|
||||
let door = dir.join(FILE);
|
||||
let text = fs::read_to_string(&door).expect("the door");
|
||||
let (head, _) = text.rsplit_once(' ').expect("a token");
|
||||
fs::write(&door, format!("{head} {}\n", hex(&[7; 32]))).expect("a forged door");
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_over_brings_every_path_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
@@ -248,22 +324,91 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_hands_nothing_over() {
|
||||
for _ in 0..12 {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
forge(dir.path());
|
||||
let refused = hand(
|
||||
dir.path(),
|
||||
&[PathBuf::from("x.md")],
|
||||
Duration::from_millis(300),
|
||||
);
|
||||
assert_eq!(
|
||||
refused.map_err(|error| error.kind()),
|
||||
Err(io::ErrorKind::PermissionDenied)
|
||||
);
|
||||
assert!(heard.try_recv().is_err(), "nothing reached the copy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_door_file_is_waited_out() {
|
||||
for _ in 0..5 {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
let real = forge(dir.path());
|
||||
let door = dir.path().join(FILE);
|
||||
let fixer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
fs::write(door, real).expect("the real door");
|
||||
});
|
||||
let files = vec![PathBuf::from("x.md")];
|
||||
hand(dir.path(), &files, Duration::from_secs(3))
|
||||
.expect("handed once the door is right");
|
||||
fixer.join().expect("fixed");
|
||||
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dripping_caller_neither_blocks_nor_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
let door = dir.path().join(FILE);
|
||||
let text = fs::read_to_string(&door).expect("the door");
|
||||
let (head, _) = text.rsplit_once(' ').expect("a token");
|
||||
fs::write(&door, format!("{head} {}\n", hex(&[7; 32]))).expect("a forged door");
|
||||
let refused = hand(
|
||||
dir.path(),
|
||||
&[PathBuf::from("x.md")],
|
||||
Duration::from_millis(300),
|
||||
let text = fs::read_to_string(dir.path().join(FILE)).expect("the door");
|
||||
let port: u16 = text
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|word| word.parse().ok())
|
||||
.expect("a port");
|
||||
let mut drip = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).expect("connected");
|
||||
let started = Instant::now();
|
||||
let dripper = thread::spawn(move || {
|
||||
// A token frame's header, a byte at a time.
|
||||
for byte in [32u8, 0, 0, 0, TOKEN, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
.into_iter()
|
||||
.chain([0; 32])
|
||||
{
|
||||
if drip.write_all(&[byte]).is_err() {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
let files = vec![PathBuf::from("x.md")];
|
||||
hand(dir.path(), &files, Duration::from_secs(2)).expect("handed past the drip");
|
||||
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
|
||||
dripper.join().expect("dripped");
|
||||
let held = started.elapsed();
|
||||
assert!(
|
||||
held < PATIENCE + Duration::from_secs(2),
|
||||
"the drip was cut at the deadline, not after {held:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
refused.map_err(|error| error.kind()),
|
||||
Err(io::ErrorKind::PermissionDenied)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_the_app_did_not_take_are_reported() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
Door::open(dir.path())
|
||||
.expect("a door")
|
||||
.serve(|_| false)
|
||||
.expect("served");
|
||||
let started = Instant::now();
|
||||
let untaken = hand(dir.path(), &[PathBuf::from("x.md")], Duration::from_secs(3));
|
||||
assert!(untaken.is_err(), "untaken files are no success");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"sent paths are not sent again"
|
||||
);
|
||||
assert!(heard.try_recv().is_err(), "nothing reached the copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+115
-40
@@ -1,71 +1,84 @@
|
||||
//! Files handed to the app: arguments, Finder, second copies.
|
||||
|
||||
use std::{io, path::PathBuf};
|
||||
use std::{io, path::PathBuf, time::Duration};
|
||||
|
||||
use ely_platform::instance::Door;
|
||||
use futures::{StreamExt, channel::mpsc};
|
||||
use gpui::{App, Application, Global};
|
||||
use gpui::{AnyWindowHandle, App, Application, Global};
|
||||
|
||||
/// A request to the running app.
|
||||
pub(crate) enum Ask {
|
||||
Open(PathBuf),
|
||||
/// A second copy started: come to the front.
|
||||
Raise,
|
||||
}
|
||||
/// How long a hand-over waits for the window.
|
||||
const TAKING: Duration = Duration::from_secs(5);
|
||||
|
||||
struct Asked(Option<mpsc::UnboundedReceiver<Ask>>);
|
||||
struct Asked(Option<mpsc::UnboundedReceiver<PathBuf>>);
|
||||
|
||||
impl Global for Asked {}
|
||||
|
||||
/// A second copy's files; `taken` answers once they are queued.
|
||||
pub(crate) struct Hand {
|
||||
files: Vec<PathBuf>,
|
||||
taken: std::sync::mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
/// What the system and other copies hand the running app.
|
||||
pub(crate) struct Handed {
|
||||
asked: mpsc::UnboundedReceiver<PathBuf>,
|
||||
hands: Hands,
|
||||
}
|
||||
|
||||
/// Second copies' hand-overs, for the main window.
|
||||
pub(crate) struct Hands {
|
||||
hands: mpsc::UnboundedReceiver<Hand>,
|
||||
queue: mpsc::UnboundedSender<PathBuf>,
|
||||
}
|
||||
|
||||
/// Launch arguments, relative to where the app was started.
|
||||
pub(crate) fn launch_args() -> io::Result<Vec<PathBuf>> {
|
||||
std::env::args_os().skip(1).map(std::path::absolute).collect()
|
||||
}
|
||||
|
||||
/// Queues `files`, then files the system or copies ask for.
|
||||
pub(crate) fn listen(application: &Application, files: Vec<PathBuf>, door: Door) -> io::Result<mpsc::UnboundedReceiver<Ask>> {
|
||||
let (sender, asked) = mpsc::unbounded();
|
||||
pub(crate) fn listen(application: &Application, files: Vec<PathBuf>, door: Door) -> io::Result<Handed> {
|
||||
let (queue, asked) = mpsc::unbounded();
|
||||
for path in files {
|
||||
sender.unbounded_send(Ask::Open(path)).ok();
|
||||
queue.unbounded_send(path).ok();
|
||||
}
|
||||
let handed = sender.clone();
|
||||
let (sender, hands) = mpsc::unbounded();
|
||||
door.serve(move |files| {
|
||||
for path in files {
|
||||
handed.unbounded_send(Ask::Open(path)).ok();
|
||||
}
|
||||
handed.unbounded_send(Ask::Raise).ok();
|
||||
let (taken, took) = std::sync::mpsc::channel();
|
||||
sender.unbounded_send(Hand { files, taken }).is_ok() && took.recv_timeout(TAKING).is_ok()
|
||||
})?;
|
||||
let system = queue.clone();
|
||||
application.on_open_urls(move |urls| {
|
||||
for url in urls {
|
||||
match file_path(&url) {
|
||||
Some(path) => {
|
||||
log::info!("run: the system asks to open a file");
|
||||
sender.unbounded_send(Ask::Open(path)).ok();
|
||||
system.unbounded_send(path).ok();
|
||||
}
|
||||
None => log::warn!("run: not a file URL, ignored"),
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(asked)
|
||||
Ok(Handed { asked, hands: Hands { hands, queue } })
|
||||
}
|
||||
|
||||
pub(crate) fn keep(asked: mpsc::UnboundedReceiver<Ask>, cx: &mut App) {
|
||||
cx.set_global(Asked(Some(asked)));
|
||||
/// Keeps the files for `on_open`; the hand-overs go to `home`.
|
||||
pub(crate) fn keep(handed: Handed, cx: &mut App) -> Hands {
|
||||
cx.set_global(Asked(Some(handed.asked)));
|
||||
handed.hands
|
||||
}
|
||||
|
||||
/// No files asked at launch, for tests.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn install_no_launch_files(cx: &mut App) {
|
||||
keep(mpsc::unbounded().1, cx);
|
||||
cx.set_global(Asked(Some(mpsc::unbounded().1)));
|
||||
}
|
||||
|
||||
/// Files asked before the app started: arguments and Finder's.
|
||||
pub fn launch_files(cx: &mut App) -> Vec<PathBuf> {
|
||||
let asked = cx.global_mut::<Asked>().0.as_mut();
|
||||
let asked = asked.expect("launch_files comes before on_open");
|
||||
let pending = std::iter::from_fn(|| asked.try_recv().ok());
|
||||
// The window opens in front anyway.
|
||||
pending.filter_map(|ask| if let Ask::Open(path) = ask { Some(path) } else { None }).collect()
|
||||
std::iter::from_fn(|| asked.try_recv().ok()).collect()
|
||||
}
|
||||
|
||||
/// Hands each file asked from now on to `open`.
|
||||
@@ -76,26 +89,34 @@ pub fn on_open(cx: &mut App, mut open: impl FnMut(PathBuf, &mut App) + 'static)
|
||||
.take()
|
||||
.expect("on_open runs once per app");
|
||||
cx.spawn(async move |cx| {
|
||||
while let Some(ask) = asked.next().await {
|
||||
cx.update(|cx| match ask {
|
||||
Ask::Open(path) => open(path, cx),
|
||||
Ask::Raise => raise(cx),
|
||||
});
|
||||
while let Some(path) = asked.next().await {
|
||||
cx.update(|cx| open(path, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Brings the main window, the first opened, to the front.
|
||||
fn raise(cx: &mut App) {
|
||||
cx.activate(true);
|
||||
let Some(window) = cx.windows().into_iter().next() else {
|
||||
log::warn!("run: no window to bring to the front");
|
||||
return;
|
||||
};
|
||||
if let Err(error) = window.update(cx, |_, window, _| window.activate_window()) {
|
||||
log::warn!("run: the window could not come to the front: {error}");
|
||||
}
|
||||
/// Brings `main` forward for each hand-over, then queues its files.
|
||||
pub(crate) fn home(hands: Hands, main: AnyWindowHandle, cx: &mut App) {
|
||||
let Hands { mut hands, queue } = hands;
|
||||
cx.spawn(async move |cx| {
|
||||
while let Some(hand) = hands.next().await {
|
||||
let raised = cx.update(|cx| {
|
||||
cx.activate(true);
|
||||
main.update(cx, |_, window, _| window.activate_window())
|
||||
});
|
||||
// No answer: the other copy says the files went untaken.
|
||||
if let Err(error) = raised {
|
||||
log::warn!("run: the main window is gone, so a hand-over is refused: {error}");
|
||||
continue;
|
||||
}
|
||||
for path in hand.files {
|
||||
queue.unbounded_send(path).ok();
|
||||
}
|
||||
hand.taken.send(()).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// A `file://` URL's path, percent escapes decoded.
|
||||
@@ -143,3 +164,57 @@ mod tests {
|
||||
assert_eq!(file_path("file:///a/%4"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod home_tests {
|
||||
use super::*;
|
||||
use gpui::{Context, IntoElement, Render, TestAppContext, Window};
|
||||
|
||||
struct Blank;
|
||||
|
||||
impl Render for Blank {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
gpui::div()
|
||||
}
|
||||
}
|
||||
|
||||
/// `home` on `main`; returns its hand-over and file queues.
|
||||
fn homed(main: AnyWindowHandle, cx: &mut TestAppContext) -> (mpsc::UnboundedSender<Hand>, mpsc::UnboundedReceiver<PathBuf>) {
|
||||
let (sender, hands) = mpsc::unbounded();
|
||||
let (queue, asked) = mpsc::unbounded();
|
||||
cx.update(|cx| home(Hands { hands, queue }, main, cx));
|
||||
(sender, asked)
|
||||
}
|
||||
|
||||
fn handed(sender: &mpsc::UnboundedSender<Hand>) -> std::sync::mpsc::Receiver<()> {
|
||||
let (taken, took) = std::sync::mpsc::channel();
|
||||
sender.unbounded_send(Hand { files: vec![PathBuf::from("x.md")], taken }).expect("sent");
|
||||
took
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_hand_over_raises_the_main_window_then_queues(cx: &mut TestAppContext) {
|
||||
let main: AnyWindowHandle = cx.add_window(|_, _| Blank).into();
|
||||
let _settings = cx.add_window(|_, _| Blank);
|
||||
let (sender, mut asked) = homed(main, cx);
|
||||
assert_ne!(cx.update(|cx| cx.active_window()), Some(main), "another window is in front");
|
||||
let took = handed(&sender);
|
||||
cx.run_until_parked();
|
||||
assert_eq!(cx.update(|cx| cx.active_window()), Some(main));
|
||||
assert_eq!(asked.try_recv().ok(), Some(PathBuf::from("x.md")));
|
||||
assert!(took.try_recv().is_ok(), "the other copy hears it was taken");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_hand_over_without_the_main_window_is_refused(cx: &mut TestAppContext) {
|
||||
let main: AnyWindowHandle = cx.add_window(|_, _| Blank).into();
|
||||
let _settings = cx.add_window(|_, _| Blank);
|
||||
let (sender, mut asked) = homed(main, cx);
|
||||
main.update(cx, |_, window, _| window.remove_window()).expect("closed");
|
||||
cx.run_until_parked();
|
||||
let took = handed(&sender);
|
||||
cx.run_until_parked();
|
||||
assert!(took.try_recv().is_err(), "no answer, so the other copy reports it");
|
||||
assert!(asked.try_recv().is_err(), "nothing queued for a gone window");
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,10 @@ pub fn run<V: Render>(
|
||||
startup::no_window_restore();
|
||||
let smoke = Smoke::from_env();
|
||||
let application = gpui_platform::application().with_assets(Assets);
|
||||
let asked = crate::opening::listen(&application, files, door).unwrap_or_else(|error| startup::fail(app, &format!("the door for other copies did not open: {error}")));
|
||||
let handed = crate::opening::listen(&application, files, door).unwrap_or_else(|error| startup::fail(app, &format!("the door for other copies did not open: {error}")));
|
||||
application.run(move |cx: &mut App| {
|
||||
crate::opening::keep(asked, cx);
|
||||
open(app, smoke, root, cx)
|
||||
let hands = crate::opening::keep(handed, cx);
|
||||
open(app, smoke, root, hands, cx)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ fn open<V: Render>(
|
||||
app: AppId,
|
||||
smoke: Smoke,
|
||||
root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity<V> + 'static,
|
||||
hands: crate::opening::Hands,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Err(error) = ely_gpui_component::init(cx) {
|
||||
@@ -109,11 +110,14 @@ fn open<V: Render>(
|
||||
crate::settings::follow_frame(app, window, cx);
|
||||
root(app, window, cx)
|
||||
});
|
||||
if let Err(error) = opened {
|
||||
startup::fail(app, &format!("the window did not open: {error:#}"));
|
||||
}
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
let main: gpui::AnyWindowHandle = match opened {
|
||||
Ok(window) => window.into(),
|
||||
Err(error) => startup::fail(app, &format!("the window did not open: {error:#}")),
|
||||
};
|
||||
crate::opening::home(hands, main, cx);
|
||||
// Settings alone would hold the lock yet open nothing.
|
||||
cx.on_window_closed(move |cx, closed| {
|
||||
if closed == main.window_id() {
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -242,7 +242,7 @@
|
||||
- [~] R4.8 组件库:表格行列上下左右移动、整理表格源码;代码块取代码内容;与已有的增删行列同一套拒绝。组件 bcee87c 至 6b033f3(已推送):`TableEdit` 加上下移行(表头与首行互换,超出表头列数的行不作表头,到边拒绝为 `TableRefusal::Edge`)、左右移列、整理;表格按显示宽度补齐(`unicode-width`,Tab 记 4、控制字符记 0,照 Typora 的 `wcswidth`),写回表格自身的换行;表格编辑并入后台执行器;右键代码块时 `DocumentTarget` 带出代码文字。审查三轮未过(`tasks/reviews/R4.8-r1..r3.md`):r1 两项(纯 CR 文本里整理表格把前文当作容器前缀、行内 Tab 宽度不合 Typora);r2 一项(End 后插入表格拆开 CRLF);r3 一项(r2 的修法让行辅助函数返回光标之前的位置,End 后的块命令崩溃)。r3 已修:编辑器只按 LF 分行,光标可落在 CR 与 LF 之间;执行器与插入表格在入口把选区移到 CR 前,行辅助函数改为断言不收这种位置;两项回归在 r3 的源码上失败(`.work/R4.8-fix3/red.md`);轮数用尽,未再审。遗留:编辑器自身的 CRLF 模型不变,End 仍落在 CR 之后;表情宽度按 `unicode-width` 记 2 列(Typora 记 1)
|
||||
- [x] R4.9 ElyMark:“段落”“格式”菜单与 Typora 同项同键(Ctrl+1–6、Ctrl+0、Ctrl+=/-、Ctrl+B/I/U、Ctrl+Shift+`、Alt+Shift+5、Ctrl+K、Ctrl+Shift+I、Ctrl+\、Ctrl+T、Ctrl+Shift+K/M/Q/[/]);源码模式 Ctrl+/;字号 Ctrl+Shift+=/-/0;专注 F8、打字机 F9;表格菜单加移动行列;复制为 Markdown Ctrl+Shift+C、粘贴为纯文本 Ctrl+Shift+V。ElyMark 5392062、f84f990、09fcb0b,组件 6d90351、24ffc45、6c2d28c(已推送),pin 6c2d28c:键与菜单项取自 Typora 字节码与其中文菜单;表格为“段落”子菜单,加删除表格;拒绝成提示;菜单借打开的块的焦点,块与选区留着;复制为 Markdown、粘贴为纯文本只作用于文档。R1 FAIL 六项、R2 FAIL 四项已修,R3 PASS
|
||||
- [ ] R4.10 ElyMark:右键菜单照 Typora 按目标变化:剪贴板行(剪切、复制、粘贴、删除)、复制/粘贴为 ▸(Markdown、HTML 代码、纯文本、粘贴为纯文本)、格式行、段落 ▸ 或表格 ▸ 或代码项、插入 ▸、链接(打开、复制地址)、图片(打开所在位置、删除图片)、删除块;预览只读时只留复制类
|
||||
- [ ] R4.12 全部应用:已开着时再打开文件,交给运行中的那份开成新标签并带到前台,不再弹“已在运行”(用户报,2026-10-14);先于 R4.11 做
|
||||
- [ ] R4.12 全部应用:已开着时再打开文件,交给运行中的那份开成新标签并带到前台,不再弹“已在运行”(用户报,2026-10-14);先于 R4.11 做。第 1 轮 4 项:令牌被拒时连接重置使重试失效、慢速来者占住门、主窗口关后仍回执、ElyMail 与 ElyAgent 不到前台;已修,各有回归
|
||||
- [ ] R4.11 验收:真窗口逐项操作,每项功能可用(不求与 Typora 一一相同);帮助、PRD、`agents.md` 随之;逐条核对表
|
||||
|
||||
## R5 ElyDoc 照 Word 的右键与表格(用户 2026-10-13 报:右键没反应;表格不能加行列、不能调大小;三项方案经用户确认)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FAIL
|
||||
|
||||
1. `crates/ely-platform/src/instance/door.rs:131`:鉴权尚未完成就发送所有 `PATH` 和 `DONE`,而接收端遇到错误令牌立即返回并关闭仍有未读数据的连接。连接重置会先于 `REFUSED` 被客户端读到,`hand` 第 90 行的 `?` 随即退出,旧门文件的重试失效。Windows 原有 `a_wrong_token_hands_nothing_over` 测试实跑失败,单独重复 12 次失败 5 次;探针把旧令牌在 100 毫秒后恢复为正确值,Windows 5/5、Linux 4/5 次交接仍提前失败,远未到 10 秒期限。修复方向:发送路径前完成明确的鉴权确认,让鉴权阶段的断连可以安全重试,保留路径发送后不重试的约束。证据:`.work/R4.12-r1/token-repeat.log`、`protocol-windows.log`、`protocol-linux.log`。
|
||||
|
||||
2. `crates/ely-ui/src/opening.rs:92`:剩下的第一个窗口未必是文档主窗口,接收文件的回调也可能仍指向已关闭的主窗口。Windows 真窗口复现:打开 ElyMark 设置,关闭文档主窗口,保留设置窗口,再启动第二份打开 `third.md`;第二份退出 0,第一份只显示设置窗口,文件未开成标签,日志报 `ely-office::open: window not found`。修复方向:主窗口关闭后不能继续把交接确认为成功并派给失效窗口;交接时重建有效主窗口及其打开回调,或让主窗口关闭时结束残留进程,使下一次启动能正常打开文件。证据:`.work/R4.12-r1/main-closed-results.log`。
|
||||
|
||||
3. `crates/ely-ui/src/opening.rs:78`:`Ask::Raise` 的唯一消费者依赖应用调用 `on_open`,但 ElyMail、ElyAgent 使用的 `ely_ui::blank` 从不调用它;这两个应用收到交接后只将请求积在队列中,却仍回复成功。Windows 真窗口分别将 ElyMail、ElyAgent 最小化,再无参数启动第二份:两次都退出 0,但原窗口仍最小化且未到前台。修复方向:在每个应用必经的启动路径安装前台请求的消费者,不让窗口激活依赖文件打开订阅。证据:`.work/R4.12-r1/window-results.log`。
|
||||
|
||||
4. `crates/ely-platform/src/instance/door.rs:57`:五秒 `read_timeout` 限制单次阻塞读取,不限制整个鉴权请求;第 47–48 行又在唯一接收线程上串行处理连接。没有令牌的本地进程可以发送一个未完成的 TOKEN 帧并持续少量补字节,长期占住接收线程,使正常实例交接超时。Windows、Linux 探针均在未鉴权连接每 500 毫秒发送一个字节、已占用超过六秒后发起正常交接,正常客户端再等约五秒即失败;关闭该连接后交接立即恢复。修复方向:给未鉴权请求设置不可被零星输入续期的整体截止时间,确保它不能持续阻塞后续合法交接,并加入对应回归。证据:`.work/R4.12-r1/protocol-windows.log`、`protocol-linux.log`。
|
||||
Reference in new issue
Block a user