|
13 | 13 | from functools import partial |
14 | 14 | from io import BytesIO |
15 | 15 | from os import urandom |
16 | | -from threading import Thread |
| 16 | +from threading import Event as ThreadEvent, Thread |
17 | 17 | from types import NoneType |
18 | 18 | from typing import List, Optional, Sequence, overload |
19 | 19 | from uuid import uuid4 |
|
33 | 33 | gocode, |
34 | 34 | ) |
35 | 35 | from .builder import build_edit, build_revoke |
36 | | -from .events import Event, EventsManager |
| 36 | +from .events import Event, EventsManager, event as stop_event |
37 | 37 | from .exc import ( |
38 | 38 | BuildPollVoteCreationError, |
39 | 39 | BuildPollVoteError, |
@@ -3162,7 +3162,7 @@ def stop(self): |
3162 | 3162 | Stops the client by disconnecting it from the WhatsApp servers. |
3163 | 3163 | """ |
3164 | 3164 | _log_.debug("Stopping client and disconnecting from WhatsApp servers.") |
3165 | | - self.__client.stop(self.uuid) |
| 3165 | + self.__client.Stop(self.uuid) |
3166 | 3166 |
|
3167 | 3167 | def get_message_for_retry( |
3168 | 3168 | self, requester: JID, to: JID, message_id: str |
@@ -3312,25 +3312,88 @@ def connect_with_proxy(self, proxy_settings: ProxySettings | None = None): |
3312 | 3312 | c_settings = proxy_settings._to_c_struct() |
3313 | 3313 | proxy_ref = ctypes.byref(c_settings) |
3314 | 3314 |
|
3315 | | - # Initiate connection to the server |
3316 | | - err = self.__client.Neonize( |
3317 | | - self.name.encode(), |
3318 | | - self.uuid, |
3319 | | - jidbuf, |
3320 | | - jidbuf_size, |
3321 | | - LogLevel.from_logging(log.level).level, |
| 3315 | + # Keep the ctypes callback wrappers and the subscriber buffer referenced |
| 3316 | + # for the whole lifetime of the connection. ``Neonize`` runs on a worker |
| 3317 | + # thread (below) and Go invokes these callbacks throughout the session; |
| 3318 | + # if they were mere temporaries they could be garbage-collected mid-call. |
| 3319 | + subscriber_buf = (ctypes.c_char * len(self.event.list_func)).from_buffer(d) |
| 3320 | + self._connect_refs = ( |
3322 | 3321 | func_string(self.__onQr), |
3323 | 3322 | func_string(self.__onLoginStatus), |
3324 | 3323 | func_callback_bytes(self.event.execute), |
3325 | 3324 | func_callback_bytes2(log_whatsmeow), |
3326 | | - (ctypes.c_char * len(self.event.list_func)).from_buffer(d), |
3327 | | - len(d), |
3328 | | - deviceprops, |
3329 | | - len(deviceprops), |
3330 | | - b"", |
3331 | | - 0, |
3332 | | - proxy_ref, |
| 3325 | + subscriber_buf, |
| 3326 | + d, |
3333 | 3327 | ) |
| 3328 | + qr_cb, login_cb, event_cb, log_cb, subscriber_buf, _ = self._connect_refs |
| 3329 | + |
| 3330 | + # ``Neonize`` is a blocking Go call that only returns once the Go-side |
| 3331 | + # context is cancelled (via ``Stop``). Running it directly on the caller |
| 3332 | + # thread would trap that thread inside the C call, so CPython could never |
| 3333 | + # run signal handlers or raise ``KeyboardInterrupt`` -- Ctrl+C would be |
| 3334 | + # ignored. Instead we run it on a worker thread and wait interruptibly on |
| 3335 | + # the caller thread; on Ctrl+C (or the global stop event) we cancel Go so |
| 3336 | + # ``Neonize`` returns and the worker exits cleanly. |
| 3337 | + holder: typing.Dict[str, typing.Any] = {} |
| 3338 | + # Set the instant Neonize() returns. This -- not worker.is_alive() -- is |
| 3339 | + # the source of truth for "the Go call finished": when a KeyboardInterrupt |
| 3340 | + # interrupts Thread.join(), CPython can leave is_alive() wrongly reporting |
| 3341 | + # False for a still-running thread, whereas an Event flag is never |
| 3342 | + # corrupted by the interrupt. |
| 3343 | + done = ThreadEvent() |
| 3344 | + |
| 3345 | + def _worker(): |
| 3346 | + try: |
| 3347 | + holder["err"] = self.__client.Neonize( |
| 3348 | + self.name.encode(), |
| 3349 | + self.uuid, |
| 3350 | + jidbuf, |
| 3351 | + jidbuf_size, |
| 3352 | + LogLevel.from_logging(log.level).level, |
| 3353 | + qr_cb, |
| 3354 | + login_cb, |
| 3355 | + event_cb, |
| 3356 | + log_cb, |
| 3357 | + subscriber_buf, |
| 3358 | + len(d), |
| 3359 | + deviceprops, |
| 3360 | + len(deviceprops), |
| 3361 | + b"", |
| 3362 | + 0, |
| 3363 | + proxy_ref, |
| 3364 | + ) |
| 3365 | + except BaseException as exc: # noqa: BLE001 - surfaced on caller thread |
| 3366 | + holder["exc"] = exc |
| 3367 | + finally: |
| 3368 | + done.set() |
| 3369 | + |
| 3370 | + worker = Thread(target=_worker, name=self.uuid.decode(), daemon=False) |
| 3371 | + worker.start() |
| 3372 | + try: |
| 3373 | + # Short timeouts return control to the interpreter periodically, so a |
| 3374 | + # pending signal / KeyboardInterrupt can fire on this thread instead |
| 3375 | + # of being trapped behind the blocking Go call on the worker thread. |
| 3376 | + while not done.is_set(): |
| 3377 | + if stop_event.is_set(): |
| 3378 | + break |
| 3379 | + done.wait(0.2) |
| 3380 | + except KeyboardInterrupt: |
| 3381 | + pass # clean shutdown, no traceback |
| 3382 | + finally: |
| 3383 | + if not done.is_set(): |
| 3384 | + self.stop() # cancel the Go context so Neonize() returns |
| 3385 | + # Drain the worker, absorbing any further interrupts, until Neonize |
| 3386 | + # has actually returned. |
| 3387 | + while not done.is_set(): |
| 3388 | + try: |
| 3389 | + done.wait(0.2) |
| 3390 | + except KeyboardInterrupt: |
| 3391 | + self.stop() |
| 3392 | + worker.join() |
| 3393 | + |
| 3394 | + if "exc" in holder: |
| 3395 | + raise holder["exc"] |
| 3396 | + err = holder.get("err") |
3334 | 3397 | if err: |
3335 | 3398 | raise NeonizeError(err.decode()) |
3336 | 3399 |
|
@@ -3411,10 +3474,46 @@ def new_client( |
3411 | 3474 | self.clients.append(client) |
3412 | 3475 | return client |
3413 | 3476 |
|
| 3477 | + def stop(self): |
| 3478 | + """Stop every client managed by this factory. |
| 3479 | +
|
| 3480 | + Cancels the Go-side context of all running clients at once so their |
| 3481 | + blocking ``Neonize`` calls return and the worker threads can exit. |
| 3482 | + """ |
| 3483 | + gocode.StopAll() |
| 3484 | + |
3414 | 3485 | def run(self): |
| 3486 | + """Connect every client and block until interrupted. |
| 3487 | +
|
| 3488 | + Each client's blocking ``connect`` runs on its own (non-daemon) worker |
| 3489 | + thread, while this call blocks the caller thread interruptibly. On Ctrl+C |
| 3490 | + (or when the global stop event is set) all clients are stopped and every |
| 3491 | + worker thread is joined before returning, so shutdown is clean. |
| 3492 | + """ |
| 3493 | + threads = [] |
3415 | 3494 | for client in self.clients: |
3416 | | - Thread( |
| 3495 | + t = Thread( |
3417 | 3496 | target=client.connect, |
3418 | | - daemon=True, |
| 3497 | + daemon=False, |
3419 | 3498 | name=client.uuid.decode(), |
3420 | | - ).start() |
| 3499 | + ) |
| 3500 | + t.start() |
| 3501 | + threads.append(t) |
| 3502 | + try: |
| 3503 | + while any(t.is_alive() for t in threads): |
| 3504 | + if stop_event.is_set(): |
| 3505 | + break |
| 3506 | + for t in threads: |
| 3507 | + t.join(0.2) |
| 3508 | + except KeyboardInterrupt: |
| 3509 | + pass |
| 3510 | + finally: |
| 3511 | + self.stop() # StopAll: cancel every client's Go context |
| 3512 | + # Drain all worker threads, absorbing any further interrupts so a |
| 3513 | + # repeated Ctrl+C can't leave a client's Go call running. |
| 3514 | + for t in threads: |
| 3515 | + while t.is_alive(): |
| 3516 | + try: |
| 3517 | + t.join(0.2) |
| 3518 | + except KeyboardInterrupt: |
| 3519 | + self.stop() |
0 commit comments