Rust 小ネタ: Leptos でシグナルの書き込み中に読み取りしてランタイムエラーを起こさせてみる
こんにちは。Fairy Devices株式会社 となんらかの関わりがある nogiro (Twitter (現 Twitter): @nogiro_iota) です。
Leptos Book を読んでいると Working with Signals [1] で「シグナルを .read() している最中に .write() するとランタイムエラーするよ」と書かれています。(逆の .write() 中に .read() もそう。) 具体的にどういうことが起こるのか知っておくと、実際にやらかしたときに気づくことができるかもしれません。
というか単純にやってみたいですよね。やってみましょう。
作る
プロジェクト作成
以下でプロジェクトを作って、開発サーバーを起動しつつ、
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install trunk
cargo new ~/tmp/rust-sandbox-leptos-panic-simultaneous-rw
cd $_
cargo add --features=leptos/csr leptos console_error_panic_hook
vim index.html # 後述
trunk serve
Vim などで src/main.rs に以下を書けば Leptos のほぼ最低限の構成になります。console_error_panic_hook は最低限の構成には必要ないですが、panic (ランタイムエラー) を見るのが目的なので入れます。
use leptos::mount::mount_to_body;
use leptos::prelude::{IntoView, component, view};
fn main() {
console_error_panic_hook::set_once();
mount_to_body(App);
}
#[component]
fn App() -> impl IntoView {
view! {}
}
index.html は以下にします。(Leptos Book より) [2]
<!DOCTYPE html>
<html>
<head></head>
<body></body>
</html>
ざっくり Leptos の使い方
Leptos では「シグナル」を使って動的に更新できる UI を作成します。App() として、ボタンを押すたびに true と false の表示をトグルする UI コンポーネントを作るには、以下のようなソースを書きます。signal.get() で取得した bool を <div> の中に表示して、<button> を押したら set_signal.update() でシグナルの値を更新しています。
use leptos::prelude::{signal, Get, Update, ElementChild, OnAttribute}; // 追加
#[component]
fn App() -> impl IntoView {
let (signal, set_signal) = signal(true);
view! {
<div>{move || signal.get()}</div>
<button on:click=move |_| set_signal.update(|s| *s = !*s)>toggle</button>
}
}

ランタイムエラー (panic) させてみる
実は .get() では呼ばれるたびに clone されるため、冒頭の「.read() している最中に」を満たすことができません [3]。読み取りのための参照と書き込みのための可変参照を同時に取得しようとすることでランタイムエラーするという話です。いかにも起こりそうですね。
値の参照と可変参照をシグナルから受け取る関数が、冒頭の .read() と .write() なわけです。なので例えば、可変参照を参照から取った値で更新しようとすればランタイムエラーが起こせるはずです。以下のボタンを新しく追加してみましょう。
<button on:click=move |_| *set_signal.write() = !signal.read().clone()>toggle 2</button>

この toggle 2 ボタンをクリックしてみましょう。

スクリーンキャプチャー内の文字列
rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x8636c Uncaught RuntimeError: unreachable
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.std::sys::pal::wasm::common::abort_internal::h0e6fef7e32045f57 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x8636c)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.std::sys::sync::rwlock::no_threads::RwLock::write::h59dd58e981643f10 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x30051)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.std::sync::poison::rwlock::RwLock<T>::write::h156177e30a6b1387 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x71dd7)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.guardian::ArcRwLockWriteGuardian<T>::take::hf5f803195cf80568 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x488b2)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.<reactive_graph::signal::write::WriteSignal<T,S> as reactive_graph::traits::Write>::try_write::{{closure}}::h04b8d0469817cac0 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x67af0)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.core::option::Option<T>::map::h70592a7ba10f26ba (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x5edb5)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.<reactive_graph::owner::storage::SyncStorage as reactive_graph::owner::storage::Storage<T>>::try_with::{{closure}}::hb745ed4d1cbd14cd (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x6eeea)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.reactive_graph::owner::arena::Arena::try_with::h8880e9c5d23ac656 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x4c407)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.<reactive_graph::owner::storage::SyncStorage as reactive_graph::owner::storage::Storage<T>>::try_with::h984f6bfcb193cdfb (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x69545)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.reactive_graph::owner::arena_item::ArenaItem<T,S>::try_with_value::h5c402e394cf97b46 (rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:0x7942f)
思ったよりしょぼい!しかし、2 回以上クリックするとちょっとだけ親切なエラー文がでます。(console_error_panic_hook が必要です。)

