diff --git a/crates/ely-lok/src/client/doc.rs b/crates/ely-lok/src/client/doc.rs index ec445cb..86bd56b 100644 --- a/crates/ely-lok/src/client/doc.rs +++ b/crates/ely-lok/src/client/doc.rs @@ -183,20 +183,37 @@ impl Doc { let head = worker.request(tag::SLIDE_LAYERS, &request)?; let mut unpack = Unpack::new(&head); let (width, height, count) = (unpack.u64()?, unpack.u64()?, unpack.u64()?); - // One layer per reply keeps each frame small. + // Layers arrive in pieces that keep each frame small. let mut whole = Pack::new().u64(width).u64(height).u64(count); for index in 0..count { let (json, painted) = (unpack.str()?, unpack.u64()?); whole = whole.str(&json).u64(painted); if painted != 0 { - let id = worker.doc_id(self)?; - let reply = worker.request(tag::SLIDE_LAYER, &Pack::new().u64(id).u64(index).bytes())?; - whole = whole.blob(&Unpack::new(&reply).blob()?); + whole = whole.blob(&self.slide_layer(worker, index)?); } } crate::show::layers(&whole.bytes()) } + /// Layer `index` of the last layers read, piece by piece. + fn slide_layer(&self, worker: &mut Worker, index: u64) -> io::Result> { + let id = worker.doc_id(self)?; + let mut pixels = Vec::new(); + loop { + let ask = Pack::new().u64(id).u64(index).u64(pixels.len() as u64).bytes(); + let reply = worker.request(tag::SLIDE_LAYER, &ask)?; + let mut unpack = Unpack::new(&reply); + let (total, piece) = (unpack.u64()?, unpack.blob()?); + pixels.extend_from_slice(&piece); + if pixels.len() as u64 >= total { + return Ok(pixels); + } + if piece.is_empty() { + return Err(io::Error::other(format!("layer {index} stopped at byte {}", pixels.len()))); + } + } + } + /// Saves to `url` as `format`; failure surfaces (LOK-09). pub fn save_as(&self, worker: &mut Worker, url: &str, format: &str) -> io::Result<()> { self.store(worker, url, format, "") diff --git a/crates/ely-lok/src/client/tests/motion.rs b/crates/ely-lok/src/client/tests/motion.rs index 3e53e84..f1b5b54 100644 --- a/crates/ely-lok/src/client/tests/motion.rs +++ b/crates/ely-lok/src/client/tests/motion.rs @@ -238,3 +238,34 @@ fn layers_larger_than_one_frame_arrive_one_by_one() { assert!(layers.list.len() * bytes > crate::ipc::MAX_FRAME, "{} layers of {bytes}", layers.list.len()); assert!(layers.list.iter().all(|layer| layer.pixels.len() == bytes), "every layer arrives whole"); } + +#[test] +fn a_layer_larger_than_one_frame_arrives_in_pieces() { + if install().is_none() { + eprintln!("SKIP: no pinned LOK build"); + return; + } + let (_dir, mut worker, doc) = open_deck(); + worker.run(&doc, "slide.size", &["10000", "120000", "true"]).expect("a tall slide"); + worker.run(&doc, "draw.shape", &["rectangle", "true"]).expect("draws"); + let layers = doc.slide_layers(&mut worker, 0, (1280, 15360)).expect("layers"); + let (w, h) = layers.size; + let bytes = w as usize * h as usize * 4; + assert!(bytes > crate::ipc::MAX_FRAME, "one layer of {bytes} bytes"); + assert!(layers.list.iter().all(|layer| layer.pixels.len() == bytes), "every layer arrives whole"); +} + +#[test] +fn closing_a_deck_drops_its_waiting_layers() { + if install().is_none() { + eprintln!("SKIP: no pinned LOK build"); + return; + } + let (_dir, mut worker, doc) = open_deck(); + let id = worker.doc_id(&doc).expect("the id"); + let ask = crate::ipc::Pack::new().u64(id).u64(0).u64(1920).u64(1080).bytes(); + worker.request(crate::ipc::tag::SLIDE_LAYERS, &ask).expect("layers wait"); + worker.close_doc(doc).expect("closes"); + let late = crate::ipc::Pack::new().u64(id).u64(0).u64(0).bytes(); + assert!(worker.request(crate::ipc::tag::SLIDE_LAYER, &late).is_err(), "no layer outlives its deck"); +} diff --git a/crates/ely-lok/src/worker.rs b/crates/ely-lok/src/worker.rs index e2de5ff..481342e 100644 --- a/crates/ely-lok/src/worker.rs +++ b/crates/ely-lok/src/worker.rs @@ -347,6 +347,7 @@ fn close(id: u64) { } docs.remove(&id); revision::forget(id); + slides::forget(id); log::info!("lok-worker: {} documents open", docs.len()); } diff --git a/crates/ely-lok/src/worker/slides.rs b/crates/ely-lok/src/worker/slides.rs index b4d7f21..0afca63 100644 --- a/crates/ely-lok/src/worker/slides.rs +++ b/crates/ely-lok/src/worker/slides.rs @@ -1,4 +1,8 @@ -use std::{cell::RefCell, collections::HashMap, io}; +use std::{ + collections::HashMap, + io, + sync::{LazyLock, Mutex, MutexGuard}, +}; use super::with_doc; use crate::ipc::{Pack, Unpack}; @@ -6,9 +10,20 @@ use crate::ipc::{Pack, Unpack}; /// The most a slide's layers may take in memory. const LAYER_MEMORY: usize = 512 * 1024 * 1024; -// Rendered layers wait here; each leaves on its own request. -thread_local! { - static WAITING: RefCell>>>> = RefCell::new(HashMap::new()); +/// The most pixel bytes one reply carries. +const CHUNK: usize = 16 * 1024 * 1024; + +/// Rendered layers by document; each leaves as it is read. +type Waiting = HashMap>>>; + +fn waiting() -> MutexGuard<'static, Waiting> { + static WAITING: LazyLock> = LazyLock::new(Mutex::default); + WAITING.lock().expect("the layers lock") +} + +/// Drops document `id`'s waiting layers. +pub(super) fn forget(id: u64) { + waiting().remove(&id); } /// Slide `part`'s layers, its pixels held back. @@ -38,23 +53,36 @@ pub(super) fn slide_layers(unpack: &mut Unpack) -> io::Result> { body = body.str(&layer.json).u64(u64::from(layer.pixels.is_some())); pixels.push(layer.pixels); } - WAITING.with(|waiting| waiting.borrow_mut().insert(id, pixels)); + waiting().insert(id, pixels); Ok(body.bytes()) })? } -/// Hands over layer `index`'s pixels once, then frees them. +/// Hands over layer `index` from byte `from`; read layers free. pub(super) fn slide_layer(unpack: &mut Unpack) -> io::Result> { - let (id, index) = (unpack.u64()?, unpack.u64()?); - let taken = WAITING.with(|waiting| { - let mut waiting = waiting.borrow_mut(); - let layers = waiting.get_mut(&id)?; - let pixels = layers.get_mut(usize::try_from(index).ok()?)?.take(); - if layers.iter().all(Option::is_none) { - waiting.remove(&id); - } - pixels - }); - let pixels = taken.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("no layer {index} waits")))?; - Ok(Pack::new().blob(&pixels).bytes()) + let (id, index, from) = (unpack.u64()?, unpack.u64()?, unpack.u64()?); + let mut waiting = waiting(); + let Some((total, bytes)) = piece(waiting.get_mut(&id), index, from) else { + // A request off the batch ends it. + waiting.remove(&id); + let what = format!("no layer {index} from byte {from} waits"); + return Err(io::Error::new(io::ErrorKind::InvalidInput, what)); + }; + if waiting.get(&id).is_some_and(|layers| layers.iter().all(Option::is_none)) { + waiting.remove(&id); + } + Ok(Pack::new().u64(total).blob(&bytes).bytes()) +} + +/// Up to `CHUNK` bytes of a layer and its length. +fn piece(layers: Option<&mut Vec>>>, index: u64, from: u64) -> Option<(u64, Vec)> { + let slot = layers?.get_mut(usize::try_from(index).ok()?)?; + let pixels = slot.as_ref()?; + let from = usize::try_from(from).ok().filter(|from| *from < pixels.len() || *from == 0)?; + let to = (from + CHUNK).min(pixels.len()); + let read = (pixels.len() as u64, pixels[from..to].to_vec()); + if to == pixels.len() { + *slot = None; + } + Some(read) } diff --git a/tasks/reviews/R2.20-r1.md b/tasks/reviews/R2.20-r1.md new file mode 100644 index 0000000..ab56854 --- /dev/null +++ b/tasks/reviews/R2.20-r1.md @@ -0,0 +1,5 @@ +FAIL + +crates/ely-lok/src/worker/slides.rs:59 — 单个图层仍整体装进一帧,逐层回复仍会触发 64 MiB 帧上限,违反 SHOW-09 的动画预览要求。具体复现:真实 worker 打开测试 DECK,执行 `slide.size(10000, 120000, true)`,得到界面允许的 10 × 120 cm 页面;按 `apps/ely-show/src/preview.rs:67` 的尺寸公式请求 `SLIDE_LAYERS(doc, 0, 1280, 15360)`,返回两个有像素的图层,每层 78,643,200 字节;首个 `SLIDE_LAYER` 返回 ERR:`a 78643204 byte frame is over the 67108864 byte cap`,客户端在 `client/doc.rs:193` 提前返回,预览失败。另以普通比例请求 6000 × 4500,内核实际返回 6000 × 3375,同样报 81,000,004 字节帧超限。修复方向:单层像素也按帧容量分块传输,计入长度前缀,并增加单层超过上限的回归用例。独立执行的 worker 构建、4 项 motion 测试、3 项 playlist 测试及注释检查均通过,现有新增测试覆盖的是总量超过上限、各单层仍小于上限的情况。复现日志:`/tmp/ely-r220-portrait.log`、`/tmp/ely-r220-probe.log`。 + +crates/ely-lok/src/worker/slides.rs:41 — 新增的 WAITING 暂存集合缺少文档关闭和取层失败时的清理,违反 LOK-10 的关闭资源释放要求。具体复现:上述合法长页面取首层失败后,客户端通过 `?` 退出,第二层的 78,643,200 字节仍留在 WAITING;发送 `CLOSE_DOC` 后,再按旧文档编号取第二层,仍得到同一超限错误,证明关闭后像素仍被持有。另以 1920 × 1080 请求图层后直接关闭文档,再发旧编号的 `SLIDE_LAYER`,实际返回 OK 和 8,294,404 字节,确认暂存数据跨越文档生命周期。`worker.rs:343` 的 close 仅清理文档、事件和 revision,WAITING 只在逐层取尽时移除;连续打开、预览失败、关闭会按每次遗留一层持续累积内存。修复方向:把暂存图层纳入文档生命周期,在关闭及取回失败或取消时释放整批数据,拒绝已关闭会话的取层请求,并覆盖失败后关闭的回归路径。复现日志:`/tmp/ely-r220-portrait.log`、`/tmp/ely-r220-probe.log`。 diff --git a/tasks/reviews/R2.6-r1.md b/tasks/reviews/R2.6-r1.md new file mode 100644 index 0000000..475982f --- /dev/null +++ b/tasks/reviews/R2.6-r1.md @@ -0,0 +1,7 @@ +FAIL + +apps/ely-pdf/src/edits.rs:102 — 编辑后的尺寸表只含前 100 页的实测值,其余页由 sizes() 填成第一页尺寸;这里将它与后台补读完整的 self.sizes 比较,混合纸张文档会进入 retire_tiles(),立即清空整屏旧图块。复现:生成 101 页 PDF,前 100 页为 612×792,第 101 页为 612×900;等尺寸补读与第一页绘制完成,在第一页添加便签。真实 worker 探针连续两次捕获 edited == 1 且 tiles.is_empty(),违反 R2.6。修复方向:保留已测量的尺寸,根据真实页面结构和几何变化决定失效,覆盖超过 100 页的混合尺寸文档。证据:/tmp/ely-r26-probes-repeat.log。 + +apps/ely-pdf/src/pages.rs:196 — stale 图块仍按当前布局的完整 TileKey 淘汰,scale 变化会让保留机制失效。复现:单页 PDF 使用默认适合宽度,点击便签工具,在页面点击并等右侧属性栏和图块稳定,再提交便签。refresh() 清掉 aim,属性栏收起,显示比例从 2.2026143 变为 2.72549;旧比例图块被 tiles.retain() 和 stale.retain() 淘汰,探针捕获 painting == true、tiles 为空、stale 为空的状态,页面继续闪白。加入布局稳定等待后仍复现,违反 R2.6。修复方向:处理编辑落地引起的布局变化,保留可绘制的旧页面图块直到新布局图块到达,并覆盖属性栏提交便签的实际路径。证据:/tmp/ely-r26-probes-repeat.log。 + +apps/ely-pdf/src/tests/input.rs:210 — 新增回归只观察缓存是否为空及 Arc 地址是否更换,无法验证旧图块实际进入绘制,也无法验证新画面的内容。隔离副本中将 pages.rs 的 Some(image) 绘制分支加上 !self.stale.contains(&key) 条件,对 stale 图块直接跳过绘制;日志确认该分支反复执行,原测试 an_edit_keeps_the_page_drawn_until_its_repaint_lands 仍通过。这个变异会在重绘期间显示纸张背景,直接破坏 R2.6。修复方向:验证重绘等待期间每帧的实际页面绘制,以及更新后批注或可视签名区域的像素;让跳过旧图块绘制的变异触发失败。基线应用和 worker 构建通过,ely-pdf 原有 115 项测试及注释检查通过;变异证据:/tmp/ely-r26-mutation.log。探针源码副本及独立 CARGO_TARGET_DIR 已在写入结论前删除。