22//!
33//! This module provides the host-side implementation of functions that WASM plugins
44//! can call. These functions are exposed to plugins through the wasmtime Linker.
5+ //!
6+ //! # Synchronous Context
7+ //!
8+ //! WASM host functions run in a synchronous context (called from wasmtime's sync API).
9+ //! We avoid using `tokio::runtime::Handle::block_on()` to prevent potential deadlocks
10+ //! when the tokio runtime is already blocked on the WASM call. Instead, we use
11+ //! `std::sync::Mutex` for state that needs synchronous access from host functions.
512
613use std:: collections:: HashMap ;
7- use std:: sync:: Arc ;
8- use tokio:: sync:: RwLock ;
14+ use std:: sync:: { Arc , Mutex } ;
915use wasmtime:: { Caller , Engine , Linker } ;
1016
1117use crate :: Result ;
@@ -77,25 +83,34 @@ impl ToastLevel {
7783}
7884
7985/// State shared between the host and WASM plugins.
86+ ///
87+ /// Uses `std::sync::Mutex` instead of `tokio::sync::RwLock` to allow synchronous
88+ /// access from WASM host functions without risking deadlocks. WASM host functions
89+ /// are called synchronously by wasmtime, and using `block_on()` to access async
90+ /// locks could deadlock if the tokio runtime is already blocked on the WASM call.
8091#[ derive( Debug , Clone ) ]
8192pub struct PluginHostState {
8293 pub plugin_id : String ,
8394 pub context : PluginContext ,
84- pub widgets : Arc < RwLock < HashMap < UiRegion , Vec < String > > > > ,
85- pub keybindings : Arc < RwLock < HashMap < String , String > > > ,
86- pub events : Arc < RwLock < Vec < PluginEvent > > > ,
87- pub toasts : Arc < RwLock < Vec < ToastNotification > > > ,
95+ /// Registered widgets by UI region. Uses sync Mutex for safe access from WASM host functions.
96+ pub widgets : Arc < Mutex < HashMap < UiRegion , Vec < String > > > > ,
97+ /// Registered keybindings (key -> action). Uses sync Mutex for safe access from WASM host functions.
98+ pub keybindings : Arc < Mutex < HashMap < String , String > > > ,
99+ /// Emitted events queue. Uses sync Mutex for safe access from WASM host functions.
100+ pub events : Arc < Mutex < Vec < PluginEvent > > > ,
101+ /// Toast notifications queue. Uses sync Mutex for safe access from WASM host functions.
102+ pub toasts : Arc < Mutex < Vec < ToastNotification > > > ,
88103}
89104
90105impl PluginHostState {
91106 pub fn new ( plugin_id : impl Into < String > , context : PluginContext ) -> Self {
92107 Self {
93108 plugin_id : plugin_id. into ( ) ,
94109 context,
95- widgets : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
96- keybindings : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
97- events : Arc :: new ( RwLock :: new ( Vec :: new ( ) ) ) ,
98- toasts : Arc :: new ( RwLock :: new ( Vec :: new ( ) ) ) ,
110+ widgets : Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ,
111+ keybindings : Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ,
112+ events : Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ,
113+ toasts : Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ,
99114 }
100115 }
101116}
@@ -333,11 +348,17 @@ fn register_widget_impl<T: HasHostState>(
333348 }
334349 } ;
335350
336- if let Ok ( handle) = tokio:: runtime:: Handle :: try_current ( ) {
337- handle. block_on ( async {
338- let mut w = widgets. write ( ) . await ;
351+ // Use sync Mutex instead of async RwLock to avoid deadlock risk.
352+ // WASM host functions run synchronously, and using block_on() on an async lock
353+ // could deadlock if the tokio runtime is already blocked on this WASM call.
354+ match widgets. lock ( ) {
355+ Ok ( mut w) => {
339356 w. entry ( ui_region) . or_default ( ) . push ( widget_type. clone ( ) ) ;
340- } ) ;
357+ }
358+ Err ( e) => {
359+ tracing:: error!( plugin = %plugin_id, error = %e, "Failed to acquire widget lock (poisoned)" ) ;
360+ return HostError :: InternalError . into ( ) ;
361+ }
341362 }
342363 tracing:: debug!( plugin = %plugin_id, widget_type = %widget_type, region = ?ui_region, "Widget registered" ) ;
343364 HostError :: Success . into ( )
@@ -369,11 +390,17 @@ fn register_keybinding_impl<T: HasHostState>(
369390 return HostError :: InvalidArgument . into ( ) ;
370391 }
371392
372- if let Ok ( handle) = tokio:: runtime:: Handle :: try_current ( ) {
373- handle. block_on ( async {
374- let mut kb = keybindings. write ( ) . await ;
393+ // Use sync Mutex instead of async RwLock to avoid deadlock risk.
394+ // WASM host functions run synchronously, and using block_on() on an async lock
395+ // could deadlock if the tokio runtime is already blocked on this WASM call.
396+ match keybindings. lock ( ) {
397+ Ok ( mut kb) => {
375398 kb. insert ( key. clone ( ) , action. clone ( ) ) ;
376- } ) ;
399+ }
400+ Err ( e) => {
401+ tracing:: error!( plugin = %plugin_id, error = %e, "Failed to acquire keybinding lock (poisoned)" ) ;
402+ return HostError :: InternalError . into ( ) ;
403+ }
377404 }
378405 tracing:: debug!( plugin = %plugin_id, key = %key, action = %action, "Keybinding registered" ) ;
379406 HostError :: Success . into ( )
@@ -406,11 +433,17 @@ fn show_toast_impl<T: HasHostState>(
406433 plugin_id : plugin_id. clone ( ) ,
407434 } ;
408435
409- if let Ok ( handle) = tokio:: runtime:: Handle :: try_current ( ) {
410- handle. block_on ( async {
411- let mut t = toasts. write ( ) . await ;
436+ // Use sync Mutex instead of async RwLock to avoid deadlock risk.
437+ // WASM host functions run synchronously, and using block_on() on an async lock
438+ // could deadlock if the tokio runtime is already blocked on this WASM call.
439+ match toasts. lock ( ) {
440+ Ok ( mut t) => {
412441 t. push ( toast) ;
413- } ) ;
442+ }
443+ Err ( e) => {
444+ tracing:: error!( plugin = %plugin_id, error = %e, "Failed to acquire toast lock (poisoned)" ) ;
445+ return HostError :: InternalError . into ( ) ;
446+ }
414447 }
415448 tracing:: debug!( plugin = %plugin_id, message = %message, "Toast queued" ) ;
416449 HostError :: Success . into ( )
@@ -442,10 +475,13 @@ fn emit_event_impl<T: HasHostState>(
442475 return HostError :: InvalidArgument . into ( ) ;
443476 }
444477
445- if !data. is_empty ( )
446- && serde_json:: from_str :: < serde_json:: Value > ( & data) . is_err ( ) {
447- return HostError :: InvalidArgument . into ( ) ;
448- }
478+ // Validate that data is valid JSON if non-empty.
479+ // Empty data is allowed and represents "no data" (null/empty event payload).
480+ // This avoids confusing behavior where `serde_json::from_str("")` would fail,
481+ // which we explicitly want to allow as a valid "no data" case.
482+ if !data. is_empty ( ) && serde_json:: from_str :: < serde_json:: Value > ( & data) . is_err ( ) {
483+ return HostError :: InvalidArgument . into ( ) ;
484+ }
449485
450486 let event = PluginEvent {
451487 name : name. clone ( ) ,
@@ -454,11 +490,17 @@ fn emit_event_impl<T: HasHostState>(
454490 timestamp : chrono:: Utc :: now ( ) ,
455491 } ;
456492
457- if let Ok ( handle) = tokio:: runtime:: Handle :: try_current ( ) {
458- handle. block_on ( async {
459- let mut e = events. write ( ) . await ;
493+ // Use sync Mutex instead of async RwLock to avoid deadlock risk.
494+ // WASM host functions run synchronously, and using block_on() on an async lock
495+ // could deadlock if the tokio runtime is already blocked on this WASM call.
496+ match events. lock ( ) {
497+ Ok ( mut e) => {
460498 e. push ( event) ;
461- } ) ;
499+ }
500+ Err ( e) => {
501+ tracing:: error!( plugin = %plugin_id, error = %e, "Failed to acquire event lock (poisoned)" ) ;
502+ return HostError :: InternalError . into ( ) ;
503+ }
462504 }
463505 tracing:: debug!( plugin = %plugin_id, event_name = %name, "Event emitted" ) ;
464506 HostError :: Success . into ( )
@@ -508,19 +550,19 @@ mod tests {
508550 assert ! ( result. is_ok( ) ) ;
509551 }
510552
511- #[ tokio :: test]
512- async fn test_plugin_host_state_widgets ( ) {
553+ #[ test]
554+ fn test_plugin_host_state_widgets ( ) {
513555 let context = PluginContext :: new ( "/tmp" ) ;
514556 let state = PluginHostState :: new ( "test-plugin" , context) ;
515557 {
516- let mut widgets = state. widgets . write ( ) . await ;
558+ let mut widgets = state. widgets . lock ( ) . expect ( "lock should not be poisoned" ) ;
517559 widgets
518560 . entry ( UiRegion :: StatusBar )
519561 . or_default ( )
520562 . push ( "test_widget" . to_string ( ) ) ;
521563 }
522564 {
523- let widgets = state. widgets . read ( ) . await ;
565+ let widgets = state. widgets . lock ( ) . expect ( "lock should not be poisoned" ) ;
524566 assert ! ( widgets. get( & UiRegion :: StatusBar ) . is_some( ) ) ;
525567 assert_eq ! ( widgets. get( & UiRegion :: StatusBar ) . unwrap( ) [ 0 ] , "test_widget" ) ;
526568 }
0 commit comments