ElyShow: a layer comes in 16 MiB pieces; closing a deck drops its waiting layers (R2.20)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 04:28:04 -04:00
1 parent b591aa8625
commit 50e18d835e
6 files changed
+111 -22

No files matched your search

+21 -4
View File
@@ -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<Vec<u8>> {
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, "")
+31
View File
@@ -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");
}
+1
View File
@@ -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());
}
+46 -18
View File
@@ -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<HashMap<u64, Vec<Option<Vec<u8>>>>> = 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<u64, Vec<Option<Vec<u8>>>>;
fn waiting() -> MutexGuard<'static, Waiting> {
static WAITING: LazyLock<Mutex<Waiting>> = 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<Vec<u8>> {
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<Vec<u8>> {
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<Option<Vec<u8>>>>, index: u64, from: u64) -> Option<(u64, Vec<u8>)> {
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)
}
+5
View File
@@ -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`。
+7
View File
@@ -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 已在写入结论前删除。