@@ -39,34 +39,31 @@ class WokwiClientSync:
3939        tracked, so we can cancel & drain them on `disconnect()`. 
4040    """ 
4141
42-     # Public attributes mirrored for convenience 
43-     version : str 
44-     last_pause_nanos : int   # this proxy resolves via __getattr__ 
45- 
4642    def  __init__ (self , token : str , server : str  |  None  =  None ):
47-         # Create a fresh  event loop + thread (daemon so it won't prevent process exit).  
43+         # Create a new  event loop for the background thread  
4844        self ._loop  =  asyncio .new_event_loop ()
45+         # Event to signal that the event loop is running 
46+         self ._loop_started_event  =  threading .Event ()
47+         # Start background thread running the event loop 
4948        self ._thread  =  threading .Thread (
5049            target = self ._run_loop , args = (self ._loop ,), daemon = True , name = "wokwi-sync-loop" 
5150        )
5251        self ._thread .start ()
53- 
54-         # Underlying async client 
52+         # **Wait until loop is fully started before proceeding** (prevents race conditions) 
53+         if  not  self ._loop_started_event .wait (timeout = 8.0 ):  # timeout to avoid deadlock 
54+             raise  RuntimeError ("WokwiClientSync event loop failed to start" )
55+         # Initialize underlying async client on the running loop 
5556        self ._async_client  =  WokwiClient (token , server )
56- 
57-         # Mirror library version for quick access 
58-         self .version  =  self ._async_client .version 
59- 
60-         # Track background tasks created via run_coroutine_threadsafe (serial monitors) 
57+         # Track background monitor tasks (futures) for cancellation on exit 
6158        self ._bg_futures : set [Future [Any ]] =  set ()
62- 
63-         # Idempotent disconnect guard 
59+         # Flag to avoid double-closing 
6460        self ._closed  =  False 
6561
66-     @staticmethod  
67-     def  _run_loop (loop : asyncio .AbstractEventLoop ) ->  None :
68-         """Background thread loop runner.""" 
62+     def  _run_loop (self , loop : asyncio .AbstractEventLoop ) ->  None :
63+         """Target function for the background thread: runs the asyncio event loop.""" 
6964        asyncio .set_event_loop (loop )
65+         # Signal that the loop is now running and ready to accept tasks 
66+         loop .call_soon (self ._loop_started_event .set )
7067        loop .run_forever ()
7168
7269    # ----- Internal helpers ------------------------------------------------- 
@@ -75,8 +72,11 @@ def _submit(self, coro: Coroutine[Any, Any, T]) -> Future[T]:
7572        return  asyncio .run_coroutine_threadsafe (coro , self ._loop )
7673
7774    def  _call (self , coro : Coroutine [Any , Any , T ]) ->  T :
78-         """Submit a coroutine to the loop and block until it completes (or raises).""" 
79-         return  self ._submit (coro ).result ()
75+         """Submit a coroutine to the background loop and wait for result.""" 
76+         if  self ._closed :
77+             raise  RuntimeError ("Cannot call methods on a closed WokwiClientSync" )
78+         future  =  asyncio .run_coroutine_threadsafe (coro , self ._loop )
79+         return  future .result ()  # Block until the coroutine completes or raises 
8080
8181    def  _add_bg_future (self , fut : Future [Any ]) ->  None :
8282        """Track a background future so we can cancel & drain on shutdown.""" 
@@ -96,37 +96,35 @@ def connect(self) -> dict[str, Any]:
9696        return  self ._call (self ._async_client .connect ())
9797
9898    def  disconnect (self ) ->  None :
99-         """Disconnect and stop the background loop. 
100- 
101-         Order matters: 
102-           1) Cancel and drain background serial-monitor futures. 
103-           2) Disconnect the underlying transport. 
104-           3) Stop the loop and join the thread. 
105-         Safe to call multiple times. 
106-         """ 
10799        if  self ._closed :
108100            return 
109-         self ._closed  =  True 
110101
111102        # (1) Cancel + drain monitors 
112103        for  fut  in  list (self ._bg_futures ):
113104            fut .cancel ()
114105        for  fut  in  list (self ._bg_futures ):
115106            with  contextlib .suppress (FutureTimeoutError , Exception ):
116-                 # Give each monitor a short window to handle cancellation cleanly. 
117107                fut .result (timeout = 1.0 )
118108            self ._bg_futures .discard (fut )
119109
120110        # (2) Disconnect transport 
121111        with  contextlib .suppress (Exception ):
122-             self ._call (self ._async_client ._transport .close ())
112+             fut  =  asyncio .run_coroutine_threadsafe (self ._async_client .disconnect (), self ._loop )
113+             fut .result (timeout = 2.0 )
123114
124115        # (3) Stop loop / join thread 
125116        if  self ._loop .is_running ():
126117            self ._loop .call_soon_threadsafe (self ._loop .stop )
127118        if  self ._thread .is_alive ():
128119            self ._thread .join (timeout = 5.0 )
129120
121+         # (4) Close loop 
122+         with  contextlib .suppress (Exception ):
123+             self ._loop .close ()
124+ 
125+         # (5) Mark closed at the very end 
126+         self ._closed  =  True 
127+ 
130128    # ----- Serial monitoring ------------------------------------------------ 
131129    def  serial_monitor (self , callback : Callable [[bytes ], Any ]) ->  None :
132130        """ 
@@ -138,17 +136,25 @@ def serial_monitor(self, callback: Callable[[bytes], Any]) -> None:
138136        """ 
139137
140138        async  def  _runner () ->  None :
141-             async  for  line  in  monitor_lines (self ._async_client ._transport ):
142-                 try :
143-                     maybe_awaitable  =  callback (line )
144-                     if  inspect .isawaitable (maybe_awaitable ):
145-                         await  maybe_awaitable 
146-                 except  Exception :
147-                     # Keep the monitor alive even if the callback throws. 
148-                     pass 
149- 
150-         fut  =  self ._submit (_runner ())
151-         self ._add_bg_future (fut )
139+             try :
140+                 # **Prepare to receive serial events before enabling monitor** 
141+                 # (monitor_lines will subscribe to serial events internally) 
142+                 async  for  line  in  monitor_lines (self ._async_client ._transport ):
143+                     try :
144+                         result  =  callback (line )  # invoke callback with the raw bytes line 
145+                         if  inspect .isawaitable (result ):
146+                             await  result   # await if callback is async 
147+                     except  Exception :
148+                         # Swallow exceptions from callback to keep monitor alive 
149+                         pass 
150+             finally :
151+                 # Remove this task’s future from the set when done 
152+                 self ._bg_futures .discard (task_future )
153+ 
154+         # Schedule the serial monitor runner on the event loop: 
155+         task_future  =  asyncio .run_coroutine_threadsafe (_runner (), self ._loop )
156+         self ._bg_futures .add (task_future )
157+         # (No return value; monitoring happens in background) 
152158
153159    def  serial_monitor_cat (self , decode_utf8 : bool  =  True , errors : str  =  "replace" ) ->  None :
154160        """ 
@@ -160,34 +166,32 @@ def serial_monitor_cat(self, decode_utf8: bool = True, errors: str = "replace")
160166        """ 
161167
162168        async  def  _runner () ->  None :
163-             async  for  line  in  monitor_lines (self ._async_client ._transport ):
164-                 try :
165-                     if  decode_utf8 :
166-                         try :
167-                             print (line .decode ("utf-8" , errors = errors ), end = "" , flush = True )
168-                         except  UnicodeDecodeError :
169+             try :
170+                 # **Subscribe to serial events before reading output** 
171+                 async  for  line  in  monitor_lines (self ._async_client ._transport ):
172+                     try :
173+                         if  decode_utf8 :
174+                             # Decode bytes to string (handle errors per parameter) 
175+                             text  =  line .decode ("utf-8" , errors = errors )
176+                             print (text , end = "" , flush = True )
177+                         else :
178+                             # Print raw bytes 
169179                            print (line , end = "" , flush = True )
170-                     else :
171-                         print ( line ,  end = "" ,  flush = True ) 
172-                 except   Exception : 
173-                      # Keep the monitor alive even if printing raises intermittently. 
174-                      pass 
180+                     except   Exception :
181+                         # Swallow  print errors to keep stream alive 
182+                          pass 
183+             finally : 
184+                 self . _bg_futures . discard ( task_future ) 
175185
176-         fut  =  self ._submit (_runner ())
177-         self ._add_bg_future (fut )
186+         task_future  =  asyncio .run_coroutine_threadsafe (_runner (), self ._loop )
187+         self ._bg_futures .add (task_future )
188+         # (No return; printing continues in background) 
178189
179190    def  stop_serial_monitors (self ) ->  None :
180-         """ 
181-         Cancel and drain all running serial monitors without disconnecting. 
182- 
183-         Useful if you want to stop printing but keep the connection alive. 
184-         """ 
191+         """Stop all active serial monitor background tasks.""" 
185192        for  fut  in  list (self ._bg_futures ):
186193            fut .cancel ()
187-         for  fut  in  list (self ._bg_futures ):
188-             with  contextlib .suppress (FutureTimeoutError , Exception ):
189-                 fut .result (timeout = 1.0 )
190-             self ._bg_futures .discard (fut )
194+         self ._bg_futures .clear ()
191195
192196    # ----- Dynamic method wrapping ----------------------------------------- 
193197    def  __getattr__ (self , name : str ) ->  Any :
@@ -197,16 +201,17 @@ def __getattr__(self, name: str) -> Any:
197201        If the attribute on `WokwiClient` is a coroutine function, return a 
198202        sync wrapper that blocks until the coroutine completes. 
199203        """ 
200-         # Explicit methods above (serial monitors ) take precedence.  
204+         # Explicit methods (like serial_monitor functions above ) take precedence over __getattr__  
201205        attr  =  getattr (self ._async_client , name )
202206        if  callable (attr ):
207+             # Get the function object from WokwiClient class (unbound) to check if coroutine 
203208            func  =  getattr (WokwiClient , name , None )
204209            if  func  is  not   None  and  inspect .iscoroutinefunction (func ):
205- 
210+                  # Wrap coroutine method to run in background loop 
206211                def  sync_wrapper (* args : Any , ** kwargs : Any ) ->  Any :
207212                    return  self ._call (attr (* args , ** kwargs ))
208213
209214                sync_wrapper .__name__  =  name 
210-                 sync_wrapper .__doc__  =  func . __doc__ 
215+                 sync_wrapper .__doc__  =  getattr ( func ,  " __doc__" ,  "" ) 
211216                return  sync_wrapper 
212217        return  attr 
0 commit comments