@@ -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