Linux: file watchers skip reads, so idle apps stop rereading theme, settings and repos (L1.2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c62d548688
commit
b2e13230b5
12 files changed
+211
-9
No files matched your search
@@ -131,6 +131,7 @@ scripts/review.sh ID 轮次 路径 审查一条任务
|
||||
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
|
||||
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
|
||||
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
|
||||
tests/scripts/linux-idle.sh 真窗口 ElyTree:打开仓库后、Omarchy 换主题与改设置后,空闲时不读主题、设置,不跑 git status;照样跟随换主题、改设置与改文件,经内存映射的写入也跟随
|
||||
tests/scripts/linux-backends.sh Linux 构建带 Wayland 与 X11;Hyprland 上 ElyTree 在两者上都映射出窗口、画出页面与起始页文字(截窗口量色,tesseract 读字)
|
||||
cargo test -p ely-show --test compose 切换半程帧经 Metal 取像素(主线程,仅 macOS)
|
||||
tests/scripts/*.sh *.py 脚本测试(缺工具时报 SKIP)
|
||||
@@ -294,3 +295,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-11:ElyMark 的 AppImage(0.1.3 的 Linux 发布查出)。`linuxdeploy` 把可执行文件的 RUNPATH 改成 `$ORIGIN/../lib`,并把它找得到的 `libcef.so` 拷进 `usr/lib`:原先 `libcef.so` 与 CEF 的资料在 `usr/bin`,结果是两份 1.45 GB 的内核、程序载入 `usr/lib` 那份、CEF 在它旁边找不到 ICU 数据而退出(从未在 AppImage 里启动过)。现在 `appimage.sh` 把从 `engines/cef` 分装来的全部条目移进 `usr/lib`(按名字对照 CEF 文件夹,清单仍只在 `stage.sh`),以 `LD_LIBRARY_PATH=AppDir/usr/lib` 运行 `linuxdeploy`,`libcef.so` 只此一份、依赖照样带上,CEF 按所载 `libcef.so` 的文件夹取资料(`ely-web-content` 的 `open`)。ElyMark 的 AppImage 由 715 MB 降到 423 MB;WSL 里启动后 CEF 载入、窗口打开(WSL 里各应用都不出首帧,未在真桌面上验证)。
|
||||
- 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
|
||||
- 2026-10-11(用户报:Omarchy 上六个 AppImage 都打不开):`gpui-pre-platform` 对 `gpui-pre-linux` 关了默认特性,Wayland 与 X11 只经它自己的 `wayland`、`x11` 特性打开;工作区原只开 `font-kit`,Linux 上 gpui 退成无头平台,日志照写“window open”,合成器里从无窗口。工作区的 `gpui_platform` 加开两者;`tests/scripts/linux-backends.sh` 核对特性,在 Hyprland 上核对 Wayland 与 X11 两路都映射出窗口、截图里有均匀的深色页面,tesseract 读出起始页的 “Open a repository”(不交缓冲的窗口 Hyprland 不映射;不画字形时读不出)。
|
||||
- 2026-10-11:Linux 的 inotify 把打开文件报成事件(notify 的 `Access`),macOS 的 FSEvents 不报;四处文件监视原把任何事件当作改动,重读本身又触发下一次:Omarchy 主题与通用设置在第一次改动后、ElyTree 打开仓库起(`git status` 读仓库),空闲时每 250 毫秒重读。四处都经 `ely_ui::watching::only_read` 略去读取(打开、读、只读关闭),写后关闭照算改动(经内存映射的写入只报 CLOSE_WRITE,不报 MODIFY);`tests/scripts/linux-idle.sh` 以 inotifywait 从外面数空闲时的读取与 git status 次数。
|
||||
@@ -376,6 +376,10 @@ impl Desk {
|
||||
fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<RecommendedWatcher> {
|
||||
let (sender, mut changes) = mpsc::unbounded();
|
||||
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
// Reads, ours included, are no change.
|
||||
if event.as_ref().is_ok_and(|event| ely_ui::watching::only_read(&event.kind)) {
|
||||
return;
|
||||
}
|
||||
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
|
||||
})?;
|
||||
for dir in dirs {
|
||||
|
||||
@@ -17,6 +17,8 @@ pub fn start(id: u64, repo: &Repo, cx: &mut Context<Tree>) -> notify::Result<Rec
|
||||
let root = repo.root.display().to_string();
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event {
|
||||
// Git's reads, ours included, are no change.
|
||||
Ok(event) if ely_ui::watching::only_read(&event.kind) => {}
|
||||
Ok(_) => {
|
||||
let _ = sender.unbounded_send(());
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod shell;
|
||||
pub mod tasks;
|
||||
mod theme;
|
||||
pub mod updates;
|
||||
pub mod watching;
|
||||
|
||||
pub use opening::{launch_files, on_open};
|
||||
pub use run::run;
|
||||
|
||||
@@ -82,6 +82,8 @@ fn follow(cx: &mut App) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let (sender, mut changes) = mpsc::unbounded();
|
||||
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event {
|
||||
// Reads, ours included, are no change.
|
||||
Ok(event) if crate::watching::only_read(&event.kind) => {}
|
||||
Ok(event) if event.paths.iter().any(|changed| changed.file_name() == path.file_name()) => {
|
||||
let _ = sender.unbounded_send(());
|
||||
}
|
||||
|
||||
@@ -29,9 +29,12 @@ pub fn load(current: &Path) -> Result<Option<OmarchyTheme>> {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error).with_context(|| format!("cannot read {}", file.display())),
|
||||
};
|
||||
let name = fs::read_to_string(current.join("theme.name"))
|
||||
.ok()
|
||||
.map(|name| name.trim().to_string());
|
||||
let name_file = current.join("theme.name");
|
||||
let name = match fs::read_to_string(&name_file) {
|
||||
Ok(name) => Some(name.trim().to_string()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error).with_context(|| format!("cannot read {}", name_file.display())),
|
||||
};
|
||||
parse(&text, name, theme.join("light.mode").exists())
|
||||
.with_context(|| format!("{} is not a usable theme", file.display()))
|
||||
.map(Some)
|
||||
@@ -53,6 +56,8 @@ pub fn changes(current: &Path) -> Result<(RecommendedWatcher, UnboundedReceiver<
|
||||
let (sender, receiver) = mpsc::unbounded();
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event {
|
||||
// Reads, ours included, are no change.
|
||||
Ok(event) if crate::watching::only_read(&event.kind) => {}
|
||||
Ok(_) => {
|
||||
let _ = sender.unbounded_send(());
|
||||
}
|
||||
|
||||
@@ -314,3 +314,29 @@ fn changes_sees_a_theme_swap_in_the_order_omarchy_does_it() {
|
||||
"no event for a swapped theme"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_the_theme_is_no_change() {
|
||||
let current = tempfile::tempdir().unwrap();
|
||||
let theme_dir = current.path().join("theme");
|
||||
fs::create_dir(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("colors.toml"), LATTE).unwrap();
|
||||
fs::write(current.path().join("theme.name"), "catppuccin-latte\n").unwrap();
|
||||
let (_watcher, mut events) = changes(current.path()).unwrap();
|
||||
|
||||
load(current.path()).unwrap().expect("a theme");
|
||||
assert!(!event_within(&mut events, 10), "a read counted as a change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_theme_name_is_an_error_and_a_missing_one_is_none() {
|
||||
let current = tempfile::tempdir().unwrap();
|
||||
let theme_dir = current.path().join("theme");
|
||||
fs::create_dir(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("colors.toml"), LATTE).unwrap();
|
||||
assert_eq!(load(current.path()).unwrap().unwrap().name, None);
|
||||
|
||||
fs::create_dir(current.path().join("theme.name")).unwrap();
|
||||
let error = format!("{:#}", load(current.path()).unwrap_err());
|
||||
assert!(error.contains("theme.name"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use notify::EventKind;
|
||||
use notify::event::{AccessKind, AccessMode};
|
||||
|
||||
/// A read; Linux reports those, macOS does not.
|
||||
pub fn only_read(kind: &EventKind) -> bool {
|
||||
matches!(kind, EventKind::Access(access) if *access != AccessKind::Close(AccessMode::Write))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use notify::EventKind;
|
||||
use notify::event::{AccessKind, AccessMode, DataChange, ModifyKind};
|
||||
|
||||
use super::only_read;
|
||||
|
||||
#[test]
|
||||
fn opens_and_reads_are_reads_and_a_written_close_is_a_change() {
|
||||
for read in [
|
||||
AccessKind::Open(AccessMode::Any),
|
||||
AccessKind::Read,
|
||||
AccessKind::Close(AccessMode::Read),
|
||||
AccessKind::Any,
|
||||
] {
|
||||
assert!(only_read(&EventKind::Access(read)), "{read:?}");
|
||||
}
|
||||
assert!(!only_read(&EventKind::Access(AccessKind::Close(AccessMode::Write))));
|
||||
assert!(!only_read(&EventKind::Modify(ModifyKind::Data(DataChange::Any))));
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -201,9 +201,10 @@
|
||||
|
||||
## L1 Linux 发布(用户 2026-10-11 报:Omarchy 上六个 AppImage 都打不开;要 deb、rpm、Arch、AppImage;在 ubuntu:26.04 容器里本机构建,替换 v0.1.3 的 Linux 包)
|
||||
|
||||
- [x] L1.1 Linux 显示后端:`gpui_platform` 开 `wayland` 与 `x11`(原样无头,“window open”而合成器里没有窗口);Omarchy(Hyprland)上 ElyTree 在 Wayland 与 X11 上画出窗口(其余五个要引擎,随 L1.5 实测);`tests/scripts/linux-backends.sh` — R1 锁文件、目标目录、未证明交帧;R2 产物路径、字形未核;R3 语言、OCR 错误被吞;R4 PASS
|
||||
- [ ] L1.2 Omarchy 主题同步:查清空闲时每秒数次重套主题的根因并修;Omarchy 换主题时应用跟随
|
||||
- [ ] L1.3 Linux 构建环境:`ubuntu:26.04` 容器里构建全部引擎与六个应用(glibc 不高于 2.43)
|
||||
- [ ] L1.4 Linux 包:deb、rpm、Arch(pkg.tar.zst)、AppImage 出自同一份分装,装到 `/opt/ely-office/<应用>`,带桌面项、图标与文件类型
|
||||
- [ ] L1.5 本机出六个应用的四种包;Omarchy 上装 Arch 包与 AppImage 出窗口;deb、rpm 在各自发行版的容器里装上并冒烟
|
||||
- [ ] L1.6 v0.1.3:删去旧 AppImage,上传新包;`ci.sh` 的 Linux 产出四种包;agents.md 与官网随之
|
||||
- [x] L1.1 Linux 显示后端:`gpui_platform` 开 `wayland` 与 `x11`(原样无头,“window open”而合成器里没有窗口);Omarchy(Hyprland)上 ElyTree 在 Wayland 与 X11 上画出窗口(其余五个要引擎,随 L1.6 实测);`tests/scripts/linux-backends.sh` — R1 锁文件、目标目录、未证明交帧;R2 产物路径、字形未核;R3 语言、OCR 错误被吞;R4 PASS
|
||||
- [x] L1.2 Linux 上的文件监视把读取当作改动(inotify 报打开,FSEvents 不报):Omarchy 主题、通用设置在第一次改动后、ElyTree 打开仓库起,每次重读自己又触发下一次,空闲时每 250 毫秒读一遍;四处监视(主题、设置、ElyTree、ElyMark)经 `ely_ui::watching::only_read` 略去读取,写后关闭照算改动;`theme.name` 读不出不再吞掉;`tests/scripts/linux-idle.sh`(ElyMark 要 CEF,随 L1.6 实测) — R1 写后关闭也被略去、注释超长;R2 PASS
|
||||
- [ ] L1.3 Linux 上设置窗口打开即崩(无菜单的窗口画菜单条,组件库断言“a menu bar needs menus”)
|
||||
- [ ] L1.4 Linux 构建环境:`ubuntu:26.04` 容器里构建全部引擎与六个应用(glibc 不高于 2.43)
|
||||
- [ ] L1.5 Linux 包:deb、rpm、Arch(pkg.tar.zst)、AppImage 出自同一份分装,装到 `/opt/ely-office/<应用>`,带桌面项、图标与文件类型
|
||||
- [ ] L1.6 本机出六个应用的四种包;Omarchy 上装 Arch 包与 AppImage 出窗口;deb、rpm 在各自发行版的容器里装上并冒烟
|
||||
- [ ] L1.7 v0.1.3:删去旧 AppImage,上传新包;`ci.sh` 的 Linux 产出四种包;agents.md 与官网随之
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
- apps/ely-tree/src/watch.rs:21; apps/ely-mark/src/outside.rs:380; crates/ely-ui/src/settings/mod.rs:86; crates/ely-ui/src/theme/omarchy/mod.rs:60 — `is_access()` also discards `Access(Close(Write))`, so actual external writes can disappear. Reproduced on Linux by modifying `a.txt`, `theme.name`, and `settings.json` through writable shared memory mappings, flushing, then closing: inotify emitted OPEN and CLOSE_WRITE without MODIFY. The running ElyTree performed no new status read and observed neither the theme-name nor settings change; ordinary writes immediately triggered all three. ElyMark has the same filter, which also drops this content-change signal (MARK-08). Evidence: `.work/L1.2-r1/mmap-results.log` and `mmap-events.log`; the existing idle script passes despite this defect. Preserve writable-close notifications while filtering read events, and add regression coverage for mapped writes.
|
||||
|
||||
- tests/scripts/linux-idle.sh:59 — The comment has 12 words, exceeding the ten-word limit in agents.md. Running `python tests/scripts/comments.py` fails on this line (`.work/L1.2-r1/comments.log`). Shorten the comment to ten words or fewer.
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idle on Linux: ElyTree rereads neither theme, settings nor repo.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(uname)" = Linux ] || {
|
||||
echo "SKIP: inotify is Linux"
|
||||
exit 0
|
||||
}
|
||||
[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] || {
|
||||
echo "SKIP: no display"
|
||||
exit 0
|
||||
}
|
||||
for tool in inotifywait jq git; do
|
||||
command -v "$tool" >/dev/null || {
|
||||
echo "SKIP: no $tool"
|
||||
exit 0
|
||||
}
|
||||
done
|
||||
app=$(cargo build -q --locked --message-format=json --manifest-path "$root/Cargo.toml" -p ely-tree --bin ely-tree |
|
||||
jq -r 'select(.reason == "compiler-artifact" and .target.name == "ely-tree") | .executable // empty')
|
||||
[ -x "$app" ] || fail "Cargo reported no ElyTree binary"
|
||||
|
||||
work=$(mktemp -d)
|
||||
pid=
|
||||
spy=
|
||||
trap '[ -z "$pid" ] || kill "$pid" 2>/dev/null; [ -z "$spy" ] || kill "$spy" 2>/dev/null; rm -rf "$work"' EXIT
|
||||
current=$work/omarchy/current
|
||||
mkdir -p "$current/theme" "$work/data/ElyOffice" "$work/bin"
|
||||
cp "$root/crates/ely-ui/tests/fixtures/omarchy/catppuccin.toml" "$current/theme/colors.toml"
|
||||
echo catppuccin >"$current/theme.name"
|
||||
echo '{"version": 1}' >"$work/data/ElyOffice/settings.json"
|
||||
repo=$work/repo
|
||||
git init -q "$repo"
|
||||
echo one >"$repo/a.txt"
|
||||
git -C "$repo" add a.txt
|
||||
git -C "$repo" -c user.name=t -c user.email=t@t commit -q -m one
|
||||
real=$(command -v git)
|
||||
printf '#!/bin/sh\necho "$*" >>"%s"\nexec "%s" "$@"\n' "$work/git.log" "$real" >"$work/bin/git"
|
||||
chmod +x "$work/bin/git"
|
||||
|
||||
ELY_GIT="$work/bin/git" ELY_OMARCHY_DIR="$current" ELY_SMOKE=hold ELY_LOG=info HOME="$work" \
|
||||
XDG_DATA_HOME="$work/data" "$app" "$repo" 2>"$work/app.err" &
|
||||
pid=$!
|
||||
statuses() { if [ -f "$work/git.log" ]; then grep -c '^status' "$work/git.log" || true; else echo 0; fi; }
|
||||
for _ in $(seq 120); do
|
||||
[ "$(statuses)" -gt 0 ] && break
|
||||
kill -0 "$pid" 2>/dev/null || fail "ElyTree quit: $(tail -n 5 "$work/app.err")"
|
||||
sleep 0.5
|
||||
done
|
||||
[ "$(statuses)" -gt 0 ] || fail "ElyTree never read the repo"
|
||||
grep -q 'omarchy catppuccin applied' "$work/app.err" || fail "the Omarchy theme was not applied"
|
||||
sleep 3
|
||||
|
||||
# Four quiet seconds: no reads, no git status.
|
||||
idle() { # when
|
||||
inotifywait -q -m -e open --format '%w%f' "$current" "$current/theme" "$work/data/ElyOffice" >"$work/opens" 2>/dev/null &
|
||||
spy=$!
|
||||
sleep 1
|
||||
local before opens
|
||||
before=$(statuses)
|
||||
sleep 4
|
||||
kill "$spy"
|
||||
spy=
|
||||
opens=$(grep -c -E '(colors\.toml|theme\.name|settings\.json)$' "$work/opens" || true)
|
||||
[ "$opens" -eq 0 ] || fail "ElyTree $1 read the theme or settings $opens times: $(sort "$work/opens" | uniq -c)"
|
||||
[ "$(statuses)" -eq "$before" ] || fail "ElyTree $1 ran git status $(($(statuses) - before)) more times"
|
||||
echo "linux: ElyTree $1 reads nothing"
|
||||
}
|
||||
seen() { grep -c "$1" "$work/app.err" || true; }
|
||||
logged() { # pattern, what; once more than before
|
||||
local before=${3:-0}
|
||||
for _ in $(seq 40); do
|
||||
[ "$(seen "$1")" -gt "$before" ] && return
|
||||
sleep 0.25
|
||||
done
|
||||
fail "ElyTree missed $2"
|
||||
}
|
||||
grown() { # what
|
||||
for _ in $(seq 40); do
|
||||
[ "$(statuses)" -gt "$before" ] && return
|
||||
sleep 0.25
|
||||
done
|
||||
fail "ElyTree missed $1"
|
||||
}
|
||||
# Writes through a mapping: CLOSE_WRITE, no MODIFY.
|
||||
mapped() { # file, old, new
|
||||
python3 -I -c 'import mmap, sys
|
||||
with open(sys.argv[1], "r+b") as f, mmap.mmap(f.fileno(), 0) as m:
|
||||
at = m.find(sys.argv[2].encode())
|
||||
assert at >= 0, sys.argv[2]
|
||||
m[at:at + len(sys.argv[3])] = sys.argv[3].encode()
|
||||
m.flush()' "$@"
|
||||
}
|
||||
idle "idle after opening"
|
||||
|
||||
# Omarchy's swap, a settings write, an edit; then quiet again.
|
||||
mkdir "$current/next-theme"
|
||||
cp "$root/crates/ely-ui/tests/fixtures/omarchy/gruvbox.toml" "$current/next-theme/colors.toml"
|
||||
rm -rf "$current/theme"
|
||||
mv "$current/next-theme" "$current/theme"
|
||||
echo gruvbox >"$current/theme.name"
|
||||
logged 'omarchy gruvbox applied' "Omarchy's theme swap"
|
||||
echo '{"version": 1, "author": "Tester"}' >"$work/data/ElyOffice/settings.json"
|
||||
logged 'settings: changed on disk' "a settings write"
|
||||
before=$(statuses)
|
||||
echo two >>"$repo/a.txt"
|
||||
grown "an edit in the repo"
|
||||
sleep 2
|
||||
idle "idle after changes"
|
||||
|
||||
mapped "$current/theme.name" gruvbox Gruvbox
|
||||
logged 'omarchy Gruvbox applied' "a mapped theme name"
|
||||
changed=$(seen 'settings: changed on disk')
|
||||
mapped "$work/data/ElyOffice/settings.json" Tester Testor
|
||||
logged 'settings: changed on disk' "a mapped settings write" "$changed"
|
||||
before=$(statuses)
|
||||
mapped "$repo/a.txt" two Two
|
||||
grown "a mapped edit in the repo"
|
||||
echo "linux: ElyTree follows mapped writes"
|
||||
Reference in new issue
Block a user