最近花了一点时间给我的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 | use wasm_bindgen::prelude::*; |
然后在JS那边调用它,结果会显示RuntimeError: unreachable
,没有任何关于这个字符串hello
的信息。debug build和release build结果差不多,前者会多一些栈帧信息,但是也没有关于hello
的信息。在实际场景中,panic时传入的字符串是描述编译错误类型的信息,希望能够在panic之后得到这个字符串并把它在浏览器中显示出来。
尝试1:catch_unwind
尝试像平常一样用catch_unwind
来捕获这个panic:
1 |
|
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 | [profile.release] |
和
1 | [profile.release] |
在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。但是在这个实际问题中,它还是可以达到我期望的效果(获取错误信息,在浏览器中显示出来)的,有两个方法:
- 可以在JS那边定义一个函数,它接受错误信息,负责在浏览器中它,Rust这边导入这个函数,在这里调用它。虽然后面还是会挂掉,但是任务已经达成了,只需要在JS那边catch这个
RuntimeError: unreachable
错误,然后直接忽略它即可 - 可以调用
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 | extern "C" { |
我在普通的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 | // 一开始执行一次 |
这次尝试最终成功了。