スクリーンキャプチャー内の文字列
panicked at /home/nog/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/reactive_graph-0.2.2/src/traits.rs:171:29:
At /home/nog/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/reactive_graph-0.2.2/src/signal/read.rs:160:32, you tried to access a reactive value which was defined at src/lib.rs:8:32, but it has already been disposed.
Stack:
Error
at imports.wbg.__wbg_new_8a6f238a6ece86ea (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c.js:516:21)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.__wbg_new_8a6f238a6ece86ea externref shim (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[2926]:0x85e59)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.console_error_panic_hook::Error::new::ha58bf751b23c3411 (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[1658]:0x745f2)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.console_error_panic_hook::hook_impl::h3d60b7cef5c4c62e (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[372]:0x3ad36)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.console_error_panic_hook::hook::h05431422e93c1c03 (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[2327]:0x7f90e)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.core::ops::function::Fn::call::hbc3460c593f1816e (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[2011]:0x7af9b)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.std::panicking::rust_panic_with_hook::h1f8226e239ffaa71 (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[744]:0x5560a)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.std::panicking::begin_panic_handler::{{closure}}::hc8d13715338b2080 (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[1040]:0x62fe8)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.std::sys::backtrace::__rust_end_short_backtrace::hab1143a2823b7bb3 (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[2997]:0x8627d)
at rust_sandbox_leptos_panic_simultaneous_rw-1e51f24cd815e432.wasm.__rustc[95feac21a9532783]::rust_begin_unwind (http://****/rust-sandbox-leptos-panic-simultaneous-rw-dff23c9e6cfcb87c_bg.wasm:wasm-function[2680]:0x83fe1)
以下のエラー文が出ています。「リアクティブな値 (シグナル) にアクセスしようとしたけど、もう利用されてますよ」みたいなエラーですね。(disposed に若干違和感があるけど……)
you tried to access a reactive value which was defined at src/main.rs:12:32, but it has already been disposed.
.update() でランタイムエラーさせてみる版
ちなみに、.update() に渡した関数の中で .read() すれば初回のクリックからエラー文が出ます。なんでなのかはあんまりわかりません。
<button on:click=move |_| set_signal.update(|s| *s = signal.read().clone())>toggle 3</button>
自動テストで検出する
Leptos Book では wasm-pack を利用した e2e テストのやり方が紹介されています。自動テストで検出できるかを試してみましょう。
e2e テストに必要な実行可能バイナリーとクレートを以下でインストールします。
cargo install wasm-pack
cargo add --dev wasm-bindgen wasm-bindgen-test web-sys
最終的な `Cargo.toml`
[package]
name = "rust-sandbox-leptos-panic-simultaneous-rw"
version = "0.1.0"
edition = "2024"
[dependencies]
console_error_panic_hook = "0.1.7"
leptos = { version = "0.8.2", features = ["csr"] }
[dev-dependencies]
wasm-bindgen = "0.2.100"
wasm-bindgen-test = "0.3.50"
web-sys = "0.3.77"
(Chrome で実行するなら) Google Chrome と ChromeDriver も必要ですが、環境差異が大きいのでインストールについては省略します。https://developer.chrome.com/docs/chromedriver/downloads?hl=ja の最初の警告あたりを読んでください。
自動テストのソースとして、tests/e2e.rs として以下を保存します。なお、tests/e2e.rs から利用できるように App を src/lib.rs に移動しています。
use leptos::mount::mount_to;
use leptos::prelude::{document, view};
use leptos::task::tick;
use rust_sandbox_leptos_panic_simultaneous_rw::App;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
async fn app() {
let document = document();
let test_wrapper = document.create_element("section").unwrap();
let _ = document.body().unwrap().append_child(&test_wrapper);
let _dispose = mount_to(test_wrapper.clone().unchecked_into(), || view! { <App/> });
// test_wrapper の中に div:nth-child(1)、button:nth-child(2)、button:nth-child(3)、... で並んでいます。
let div = test_wrapper.query_selector("div").unwrap().unwrap();
assert_eq!(div.inner_html(), "true"); // 最初は true が表示されている。
let button = test_wrapper
.query_selector("button:nth-child(2)") // (div を含めた要素全体で見て) 2 番目のランタイムエラーしない button。
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
button.click();
tick().await; // シグナルの値の変化を UI に反映させる。
assert_eq!(div.inner_html(), "false"); // トグルしたので false になる。
let button = test_wrapper
.query_selector("button:nth-child(3)") // 3 番目のランタイムエラーする button。
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
button.click(); // ここで `JS exception that was thrown:` が発生する。
// なお、「`.update()` でランタイムエラーさせてみる版」(`button:nth-child(4)`) だと、
// なぜか初回のクリックで JS exception が飛ばないので、値もきちんと確認したほうが良いです。
// (2 回目のクリックでは JS exception は飛ぶ。)
tick().await;
assert_eq!(div.inner_html(), "true");
}
自動テストを以下で実行すると、無事 JS exception that was thrown で失敗してくれていることがわかります。
wasm-pack test --headless --chrome
テストの実行結果
wasm-pack test --headless --chrome を実行した結果は以下になります。
[INFO]: 🎯 Checking for the Wasm target...
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
[INFO]: ⬇️ Installing wasm-bindgen...
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s
Running unittests src/lib.rs (target/wasm32-unknown-unknown/debug/deps/rust_sandbox_leptos_panic_simultaneous_rw-6bdbb9438274b337.wasm)
no tests to run!
Running unittests src/main.rs (target/wasm32-unknown-unknown/debug/deps/rust_sandbox_leptos_panic_simultaneous_rw-20ca39a3534fc5e5.wasm)
no tests to run!
Running tests/e2e.rs (target/wasm32-unknown-unknown/debug/deps/e2e-41f2a4a0fb6c7dc1.wasm)
Running headless tests in Chrome on `http://127.0.0.1:34645/`
Try find `webdriver.json` for configure browser's capabilities:
Not found
running 1 test
test app ... FAIL
failures:
---- app output ----
JS exception that was thrown:
RuntimeError: unreachable
at e2e-41f2a4a0fb6c7dc1.wasm.std::sys::pal::wasm::common::abort_internal::h0e6fef7e32045f57 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[4193]:0xe35a2)
at e2e-41f2a4a0fb6c7dc1.wasm.std::sys::sync::rwlock::no_threads::RwLock::write::heff85dd5980e45a8 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[465]:0x6cbad)
at e2e-41f2a4a0fb6c7dc1.wasm.std::sync::poison::rwlock::RwLock<T>::write::h353d51636abd2697 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[2218]:0xc8b95)
at e2e-41f2a4a0fb6c7dc1.wasm.reactive_graph::owner::arena::Arena::with_mut::hb8ef836ba37aeae4 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[1648]:0xb8c4c)
at e2e-41f2a4a0fb6c7dc1.wasm.<reactive_graph::owner::OwnerInner as core::ops::drop::Drop>::drop::h14ba9313ed997374 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[180]:0x3aa2c)
at e2e-41f2a4a0fb6c7dc1.wasm.core::ptr::drop_in_place<reactive_graph::owner::OwnerInner>::hf27de93b1cf162a2 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[1780]:0xbd07d)
at e2e-41f2a4a0fb6c7dc1.wasm.core::ptr::drop_in_place<core::cell::UnsafeCell<reactive_graph::owner::OwnerInner>>::h6f82eabda2af5018 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[3452]:0xdd425)
at e2e-41f2a4a0fb6c7dc1.wasm.core::ptr::drop_in_place<std::sync::poison::rwlock::RwLock<reactive_graph::owner::OwnerInner>>::h0f87fe8f72add69c (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[2856]:0xd505c)
at e2e-41f2a4a0fb6c7dc1.wasm.alloc::sync::Arc<T,A>::drop_slow::h3635d5721111bf51 (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[1863]:0xbf82c)
at e2e-41f2a4a0fb6c7dc1.wasm.<alloc::sync::Arc<T,A> as core::ops::drop::Drop>::drop::h4dd162030ba76b3c (http://127.0.0.1:38973/wasm-bindgen-test_bg.wasm:wasm-function[1053]:0x9de39)
failures:
app
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 filtered out; finished in 0.03s
Error: some tests failed
error: test failed, to rerun pass `--test e2e`
Caused by:
process didn't exit successfully: `/home/nog/.cache/.wasm-pack/wasm-bindgen-c59d5019a2b42393/wasm-bindgen-test-runner /home/nog/tmp/rust-sandbox-leptos-panic-simultaneous-rw/target/wasm32-unknown-unknown/debug/deps/e2e-41f2a4a0fb6c7dc1.wasm` (exit status: 1)
note: test exited abnormally; to see the full output pass --nocapture to the harness.
Error: Running Wasm tests with wasm-bindgen-test failed
Caused by: Running Wasm tests with wasm-bindgen-test failed
Caused by: failed to execute `cargo test`: exited with exit status: 1
full command: cd "/home/nog/tmp/rust-sandbox-leptos-panic-simultaneous-rw" && CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER="/home/nog/.cache/.wasm-pack/wasm-bindgen-c59d5019a2b42393/wasm-bindgen-test-runner" CHROMEDRIVER="/usr/local/bin/chromedriver" WASM_BINDGEN_TEST_ONLY_WEB="1" "cargo" "test" "--target" "wasm32-unknown-unknown"
まとめ
it has already been disposed と言われれば、参照と可変参照を同時に取得しようとしている可能性があることがわかりました。ライフタイムで防げそうにも思えますが、Mutex でデッドロックするのと同じ話なので難しそうです [4]。
最終的には自動テストで検出するのがベターでしょう。
-
非公式翻訳: https://nogiro.gitlab.io/leptos-book-unofficial-japanese-translation/reactivity/working_with_signals.html#取得する ↩︎
-
trunk0.18.4 だとindex.htmlが完全に空のファイルでも trunk が動くことをこの記事を書いていて知りました。直ってました。 ↩︎ -
ちょっと釣りタイトルだったわけですね。すみません…… ↩︎
-
Mutex::try_lock()と同様、try_read()、try_write()すれば良くはある。 ↩︎
Discussion