merge: resolve manuscript conflicts with latest main comments
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
//! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。
|
||||
//!
|
||||
//! Live agent-facing guide generated from the current namespace.
|
||||
//! 指南正文是返回给客户端的静态 Markdown 模板,只有命名空间、版本、地址和内置示例按参数填充。
|
||||
use super::*;
|
||||
|
||||
/// 用当前命名空间、地址和版本填充在线指南,示例来自项目内置 Endor 源码。
|
||||
/// 为 /agent 生成可提交代码与查询接口的 Markdown 说明。
|
||||
/// format! 使用具名插值注入 namespace.name/revision 与监听地址;示例在编译时嵌入,
|
||||
/// 因而请求不会访问示例文件、执行示例或改变会话。正文中的 JSON/Endor 是文档内容。
|
||||
pub(super) fn agent_guide(namespace: &NamespaceState) -> String {
|
||||
format!(
|
||||
r##"# Tessera Agent API
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::{
|
||||
};
|
||||
|
||||
#[test]
|
||||
/// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。
|
||||
/// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。
|
||||
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
|
||||
let (_, mut picture) = endor_host::ReplSession::load(
|
||||
include_str!("../../../examples/windows.endor"),
|
||||
@@ -18,6 +20,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
|
||||
150,
|
||||
)
|
||||
.unwrap();
|
||||
// 把旧文档标成无活动 VM 的保留投影;编号相同也不能据此识别当前资源。
|
||||
picture.frame.session = 0;
|
||||
for view in picture.windows.values_mut() {
|
||||
view.frame.session = 0;
|
||||
@@ -80,6 +83,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。
|
||||
struct TestServer {
|
||||
server: Arc<Server>,
|
||||
address: SocketAddr,
|
||||
@@ -87,6 +91,8 @@ struct TestServer {
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。
|
||||
/// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。
|
||||
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
|
||||
let mut stream = TcpStream::connect(self.address).unwrap();
|
||||
stream
|
||||
@@ -105,6 +111,7 @@ impl TestServer {
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
/// 解除服务端 accept 等待并等待线程结束,避免测试之间遗留监听线程。
|
||||
fn drop(&mut self) {
|
||||
self.server.unblock();
|
||||
if let Some(api) = self.api.take() {
|
||||
@@ -114,6 +121,8 @@ impl Drop for TestServer {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。
|
||||
/// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。
|
||||
fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
@@ -157,6 +166,7 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
|
||||
}),
|
||||
}
|
||||
};
|
||||
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
|
||||
let body = serde_json::to_string(&input).unwrap();
|
||||
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
@@ -177,6 +187,8 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。
|
||||
/// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。
|
||||
fn background_failure_preserves_its_cause_after_rejected_console_input() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
@@ -215,6 +227,8 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。
|
||||
/// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。
|
||||
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
@@ -226,6 +240,7 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
|
||||
};
|
||||
let repl = "/v1/namespaces/app.main/repl";
|
||||
let code = "/v1/namespaces/app.main/code";
|
||||
// 先创建对象但不 render;返回值可以跨 REPL 请求复用,发布 revision 此时仍为 0。
|
||||
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
|
||||
assert_eq!(created.0, 200, "{}", created.2);
|
||||
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
|
||||
@@ -262,6 +277,8 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。
|
||||
/// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。
|
||||
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
@@ -317,6 +334,8 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 直接推进 worker 的 submission,模拟新候选已接受但尚未成功替换的时刻。
|
||||
/// 旧 VM 的 retained 仍可读;clear 撤销 namespace 后再刷新,才释放 live owner。
|
||||
fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
|
||||
let (runtime, document) = endor_host::ReplSession::load(
|
||||
"retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))",
|
||||
@@ -361,6 +380,8 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 用通知回调中的通道阻塞第一轮 worker,再使第二个 PUT 排队。
|
||||
/// 此时 GET/DELETE 仍应可用;释放 worker 后第一请求成功、被 DELETE 超越的候选返回 409,文档保持清空。
|
||||
fn http_delete_remains_available_and_invalidates_queued_put() {
|
||||
use std::{
|
||||
sync::{Mutex, mpsc},
|
||||
@@ -370,6 +391,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
// 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。
|
||||
let release_rx = Mutex::new(release_rx);
|
||||
let calls = AtomicUsize::new(0);
|
||||
let notify: Notify = Arc::new(move || {
|
||||
@@ -384,9 +406,11 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
|
||||
server,
|
||||
api: Some(api),
|
||||
};
|
||||
// Unblock even if a test assertion fails, before scoped threads join.
|
||||
// 析构守卫在 scoped 线程 join 前释放阻塞通知;即使断言 panic 也不能让 worker 永久等通道。
|
||||
// 持有释放信号的发送端;离开作用域即通知 worker 继续,包括异常展开路径。
|
||||
struct Release(mpsc::Sender<()>);
|
||||
impl Drop for Release {
|
||||
// send 失败意味着接收端已结束,此时无需再阻塞测试清理。
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.send(());
|
||||
}
|
||||
@@ -406,6 +430,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
|
||||
}
|
||||
assert_eq!(client.request("GET", route, "").2, example);
|
||||
assert_eq!(client.request("DELETE", route, "").0, 200);
|
||||
// DELETE 已同步清空文档并提升提交身份,现在解除第一请求通知阻塞,让排队候选观察到失效。
|
||||
drop(release);
|
||||
assert_eq!(first.join().unwrap().0, 200);
|
||||
assert_eq!(pending.join().unwrap().0, 409);
|
||||
@@ -415,6 +440,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 检查 code 路径解析只接受完整的单段 namespace;缺少后缀和嵌套斜线均返回 None。
|
||||
fn extracts_namespace_only_from_code_routes() {
|
||||
assert_eq!(
|
||||
namespace_from_path("/v1/namespaces/app.main/code"),
|
||||
@@ -425,6 +451,7 @@ fn extracts_namespace_only_from_code_routes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。
|
||||
fn guide_contains_only_code_crud_routes() {
|
||||
let guide = agent_guide(&NamespaceState::default());
|
||||
assert!(guide.contains("Code CRUD"));
|
||||
@@ -435,6 +462,8 @@ fn guide_contains_only_code_crud_routes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。
|
||||
/// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。
|
||||
fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() {
|
||||
let source = r#"
|
||||
class Action {
|
||||
@@ -459,6 +488,7 @@ page.render(TextBlock("input priority"))
|
||||
next_session: 801,
|
||||
};
|
||||
let notify: Notify = Arc::new(|| {});
|
||||
// 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。
|
||||
worker.refresh_viewport_mode(&namespace, ¬ify, true);
|
||||
let value = worker
|
||||
.repl
|
||||
@@ -484,6 +514,8 @@ page.render(TextBlock("input priority"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。
|
||||
/// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。
|
||||
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
@@ -586,6 +618,8 @@ page.render(button)
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 给局部 CanvasViewport 添加父内边距和自身左边框,检验页面坐标到内容相机锚点的转换。
|
||||
/// 成功平移/缩放后核对相机状态;边框、边界与视口外锚点均须返回 400,保留健康运行时。
|
||||
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
@@ -658,6 +692,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
|
||||
expected
|
||||
);
|
||||
}
|
||||
// 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。
|
||||
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
|
||||
let response = client.request(
|
||||
"POST",
|
||||
@@ -670,6 +705,8 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 通过真实 HTTP 检查透视缩放/orbit、正交缩放,再替换为源实现 PanOnly。
|
||||
/// 比较相机数值与自定义控制器结果,并验证视口外锚点和禁用控制器的拒绝状态不会停掉 VM。
|
||||
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
@@ -761,6 +798,8 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// 在旋转的 Panel3D 上嵌入二维 CanvasViewport,经射线/UV 映射解释相机事件。
|
||||
/// 缺失指针必须拒绝;带指针的平移/缩放只改变面板内相机,外层三维相机保持原值。
|
||||
fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
@@ -833,6 +872,7 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
|
||||
.unwrap()
|
||||
};
|
||||
let pan = value("canvas.camera.x");
|
||||
// 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。
|
||||
assert!(pan > 10.);
|
||||
assert_eq!(value("view.camera.x"), 0.);
|
||||
let response = client.request(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::*;
|
||||
|
||||
type HttpResponse = Response<std::io::Cursor<Vec<u8>>>;
|
||||
// 外层 thread::Result 区分 Rust panic,内层 Result 区分可诊断编译/运行错误;正常结果另带待发布窗口命令。
|
||||
type InputOutcome =
|
||||
std::thread::Result<Result<(endor_host::ReplResult, Vec<crate::window::Command>)>>;
|
||||
|
||||
@@ -19,6 +20,7 @@ type CandidateOutcome = std::thread::Result<
|
||||
|
||||
/// 已执行业务部分但仍在等待展示刷新的输入元数据,避免恢复时重放回调。
|
||||
pub(super) struct PendingInput {
|
||||
// 输入开始时所属 submission,与 id(VM 会话)分开;新 PUT 接受时可保留正在执行的原 VM 至输入收敛。
|
||||
epoch: u64,
|
||||
id: u64,
|
||||
event: bool,
|
||||
@@ -27,6 +29,7 @@ pub(super) struct PendingInput {
|
||||
|
||||
/// 挂起 HTTP 请求的阶段:等旧刷新、继续输入或继续隔离候选。
|
||||
pub(super) enum PendingRequest {
|
||||
// 仅等待旧只读刷新结束,业务输入尚未执行;恢复时可重新走入口,而 Input 阶段不能重放业务副作用。
|
||||
BeforeInput,
|
||||
Input(Box<PendingInput>),
|
||||
Candidate(Box<endor_host::ReplSession>),
|
||||
@@ -56,6 +59,7 @@ fn current_viewports(
|
||||
})
|
||||
.map(|(id, size)| (*id, *size))
|
||||
.collect();
|
||||
// 主窗口逻辑视口优先覆盖 map 同名值,主壳可以跨会话沿用;副窗口必须仍属于当前发布世代。
|
||||
if let Some(viewport) = current.viewport {
|
||||
viewports.insert(crate::window::MAIN_WINDOW, viewport);
|
||||
}
|
||||
@@ -77,6 +81,7 @@ impl Worker {
|
||||
error: None,
|
||||
};
|
||||
};
|
||||
// 正常域允许有界关闭回调,故障域只做宿主清理;catch_unwind 防止清理 panic 跳过原生退出通知。
|
||||
let callbacks = !runtime.is_faulted();
|
||||
let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown()));
|
||||
match result {
|
||||
@@ -108,6 +113,7 @@ impl Worker {
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
notify: &Notify,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
// 故障/失活资源的专用关闭只处理副窗口,主壳由 OS 关闭控制走全应用退出流程。
|
||||
if input.window == crate::window::MAIN_WINDOW {
|
||||
return error_response(
|
||||
StatusCode(409),
|
||||
@@ -126,6 +132,7 @@ impl Worker {
|
||||
.1
|
||||
.retire_window(input.window)
|
||||
}));
|
||||
// 退役失败不能继续借用半清理 VM,放弃整个活域并在下方清其他帧的会话与命令资格。
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
// Never keep a half-cleaned VM live or borrow another generation.
|
||||
self.repl = None;
|
||||
@@ -141,6 +148,7 @@ impl Worker {
|
||||
let Some(view) = current.document.windows.get(&input.window) else {
|
||||
return error_response(StatusCode(409), "window is no longer active");
|
||||
};
|
||||
// 即便副窗口数值 ID 相同,也不能让旧会话的关闭请求删除新会话投影。
|
||||
if view.frame.session != input.session {
|
||||
return error_response(
|
||||
StatusCode(409),
|
||||
@@ -200,6 +208,7 @@ impl Worker {
|
||||
let Some((owner, runtime)) = &mut self.repl else {
|
||||
return;
|
||||
};
|
||||
// submission 变化先尝试保留仍权威的活域所有者,不立即拿旧 epoch 继续发布后台结果。
|
||||
if *owner != epoch {
|
||||
if let Ok(current) = namespace.read() {
|
||||
self.preserve_live_owner(¤t);
|
||||
@@ -218,11 +227,13 @@ impl Worker {
|
||||
}
|
||||
// An accepted foreground input waits only for the existing
|
||||
// refresh; do not keep placing new callbacks ahead of it.
|
||||
// 已有前台请求只等当前刷新,不不断把新任务回调插到它前面,避免忙后台工作饿死用户输入。
|
||||
let completed = if before_input {
|
||||
None
|
||||
} else {
|
||||
runtime.poll_tasks_incremental(&viewports)?
|
||||
};
|
||||
// 任务后续刷新未收敛时可以发布较早已完整的 resize 文档,但暂扣任务产生的命令,防止半完成副作用外泄。
|
||||
if runtime.has_pending_refresh() {
|
||||
// A complete earlier resize may publish; task effects and their
|
||||
// native commands wait until the task's refresh has settled.
|
||||
@@ -235,6 +246,7 @@ impl Worker {
|
||||
Ok(current) => current,
|
||||
Err(_) => return,
|
||||
};
|
||||
// 耗时 VM 工作期间 HTTP 线程可能接受 PUT/DELETE,发布写锁内再核对 submission 与 session 双身份。
|
||||
if current.submission != epoch || current.runtime_session != id {
|
||||
return;
|
||||
}
|
||||
@@ -243,6 +255,7 @@ impl Worker {
|
||||
if let Some(document) = document {
|
||||
(document, Some(commands))
|
||||
} else {
|
||||
// 无新文档也可能有标题/焦点等原生命令;只在实际有投影变化时唤醒 GUI,避免空闲无效重绘。
|
||||
let projected = !commands.is_empty();
|
||||
current.publish_window_commands(id, commands);
|
||||
drop(current);
|
||||
@@ -259,6 +272,7 @@ impl Worker {
|
||||
.chars()
|
||||
.take(1024)
|
||||
.collect(),
|
||||
// 内部 panic 与普通背景错误不同,必须丢弃 VM 并撤销所有原生命令,不能继续运行损坏的域。
|
||||
_ => {
|
||||
self.repl = None;
|
||||
current.runtime_session = 0;
|
||||
@@ -289,6 +303,7 @@ impl Worker {
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
notify: &Notify,
|
||||
) -> RequestProgress {
|
||||
// 完整 PUT 独立候选,表达式隔离求值;只有 REPL/事件会进入当前持久域并等待已有刷新。
|
||||
if job.kind == RequestKind::Code {
|
||||
return self.begin_program_request(job, namespace, notify);
|
||||
}
|
||||
@@ -311,6 +326,7 @@ impl Worker {
|
||||
if epoch != job.submission {
|
||||
return RequestProgress::Complete(superseded(job.submission));
|
||||
}
|
||||
// 重置丢弃内存会话但保留显示文档,清帧 session/focus 使旧画面不再接受旧对象事件。
|
||||
if job.kind == RequestKind::ResetRepl {
|
||||
self.repl = None;
|
||||
if let Ok(mut current) = namespace.write() {
|
||||
@@ -336,6 +352,7 @@ impl Worker {
|
||||
{
|
||||
return RequestProgress::Pending(PendingRequest::BeforeInput);
|
||||
}
|
||||
// 事件先做 JSON 结构、kind 白名单和 Input 字段校验,再用权威发布帧核对具体资源能力。
|
||||
let event = if job.kind == RequestKind::Event {
|
||||
let mut input: crate::events::Input = if let Some(input) = &job.native {
|
||||
input.clone()
|
||||
@@ -413,6 +430,7 @@ impl Worker {
|
||||
};
|
||||
&document.frame
|
||||
};
|
||||
// 窗口关闭允许处理已失活/故障副窗口,不像点击一样要求可运行 VM;由专用宿主退役路径处理。
|
||||
if input.kind == "window-close"
|
||||
&& frame.window == input.window
|
||||
&& frame.session == input.session
|
||||
@@ -433,6 +451,7 @@ impl Worker {
|
||||
);
|
||||
}
|
||||
}
|
||||
// 标签拖放同时验证目标窗口的会话、版本、无故障及插入位置,源窗口校验不能替代目标校验。
|
||||
if let Some(target) = &input.tab_drop {
|
||||
let target_frame = if target.window == crate::window::MAIN_WINDOW {
|
||||
Some(¤t.document.frame)
|
||||
@@ -460,6 +479,7 @@ impl Worker {
|
||||
));
|
||||
}
|
||||
}
|
||||
// 统一事件归属门:窗口/session/版本及操作对应资源必须都合法;window-close 特别允许旧布局版本。
|
||||
if frame.window != input.window
|
||||
|| frame.session != input.session
|
||||
|| (input.kind != "window-close" && frame.layout_version != input.layout_version)
|
||||
@@ -473,6 +493,7 @@ impl Worker {
|
||||
input.kind.as_str(),
|
||||
"tab-activate" | "tab-close" | "tab-detach" | "tab-drop"
|
||||
) {
|
||||
// 标签操作的 mount 字段承载 Tab ID,而普通输入承载 Mount ID;两套身份不能混着查询。
|
||||
!frame.tabs.iter().any(|tab| tab.id == input.mount)
|
||||
} else {
|
||||
!frame.mounts.iter().any(|mount| {
|
||||
@@ -480,6 +501,7 @@ impl Worker {
|
||||
&& if input.kind.starts_with("viewport-") {
|
||||
mount.canvas_viewport
|
||||
} else if input.kind.starts_with("scene-") {
|
||||
// 3D 手势还要求显式控制器能力,scroll 要有滚动投影,点击/焦点则要求 interactive。
|
||||
mount.scene3d_viewport && mount.camera_controls
|
||||
} else if input.kind == "scroll" {
|
||||
mount.scroll.is_some()
|
||||
@@ -499,6 +521,7 @@ impl Worker {
|
||||
"event targets an obsolete or inactive frame",
|
||||
));
|
||||
}
|
||||
// 兼容页面仅 Canvas 可缩放,Window 模式不接受 pan;结构合法不等于当前页面模式支持操作。
|
||||
if input.kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas
|
||||
|| input.kind == "page-pan" && frame.surface.mode == crate::page::Mode::Window
|
||||
{
|
||||
@@ -507,6 +530,7 @@ impl Worker {
|
||||
"camera input is unavailable in this Page mode",
|
||||
));
|
||||
}
|
||||
// 缩放锚点须经过面板反投影与内容 clip 检查,不能落在视口边框或被遮挡面板上仍缩放。
|
||||
if matches!(input.kind.as_str(), "viewport-zoom" | "scene-zoom")
|
||||
&& !frame.mounts.iter().any(|mount| {
|
||||
mount.id == input.mount
|
||||
@@ -529,6 +553,7 @@ impl Worker {
|
||||
"zoom anchor is outside content viewport",
|
||||
));
|
||||
}
|
||||
// 世界 UI 的非线性位移变换需要位置锚点;普通二维视口的线性位移可以省略 point。
|
||||
if (input.kind.starts_with("viewport-") || input.kind.starts_with("scene-"))
|
||||
&& input.point.is_none()
|
||||
&& frame
|
||||
@@ -550,6 +575,7 @@ impl Worker {
|
||||
.as_ref()
|
||||
.is_none_or(|(current, _)| *current != epoch)
|
||||
{
|
||||
// 没有当前 epoch 会话时惰性建立空 REPL,每次分配新会话 ID 并检查溢出,禁止复用旧输入身份。
|
||||
self.next_session = match self.next_session.checked_add(1) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
@@ -594,6 +620,7 @@ impl Worker {
|
||||
} else {
|
||||
session.execute_incremental(&job.source, &job.namespace, &viewports)?
|
||||
};
|
||||
// 业务执行可能已改 VM,但展示未完成时暂不取窗口命令;PendingInput 保存结果并只恢复展示阶段。
|
||||
let commands = if session.has_pending_refresh() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -611,6 +638,7 @@ impl Worker {
|
||||
result,
|
||||
})));
|
||||
}
|
||||
// 启动失败且残留挂起刷新时直接丢弃域,不能保留一个无法安全推进的 HTTP 请求。
|
||||
// A failed start must never leave a request waiting on a broken VM.
|
||||
self.repl = None;
|
||||
}
|
||||
@@ -652,6 +680,7 @@ impl Worker {
|
||||
notify: &Notify,
|
||||
) -> RequestProgress {
|
||||
let viewports = match namespace.read() {
|
||||
// 正在执行输入只要仍属当前提交或仍拥有权威 VM 就可收敛;两项皆失效才彻底废弃。
|
||||
Ok(current)
|
||||
if current.submission == input.epoch || current.runtime_session == input.id =>
|
||||
{
|
||||
@@ -679,6 +708,7 @@ impl Worker {
|
||||
return RequestProgress::Complete(superseded(job.submission));
|
||||
}
|
||||
let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
|
||||
// 这里只恢复 pending 展示/尺寸工作,不再调用 execute/dispatch,所以前一个量子的源副作用不会重放。
|
||||
let document = session.resize_windows_incremental(&viewports, false)?;
|
||||
if session.has_pending_refresh() {
|
||||
return Ok(None);
|
||||
@@ -723,6 +753,7 @@ impl Worker {
|
||||
let Ok(mut current) = namespace.write() else {
|
||||
return error_response(StatusCode(500), "namespace lock is poisoned");
|
||||
};
|
||||
// 接受新 PUT 会使排队旧输入失效,但已执行的活域输入需先收敛,才能在候选失败时留下内部一致的旧程序。
|
||||
let epoch = if current.submission != epoch {
|
||||
if current.runtime_session != id {
|
||||
drop(current);
|
||||
@@ -744,6 +775,7 @@ impl Worker {
|
||||
match result {
|
||||
Ok(Ok((result, commands))) => {
|
||||
let projected = !commands.is_empty();
|
||||
// rendered 只表示此次交付带新文档,成功执行但页面未变仍可返回 value/session;不因此虚增 revision。
|
||||
let rendered = result.document.is_some();
|
||||
if let Some(document) = result.document {
|
||||
match current.commit(epoch, document) {
|
||||
@@ -769,6 +801,7 @@ impl Worker {
|
||||
// Preserve the first failure of this VM; subsequent
|
||||
// rejected Console input must not hide the callback cause.
|
||||
// A preserved picture from another generation is unrelated.
|
||||
// 同 VM 保留最初故障原因,后续“域已停止”错误不能覆盖真正回调故障;其他世代旧图不沿用故障。
|
||||
let fault = (document.frame.session == id)
|
||||
.then(|| document.frame.fault.clone())
|
||||
.flatten()
|
||||
@@ -790,6 +823,7 @@ impl Worker {
|
||||
for view in document.windows.values_mut() {
|
||||
view.frame.fault = document.frame.fault.clone();
|
||||
}
|
||||
// 输入 panic 保留最近画面但撤销运行时/命令并发布故障条,再将 self.repl 丢弃。
|
||||
current.runtime_session = 0;
|
||||
current.window_commands.clear();
|
||||
let _ = current.commit(epoch, document);
|
||||
@@ -847,6 +881,7 @@ impl Worker {
|
||||
}
|
||||
};
|
||||
let id = self.next_session;
|
||||
// 候选装载运行在独立 ReplSession,未成功前不修改 self.repl,旧程序仍是权威展示来源。
|
||||
let started = catch_unwind(AssertUnwindSafe(|| {
|
||||
endor_host::ReplSession::load_incremental(
|
||||
&job.source,
|
||||
@@ -857,6 +892,7 @@ impl Worker {
|
||||
)
|
||||
}));
|
||||
let result = match started {
|
||||
// 候选业务装载成功但布局未完成,整个候选移进 PendingRequest;不能把 None 当成空文档提交。
|
||||
Ok(Ok((runtime, None))) => {
|
||||
return RequestProgress::Pending(PendingRequest::Candidate(Box::new(runtime)));
|
||||
}
|
||||
@@ -882,6 +918,7 @@ impl Worker {
|
||||
Ok(current) if current.submission == job.submission => {
|
||||
// Secondary native resources still belong to the old VM. Only
|
||||
// the primary shell's current viewport applies to a candidate.
|
||||
// 候选不沿用旧副窗口的视口;副窗口资源归旧 VM,只有主壳当前尺寸可作为新候选分配条件。
|
||||
current
|
||||
.viewport
|
||||
.map(|size| (crate::window::MAIN_WINDOW, size))
|
||||
@@ -905,6 +942,7 @@ impl Worker {
|
||||
if runtime.has_pending_refresh() {
|
||||
return Ok(None);
|
||||
}
|
||||
// 所有刷新结束仍没有 Page 文档时明确失败,不用旧显示值冒充候选自己的输出。
|
||||
let document =
|
||||
document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?;
|
||||
Ok(Some((document, runtime.take_window_commands()?)))
|
||||
@@ -937,6 +975,7 @@ impl Worker {
|
||||
match result {
|
||||
Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document)
|
||||
{
|
||||
// 写锁内先原子提交文档,再切换命令世代与活域;解锁后唤醒 GUI,新 VM 至此才取代旧 VM。
|
||||
Ok(Some(revision)) => {
|
||||
current.publish_window_commands(id, commands);
|
||||
self.repl = Some((job.submission, runtime));
|
||||
@@ -953,6 +992,7 @@ impl Worker {
|
||||
failed => {
|
||||
// The candidate never mutated the displayed program's heap.
|
||||
// Restore that program's event epoch when a replacement fails.
|
||||
// 候选失败只更新旧活域 epoch,保留其堆与页面;不存在旧域时也不会借用其他会话伪造成功。
|
||||
self.preserve_live_owner(¤t);
|
||||
drop(current);
|
||||
match failed {
|
||||
@@ -973,6 +1013,7 @@ fn build(
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
notify: &Notify,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
// 隔离表达式不读写持久 REPL 堆,也不发布文档,响应明确 persistent=false。
|
||||
if job.kind == RequestKind::Expression {
|
||||
return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) {
|
||||
Ok(Ok(value)) => json(
|
||||
@@ -990,6 +1031,7 @@ fn build(
|
||||
}
|
||||
// Only an independent native snapshot crosses into the published state.
|
||||
// A fresh VM is discarded on success, error and unwind alike.
|
||||
// 一次性构建同样用独立 VM,计算锁外执行,返回后再次核对 submission 才能发布原生快照。
|
||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||
endor_host::compile(&job.source, &job.namespace, job.submission)
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user