0%

在wasm中尝试从panic中恢复

最近花了一点时间给我的Rust版minidecaf写了一个前端展示,地址在https://mashplant.online/minidecaf-frontend/。写的时候遇到了一个问题:Rust编译到wasm时看起来没法用catch_unwind从panic中恢复。这里记录一下我为了解决这个问题所做的尝试,虽然只有一种方法成功解决了问题,但是过程还是有点记录的价值。

背景

为了简单,我的编译器代码中对各种类型错误,比如找不到变量,操作数类型不对之类的,都直接panic了,而不是用更加方便后续处理的Result那一套来表示失败。这样做确实简单一些,而且一般编译器本来一次运行也只编译一个程序,panic终止程序和返回Err然后上层处理在效果上其实没什么区别,但是如果做一个网页展示,用户会经常改变输入,那失败一次直接挂了就不太好了。我也不愿意为这种事情修改代码,所以要尝试在调用端解决这个问题。

Rust中不鼓励从panic中恢复,但确实存在恢复的手段:catch_unwind。然而当我在wasm中尝试使用这个函数时,仍然不能从panic中恢复,体现在JS那一侧就是函数抛出了一个异常:RuntimeError: unreachable,但是没法得到panic时的信息。

首先创建一个简单的复现环境:

1
2
3
4
5
6
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn work() {
panic!("hello");
}

然后在JS那边调用它,结果会显示RuntimeError: unreachable,没有任何关于这个字符串hello的信息。debug build和release build结果差不多,前者会多一些栈帧信息,但是也没有关于hello的信息。在实际场景中,panic时传入的字符串是描述编译错误类型的信息,希望能够在panic之后得到这个字符串并把它在浏览器中显示出来。

尝试1:catch_unwind

尝试像平常一样用catch_unwind来捕获这个panic:

1
2
3
4
5
6
7
#[wasm_bindgen]
pub fn work() -> String {
let res = std::panic::catch_unwind(|| {
panic!("hello");
});
format!("{:#?}", res)
}

catch_unwind返回一个Result<T, Box<dyn Any + Send + 'static>>,如果闭包中的操作发生了panic,就返回Err,这个Any中包含了panic的信息。虽然有点麻烦,但是确实可以把panic的字符串从Any中提取出来,这里就不讨论了,只要能够得到这个Result就算成功获取了我们需要的信息。

但是结果没有任何变化,work函数仍然不能正常返回,JS那边还是显示RuntimeError: unreachable。根本原因在于,catch_unwind其实本来就不保证能从任意的panic中恢复,它要求panic必须是以stack unwind的形式实现的,但是实际上目前Rust的wasm后端,不管指定panic按unwind实现还是按abort实现,结果都是按abort实现的。我想找相关的代码来验证这一点,但是不知道应该在什么地方找,就放弃了。

用实验来验证比较容易,分别在Cargo.toml中指定

1
2
[profile.release]
panic = "unwind"

1
2
[profile.release]
panic = "abort"

在release build下生成的wasm文件是逐字节相同的。把profile.release都改成profile.dev,结果倒确实不是逐字节相同,但是我简单观察了一下也没有什么本质区别。

所以本质上catch_unwind这一条路是走不通的,只要现在的wasm后端panic实现不改成unwind,无论做什么修补的工作都不可能让它成功捕获panic,从而得到想要的信息。

尝试2:panic_handler

之前在一些介绍bare metal下的Rust编程的文章中见过panic_handler这个东西,简单来说就是在no_std模式下需要定义一个panic_handler函数,类型是fn (&PanicInfo) -> !,用来在panic时调用。

如果能够控制panic时的行为就能解决问题了,但是简单尝试一下之后就放弃了。因为不仅是no_std下需要定义panic_handler,而且也是只有在no_std下才能定义panic_handler,因为std中已经定义了一个panic_handler,自己再定义一个会报重复定义的错误。虽然我的程序稍微改改应该是可以在no_std下运行的,只要有alloc就行了,但是wasm_bindgen目前还是不能在no_std下运行:在Cargo.toml中为它定义default-features = false就可以让它不依赖std,但是会有很多编译错误,有一个issue讨论这个问题,好像他们本来就没打算支持在no_std下运行。

尝试3:set_hook

set_hook函数可以定义一个钩子,在触发panic后,但在unwind或者abort前执行。

一般来说用它不能实现通用意义上的”从panic中恢复”,如果不借助什么魔法的话,不可能从这个钩子函数里将控制流转移到触发panic前定义的的一个位置(类似其他语言中catch的位置),只能在这个函数里做一点处理之后等着unwind或者abort。但是在这个实际问题中,它还是可以达到我期望的效果(获取错误信息,在浏览器中显示出来)的,有两个方法:

  1. 可以在JS那边定义一个函数,它接受错误信息,负责在浏览器中它,Rust这边导入这个函数,在这里调用它。虽然后面还是会挂掉,但是任务已经达成了,只需要在JS那边catch这个RuntimeError: unreachable错误,然后直接忽略它即可
  2. 可以调用wasm_bindgen::throw_str,把错误信息字符串抛出去,在JS那边catch它,然后就可以随便操作了。这其实就算是上面说的”魔法”,因为它真的改变了控制流,可以不unwind或者abort,而是跳转到panic前定义的的一个位置,虽然这个位置只能在JS中,不能在Rust中

