C14.11 r1: fingerprints carry the file's identity; stale disk reads never land

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 08:04:18 -04:00
1 parent 1dc0d31615
commit e2cb83ceaa
6 files changed
+86 -3

No files matched your search

+1 -1
View File
@@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
+18 -1
View File
@@ -53,6 +53,8 @@ pub struct Extra {
pub pointing: bool,
/// A kept tab's mode, place and text came back once.
pub restored: bool,
/// Disk reads started; only the newest lands.
pub reads: u64,
}
impl Extra {
@@ -76,6 +78,7 @@ impl Default for Extra {
pointed: None,
pointing: false,
restored: false,
reads: 0,
}
}
}
@@ -199,13 +202,18 @@ impl Desk {
/// Changed: a clean tab follows, a dirty one asks.
fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.reads += 1;
let number = tab.extra.reads;
let reading = path.clone();
cx.spawn(async move |desk, cx| {
let read = cx
.background_executor()
.spawn(async move { read_still(&reading) })
.await;
desk.update(cx, |desk, cx| desk.disk_read(id, path, read, cx))
desk.update(cx, |desk, cx| desk.disk_read(id, path, number, read, cx))
.ok();
})
.detach();
@@ -215,16 +223,25 @@ impl Desk {
&mut self,
id: u64,
path: PathBuf,
number: u64,
read: io::Result<(Fingerprint, Source)>,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
return log::info!("mark: a read for a moved or closed tab was dropped");
};
if tab.extra.reads != number {
return log::info!("mark: read {number} of tab {id} lapsed");
}
let (fingerprint, disk) = match read {
Ok(read) => read,
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
};
// The disk moved on since the read: look again.
if Fingerprint::of(&path).ok() != Some(fingerprint) {
log::info!("mark: tab {id}'s file changed while read");
return self.look(id, cx);
}
let current = tab.doc.lock().expect("the text lock").current.clone();
let kept = disk.text == tab.extra.base;
let dirty = tab.dirty(cx);
+42
View File
@@ -138,3 +138,45 @@ fn a_path_that_cannot_be_read_says_why(cx: &mut TestAppContext) {
});
assert!(unread);
}
#[gpui::test]
fn a_replace_that_keeps_the_time_still_reloads(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("old text\n", cx);
let modified = std::fs::metadata(&path)
.expect("meta")
.modified()
.expect("a time");
let next = path.with_extension("new");
std::fs::write(&next, "new text\n").expect("a new file");
let file = std::fs::File::options()
.write(true)
.open(&next)
.expect("open");
file.set_modified(modified).expect("the old time");
drop(file);
std::fs::rename(&next, &path).expect("a replace");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "new text\n");
}
#[gpui::test]
fn a_stale_read_never_lands(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("first\n", cx);
std::fs::write(&path, "older\n").expect("an older write");
let older = (
ely_files::Fingerprint::of(&path).expect("its fingerprint"),
crate::text::Source::decode(b"older\n".to_vec()).expect("reads"),
);
std::fs::write(&path, "newest disk\n").expect("a newer write");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "newest disk\n");
let reads = desk.read_with(cx, |desk, _| desk.tab(id).expect("open").extra.reads);
for number in [reads - 1, reads] {
let (path, older) = (path.clone(), (older.0, older.1.clone()));
desk.update(cx, |desk, cx| {
desk.disk_read(id, path, number, Ok(older), cx)
});
cx.run_until_parked();
assert_eq!(shown(&desk, cx), "newest disk\n", "read {number}");
}
}
+4 -1
View File
@@ -4,11 +4,13 @@ use std::{
time::SystemTime,
};
/// Length and mtime when last loaded or saved.
/// Length, mtime and identity when last loaded or saved.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fingerprint {
pub len: u64,
pub modified: SystemTime,
/// A replace with the same time is a new file.
pub id: Option<(u64, u64)>,
}
impl Fingerprint {
@@ -17,6 +19,7 @@ impl Fingerprint {
Ok(Self {
len: meta.len(),
modified: meta.modified()?,
id: file_id(path)?,
})
}
}
+16
View File
@@ -482,3 +482,19 @@ fn a_stale_load_callback_keeps_the_reloaded_state() {
}
mod failed;
#[test]
fn a_replace_with_the_same_time_and_size_is_a_new_fingerprint() {
let dir = tempfile::tempdir().expect("a temp dir");
let (path, next) = (dir.path().join("a.md"), dir.path().join("a.md.new"));
write(&path, "old text\n");
let before = Fingerprint::of(&path).expect("the first fingerprint");
write(&next, "new text\n");
let file = std::fs::File::options().write(true).open(&next).expect("the new file");
file.set_modified(before.modified).expect("the old time");
drop(file);
std::fs::rename(&next, &path).expect("the replace");
let after = Fingerprint::of(&path).expect("the second fingerprint");
assert_eq!((after.len, after.modified), (before.len, before.modified));
assert_ne!(after, before, "the identity tells them apart");
}
+5
View File
@@ -0,0 +1,5 @@
FAIL
- apps/ely-mark/src/outside.rs:154:仅以长度和修改时间相等判定文件未变,漏掉保留时间戳的原子替换,违反 MARK-08、§14.4、QA-22。复现:打开 `old text\n`,另建内容为 `new text\n` 的文件,保留原文件的修改时间,再替换原路径;调用实际的 `look` 路径后,页面仍是 `old text\n`,没有重载或冲突提示,手动刷新也无效。Windows、WSL 探针均失败。修复方向:将文件身份纳入替换判定,不能仅凭长度、mtime 相同跳过真实变更;补上保留时间戳的原子替换回归。证据:`.work/C14.11-r1/probes.log`、`.work/C14.11-r1/linux-probes.log` 的 `review_clean_atomic_replacement_with_preserved_time`。
- apps/ely-mark/src/outside.rs:221:异步读取结果只核对标签路径,没有读取代次或当前磁盘版本校验,迟到的旧读取会覆盖已经接收的新版本。复现:同一路径连续写入 A、B,两次后台读取分别取得 A、B,B 的完成回调先落下,A 的回调后落下;干净标签先显示 B,随后被重载为 A,并被标成已保存、清空撤销历史,即使磁盘仍是 B。探针用实际的 `disk_read` 完成入口按此顺序交付两份成功快照,Windows、WSL 均得到 `older\n`,预期为 `newest disk\n`。修复方向:为每标签的读取编号并丢弃过期结果,应用结果前校验磁盘版本;补上读取乱序及读取期间再次写入的回归。证据:上述两份探针日志的 `review_old_read_cannot_replace_newer_disk_text`。