@@ -39,34 +39,31 @@ class WokwiClientSync:
39
39
tracked, so we can cancel & drain them on `disconnect()`.
40
40
"""
41
41
42
- # Public attributes mirrored for convenience
43
- version : str
44
- last_pause_nanos : int # this proxy resolves via __getattr__
45
-
46
42
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
48
44
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
49
48
self ._thread = threading .Thread (
50
49
target = self ._run_loop , args = (self ._loop ,), daemon = True , name = "wokwi-sync-loop"
51
50
)
52
51
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
55
56
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
61
58
self ._bg_futures : set [Future [Any ]] = set ()
62
-
63
- # Idempotent disconnect guard
59
+ # Flag to avoid double-closing
64
60
self ._closed = False
65
61
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."""
69
64
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 )
70
67
loop .run_forever ()
71
68
72
69
# ----- Internal helpers -------------------------------------------------
@@ -75,8 +72,11 @@ def _submit(self, coro: Coroutine[Any, Any, T]) -> Future[T]:
75
72
return asyncio .run_coroutine_threadsafe (coro , self ._loop )
76
73
77
74
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
80
80
81
81
def _add_bg_future (self , fut : Future [Any ]) -> None :
82
82
"""Track a background future so we can cancel & drain on shutdown."""
@@ -96,37 +96,35 @@ def connect(self) -> dict[str, Any]:
96
96
return self ._call (self ._async_client .connect ())
97
97
98
98
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
- """
107
99
if self ._closed :
108
100
return
109
- self ._closed = True
110
101
111
102
# (1) Cancel + drain monitors
112
103
for fut in list (self ._bg_futures ):
113
104
fut .cancel ()
114
105
for fut in list (self ._bg_futures ):
115
106
with contextlib .suppress (FutureTimeoutError , Exception ):
116
- # Give each monitor a short window to handle cancellation cleanly.
117
107
fut .result (timeout = 1.0 )
118
108
self ._bg_futures .discard (fut )
119
109
120
110
# (2) Disconnect transport
121
111
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 )
123
114
124
115
# (3) Stop loop / join thread
125
116
if self ._loop .is_running ():
126
117
self ._loop .call_soon_threadsafe (self ._loop .stop )
127
118
if self ._thread .is_alive ():
128
119
self ._thread .join (timeout = 5.0 )
129
120
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
+
130
128
# ----- Serial monitoring ------------------------------------------------
131
129
def serial_monitor (self , callback : Callable [[bytes ], Any ]) -> None :
132
130
"""
@@ -138,17 +136,25 @@ def serial_monitor(self, callback: Callable[[bytes], Any]) -> None:
138
136
"""
139
137
140
138
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)
152
158
153
159
def serial_monitor_cat (self , decode_utf8 : bool = True , errors : str = "replace" ) -> None :
154
160
"""
@@ -160,34 +166,32 @@ def serial_monitor_cat(self, decode_utf8: bool = True, errors: str = "replace")
160
166
"""
161
167
162
168
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
169
179
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 )
175
185
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)
178
189
179
190
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."""
185
192
for fut in list (self ._bg_futures ):
186
193
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 ()
191
195
192
196
# ----- Dynamic method wrapping -----------------------------------------
193
197
def __getattr__ (self , name : str ) -> Any :
@@ -197,16 +201,17 @@ def __getattr__(self, name: str) -> Any:
197
201
If the attribute on `WokwiClient` is a coroutine function, return a
198
202
sync wrapper that blocks until the coroutine completes.
199
203
"""
200
- # Explicit methods above (serial monitors ) take precedence.
204
+ # Explicit methods (like serial_monitor functions above ) take precedence over __getattr__
201
205
attr = getattr (self ._async_client , name )
202
206
if callable (attr ):
207
+ # Get the function object from WokwiClient class (unbound) to check if coroutine
203
208
func = getattr (WokwiClient , name , None )
204
209
if func is not None and inspect .iscoroutinefunction (func ):
205
-
210
+ # Wrap coroutine method to run in background loop
206
211
def sync_wrapper (* args : Any , ** kwargs : Any ) -> Any :
207
212
return self ._call (attr (* args , ** kwargs ))
208
213
209
214
sync_wrapper .__name__ = name
210
- sync_wrapper .__doc__ = func . __doc__
215
+ sync_wrapper .__doc__ = getattr ( func , " __doc__" , "" )
211
216
return sync_wrapper
212
217
return attr
0 commit comments