两种方法在我的应用中没有本质区别,我也都尝试过了,但都失败了:第一次和第二次编译一个有错误的程序时都能得到错误信息,但是第三次及之后就不能了,如果第一种方法中不忽略catch到的错误,或者是用第二种方法,都会发现第三次及之后还是得到了RuntimeError: unreachable

研究之后发现标准库中执行钩子函数之前会先进行一些检查。在std::panicking::panic_count模块中定义了一个全局的和一个thread local的计数器,用来表示当前正在发生的panic数量。发生panic时先增加计数器,如果panic被捕获,在后续的清理中会减少计数器,而如果没有被捕获,也就是我们这里的情形,计数器就不会减少。在一般的Rust程序中panic没有被捕获应该会导致整个程序结束,所以减不减少不会有任何区别,但是这里panic没有被捕获只会在JS那边抛出一个异常,不会导致网页崩溃之类的后果,之后还可以调用Rust中的函数,这样之前的计数器的值就留下来了。

std::panicking::rust_panic_with_hook函数中执行钩子函数之前会检查这个计数器,如果增加1之后大于2,也就是在第三次调用它时,就会直接abort。我写这篇文章的时候的代码可以参考https://github.com/rust-lang/rust/blob/4b65872d272875adb298b6dc12d5e4e79cf8e263/library/std/src/panicking.rs#L559

尝试3-1:修改计数器

std::panicking::panic_count模块是private的,所以不能通过调用里面的函数来修改计数器。但是理论上可以找到这两个计数器mangle之后的符号,这样可以绕过private的限制。比如std::panicking::panic_count::GLOBAL_PANIC_COUNT mangle之后的符号是_ZN3std9panicking11panic_count18GLOBAL_PANIC_COUNT17he5d2b5eed51f22a6E,可以这样修改它:

1
2
3
4
5
6
7
8
extern "C" {
#[no_mangle]
static mut _ZN3std9panicking11panic_count18GLOBAL_PANIC_COUNT17he5d2b5eed51f22a6E: usize;
}

unsafe {
_ZN3std9panicking11panic_count18GLOBAL_PANIC_COUNT17he5d2b5eed51f22a6E = 0;
}

我在普通的Rust程序中试过了,这样确实可以成功修改GLOBAL_PANIC_COUNT,但是在wasm中尝试时链接器报告说找不到这个符号。不知道是为什么,我猜测可能wasm中的全局变量不是这样实现的,可能没有符号这样的概念,如果有错麻烦大家指正。

其实还有一个问题。我们实际上不是要修改GLOBAL_PANIC_COUNT,而是LOCAL_PANIC_COUNT,但是它是一个thread local变量,看起来实现有点复杂,至少不是直接保存一个usize,我没有找到它实际存储的地址的符号。

尝试3-2:派生线程

既然是想清零thread local的LOCAL_PANIC_COUNT,有一个虽然看起来有点蠢,但是理论上还是可行的做法:每次计算都派生一个新的线程,每次新线程中它应该都是初始值0。我直接尝试了一下thread::spawn,但是派生失败了,想一下也是很合理的,wasm中应该没法做到这种事情。

我看到wasm_bindgen中已经有用rayon多线程计算的例子了,本来以为现在wasm中已经支持派生线程了,但是点进去仔细看了一下,它还是基于JS中worker那一套实现的,不是基于thread::spawn派生的线程。感觉像它一样实现太麻烦了,也不确定能不能达成我要的效果,就没有尝试了。

尝试3-3:重置内存

我的程序中是不需要保存任何全局信息的,理论上每次调用前都可以使用第一次调用前的那一份内存,都应该会产生正确的结果。不管这个计数器到底在内存中的哪里,反正总是在内存里面的,只要重置整个内存总是可以把它清零的。

如果是一般的程序,这个做法看起来没有什么可行性,但是wasm中有一个导出的memory变量,可以在JS中操作它,这样做就是操作Rust的内存。new TypedArray(memory.buffer)会创建一个引用memory的数组,修改它就是修改memory,new TypedArray(array)会把array复制一份,它和原来的内存互不干扰。最后的实现是这样的:

1
2
3
4
5
// 一开始执行一次
const init_mem = new Int32Array(new Int32Array(memory.buffer));

// 每次调用前执行
new Int32Array(memory.buffer).set(init_mem);

这次尝试最终成功了。